mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +00:00
v0.42.12.0 feat: self-upgrading gbrain — invocation-riding update check + opt-in auto-upgrade (#1798)
* feat(self-upgrade): decision/cache/snooze foundation + atomic binary self-update Pure decideSelfUpgrade (invocation + autopilot channels), atomic untrusted cache + escalating snooze + shared marker grammar (forged-marker rejection), semver helpers, and real darwin-arm64/linux-x64 binary self-update (download -> fsync -> smoke -> atomic rename; failure leaves old binary intact). Tests incl. real-HTTP-server swap E2E. * feat(self-upgrade): check-update cache/markers, self-upgrade command, CLI heartbeat hook check-update gains gstack-style cache/snooze/markers + refreshUpdateCache + exported fetchLatestRelease. New 'gbrain self-upgrade' command. cli.ts emits the update marker on every invocation (cache-read-only hot path, detached single-flight refresh, skip-set + recursion guard + NODE_ENV=test gate). * feat(self-upgrade): autopilot silent channel, doctor check, runPostUpgrade setup, config + identity marker autopilot opt-in silent channel (auto+quiet+idle, swap-only+breadcrumb+exit-relaunch) + installSystemd Restart=always + migrateSystemdUnitToRestartAlways. doctor self_upgrade_health. runPostUpgrade applySelfUpgradeSetup (one-time consent + systemd rewrite). init defaults mode=notify. config self_upgrade plane + KNOWN_CONFIG_KEYS. get_brain_identity carries update marker. * docs(self-upgrade): gbrain-upgrade agent skill, RESOLVER/manifest, auto-update doc reversal, HEARTBEAT New skills/gbrain-upgrade agent flow (mirror gstack-upgrade) wired into RESOLVER + manifest. upgrades-auto-update.md reversed to document opt-in auto + conservative gates. HEARTBEAT self-upgrade --check-only line. llms-full regenerated. * chore: bump version and changelog (v0.42.12.0) Self-upgrading gbrain: invocation-riding update marker + opt-in autopilot silent channel + real atomic binary self-update. Mirrors gstack's mechanism. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(self-upgrade): write just-upgraded-from breadcrumb + clear stale cache after upgrade Codex ship-review P3: the CLI startup hook reads just-upgraded-from to print the one-time JUST_UPGRADED confirmation, but nothing wrote it — dead path. runUpgrade now writes the breadcrumb (covers full + --swap-only) and clears the update-check cache + snooze so a now-applied 'upgrade available' marker stops nudging. * feat(self-upgrade): surface what's-new in notify + wire agent integration (AGENTS.md, HEARTBEAT) + e2e - self-upgrade --check-only --json now includes changelog_diff + release_url (export fetchChangelog); the gbrain-upgrade skill shows 3-5 what's-new bullets before the 4-option prompt instead of just version numbers. - setup injects a self-upgrade marker protocol into AGENTS.md so interactive agents (Claude Code, Codex) act on the UPGRADE_AVAILABLE stderr marker — the piece that makes notify actually fire for them. - HEARTBEAT daily beat routes through the gbrain-upgrade skill (OpenClaw/Hermes cron cadence); auto-mode daemons ride the autopilot tick. - e2e: real subprocess invocation proves the marker fires (notify emits; off/snooze/up-to-date silent; JUST_UPGRADED fires+clears; --quiet suppresses). Serial test: --check-only surfaces the changelog. --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
d4211f4176
commit
a57d98b813
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { generateSystemdUnit } from '../src/commands/autopilot.ts';
|
||||
|
||||
const AUTOPILOT_SRC = readFileSync(join(import.meta.dir, '../src/commands/autopilot.ts'), 'utf8');
|
||||
|
||||
describe('generateSystemdUnit', () => {
|
||||
const unit = generateSystemdUnit('/home/u/.gbrain/autopilot-run.sh');
|
||||
|
||||
test('uses Restart=always (NOT on-failure) so a clean exit-for-relaunch respawns', () => {
|
||||
expect(unit).toContain('Restart=always');
|
||||
expect(unit).not.toContain('Restart=on-failure');
|
||||
});
|
||||
test('caps a clean-exit respawn storm with StartLimit*', () => {
|
||||
expect(unit).toContain('StartLimitIntervalSec=');
|
||||
expect(unit).toContain('StartLimitBurst=');
|
||||
});
|
||||
test('runs the given wrapper path', () => {
|
||||
expect(unit).toContain('ExecStart=/home/u/.gbrain/autopilot-run.sh');
|
||||
});
|
||||
});
|
||||
|
||||
describe('autopilot self-upgrade static-shape regressions', () => {
|
||||
test('supervisor-relaunch, NOT in-process re-exec (Bun has no execve) — no exec*-call', () => {
|
||||
// Match call-shape, not the word (the comments legitimately say "no execve").
|
||||
expect(AUTOPILOT_SRC).not.toMatch(/execve\s*\(/);
|
||||
expect(AUTOPILOT_SRC).not.toMatch(/execvp\s*\(/);
|
||||
});
|
||||
test('the silent channel does swap-only, never a blocking full post-upgrade in the tick', () => {
|
||||
expect(AUTOPILOT_SRC).toContain("execSync('gbrain upgrade --swap-only'");
|
||||
// The tick must not invoke the (up-to-30-min) post-upgrade inline.
|
||||
expect(AUTOPILOT_SRC).not.toContain("execSync('gbrain post-upgrade'");
|
||||
});
|
||||
test('boot reconciles the breadcrumb and the tick attempts the channel', () => {
|
||||
expect(AUTOPILOT_SRC).toContain('reconcileSelfUpgradeAtBoot()');
|
||||
expect(AUTOPILOT_SRC).toContain('attemptAutopilotSelfUpgrade(engine, engineType, lockPath)');
|
||||
});
|
||||
test('apply path unlinks the lock before exit so the relaunched binary does not self-exit on a stale lock', () => {
|
||||
// The exit-for-relaunch block unlinks lockPath then process.exit(0).
|
||||
expect(AUTOPILOT_SRC).toMatch(/unlinkSync\(lockPath\)[\s\S]{0,120}process\.exit\(0\)/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,138 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import {
|
||||
expectedAssetName,
|
||||
resolvePlatformAsset,
|
||||
runBinarySelfUpdate,
|
||||
type ReleaseAsset,
|
||||
} from '../src/core/binary-self-update.ts';
|
||||
|
||||
const ASSETS: ReleaseAsset[] = [
|
||||
{ name: 'gbrain-darwin-arm64', url: 'https://example.com/darwin-arm64' },
|
||||
{ name: 'gbrain-linux-x64', url: 'https://example.com/linux-x64' },
|
||||
];
|
||||
|
||||
describe('expectedAssetName / resolvePlatformAsset', () => {
|
||||
test('maps the two published targets', () => {
|
||||
expect(expectedAssetName('darwin', 'arm64')).toBe('gbrain-darwin-arm64');
|
||||
expect(expectedAssetName('linux', 'x64')).toBe('gbrain-linux-x64');
|
||||
});
|
||||
test('null for unpublished platform/arch', () => {
|
||||
expect(expectedAssetName('darwin', 'x64')).toBeNull();
|
||||
expect(expectedAssetName('linux', 'arm64')).toBeNull();
|
||||
expect(expectedAssetName('win32', 'x64')).toBeNull();
|
||||
});
|
||||
test('resolvePlatformAsset returns the matching URL or null', () => {
|
||||
expect(resolvePlatformAsset(ASSETS, 'darwin', 'arm64')).toBe('https://example.com/darwin-arm64');
|
||||
expect(resolvePlatformAsset(ASSETS, 'linux', 'x64')).toBe('https://example.com/linux-x64');
|
||||
expect(resolvePlatformAsset(ASSETS, 'win32', 'x64')).toBeNull();
|
||||
expect(resolvePlatformAsset([], 'darwin', 'arm64')).toBeNull(); // asset missing from release
|
||||
});
|
||||
});
|
||||
|
||||
async function withTmp<T>(fn: (dir: string) => Promise<T>): Promise<T> {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'gbrain-binup-'));
|
||||
try {
|
||||
return await fn(dir);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
describe('runBinarySelfUpdate', () => {
|
||||
test('happy path: stages, smokes, atomically replaces the target', async () => {
|
||||
await withTmp(async (dir) => {
|
||||
const target = join(dir, 'gbrain');
|
||||
writeFileSync(target, 'OLD BINARY');
|
||||
const res = await runBinarySelfUpdate(target, {
|
||||
platform: 'darwin',
|
||||
arch: 'arm64',
|
||||
fetchRelease: async () => ({ tag: 'v9.9.9', assets: ASSETS }),
|
||||
download: async (_url, dest) => writeFileSync(dest, 'NEW BINARY'),
|
||||
smoke: () => true,
|
||||
});
|
||||
expect(res.ok).toBe(true);
|
||||
expect(res.asset).toBe('gbrain-darwin-arm64');
|
||||
expect(readFileSync(target, 'utf8')).toBe('NEW BINARY');
|
||||
});
|
||||
});
|
||||
|
||||
test('unsupported platform → unsupported_platform, target untouched', async () => {
|
||||
await withTmp(async (dir) => {
|
||||
const target = join(dir, 'gbrain.exe');
|
||||
writeFileSync(target, 'OLD');
|
||||
const res = await runBinarySelfUpdate(target, {
|
||||
platform: 'win32',
|
||||
arch: 'x64',
|
||||
fetchRelease: async () => ({ tag: 'v9.9.9', assets: ASSETS }),
|
||||
});
|
||||
expect(res.ok).toBe(false);
|
||||
expect(res.reason).toBe('unsupported_platform');
|
||||
expect(readFileSync(target, 'utf8')).toBe('OLD');
|
||||
});
|
||||
});
|
||||
|
||||
test('fetch failure → fetch_failed, target untouched', async () => {
|
||||
await withTmp(async (dir) => {
|
||||
const target = join(dir, 'gbrain');
|
||||
writeFileSync(target, 'OLD');
|
||||
const res = await runBinarySelfUpdate(target, {
|
||||
platform: 'darwin',
|
||||
arch: 'arm64',
|
||||
fetchRelease: async () => null,
|
||||
});
|
||||
expect(res.reason).toBe('fetch_failed');
|
||||
expect(readFileSync(target, 'utf8')).toBe('OLD');
|
||||
});
|
||||
});
|
||||
|
||||
test('asset missing from release → no_asset, target untouched', async () => {
|
||||
await withTmp(async (dir) => {
|
||||
const target = join(dir, 'gbrain');
|
||||
writeFileSync(target, 'OLD');
|
||||
const res = await runBinarySelfUpdate(target, {
|
||||
platform: 'linux',
|
||||
arch: 'x64',
|
||||
fetchRelease: async () => ({ tag: 'v9.9.9', assets: [] }),
|
||||
});
|
||||
expect(res.reason).toBe('no_asset');
|
||||
expect(readFileSync(target, 'utf8')).toBe('OLD');
|
||||
});
|
||||
});
|
||||
|
||||
test('download throws → download_failed, no staged leftover, target untouched', async () => {
|
||||
await withTmp(async (dir) => {
|
||||
const target = join(dir, 'gbrain');
|
||||
writeFileSync(target, 'OLD');
|
||||
const res = await runBinarySelfUpdate(target, {
|
||||
platform: 'darwin',
|
||||
arch: 'arm64',
|
||||
fetchRelease: async () => ({ tag: 'v9.9.9', assets: ASSETS }),
|
||||
download: async () => {
|
||||
throw new Error('disk full');
|
||||
},
|
||||
});
|
||||
expect(res.reason).toBe('download_failed');
|
||||
expect(res.error).toContain('disk full');
|
||||
expect(readFileSync(target, 'utf8')).toBe('OLD');
|
||||
});
|
||||
});
|
||||
|
||||
test('smoke fail → smoke_failed, staged discarded, target untouched', async () => {
|
||||
await withTmp(async (dir) => {
|
||||
const target = join(dir, 'gbrain');
|
||||
writeFileSync(target, 'OLD');
|
||||
const res = await runBinarySelfUpdate(target, {
|
||||
platform: 'darwin',
|
||||
arch: 'arm64',
|
||||
fetchRelease: async () => ({ tag: 'v9.9.9', assets: ASSETS }),
|
||||
download: async (_url, dest) => writeFileSync(dest, 'CORRUPT'),
|
||||
smoke: () => false,
|
||||
});
|
||||
expect(res.reason).toBe('smoke_failed');
|
||||
expect(readFileSync(target, 'utf8')).toBe('OLD');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Serial (stubs globalThis.fetch): exercises the self-upgrade cache REFRESH
|
||||
* orchestration end-to-end — `refreshUpdateCache()` fetches the latest release
|
||||
* and writes the correct marker to the shared cache file that the CLI startup
|
||||
* hook reads. Network is stubbed; the cache write + marker logic are real.
|
||||
*
|
||||
* Quarantined as *.serial.test.ts because it reassigns the process-global
|
||||
* `fetch` (cross-file-unsafe under the parallel runner).
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { VERSION } from '../src/version.ts';
|
||||
import { parseSemver } from '../src/core/semver.ts';
|
||||
import { readUpdateCache } from '../src/core/self-upgrade.ts';
|
||||
import { refreshUpdateCache } from '../src/commands/check-update.ts';
|
||||
|
||||
const realFetch = globalThis.fetch;
|
||||
let homeDir: string;
|
||||
let priorHome: string | undefined;
|
||||
|
||||
function bump(kind: 'minor' | 'patch'): string {
|
||||
const v = parseSemver(VERSION)!;
|
||||
if (kind === 'minor') return `${v[0]}.${v[1] + 1}.0`;
|
||||
return `${v[0]}.${v[1]}.${v[2] + 1}`;
|
||||
}
|
||||
|
||||
function stubReleaseFetch(tag: string | null, ok = true): void {
|
||||
globalThis.fetch = (async (url: any) => {
|
||||
const u = String(url);
|
||||
if (u.includes('/releases/latest')) {
|
||||
if (tag === null) throw new Error('network down');
|
||||
return new Response(JSON.stringify({ tag_name: tag, published_at: '2026-01-01T00:00:00Z', html_url: 'https://x' }), {
|
||||
status: ok ? 200 : 500,
|
||||
});
|
||||
}
|
||||
// Changelog fetch (only happens when update available) — return empty.
|
||||
return new Response('', { status: 200 });
|
||||
}) as typeof fetch;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
priorHome = process.env.GBRAIN_HOME;
|
||||
homeDir = mkdtempSync(join(tmpdir(), 'gbrain-refresh-'));
|
||||
process.env.GBRAIN_HOME = homeDir;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = realFetch;
|
||||
if (priorHome === undefined) delete process.env.GBRAIN_HOME;
|
||||
else process.env.GBRAIN_HOME = priorHome;
|
||||
rmSync(homeDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('refreshUpdateCache — full refresh orchestration (network stubbed)', () => {
|
||||
test('minor-bump release → writes upgrade_available marker', async () => {
|
||||
const latest = bump('minor');
|
||||
stubReleaseFetch(`v${latest}`);
|
||||
await refreshUpdateCache();
|
||||
const entry = readUpdateCache();
|
||||
expect(entry?.marker).toEqual({ kind: 'upgrade_available', current: VERSION, latest });
|
||||
});
|
||||
|
||||
test('patch-only release → writes up_to_date marker (patch ignored)', async () => {
|
||||
stubReleaseFetch(`v${bump('patch')}`);
|
||||
await refreshUpdateCache();
|
||||
expect(readUpdateCache()?.marker).toEqual({ kind: 'up_to_date', current: VERSION });
|
||||
});
|
||||
|
||||
test('network failure → writes up_to_date marker (fail-open, TTL prevents hammering)', async () => {
|
||||
stubReleaseFetch(null);
|
||||
await refreshUpdateCache();
|
||||
expect(readUpdateCache()?.marker).toEqual({ kind: 'up_to_date', current: VERSION });
|
||||
});
|
||||
|
||||
test('non-OK HTTP → fail-open up_to_date', async () => {
|
||||
stubReleaseFetch(`v${bump('minor')}`, false);
|
||||
await refreshUpdateCache();
|
||||
expect(readUpdateCache()?.marker).toEqual({ kind: 'up_to_date', current: VERSION });
|
||||
});
|
||||
|
||||
test('garbage tag → fail-open up_to_date (forged/invalid version never cached as upgrade)', async () => {
|
||||
stubReleaseFetch('v$(rm -rf /)');
|
||||
await refreshUpdateCache();
|
||||
expect(readUpdateCache()?.marker).toEqual({ kind: 'up_to_date', current: VERSION });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { withEnv } from './helpers/with-env.ts';
|
||||
import { checkSelfUpgradeHealth } from '../src/commands/doctor.ts';
|
||||
import { writeUpdateCache } from '../src/core/self-upgrade.ts';
|
||||
import { logSelfUpgrade } from '../src/core/audit/self-upgrade-audit.ts';
|
||||
|
||||
async function withHome<T>(fn: (home: string) => T | Promise<T>): Promise<T> {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'gbrain-doctor-su-'));
|
||||
try {
|
||||
return await withEnv({ GBRAIN_HOME: dir, GBRAIN_AUDIT_DIR: join(dir, 'audit'), GBRAIN_SELF_UPGRADE_MODE: undefined }, () => fn(dir));
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
describe('checkSelfUpgradeHealth', () => {
|
||||
test('mode=off → ok, names disabled', async () => {
|
||||
await withEnv({ GBRAIN_SELF_UPGRADE_MODE: 'off' }, () => {
|
||||
const c = checkSelfUpgradeHealth();
|
||||
expect(c.name).toBe('self_upgrade_health');
|
||||
expect(c.status).toBe('ok');
|
||||
expect(c.message).toContain('disabled');
|
||||
});
|
||||
});
|
||||
|
||||
test('fresh install (no cache) → ok, mode=notify', async () => {
|
||||
await withHome(() => {
|
||||
const c = checkSelfUpgradeHealth();
|
||||
expect(c.status).toBe('ok');
|
||||
expect(c.message).toContain('mode=notify');
|
||||
});
|
||||
});
|
||||
|
||||
test('pending upgrade in cache → ok, surfaces it', async () => {
|
||||
await withHome(() => {
|
||||
writeUpdateCache({ kind: 'upgrade_available', current: '0.42.0', latest: '0.99.0' });
|
||||
const c = checkSelfUpgradeHealth();
|
||||
expect(c.status).toBe('ok');
|
||||
expect(c.message).toContain('update available');
|
||||
expect(c.message).toContain('0.99.0');
|
||||
});
|
||||
});
|
||||
|
||||
test('recent failed auto-upgrade → warn with hint', async () => {
|
||||
await withHome(() => {
|
||||
logSelfUpgrade({ channel: 'autopilot', action: 'apply', current: '0.42.0', latest: '0.99.0', outcome: 'failed', error: 'boom' });
|
||||
const c = checkSelfUpgradeHealth();
|
||||
expect(c.status).toBe('warn');
|
||||
expect(c.message).toContain('self-upgrade failure');
|
||||
expect(c.message).toContain('gbrain self-upgrade');
|
||||
});
|
||||
});
|
||||
|
||||
test('known-bad versions in config are surfaced', async () => {
|
||||
await withHome((home) => {
|
||||
mkdirSync(join(home, '.gbrain'), { recursive: true });
|
||||
writeFileSync(
|
||||
join(home, '.gbrain', 'config.json'),
|
||||
JSON.stringify({ engine: 'pglite', self_upgrade: { mode: 'notify', failed_versions: ['0.50.0'] } }),
|
||||
);
|
||||
const c = checkSelfUpgradeHealth();
|
||||
expect(c.message).toContain('known-bad');
|
||||
expect(c.message).toContain('0.50.0');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* E2E: real atomic binary self-update against a live local release server.
|
||||
*
|
||||
* Unlike test/binary-self-update.test.ts (which stubs `download` + `smoke` to
|
||||
* exercise the orchestration), this drives the REAL dangerous path end-to-end:
|
||||
* real HTTP download (defaultDownload) → real chmod → real `--version` smoke
|
||||
* (defaultSmoke / execFileSync) → real renameSync over a running "binary" →
|
||||
* re-exec the swapped binary and assert it reports the new version.
|
||||
*
|
||||
* Only `fetchRelease` is injected (to point at the local server instead of the
|
||||
* GitHub API). The "binary" is a `#!/bin/sh` script so the swap mechanics are
|
||||
* exercised identically on darwin + linux; platform/arch are pinned to
|
||||
* linux/x64 so `expectedAssetName` resolves deterministically regardless of host.
|
||||
*
|
||||
* No DB — runs in every environment.
|
||||
*/
|
||||
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
|
||||
import { chmodSync, existsSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { runBinarySelfUpdate, type ReleaseAsset } from '../../src/core/binary-self-update.ts';
|
||||
|
||||
const NEW_BINARY = '#!/bin/sh\necho "gbrain 0.43.0"\n';
|
||||
const OLD_BINARY = '#!/bin/sh\necho "gbrain 0.42.0"\n';
|
||||
const NON_GBRAIN = '#!/bin/sh\necho "not the tool"\n';
|
||||
|
||||
let server: ReturnType<typeof Bun.serve>;
|
||||
let base: string;
|
||||
|
||||
beforeAll(() => {
|
||||
server = Bun.serve({
|
||||
port: 0,
|
||||
fetch(req) {
|
||||
const path = new URL(req.url).pathname;
|
||||
if (path === '/good-asset') return new Response(NEW_BINARY, { status: 200 });
|
||||
if (path === '/bad-smoke-asset') return new Response(NON_GBRAIN, { status: 200 });
|
||||
if (path === '/404-asset') return new Response('nope', { status: 404 });
|
||||
if (path === '/empty-asset') return new Response('', { status: 200 });
|
||||
return new Response('not found', { status: 404 });
|
||||
},
|
||||
});
|
||||
base = `http://127.0.0.1:${server.port}`;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
server.stop(true);
|
||||
});
|
||||
|
||||
function makeTargetBinary(): { dir: string; target: string } {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'gbrain-swap-'));
|
||||
const target = join(dir, 'gbrain');
|
||||
writeFileSync(target, OLD_BINARY);
|
||||
chmodSync(target, 0o755);
|
||||
return { dir, target };
|
||||
}
|
||||
|
||||
function assets(url: string): ReleaseAsset[] {
|
||||
return [{ name: 'gbrain-linux-x64', url }];
|
||||
}
|
||||
|
||||
function versionOf(path: string): string {
|
||||
return execFileSync(path, ['--version'], { encoding: 'utf-8' }).trim();
|
||||
}
|
||||
|
||||
function tmpLeftovers(dir: string): string[] {
|
||||
return readdirSync(dir).filter((f) => f.includes('.tmp.'));
|
||||
}
|
||||
|
||||
describe('binary self-update — real swap E2E', () => {
|
||||
test('happy path: downloads, smokes, atomically replaces the running binary', async () => {
|
||||
const { dir, target } = makeTargetBinary();
|
||||
try {
|
||||
expect(versionOf(target)).toBe('gbrain 0.42.0');
|
||||
const result = await runBinarySelfUpdate(target, {
|
||||
fetchRelease: async () => ({ tag: 'v0.43.0', assets: assets(`${base}/good-asset`) }),
|
||||
platform: 'linux',
|
||||
arch: 'x64',
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.asset).toBe('gbrain-linux-x64');
|
||||
// The running "binary" was atomically replaced; a fresh exec sees the new version.
|
||||
expect(versionOf(target)).toBe('gbrain 0.43.0');
|
||||
expect(tmpLeftovers(dir)).toEqual([]); // staged temp renamed away, none left
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('smoke failure leaves the old binary untouched (no brick)', async () => {
|
||||
const { dir, target } = makeTargetBinary();
|
||||
try {
|
||||
const result = await runBinarySelfUpdate(target, {
|
||||
fetchRelease: async () => ({ tag: 'v0.43.0', assets: assets(`${base}/bad-smoke-asset`) }),
|
||||
platform: 'linux',
|
||||
arch: 'x64',
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.reason).toBe('smoke_failed');
|
||||
expect(versionOf(target)).toBe('gbrain 0.42.0'); // old binary intact
|
||||
expect(tmpLeftovers(dir)).toEqual([]); // staged temp cleaned up on failure
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('download HTTP error leaves the old binary untouched', async () => {
|
||||
const { dir, target } = makeTargetBinary();
|
||||
try {
|
||||
const result = await runBinarySelfUpdate(target, {
|
||||
fetchRelease: async () => ({ tag: 'v0.43.0', assets: assets(`${base}/404-asset`) }),
|
||||
platform: 'linux',
|
||||
arch: 'x64',
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.reason).toBe('download_failed');
|
||||
expect(versionOf(target)).toBe('gbrain 0.42.0');
|
||||
expect(existsSync(target)).toBe(true);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('empty downloaded asset is rejected, old binary intact', async () => {
|
||||
const { dir, target } = makeTargetBinary();
|
||||
try {
|
||||
const result = await runBinarySelfUpdate(target, {
|
||||
fetchRelease: async () => ({ tag: 'v0.43.0', assets: assets(`${base}/empty-asset`) }),
|
||||
platform: 'linux',
|
||||
arch: 'x64',
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.reason).toBe('download_failed');
|
||||
expect(versionOf(target)).toBe('gbrain 0.42.0');
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('no matching asset for platform → no_asset, no download attempted', async () => {
|
||||
const { dir, target } = makeTargetBinary();
|
||||
try {
|
||||
const result = await runBinarySelfUpdate(target, {
|
||||
fetchRelease: async () => ({ tag: 'v0.43.0', assets: [{ name: 'gbrain-darwin-arm64', url: `${base}/good-asset` }] }),
|
||||
platform: 'linux',
|
||||
arch: 'x64',
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.reason).toBe('no_asset');
|
||||
expect(versionOf(target)).toBe('gbrain 0.42.0');
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('unsupported platform short-circuits before any network call', async () => {
|
||||
const { dir, target } = makeTargetBinary();
|
||||
let fetched = false;
|
||||
try {
|
||||
const result = await runBinarySelfUpdate(target, {
|
||||
fetchRelease: async () => {
|
||||
fetched = true;
|
||||
return { tag: 'v0.43.0', assets: assets(`${base}/good-asset`) };
|
||||
},
|
||||
platform: 'win32',
|
||||
arch: 'x64',
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.reason).toBe('unsupported_platform');
|
||||
expect(fetched).toBe(false); // never hit the network
|
||||
expect(versionOf(target)).toBe('gbrain 0.42.0');
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* E2E: the self-upgrade marker actually fires on a real `gbrain` invocation.
|
||||
*
|
||||
* This is the load-bearing proof of the "rides invocation frequency" mechanism:
|
||||
* spawn a real `bun src/cli.ts <cmd>` with a warm "upgrade available" cache and
|
||||
* assert the startup hook prints `UPGRADE_AVAILABLE` on stderr — which is what an
|
||||
* agent (Claude Code / Codex / OpenClaw) keys off to run the gbrain-upgrade skill.
|
||||
*
|
||||
* Carrier command: `config get self_upgrade.mode` — runs the startup hook, needs
|
||||
* no DB and no network, exits fast. The cache is pre-written fresh so the hook
|
||||
* emits from cache and never spawns the detached network refresh (hermetic).
|
||||
*
|
||||
* The child is spawned with NODE_ENV unset (the production code gates the hook
|
||||
* off under NODE_ENV=test to keep the unit suite from spawning refreshers).
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync, existsSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { VERSION } from '../../src/version.ts';
|
||||
|
||||
let home: string;
|
||||
let gbrainDir: string;
|
||||
const repoRoot = process.cwd();
|
||||
|
||||
beforeEach(() => {
|
||||
home = mkdtempSync(join(tmpdir(), 'gbrain-marker-'));
|
||||
gbrainDir = join(home, '.gbrain');
|
||||
mkdirSync(gbrainDir, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function writeCache(line: string): void {
|
||||
writeFileSync(join(gbrainDir, 'last-update-check'), line + '\n'); // mtime = now → fresh
|
||||
}
|
||||
|
||||
function runGbrain(mode: string | undefined): { stdout: string; stderr: string; code: number } {
|
||||
const env: Record<string, string> = { ...process.env } as Record<string, string>;
|
||||
delete env.NODE_ENV; // un-gate the startup hook (production code skips it under test)
|
||||
delete env.GBRAIN_SKIP_STARTUP_HOOKS;
|
||||
env.GBRAIN_HOME = home;
|
||||
if (mode === undefined) delete env.GBRAIN_SELF_UPGRADE_MODE;
|
||||
else env.GBRAIN_SELF_UPGRADE_MODE = mode;
|
||||
const r = Bun.spawnSync(['bun', 'src/cli.ts', 'config', 'get', 'self_upgrade.mode'], {
|
||||
cwd: repoRoot,
|
||||
env,
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
});
|
||||
return {
|
||||
stdout: r.stdout.toString(),
|
||||
stderr: r.stderr.toString(),
|
||||
code: r.exitCode ?? -1,
|
||||
};
|
||||
}
|
||||
|
||||
describe('self-upgrade marker on a real invocation', () => {
|
||||
test('notify mode + fresh upgrade_available cache → emits UPGRADE_AVAILABLE on stderr', () => {
|
||||
writeCache(`UPGRADE_AVAILABLE ${VERSION} 0.99.0`);
|
||||
const { stderr } = runGbrain('notify');
|
||||
expect(stderr).toContain(`UPGRADE_AVAILABLE ${VERSION} 0.99.0`);
|
||||
expect(stderr).toContain('Run: gbrain self-upgrade');
|
||||
});
|
||||
|
||||
test('off mode → no marker (update checks disabled)', () => {
|
||||
writeCache(`UPGRADE_AVAILABLE ${VERSION} 0.99.0`);
|
||||
const { stderr } = runGbrain('off');
|
||||
expect(stderr).not.toContain('UPGRADE_AVAILABLE');
|
||||
});
|
||||
|
||||
test('up_to_date cache → no marker', () => {
|
||||
writeCache(`UP_TO_DATE ${VERSION}`);
|
||||
const { stderr } = runGbrain('notify');
|
||||
expect(stderr).not.toContain('UPGRADE_AVAILABLE');
|
||||
});
|
||||
|
||||
test('active snooze for the version → no marker (notify mode honors snooze)', () => {
|
||||
writeCache(`UPGRADE_AVAILABLE ${VERSION} 0.99.0`);
|
||||
// snooze record: "<version> <level> <epoch-ms>" — fresh ts so it's active.
|
||||
writeFileSync(join(gbrainDir, 'update-snoozed'), `0.99.0 1 ${Date.now()}\n`);
|
||||
const { stderr } = runGbrain('notify');
|
||||
expect(stderr).not.toContain('UPGRADE_AVAILABLE');
|
||||
});
|
||||
|
||||
test('JUST_UPGRADED breadcrumb → one-time confirmation on stderr, then cleared', () => {
|
||||
const breadcrumb = join(gbrainDir, 'just-upgraded-from');
|
||||
writeFileSync(breadcrumb, '0.42.0\n');
|
||||
const { stderr } = runGbrain('notify');
|
||||
expect(stderr).toContain(`JUST_UPGRADED 0.42.0 ${VERSION}`);
|
||||
expect(existsSync(breadcrumb)).toBe(false); // consumed
|
||||
});
|
||||
|
||||
test('--quiet suppresses the marker', () => {
|
||||
writeCache(`UPGRADE_AVAILABLE ${VERSION} 0.99.0`);
|
||||
const env: Record<string, string> = { ...process.env } as Record<string, string>;
|
||||
delete env.NODE_ENV;
|
||||
env.GBRAIN_HOME = home;
|
||||
env.GBRAIN_SELF_UPGRADE_MODE = 'notify';
|
||||
const r = Bun.spawnSync(['bun', 'src/cli.ts', '--quiet', 'config', 'get', 'self_upgrade.mode'], {
|
||||
cwd: repoRoot,
|
||||
env,
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
});
|
||||
expect(r.stderr.toString()).not.toContain('UPGRADE_AVAILABLE');
|
||||
});
|
||||
});
|
||||
@@ -109,7 +109,9 @@ describe('get_brain_identity op', () => {
|
||||
'chunk_count',
|
||||
'engine',
|
||||
'last_sync_iso',
|
||||
'latest_version',
|
||||
'page_count',
|
||||
'update_available',
|
||||
'version',
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Serial (stubs globalThis.fetch): `gbrain self-upgrade --check-only --json`
|
||||
* surfaces the changelog so the notify prompt can tell the operator WHAT they'll
|
||||
* get, not just a version number. Network stubbed; the JSON shape + changelog
|
||||
* extraction are real.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { VERSION } from '../src/version.ts';
|
||||
import { parseSemver } from '../src/core/semver.ts';
|
||||
import { runSelfUpgrade } from '../src/commands/self-upgrade.ts';
|
||||
|
||||
const realFetch = globalThis.fetch;
|
||||
const realLog = console.log;
|
||||
let home: string;
|
||||
let priorHome: string | undefined;
|
||||
let captured: string[];
|
||||
|
||||
function minorBump(): string {
|
||||
const v = parseSemver(VERSION)!;
|
||||
return `${v[0]}.${v[1] + 1}.0`;
|
||||
}
|
||||
|
||||
function stub(tag: string | null, changelog: string): void {
|
||||
globalThis.fetch = (async (url: any) => {
|
||||
const u = String(url);
|
||||
if (u.includes('/releases/latest')) {
|
||||
if (tag === null) throw new Error('network down');
|
||||
return new Response(JSON.stringify({ tag_name: tag, published_at: '2026-01-01', html_url: 'https://x/rel' }), { status: 200 });
|
||||
}
|
||||
if (u.includes('CHANGELOG.md')) return new Response(changelog, { status: 200 });
|
||||
return new Response('', { status: 200 });
|
||||
}) as typeof fetch;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
priorHome = process.env.GBRAIN_HOME;
|
||||
home = mkdtempSync(join(tmpdir(), 'gbrain-checkonly-'));
|
||||
process.env.GBRAIN_HOME = home;
|
||||
captured = [];
|
||||
console.log = (...a: unknown[]) => { captured.push(a.join(' ')); };
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = realFetch;
|
||||
console.log = realLog;
|
||||
if (priorHome === undefined) delete process.env.GBRAIN_HOME;
|
||||
else process.env.GBRAIN_HOME = priorHome;
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('self-upgrade --check-only surfaces what you get', () => {
|
||||
test('behind → JSON includes changelog_diff + release_url + update_available', async () => {
|
||||
const latest = minorBump();
|
||||
const changelog = `# Changelog\n\n## [${latest}] - 2026-01-01\n\n- Shiny new thing\n- Another fix\n\n## [${VERSION}] - 2025-12-01\n\n- old\n`;
|
||||
stub(`v${latest}`, changelog);
|
||||
await runSelfUpgrade(['--check-only', '--json']);
|
||||
const out = JSON.parse(captured.join('\n'));
|
||||
expect(out.update_available).toBe(true);
|
||||
expect(out.latest_version).toBe(latest);
|
||||
expect(out.release_url).toBe('https://x/rel');
|
||||
expect(out.changelog_diff).toContain('Shiny new thing');
|
||||
});
|
||||
|
||||
test('behind, human output prints What changed', async () => {
|
||||
const latest = minorBump();
|
||||
const changelog = `# Changelog\n\n## [${latest}] - 2026-01-01\n\n- Headline feature\n\n## [${VERSION}] - 2025-12-01\n\n- old\n`;
|
||||
stub(`v${latest}`, changelog);
|
||||
await runSelfUpgrade(['--check-only']);
|
||||
const text = captured.join('\n');
|
||||
expect(text).toContain('What changed');
|
||||
expect(text).toContain('Headline feature');
|
||||
});
|
||||
|
||||
test('up to date → no changelog fetched, empty diff', async () => {
|
||||
// Stub returns the SAME version → not behind → no changelog.
|
||||
stub(`v${VERSION}`, 'should-not-be-read');
|
||||
await runSelfUpgrade(['--check-only', '--json']);
|
||||
const out = JSON.parse(captured.join('\n'));
|
||||
expect(out.update_available).toBe(false);
|
||||
expect(out.changelog_diff).toBe('');
|
||||
});
|
||||
|
||||
test('network failure → up to date, no crash', async () => {
|
||||
stub(null, '');
|
||||
await runSelfUpgrade(['--check-only', '--json']);
|
||||
const out = JSON.parse(captured.join('\n'));
|
||||
expect(out.update_available).toBe(false);
|
||||
expect(out.changelog_diff).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,286 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { withEnv } from './helpers/with-env.ts';
|
||||
import {
|
||||
canSelfUpdate,
|
||||
clearSnooze,
|
||||
clearUpdateCache,
|
||||
decideSelfUpgrade,
|
||||
formatMarker,
|
||||
isCacheFresh,
|
||||
isSnoozeActive,
|
||||
parseMarker,
|
||||
readSnooze,
|
||||
readUpdateCache,
|
||||
reconcileBreadcrumb,
|
||||
resolveSelfUpgradeMode,
|
||||
snoozeDurationMs,
|
||||
writeSnooze,
|
||||
writeUpdateCache,
|
||||
type DecideSelfUpgradeInputs,
|
||||
} from '../src/core/self-upgrade.ts';
|
||||
|
||||
function baseInputs(over: Partial<DecideSelfUpgradeInputs> = {}): DecideSelfUpgradeInputs {
|
||||
return {
|
||||
mode: 'notify',
|
||||
currentVersion: '0.42.0',
|
||||
latestVersion: '0.43.0',
|
||||
failedVersions: [],
|
||||
channel: 'invocation',
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
async function withTmpHome<T>(fn: () => T | Promise<T>): Promise<T> {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'gbrain-selfupgrade-'));
|
||||
try {
|
||||
return await withEnv({ GBRAIN_HOME: dir, GBRAIN_SELF_UPGRADE_MODE: undefined }, fn);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
describe('decideSelfUpgrade — pure branches', () => {
|
||||
test('mode=off short-circuits', () => {
|
||||
expect(decideSelfUpgrade(baseInputs({ mode: 'off' })).action).toBe('off');
|
||||
});
|
||||
|
||||
test('null/invalid latest → not_behind (fail-open)', () => {
|
||||
expect(decideSelfUpgrade(baseInputs({ latestVersion: null })).action).toBe('not_behind');
|
||||
expect(decideSelfUpgrade(baseInputs({ latestVersion: 'garbage' })).action).toBe('not_behind');
|
||||
expect(decideSelfUpgrade(baseInputs({ latestVersion: 'rm -rf /' })).action).toBe('not_behind');
|
||||
});
|
||||
|
||||
test('equal version → not_behind', () => {
|
||||
expect(decideSelfUpgrade(baseInputs({ latestVersion: '0.42.0' })).action).toBe('not_behind');
|
||||
});
|
||||
|
||||
test('latest < current → downgrade_or_yanked (never acts)', () => {
|
||||
const d = decideSelfUpgrade(baseInputs({ currentVersion: '0.43.0', latestVersion: '0.42.0' }));
|
||||
expect(d.action).toBe('downgrade_or_yanked');
|
||||
});
|
||||
|
||||
test('patch/micro bump only → not_behind (ignored)', () => {
|
||||
expect(decideSelfUpgrade(baseInputs({ currentVersion: '0.42.0', latestVersion: '0.42.1' })).action).toBe('not_behind');
|
||||
expect(decideSelfUpgrade(baseInputs({ currentVersion: '0.42.3.0', latestVersion: '0.42.3.1' })).action).toBe('not_behind');
|
||||
});
|
||||
|
||||
test('minor bump → behind', () => {
|
||||
expect(decideSelfUpgrade(baseInputs({ currentVersion: '0.42.0', latestVersion: '0.43.0' })).action).toBe('notify');
|
||||
});
|
||||
|
||||
test('major bump → behind', () => {
|
||||
expect(decideSelfUpgrade(baseInputs({ currentVersion: '0.42.0', latestVersion: '1.0.0' })).action).toBe('notify');
|
||||
});
|
||||
|
||||
test('known-bad latest → known_bad (no retry)', () => {
|
||||
const d = decideSelfUpgrade(baseInputs({ failedVersions: ['0.43.0'] }));
|
||||
expect(d.action).toBe('known_bad');
|
||||
});
|
||||
|
||||
test('invocation channel: snoozed → throttled', () => {
|
||||
expect(decideSelfUpgrade(baseInputs({ snoozed: true })).action).toBe('throttled');
|
||||
});
|
||||
|
||||
test('invocation channel: not snoozed → notify', () => {
|
||||
expect(decideSelfUpgrade(baseInputs({ snoozed: false })).action).toBe('notify');
|
||||
});
|
||||
|
||||
describe('autopilot channel gates', () => {
|
||||
const auto = (over: Partial<DecideSelfUpgradeInputs> = {}) =>
|
||||
decideSelfUpgrade(
|
||||
baseInputs({
|
||||
mode: 'auto',
|
||||
channel: 'autopilot',
|
||||
idle: true,
|
||||
inQuietHours: true,
|
||||
canSelfUpdate: true,
|
||||
throttledByInterval: false,
|
||||
...over,
|
||||
}),
|
||||
);
|
||||
|
||||
test('all gates pass → apply', () => {
|
||||
expect(auto().action).toBe('apply');
|
||||
});
|
||||
test('throttledByInterval → throttled', () => {
|
||||
expect(auto({ throttledByInterval: true }).action).toBe('throttled');
|
||||
});
|
||||
test('not idle → busy', () => {
|
||||
expect(auto({ idle: false }).action).toBe('busy');
|
||||
});
|
||||
test('outside quiet hours → outside_quiet_hours', () => {
|
||||
expect(auto({ inQuietHours: false }).action).toBe('outside_quiet_hours');
|
||||
});
|
||||
test('cannot self-update → unsupported_install', () => {
|
||||
expect(auto({ canSelfUpdate: false }).action).toBe('unsupported_install');
|
||||
});
|
||||
test('gate order: known_bad beats idle/quiet gates', () => {
|
||||
expect(auto({ failedVersions: ['0.43.0'], idle: false }).action).toBe('known_bad');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('canSelfUpdate', () => {
|
||||
test('bun / bun-link / clawhub always self-update', () => {
|
||||
for (const m of ['bun', 'bun-link', 'clawhub']) {
|
||||
expect(canSelfUpdate(m, 'darwin', 'arm64')).toBe(true);
|
||||
expect(canSelfUpdate(m, 'win32', 'x64')).toBe(true);
|
||||
}
|
||||
});
|
||||
test('binary self-updates only where a release asset exists (darwin-arm64, linux-x64)', () => {
|
||||
expect(canSelfUpdate('binary', 'darwin', 'arm64')).toBe(true);
|
||||
expect(canSelfUpdate('binary', 'linux', 'x64')).toBe(true);
|
||||
expect(canSelfUpdate('binary', 'darwin', 'x64')).toBe(false); // no asset published
|
||||
expect(canSelfUpdate('binary', 'linux', 'arm64')).toBe(false); // no asset published
|
||||
expect(canSelfUpdate('binary', 'win32', 'x64')).toBe(false);
|
||||
});
|
||||
test('unknown method cannot self-update', () => {
|
||||
expect(canSelfUpdate('unknown', 'darwin', 'arm64')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('marker grammar', () => {
|
||||
test('round-trips up_to_date and upgrade_available', () => {
|
||||
const a = { kind: 'up_to_date' as const, current: '0.42.0' };
|
||||
expect(parseMarker(formatMarker(a))).toEqual(a);
|
||||
const b = { kind: 'upgrade_available' as const, current: '0.42.0', latest: '0.43.0' };
|
||||
expect(parseMarker(formatMarker(b))).toEqual(b);
|
||||
});
|
||||
test('rejects forged / malformed markers', () => {
|
||||
expect(parseMarker('UPGRADE_AVAILABLE 0.42.0 $(rm -rf /)')).toBeNull();
|
||||
expect(parseMarker('UPGRADE_AVAILABLE 0.42.0')).toBeNull();
|
||||
expect(parseMarker('EVIL 0.42.0 0.43.0')).toBeNull();
|
||||
expect(parseMarker('UP_TO_DATE not-a-version')).toBeNull();
|
||||
expect(parseMarker('')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('snooze', () => {
|
||||
test('escalating durations cap at 7d', () => {
|
||||
expect(snoozeDurationMs(1)).toBe(24 * 3600 * 1000);
|
||||
expect(snoozeDurationMs(2)).toBe(48 * 3600 * 1000);
|
||||
expect(snoozeDurationMs(3)).toBe(7 * 24 * 3600 * 1000);
|
||||
expect(snoozeDurationMs(99)).toBe(7 * 24 * 3600 * 1000);
|
||||
});
|
||||
test('isSnoozeActive: only for matching version, within window', () => {
|
||||
const now = 1_000_000_000_000;
|
||||
const rec = { version: '0.43.0', level: 1, ts: now };
|
||||
expect(isSnoozeActive(rec, '0.43.0', now + 1000)).toBe(true);
|
||||
expect(isSnoozeActive(rec, '0.43.0', now + 25 * 3600 * 1000)).toBe(false); // expired
|
||||
expect(isSnoozeActive(rec, '0.44.0', now + 1000)).toBe(false); // different version
|
||||
expect(isSnoozeActive(null, '0.43.0', now)).toBe(false);
|
||||
});
|
||||
test('writeSnooze escalates level for same version, resets for new version', async () => {
|
||||
await withTmpHome(() => {
|
||||
const now = 1_700_000_000_000;
|
||||
expect(writeSnooze('0.43.0', now)).toBe(1);
|
||||
expect(writeSnooze('0.43.0', now)).toBe(2);
|
||||
expect(writeSnooze('0.43.0', now)).toBe(3);
|
||||
expect(writeSnooze('0.43.0', now)).toBe(3); // capped
|
||||
expect(writeSnooze('0.44.0', now)).toBe(1); // new version resets
|
||||
const rec = readSnooze();
|
||||
expect(rec?.version).toBe('0.44.0');
|
||||
expect(rec?.level).toBe(1);
|
||||
clearSnooze();
|
||||
expect(readSnooze()).toBeNull();
|
||||
});
|
||||
});
|
||||
test('corrupt snooze file → null', async () => {
|
||||
await withTmpHome(async () => {
|
||||
const { writeFileSync, mkdirSync } = await import('node:fs');
|
||||
const { gbrainPath } = await import('../src/core/config.ts');
|
||||
mkdirSync(gbrainPath(), { recursive: true });
|
||||
writeFileSync(gbrainPath('update-snoozed'), 'garbage not three fields here');
|
||||
expect(readSnooze()).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('cache', () => {
|
||||
test('write → read round-trip, atomic, fresh check', async () => {
|
||||
await withTmpHome(() => {
|
||||
writeUpdateCache({ kind: 'upgrade_available', current: '0.42.0', latest: '0.43.0' });
|
||||
const entry = readUpdateCache();
|
||||
expect(entry?.marker).toEqual({ kind: 'upgrade_available', current: '0.42.0', latest: '0.43.0' });
|
||||
expect(isCacheFresh(entry!, entry!.mtimeMs + 1000)).toBe(true);
|
||||
expect(isCacheFresh(entry!, entry!.mtimeMs + 13 * 3600 * 1000)).toBe(false); // > 12h
|
||||
clearUpdateCache();
|
||||
expect(readUpdateCache()).toBeNull();
|
||||
});
|
||||
});
|
||||
test('up_to_date uses the 60min TTL', async () => {
|
||||
await withTmpHome(() => {
|
||||
writeUpdateCache({ kind: 'up_to_date', current: '0.42.0' });
|
||||
const entry = readUpdateCache()!;
|
||||
expect(isCacheFresh(entry, entry.mtimeMs + 59 * 60 * 1000)).toBe(true);
|
||||
expect(isCacheFresh(entry, entry.mtimeMs + 61 * 60 * 1000)).toBe(false);
|
||||
});
|
||||
});
|
||||
test('corrupt cache file → null (fail-open)', async () => {
|
||||
await withTmpHome(async () => {
|
||||
const { writeFileSync, mkdirSync } = await import('node:fs');
|
||||
const { gbrainPath } = await import('../src/core/config.ts');
|
||||
mkdirSync(gbrainPath(), { recursive: true });
|
||||
writeFileSync(gbrainPath('last-update-check'), 'EVIL not a marker');
|
||||
expect(readUpdateCache()).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('writeJustUpgraded', () => {
|
||||
test('writes the from-version breadcrumb the CLI startup hook reads', async () => {
|
||||
await withTmpHome(async () => {
|
||||
const { writeJustUpgraded, justUpgradedPath } = await import('../src/core/self-upgrade.ts');
|
||||
const { readFileSync } = await import('node:fs');
|
||||
writeJustUpgraded('0.42.0');
|
||||
expect(readFileSync(justUpgradedPath(), 'utf8').trim()).toBe('0.42.0');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('reconcileBreadcrumb', () => {
|
||||
test('no breadcrumb → no transition', () => {
|
||||
expect(reconcileBreadcrumb(undefined, '0.42.0').transition).toBeNull();
|
||||
expect(reconcileBreadcrumb({}, '0.42.0').transition).toBeNull();
|
||||
});
|
||||
test('breadcrumb matches running version → applied, breadcrumb cleared, last_applied set', () => {
|
||||
const r = reconcileBreadcrumb({ attempting_version: '0.43.0' }, '0.43.0');
|
||||
expect(r.transition).toBe('applied');
|
||||
expect(r.state.attempting_version).toBeUndefined();
|
||||
expect(r.state.last_applied_version).toBe('0.43.0');
|
||||
});
|
||||
test('breadcrumb != running version → failed, recorded known-bad, breadcrumb cleared', () => {
|
||||
const r = reconcileBreadcrumb({ attempting_version: '0.43.0' }, '0.42.0');
|
||||
expect(r.transition).toBe('failed');
|
||||
expect(r.state.attempting_version).toBeUndefined();
|
||||
expect(r.state.failed_versions).toContain('0.43.0');
|
||||
});
|
||||
test('failed dedups into existing failed_versions', () => {
|
||||
const r = reconcileBreadcrumb({ attempting_version: '0.43.0', failed_versions: ['0.43.0', '0.41.0'] }, '0.42.0');
|
||||
expect(r.state.failed_versions?.filter((v) => v === '0.43.0').length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveSelfUpgradeMode', () => {
|
||||
test('defaults to notify', async () => {
|
||||
await withEnv({ GBRAIN_SELF_UPGRADE_MODE: undefined }, () => {
|
||||
expect(resolveSelfUpgradeMode(null)).toBe('notify');
|
||||
expect(resolveSelfUpgradeMode({})).toBe('notify');
|
||||
expect(resolveSelfUpgradeMode({ self_upgrade: { mode: 'bogus' } })).toBe('notify');
|
||||
});
|
||||
});
|
||||
test('config plane honored', async () => {
|
||||
await withEnv({ GBRAIN_SELF_UPGRADE_MODE: undefined }, () => {
|
||||
expect(resolveSelfUpgradeMode({ self_upgrade: { mode: 'auto' } })).toBe('auto');
|
||||
expect(resolveSelfUpgradeMode({ self_upgrade: { mode: 'off' } })).toBe('off');
|
||||
});
|
||||
});
|
||||
test('env overrides config', async () => {
|
||||
await withEnv({ GBRAIN_SELF_UPGRADE_MODE: 'off' }, () => {
|
||||
expect(resolveSelfUpgradeMode({ self_upgrade: { mode: 'auto' } })).toBe('off');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user