feat: add in-app self-updater (App Settings → Updates)

Update Codeman from the web UI: a "Check for updates" button queries GitHub
for the latest tagged release (git ls-remote fallback) and shows release
notes; "Update now" runs git checkout <tag> → npm install → npm run build →
restart, streaming live progress that survives the service restart.

- Release-tag channel; dirty trees auto-stashed (left for manual git stash pop)
- Cross-platform restart: systemd / launchd / manual, detected at runtime
- Updater runs detached (systemd-run --scope on Linux, setsid on macOS) so the
  restart it triggers can't kill the build mid-flight
- Build-failure rollback to the pre-update commit; boot reconcile with an
  update-id/freshness guard; 409 concurrency lock; runner staged outside the
  repo; strict tag validation; CODEMAN_DISABLE_SELF_UPDATE kill-switch
- Endpoints: GET /api/system/update/check, POST /api/system/update,
  GET /api/system/update/status

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
arkon
2026-06-09 01:55:54 +02:00
co-authored by Claude Opus 4.8
parent 3503b6ae55
commit 543be8a85b
9 changed files with 1222 additions and 0 deletions
+156
View File
@@ -0,0 +1,156 @@
/**
* @fileoverview Unit tests for the self-updater's pure logic: release-tag/semver
* parsing, "update available" computation, and the boot-time reconcile state
* machine. No IO, no tmux, no port — safe to run individually.
*
* npm test -- test/self-update.test.ts
*/
import { describe, it, expect } from 'vitest';
import {
parseVersionFromTag,
compareVersions,
isNewerStableVersion,
pickLatestStableTag,
isValidReleaseTag,
parseGitHubRepo,
reconcileStatusDecision,
} from '../src/web/self-update.js';
import type { UpdateStatus } from '../src/types/update.js';
describe('parseVersionFromTag', () => {
it('parses the codeman@ / aicodeman@ / v / bare forms', () => {
expect(parseVersionFromTag('codeman@0.9.3')).toMatchObject({ major: 0, minor: 9, patch: 3, prerelease: '' });
expect(parseVersionFromTag('aicodeman@1.2.3')).toMatchObject({ major: 1, minor: 2, patch: 3 });
expect(parseVersionFromTag('v0.10.0')).toMatchObject({ major: 0, minor: 10, patch: 0 });
expect(parseVersionFromTag('0.9.3')).toMatchObject({ major: 0, minor: 9, patch: 3 });
});
it('captures a prerelease suffix', () => {
expect(parseVersionFromTag('codeman@0.9.3-rc1')).toMatchObject({ patch: 3, prerelease: 'rc1' });
});
it('returns null when there is no X.Y.Z', () => {
expect(parseVersionFromTag('codeman@latest')).toBeNull();
expect(parseVersionFromTag('garbage')).toBeNull();
});
});
describe('compareVersions', () => {
const v = (s: string) => parseVersionFromTag(s)!;
it('orders by major/minor/patch', () => {
expect(compareVersions(v('0.10.0'), v('0.9.3'))).toBeGreaterThan(0);
expect(compareVersions(v('0.9.3'), v('0.10.0'))).toBeLessThan(0);
expect(compareVersions(v('1.0.0'), v('0.99.99'))).toBeGreaterThan(0);
expect(compareVersions(v('0.9.3'), v('0.9.3'))).toBe(0);
});
it('ranks a release above a prerelease of the same core', () => {
expect(compareVersions(v('0.9.3'), v('0.9.3-rc1'))).toBeGreaterThan(0);
expect(compareVersions(v('0.9.3-rc1'), v('0.9.3'))).toBeLessThan(0);
});
});
describe('isNewerStableVersion', () => {
it('true only for a strictly newer stable release', () => {
expect(isNewerStableVersion('0.9.3', '0.9.4')).toBe(true);
expect(isNewerStableVersion('0.9.3', '0.10.0')).toBe(true);
});
it('false for same/older', () => {
expect(isNewerStableVersion('0.9.3', '0.9.3')).toBe(false);
expect(isNewerStableVersion('0.9.4', '0.9.3')).toBe(false);
});
it('never offers a prerelease as an update', () => {
expect(isNewerStableVersion('0.9.3', '0.9.4-rc1')).toBe(false);
});
it('false on unparseable input', () => {
expect(isNewerStableVersion('0.9.3', 'nope')).toBe(false);
});
});
describe('pickLatestStableTag', () => {
it('picks the highest stable tag from ls-remote-style refs', () => {
const refs = [
'deadbeef\trefs/tags/codeman@0.8.2',
'cafef00d\trefs/tags/codeman@0.9.3',
'abc123\trefs/tags/codeman@0.10.0',
'abc123\trefs/tags/codeman@0.10.0^{}', // dereferenced dup
];
expect(pickLatestStableTag(refs)).toEqual({ tag: 'codeman@0.10.0', version: '0.10.0' });
});
it('skips prereleases and unrecognized tags', () => {
const refs = ['x\trefs/tags/codeman@0.9.3', 'y\trefs/tags/codeman@0.9.4-rc1', 'z\trefs/tags/some-random-tag'];
expect(pickLatestStableTag(refs)).toEqual({ tag: 'codeman@0.9.3', version: '0.9.3' });
});
it('returns null when nothing matches', () => {
expect(pickLatestStableTag([])).toBeNull();
expect(pickLatestStableTag(['refs/tags/nightly', 'refs/heads/master'])).toBeNull();
});
});
describe('isValidReleaseTag', () => {
it('accepts only codeman@/aicodeman@ X.Y.Z (shell-injection guard)', () => {
expect(isValidReleaseTag('codeman@0.9.4')).toBe(true);
expect(isValidReleaseTag('aicodeman@1.0.0')).toBe(true);
expect(isValidReleaseTag('v0.9.4')).toBe(false);
expect(isValidReleaseTag('codeman@0.9.4; rm -rf /')).toBe(false);
expect(isValidReleaseTag('codeman@latest')).toBe(false);
});
});
describe('parseGitHubRepo', () => {
it('handles SSH and HTTPS remotes', () => {
expect(parseGitHubRepo('git@github.com:Ark0N/Codeman.git')).toEqual({ owner: 'Ark0N', repo: 'Codeman' });
expect(parseGitHubRepo('https://github.com/Ark0N/Codeman.git')).toEqual({ owner: 'Ark0N', repo: 'Codeman' });
expect(parseGitHubRepo('https://github.com/Ark0N/Codeman')).toEqual({ owner: 'Ark0N', repo: 'Codeman' });
});
it('returns null for non-GitHub remotes', () => {
expect(parseGitHubRepo('https://gitlab.com/x/y.git')).toBeNull();
});
});
describe('reconcileStatusDecision (boot handoff state machine)', () => {
const NOW = 1_000_000_000_000;
const base = (over: Partial<UpdateStatus>): UpdateStatus => ({
updateId: 'u1',
phase: 'restarting',
message: '',
fromVersion: '0.9.3',
toVersion: '0.9.4',
startedAt: NOW - 5_000,
updatedAt: NOW - 5_000,
...over,
});
it('no status / terminal status → untouched', () => {
expect(reconcileStatusDecision(null, '0.9.4', NOW)).toBeNull();
expect(reconcileStatusDecision(base({ phase: 'completed' }), '0.9.4', NOW)).toBeNull();
expect(reconcileStatusDecision(base({ phase: 'failed' }), '0.9.4', NOW)).toBeNull();
});
it('restarting + running version matches target → completed', () => {
const out = reconcileStatusDecision(base({ phase: 'restarting' }), '0.9.4', NOW);
expect(out?.phase).toBe('completed');
expect(out?.updatedAt).toBe(NOW);
});
it('restarting + version unchanged → failed', () => {
const out = reconcileStatusDecision(base({ phase: 'restarting' }), '0.9.3', NOW);
expect(out?.phase).toBe('failed');
expect(out?.error).toContain('0.9.4');
});
it('a fresh non-restart in-flight phase is left for the live updater', () => {
expect(reconcileStatusDecision(base({ phase: 'building' }), '0.9.3', NOW)).toBeNull();
expect(reconcileStatusDecision(base({ phase: 'installing' }), '0.9.3', NOW)).toBeNull();
});
it('a stale (abandoned) in-flight phase is failed by the backstop', () => {
const stale = base({ phase: 'building', startedAt: NOW - 20 * 60 * 1000 });
const out = reconcileStatusDecision(stale, '0.9.3', NOW);
expect(out?.phase).toBe('failed');
expect(out?.error).toContain('building');
});
});