feat(api): add online console update with pre-install backup

- Add console update planning, download validation and staged installation.

- Create a component backup before applying online updates.

- Expose controlled update routes from the production control plane.
This commit is contained in:
chick
2026-09-07 00:44:58 +08:00
parent 961eb928c4
commit 7168e9dba2
5 changed files with 1427 additions and 0 deletions
@@ -0,0 +1,468 @@
import Database from 'better-sqlite3';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { migrateDatabase } from '../../infrastructure/database/migrations.js';
import {
ConsoleUpdateService,
UpdateError,
createLaunchdRestartLauncher,
parseManifestVersion,
parseShortStat,
type UpdateCommandResult,
type UpdateCommandRunner,
} from './console-update-service.js';
const LOCAL = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
const REMOTE = 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb';
const dbs: Database.Database[] = [];
afterEach(() => {
for (const db of dbs.splice(0)) db.close();
});
interface FakeGit {
readonly calls: string[];
runner: UpdateCommandRunner;
head: string;
remoteHead: string;
dirty: string;
fastForwardable: boolean;
/** Whether HEAD already carries the remote commit: true means the checkout is ahead. */
containsRemote: boolean;
/** Command prefixes that should fail, e.g. `git` or `git merge-base`. */
failing: Set<string>;
}
/** Matches a `command arg ...` key against the configured failing prefixes. */
function fails(patterns: ReadonlySet<string>, key: string): boolean {
const tokens = key.split(' ');
for (const pattern of patterns) {
const wanted = pattern.split(' ');
if (wanted.length <= tokens.length && wanted.every((token, index) => tokens[index] === token))
return true;
}
return false;
}
function fakeGit(overrides: Partial<Pick<FakeGit, 'head' | 'remoteHead'>> = {}): FakeGit {
const state: FakeGit = {
calls: [],
head: overrides.head ?? LOCAL,
remoteHead: overrides.remoteHead ?? REMOTE,
dirty: '',
fastForwardable: true,
containsRemote: false,
failing: new Set<string>(),
runner: { run: async () => ({ code: 0, stdout: '', stderr: '' }) },
};
const run = async (command: string, args: readonly string[]): Promise<UpdateCommandResult> => {
const key = [command, ...args].join(' ');
state.calls.push(key);
if (fails(state.failing, key)) return { code: 1, stdout: '', stderr: `${key} failed` };
if (command === 'git') {
if (args[0] === 'rev-parse' && args[1] === 'HEAD')
return { code: 0, stdout: `${state.head}\n`, stderr: '' };
if (args[0] === 'rev-parse' && args[1] === '--abbrev-ref')
return { code: 0, stdout: 'main\n', stderr: '' };
if (args[0] === 'config')
return { code: 0, stdout: 'https://example.test/repo.git\n', stderr: '' };
if (args[0] === 'ls-remote')
return { code: 0, stdout: `${state.remoteHead}\trefs/heads/main\n`, stderr: '' };
if (args[0] === 'rev-parse' && args[1] === 'FETCH_HEAD')
return { code: 0, stdout: `${state.remoteHead}\n`, stderr: '' };
if (args[0] === 'merge-base') {
// `HEAD FETCH_HEAD` asks whether we can fast-forward; `<sha> HEAD` asks whether the
// checkout already contains the remote commit.
const fastForward = args[3] === 'FETCH_HEAD';
const ok = fastForward ? state.fastForwardable : state.containsRemote;
return { code: ok ? 0 : 1, stdout: '', stderr: '' };
}
if (args[0] === 'rev-list') return { code: 0, stdout: '3\n', stderr: '' };
if (args[0] === 'diff')
return {
code: 0,
stdout: ' 2 files changed, 34 insertions(+), 6 deletions(-)\n',
stderr: '',
};
if (args[0] === 'show')
return { code: 0, stdout: '{"name":"multi-simadmin","version":"0.9.0"}', stderr: '' };
if (args[0] === 'status') return { code: 0, stdout: state.dirty, stderr: '' };
return { code: 0, stdout: '', stderr: '' };
}
return { code: 0, stdout: '', stderr: '' };
};
state.runner = { run };
return state;
}
function service(
input: {
readonly git?: FakeGit;
readonly launcher?: { supported: boolean; restart: () => Promise<void> };
readonly restartDelayMs?: number;
readonly preInstallBackup?: (targetCommit: string) => Promise<string>;
} = {},
) {
const db = new Database(':memory:');
migrateDatabase(db);
dbs.push(db);
const git = input.git ?? fakeGit();
const serviceOptions = {
db,
version: '0.1.0',
runner: git.runner,
now: () => new Date('2026-09-06T00:00:00.000Z'),
launcher: input.launcher ?? { supported: false, async restart() {} },
...(input.preInstallBackup ? { preInstallBackup: input.preInstallBackup } : {}),
...(input.restartDelayMs === undefined ? {} : { restartDelayMs: input.restartDelayMs }),
} as const;
return { db, git, updates: new ConsoleUpdateService(serviceOptions) };
}
describe('update helpers', () => {
it('adds insertions and deletions into one change count', () => {
expect(parseShortStat(' 2 files changed, 34 insertions(+), 6 deletions(-)')).toEqual({
files: 2,
lines: 40,
});
});
it('survives an empty or malformed shortstat', () => {
expect(parseShortStat('')).toEqual({ files: 0, lines: 0 });
expect(parseShortStat('1 file changed, 2 insertions(+)')).toEqual({ files: 1, lines: 2 });
});
it('reads a version out of a manifest and ignores broken JSON', () => {
expect(parseManifestVersion('{"version":"1.2.3"}')).toBe('1.2.3');
expect(parseManifestVersion('not json')).toBe('');
expect(parseManifestVersion('{"version":42}')).toBe('');
});
});
describe('ConsoleUpdateService', () => {
it('starts idle but ready to install on a git checkout', async () => {
const { updates } = service();
const status = await updates.status();
expect(status.phase).toBe('idle');
expect(status.deploymentMode).toBe('git');
expect(status.installSupported).toBe(true);
expect(status.restartSupported).toBe(false);
expect(status.release).toBeNull();
});
it('reports the manual deployment mode when git cannot resolve HEAD', async () => {
const git = fakeGit();
git.failing.add('git');
const { updates } = service({ git });
const status = await updates.status();
expect(status.deploymentMode).toBe('manual');
expect(status.installSupported).toBe(false);
expect(status.message).toContain('手动更新');
});
it('says the console is current when the remote matches HEAD', async () => {
const git = fakeGit({ remoteHead: LOCAL });
const { updates } = service({ git });
const status = await updates.check();
expect(status.phase).toBe('up_to_date');
expect(status.release?.status).toBe('latest');
expect(status.message).toContain('已是最新版本');
});
it('offers a download when the remote moved ahead', async () => {
const git = fakeGit();
const { updates } = service({ git });
const status = await updates.check();
expect(status.phase).toBe('update_available');
expect(status.release?.latestCommit).toBe(REMOTE);
expect(status.release?.latestVersion).toBe('');
});
it('says the console is ahead when the checkout already carries the remote commit', async () => {
const git = fakeGit();
git.containsRemote = true;
const { updates } = service({ git });
const status = await updates.check();
expect(status.phase).toBe('up_to_date');
expect(status.release?.status).toBe('latest');
expect(status.release?.ahead).toBe(3);
expect(status.message).toContain('领先更新源');
expect(status.release?.summary).toContain('全部提交');
// The download button stays closed because nothing was queued.
await expect(updates.install()).rejects.toMatchObject({ code: 'STATE' });
});
it('treats an unfetchable remote commit as an update rather than a local lead', async () => {
const git = fakeGit();
git.failing.add('git merge-base');
const { updates } = service({ git });
const status = await updates.check();
expect(status.phase).toBe('update_available');
expect(status.release?.ahead).toBe(0);
});
it('refuses to check a detached HEAD', async () => {
const git = fakeGit();
const original = git.runner.run.bind(git.runner);
git.runner = {
run: async (command, args, options) =>
command === 'git' && args[0] === 'rev-parse' && args[1] === '--abbrev-ref'
? { code: 0, stdout: 'HEAD\n', stderr: '' }
: original(command, args, options),
};
const { updates } = service({ git });
await expect(updates.check()).rejects.toMatchObject({ code: 'STATE' });
});
it('fetches, verifies and describes the candidate', async () => {
const git = fakeGit();
const { updates } = service({ git });
await updates.check();
const status = await updates.download();
expect(status.phase).toBe('ready');
expect(status.release?.ahead).toBe(3);
expect(status.release?.changedFiles).toBe(2);
expect(status.release?.changedLines).toBe(40);
expect(status.release?.latestVersion).toBe('0.9.0');
expect(status.message).toContain('3 个提交');
expect(git.calls.some((call) => call.startsWith('git fetch'))).toBe(true);
});
it('refuses to download before a check', async () => {
const { updates } = service();
await expect(updates.download()).rejects.toBeInstanceOf(UpdateError);
});
it('keeps the code untouched when the branch cannot fast-forward', async () => {
const git = fakeGit();
git.fastForwardable = false;
const { updates } = service({ git });
await updates.check();
const status = await updates.download();
expect(status.phase).toBe('failed');
expect(status.error).toContain('快进');
expect(git.calls.some((call) => call.startsWith('git merge --ff-only'))).toBe(false);
});
it('installs a verified candidate and queues the restart', async () => {
const git = fakeGit();
const { updates } = service({ git });
await updates.check();
await updates.download();
const status = await updates.install();
expect(status.phase).toBe('install_queued');
expect(git.calls).toContain(`git merge --ff-only ${REMOTE}`);
expect(git.calls).toContain('corepack pnpm install --frozen-lockfile');
expect(git.calls).toContain('corepack pnpm --filter @multi-simadmin/web build');
});
it('will not install over uncommitted work', async () => {
const git = fakeGit();
git.dirty = ' M apps/api/src/control-plane.ts\n';
const { updates } = service({ git });
await updates.check();
await updates.download();
await expect(updates.install()).rejects.toMatchObject({ code: 'STATE' });
expect(git.calls.some((call) => call.startsWith('git merge --ff-only'))).toBe(false);
});
it('rolls the checkout back when the build fails', async () => {
const git = fakeGit();
const original = git.runner.run.bind(git.runner);
git.runner = {
run: async (command, args, options) =>
command === 'corepack' && args.includes('build')
? { code: 2, stdout: '', stderr: 'vite exited 1' }
: original(command, args, options),
};
const { updates } = service({ git });
await updates.check();
await updates.download();
await expect(updates.install()).rejects.toMatchObject({ code: 'COMMAND' });
const status = await updates.status();
expect(status.phase).toBe('rolled_back');
expect(status.error).toContain('回滚');
expect(git.calls).toContain(`git reset --hard ${LOCAL}`);
});
it('snapshots the configuration before the checkout moves', async () => {
const snapshots: string[] = [];
const { git, updates } = service({
preInstallBackup: async (target) => {
snapshots.push(target);
return 'multi-simadmin-components-auto-preupdate.json';
},
});
await updates.check();
await updates.download();
const status = await updates.install();
expect(snapshots).toEqual([REMOTE]);
expect(status.phase).toBe('install_queued');
// The snapshot has to land before the merge, otherwise there is nothing to return to.
expect(git.calls.some((call) => call.startsWith('git merge --ff-only'))).toBe(true);
});
it('aborts the install when the safety snapshot cannot be written', async () => {
const { git, updates } = service({
preInstallBackup: async () => {
throw new Error('disk full');
},
});
await updates.check();
await updates.download();
await expect(updates.install()).rejects.toMatchObject({ code: 'COMMAND' });
const status = await updates.status();
expect(status.phase).toBe('failed');
expect(status.message).toContain('更新前备份失败');
expect(git.calls.some((call) => call.startsWith('git merge --ff-only'))).toBe(false);
});
it('requires a verified package before installing', async () => {
const { updates } = service();
await updates.check();
await expect(updates.install()).rejects.toMatchObject({ code: 'STATE' });
});
it('reports 501 when nothing supervises the process', async () => {
const { updates } = service();
await expect(updates.restart()).rejects.toMatchObject({
code: 'NOT_SUPPORTED',
statusCode: 501,
});
});
it('hands a queued install to the supervisor', async () => {
const restart = vi.fn(async () => undefined);
const { updates } = service({
launcher: { supported: true, restart },
restartDelayMs: 0,
});
await updates.check();
await updates.download();
await updates.install();
const status = await updates.restart();
expect(status.phase).toBe('restarting');
await new Promise((resolve) => setTimeout(resolve, 10));
expect(restart).toHaveBeenCalledTimes(1);
});
it('confirms its own update once the restarted process sees the new commit', async () => {
const before = fakeGit();
const first = service({
git: before,
launcher: { supported: true, restart: async () => undefined },
restartDelayMs: 0,
});
await first.updates.check();
await first.updates.download();
await first.updates.install();
await first.updates.restart();
await new Promise((resolve) => setTimeout(resolve, 10));
// Same database, new process: HEAD moved to the target commit during the restart.
const after = fakeGit({ head: REMOTE });
const reopened = new ConsoleUpdateService({
db: first.db,
version: '0.9.0',
runner: after.runner,
now: () => new Date('2026-09-06T00:05:00.000Z'),
});
const status = await reopened.status();
expect(status.phase).toBe('up_to_date');
expect(status.message).toContain('完成重启');
});
it('marks a restart that came back on the old commit as failed', async () => {
const git = fakeGit();
const { updates, db } = service({
git,
launcher: { supported: true, restart: async () => {} },
restartDelayMs: 0,
});
await updates.check();
await updates.download();
await updates.install();
await updates.restart();
const reopened = new ConsoleUpdateService({
db,
version: '0.1.0',
runner: git.runner,
now: () => new Date('2026-09-06T00:05:00.000Z'),
});
const status = await reopened.status();
expect(status.phase).toBe('failed');
expect(status.error).toContain('目标版本');
});
it('rejects a second command while one is running', async () => {
const git = fakeGit();
let release: () => void = () => {};
const gate = new Promise<void>((resolve) => {
release = resolve;
});
const original = git.runner.run.bind(git.runner);
git.runner = {
run: async (command, args, options) => {
if (command === 'git' && args[0] === 'ls-remote') await gate;
return original(command, args, options);
},
};
const { updates } = service({ git });
const first = updates.check();
await expect(updates.check()).rejects.toMatchObject({ code: 'BUSY' });
release();
await first;
});
it('keeps a corrupt stored state readable', async () => {
const { db, updates } = service();
db.prepare(
`INSERT INTO app_settings (key,value_json,created_at,updated_at)
VALUES ('console.update','{not json',?,?)`,
).run('2026-09-06T00:00:00.000Z', '2026-09-06T00:00:00.000Z');
const status = await updates.status();
expect(status.phase).toBe('idle');
});
});
describe('createLaunchdRestartLauncher', () => {
it('is unsupported without a launchd label', () => {
const launcher = createLaunchdRestartLauncher({
env: {},
runner: { run: async () => ({ code: 0, stdout: '', stderr: '' }) },
uid: () => 501,
});
expect(launcher.supported).toBe(false);
});
it('kickstarts its own launchd label', async () => {
const calls: string[][] = [];
const launcher = createLaunchdRestartLauncher({
env: { XPC_SERVICE_NAME: 'fun.chickliu.multi-simadmin-api' },
runner: {
run: async (command, args) => {
calls.push([command, ...args]);
return { code: 0, stdout: '', stderr: '' };
},
},
uid: () => 501,
});
expect(launcher.supported).toBe(true);
await launcher.restart();
expect(calls[0]).toEqual([
'launchctl',
'kickstart',
'-k',
'gui/501/fun.chickliu.multi-simadmin-api',
]);
});
it('surfaces a launchctl failure', async () => {
const launcher = createLaunchdRestartLauncher({
env: { XPC_SERVICE_NAME: 'fun.chickliu.multi-simadmin-api' },
runner: { run: async () => ({ code: 1, stdout: '', stderr: 'Could not find service' }) },
uid: () => 501,
});
await expect(launcher.restart()).rejects.toMatchObject({ code: 'COMMAND', statusCode: 500 });
});
});
@@ -0,0 +1,736 @@
import { spawn } from 'node:child_process';
import type Database from 'better-sqlite3';
/**
* Online update for the console itself, modelled on the Hub update card: check a remote
* branch, fetch and verify the candidate, install it, then hand the process back to the
* supervisor. Each step is a separate, operator-visible phase, so a half-finished update stays
* readable after a crash instead of silently half-applied.
*/
export const UPDATE_PHASES = [
'idle',
'checking',
'up_to_date',
'update_available',
'downloading',
'ready',
'installing',
'install_queued',
'restarting',
'failed',
'rolled_back',
] as const;
export type UpdatePhase = (typeof UPDATE_PHASES)[number];
/** Phases where work is in flight: the UI polls these fast and refuses a second command. */
export const BUSY_UPDATE_PHASES: ReadonlySet<UpdatePhase> = new Set<UpdatePhase>([
'checking',
'downloading',
'installing',
'install_queued',
'restarting',
]);
export interface UpdateRelease {
readonly status: 'latest' | 'update_available' | 'unavailable';
readonly remote: string;
readonly branch: string;
readonly currentCommit: string;
readonly latestCommit: string;
readonly currentVersion: string;
readonly latestVersion: string;
readonly ahead: number;
readonly changedFiles: number;
readonly changedLines: number;
readonly summary: string;
}
export interface UpdateStatus {
readonly phase: UpdatePhase;
readonly message: string;
readonly error: string;
readonly progressPercent: number;
readonly checkedAt: string;
readonly deploymentMode: 'git' | 'manual';
readonly installSupported: boolean;
readonly restartSupported: boolean;
readonly release: UpdateRelease | null;
}
export interface UpdateCommandResult {
readonly code: number;
readonly stdout: string;
readonly stderr: string;
}
export interface UpdateCommandRunner {
run(
command: string,
args: readonly string[],
options?: { readonly timeoutMs?: number },
): Promise<UpdateCommandResult>;
}
export interface RestartLauncher {
readonly supported: boolean;
restart(): Promise<void>;
}
export class UpdateError extends Error {
constructor(
readonly code: 'BUSY' | 'NOT_SUPPORTED' | 'STATE' | 'COMMAND',
message: string,
readonly statusCode = 409,
) {
super(message);
this.name = 'UpdateError';
}
}
const STATE_KEY = 'console.update';
const DEFAULT_TIMEOUT_MS = 120_000;
const INSTALL_TIMEOUT_MS = 15 * 60_000;
const MAX_OUTPUT = 4 * 1024 * 1024;
const COMMIT = /^[0-9a-f]{7,40}$/u;
type PendingAction = 'install' | 'restart' | '';
interface PersistedState {
readonly phase: UpdatePhase;
readonly message: string;
readonly error: string;
readonly progressPercent: number;
readonly checkedAt: string;
readonly release: UpdateRelease | null;
/** Where the update is aimed; lets a restarted process confirm its own success. */
readonly pendingCommit: string;
readonly pendingAction: PendingAction;
}
function text(value: unknown, maximum = 512): string {
return typeof value === 'string' ? value.slice(0, maximum) : '';
}
function count(value: unknown): number {
return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : 0;
}
function percent(value: unknown): number {
return typeof value === 'number' && Number.isFinite(value)
? Math.min(100, Math.max(0, Math.round(value)))
: 0;
}
function phaseOf(value: unknown): UpdatePhase {
return typeof value === 'string' && (UPDATE_PHASES as readonly string[]).includes(value)
? (value as UpdatePhase)
: 'idle';
}
function readRelease(value: unknown): UpdateRelease | null {
if (typeof value !== 'object' || value === null || Array.isArray(value)) return null;
const source = value as Record<string, unknown>;
const status = source.status;
return {
status:
status === 'latest' || status === 'update_available' || status === 'unavailable'
? status
: 'unavailable',
remote: text(source.remote, 256),
branch: text(source.branch, 128),
currentCommit: text(source.currentCommit, 40),
latestCommit: text(source.latestCommit, 40),
currentVersion: text(source.currentVersion, 64),
latestVersion: text(source.latestVersion, 64),
ahead: count(source.ahead),
changedFiles: count(source.changedFiles),
changedLines: count(source.changedLines),
summary: text(source.summary, 512),
};
}
/** `2 files changed, 34 insertions(+), 6 deletions(-)` from `git diff --shortstat`. */
export function parseShortStat(value: string): { files: number; lines: number } {
const files = Number(/(\d+) files? changed/u.exec(value)?.[1] ?? 0);
const insertions = Number(/(\d+) insertions?\(\+\)/u.exec(value)?.[1] ?? 0);
const deletions = Number(/(\d+) deletions?\(-\)/u.exec(value)?.[1] ?? 0);
return {
files: Number.isFinite(files) ? files : 0,
lines:
(Number.isFinite(insertions) ? insertions : 0) + (Number.isFinite(deletions) ? deletions : 0),
};
}
/** Reads the version field out of a package.json blob fetched from a commit. */
export function parseManifestVersion(value: string): string {
try {
const parsed: unknown = JSON.parse(value);
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
const version = (parsed as Record<string, unknown>).version;
if (typeof version === 'string' && version.length <= 64) return version;
}
} catch {
// A malformed manifest simply means there is no version to show.
}
return '';
}
export function createCommandRunner(options: {
readonly cwd: string;
readonly timeoutMs?: number;
}): UpdateCommandRunner {
return {
run(command, args, runOptions) {
return new Promise((resolve) => {
const child = spawn(command, [...args], { cwd: options.cwd, shell: false });
let stdout = '';
let stderr = '';
const timer = setTimeout(
() => child.kill('SIGKILL'),
runOptions?.timeoutMs ?? options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
);
child.stdout.on('data', (chunk: Buffer) => {
if (stdout.length < MAX_OUTPUT) stdout += chunk.toString('utf8');
});
child.stderr.on('data', (chunk: Buffer) => {
if (stderr.length < MAX_OUTPUT) stderr += chunk.toString('utf8');
});
child.once('error', (error: Error) => {
clearTimeout(timer);
resolve({ code: -1, stdout, stderr: `${stderr}${error.message}` });
});
child.once('close', (code) => {
clearTimeout(timer);
resolve({ code: code ?? -1, stdout, stderr });
});
});
},
};
}
/**
* macOS LaunchAgents learn their own label through XPC_SERVICE_NAME, which is the only
* dependable hint that this process is supervised and will be started again after it exits.
*/
export function createLaunchdRestartLauncher(input: {
readonly env: Readonly<Record<string, string | undefined>>;
readonly runner: UpdateCommandRunner;
readonly uid?: () => number | undefined;
}): RestartLauncher {
const label = text(input.env.XPC_SERVICE_NAME, 256);
const uid =
input.uid?.() ?? (typeof process.getuid === 'function' ? process.getuid() : undefined);
const supported = label.length > 0 && typeof uid === 'number';
return {
supported,
async restart() {
if (!supported) throw new UpdateError('NOT_SUPPORTED', '当前进程未受服务管理器托管。', 501);
const result = await input.runner.run('launchctl', [
'kickstart',
'-k',
`gui/${uid}/${label}`,
]);
if (result.code !== 0)
throw new UpdateError(
'COMMAND',
text(result.stderr, 200) || 'launchctl kickstart 失败。',
500,
);
},
};
}
export interface ConsoleUpdateServiceOptions {
readonly db: Database.Database;
readonly version: string;
readonly remote?: string;
readonly runner?: UpdateCommandRunner;
readonly launcher?: RestartLauncher;
readonly now?: () => Date;
/**
* Safety net run before the checkout moves: a new version may migrate the database in a way
* that cannot be undone by rolling the code back, so the config is snapshotted first.
*/
readonly preInstallBackup?: (targetCommit: string) => Promise<string>;
/** Test hook: the real hand-off waits a beat so the HTTP response can flush first. */
readonly restartDelayMs?: number;
}
export class ConsoleUpdateService {
readonly #db: Database.Database;
readonly #version: string;
readonly #remote: string;
readonly #runner: UpdateCommandRunner;
readonly #launcher: RestartLauncher;
readonly #now: () => Date;
readonly #preInstallBackup: ((targetCommit: string) => Promise<string>) | undefined;
readonly #restartDelayMs: number;
#state: PersistedState;
#inFlight: Promise<UpdateStatus> | undefined;
#mode: 'unknown' | 'git' | 'manual' = 'unknown';
#reconciled = false;
constructor(options: ConsoleUpdateServiceOptions) {
this.#db = options.db;
this.#version = options.version;
this.#remote = options.remote ?? 'origin';
this.#runner =
options.runner ?? createCommandRunner({ cwd: process.cwd(), timeoutMs: DEFAULT_TIMEOUT_MS });
this.#launcher = options.launcher ?? { supported: false, async restart() {} };
this.#now = options.now ?? (() => new Date());
this.#preInstallBackup = options.preInstallBackup;
this.#restartDelayMs = options.restartDelayMs ?? 250;
this.#state = this.#read();
}
/** Resolves the deployment mode, because a plain status poll must not shell out to git. */
async status(): Promise<UpdateStatus> {
if (this.#mode === 'unknown') await this.#localHead();
await this.#reconcile();
return this.#view();
}
async check(): Promise<UpdateStatus> {
return this.#exclusive(async () => {
const head = await this.#localHead();
if (!head) return this.#view();
const branch = (await this.#text(['rev-parse', '--abbrev-ref', 'HEAD'])).trim();
const remoteUrl = (
await this.#text(['config', '--get', `remote.${this.#remote}.url`])
).trim();
if (!branch || branch === 'HEAD')
throw new UpdateError('STATE', '当前处于游离提交,请先切回分支再检查更新。', 409);
if (!remoteUrl)
throw new UpdateError('STATE', `未配置远端 ${this.#remote},无法检查更新。`, 409);
const listing = await this.#git(['ls-remote', remoteUrl, `refs/heads/${branch}`]);
if (listing.code !== 0)
throw new UpdateError('COMMAND', '无法连接更新源,请检查网络后重试。', 502);
const latest = text(listing.stdout).trim().split(/\s+/u)[0];
if (!latest || !COMMIT.test(latest))
throw new UpdateError('COMMAND', `远端分支 ${branch} 不存在或不可读。`, 502);
// A different SHA is not enough: a checkout that already carries the remote commit is
// ahead of the update source, not behind it. Only an ancestry test tells the two apart.
const behind = latest !== head && !(await this.#contains(latest));
const ahead = behind
? 0
: latest === head
? 0
: Number(await this.#text(['rev-list', '--count', `${latest}..HEAD`]));
const upToDate = !behind;
const release: UpdateRelease = {
status: upToDate ? 'latest' : 'update_available',
remote: remoteUrl,
branch,
currentCommit: head,
latestCommit: latest,
currentVersion: this.#version,
latestVersion: '',
ahead: behind ? 0 : Number.isFinite(ahead) ? ahead : 0,
changedFiles: 0,
changedLines: 0,
summary: behind
? '下载更新包后可以看到提交数量与改动规模。'
: upToDate && latest !== head
? `本地已包含 ${remoteUrl}/${branch} 的全部提交。`
: '',
};
this.#save({
phase: upToDate ? 'up_to_date' : 'update_available',
message: upToDate
? latest === head
? `当前已是最新版本(${head.slice(0, 7)})。`
: `本地版本已领先更新源 ${Number.isFinite(ahead) ? ahead : 0} 个提交,无需更新。`
: `发现新版本 ${latest.slice(0, 7)},可以下载更新包。`,
error: '',
progressPercent: 100,
release,
pendingCommit: '',
pendingAction: '',
});
return this.#view();
});
}
async download(): Promise<UpdateStatus> {
return this.#exclusive(async () => {
const release = this.#requireCandidate();
this.#save({
phase: 'downloading',
message: '正在下载并校验更新包…',
error: '',
progressPercent: 35,
release,
pendingCommit: release.latestCommit,
pendingAction: '',
});
const fetched = await this.#git([
'fetch',
'--quiet',
'--no-tags',
release.remote,
release.branch,
]);
if (fetched.code !== 0) {
this.#fail('下载更新包失败,请检查网络后重试。');
throw new UpdateError('COMMAND', '下载更新包失败,请检查网络后重试。', 502);
}
const candidate = (await this.#text(['rev-parse', 'FETCH_HEAD'])).trim();
if (candidate !== release.latestCommit) {
this.#fail('远端分支已变化,请重新检查更新。');
throw new UpdateError('STATE', '远端分支已变化,请重新检查更新。', 409);
}
const ancestor = await this.#git(['merge-base', '--is-ancestor', 'HEAD', 'FETCH_HEAD']);
if (ancestor.code !== 0) {
this.#fail('本地分支已偏离远端,无法快进合并,请先处理本地提交。');
return this.#view();
}
const ahead = Number(await this.#text(['rev-list', '--count', 'HEAD..FETCH_HEAD']));
const stat = parseShortStat(await this.#text(['diff', '--shortstat', 'HEAD', 'FETCH_HEAD']));
const manifest = parseManifestVersion(await this.#text(['show', 'FETCH_HEAD:package.json']));
const commits = Number.isFinite(ahead) ? ahead : 0;
const updated: UpdateRelease = {
...release,
latestVersion: manifest,
ahead: commits,
changedFiles: stat.files,
changedLines: stat.lines,
summary: `领先 ${commits} 个提交,${stat.files} 个文件、${stat.lines} 行改动。`,
};
this.#save({
phase: 'ready',
message: `更新包已校验:${updated.summary}`,
error: '',
progressPercent: 100,
release: updated,
pendingCommit: updated.latestCommit,
pendingAction: '',
});
return this.#view();
});
}
async install(): Promise<UpdateStatus> {
return this.#exclusive(async () => {
const release = this.#requireCandidate();
if (this.#state.phase !== 'ready' && this.#state.phase !== 'rolled_back')
throw new UpdateError('STATE', '请先下载并校验更新包。', 409);
// The rollback below resets the working tree, so it is only ever allowed to start from a
// clean one; otherwise an operator's uncommitted work would be the first casualty.
if ((await this.#text(['status', '--porcelain'])).trim())
throw new UpdateError('STATE', '工作区存在未提交改动,为避免丢失内容已中止安装。', 409);
const previousCommit = await this.#localHead();
if (!previousCommit) return this.#view();
if (this.#preInstallBackup) {
this.#save({
phase: 'installing',
message: '正在创建更新前备份…',
error: '',
progressPercent: 5,
release,
pendingCommit: '',
pendingAction: '',
});
try {
await this.#preInstallBackup(release.latestCommit);
} catch {
this.#fail('更新前备份失败,已中止安装,代码保持原版本。');
throw new UpdateError('COMMAND', '更新前备份失败,已中止安装。', 500);
}
}
this.#save({
phase: 'installing',
message: '正在安装更新…',
error: '',
progressPercent: 20,
release,
pendingCommit: release.latestCommit,
pendingAction: 'install',
});
const merge = await this.#git(['merge', '--ff-only', release.latestCommit]);
if (merge.code !== 0) {
this.#fail('快进合并失败,代码保持原版本。');
throw new UpdateError('COMMAND', '快进合并失败,代码保持原版本。', 500);
}
this.#progress(55, '依赖安装中…');
const dependencies = await this.#runner.run(
'corepack',
['pnpm', 'install', '--frozen-lockfile'],
{ timeoutMs: INSTALL_TIMEOUT_MS },
);
if (dependencies.code !== 0) {
await this.#rollback(previousCommit, '依赖安装失败,已回滚到原版本。');
throw new UpdateError('COMMAND', '依赖安装失败,已回滚到原版本。', 500);
}
this.#progress(80, '前端构建中…');
const build = await this.#runner.run(
'corepack',
['pnpm', '--filter', '@multi-simadmin/web', 'build'],
{ timeoutMs: INSTALL_TIMEOUT_MS },
);
if (build.code !== 0) {
await this.#rollback(previousCommit, '前端构建失败,已回滚到原版本。');
throw new UpdateError('COMMAND', '前端构建失败,已回滚到原版本。', 500);
}
this.#save({
phase: 'install_queued',
message: '安装完成,重启后生效。',
error: '',
progressPercent: 100,
release,
pendingCommit: release.latestCommit,
pendingAction: 'install',
});
return this.#view();
});
}
/**
* Hands the process back to the supervisor. The reply flushes first: killing the API in the
* middle of a request leaves the browser with a connection error instead of a result.
*/
async restart(): Promise<UpdateStatus> {
if (!this.#launcher.supported)
throw new UpdateError('NOT_SUPPORTED', '当前部署方式不支持在线重启,请手动重启服务。', 501);
if (this.#inFlight) throw new UpdateError('BUSY', '更新任务正在进行中。', 409);
const installing = this.#state.phase === 'install_queued';
this.#save({
phase: 'restarting',
message: '服务正在重启,页面连接将短暂中断。',
error: '',
progressPercent: 100,
release: this.#state.release,
pendingCommit: installing ? this.#state.pendingCommit : '',
pendingAction: installing ? 'install' : 'restart',
});
setTimeout(() => {
void this.#launcher.restart().catch(() => {
this.#save({
phase: 'failed',
message: '重启请求未能交给服务管理器。',
error: '重启请求未能交给服务管理器,请手动重启服务。',
progressPercent: 100,
release: this.#state.release,
pendingCommit: '',
pendingAction: '',
});
});
}, this.#restartDelayMs);
return this.#view();
}
async #exclusive(action: () => Promise<UpdateStatus>): Promise<UpdateStatus> {
if (this.#inFlight) throw new UpdateError('BUSY', '更新任务正在进行中,请稍候。', 409);
const pending = (async () => {
try {
return await action();
} finally {
this.#inFlight = undefined;
}
})();
this.#inFlight = pending;
return pending;
}
#requireCandidate(): UpdateRelease {
const release = this.#state.release;
if (!release || release.status !== 'update_available' || !COMMIT.test(release.latestCommit))
throw new UpdateError('STATE', '请先检查更新。', 409);
return release;
}
async #localHead(): Promise<string> {
const result = await this.#git(['rev-parse', 'HEAD']);
const head = result.stdout.trim();
if (result.code !== 0 || !COMMIT.test(head)) {
this.#mode = 'manual';
this.#save({
phase: 'idle',
message: '当前部署不是 Git 工作副本,请按安装文档手动更新后重启。',
error: '',
progressPercent: 0,
release: null,
pendingCommit: '',
pendingAction: '',
});
return '';
}
this.#mode = 'git';
return head;
}
async #git(args: readonly string[]): Promise<UpdateCommandResult> {
return this.#runner.run('git', args);
}
/** True when HEAD already carries `commit`. A missing object reads as "not contained". */
async #contains(commit: string): Promise<boolean> {
if (!COMMIT.test(commit)) return false;
const probe = await this.#git(['merge-base', '--is-ancestor', commit, 'HEAD']);
return probe.code === 0;
}
async #text(args: readonly string[]): Promise<string> {
const result = await this.#git(args);
return result.code === 0 ? text(result.stdout) : '';
}
#progress(progressPercent: number, message: string): void {
this.#save({
phase: 'installing',
message,
error: '',
progressPercent,
release: this.#state.release,
pendingCommit: this.#state.pendingCommit,
pendingAction: 'install',
});
}
async #rollback(previousCommit: string, message: string): Promise<void> {
await this.#git(['reset', '--hard', previousCommit]);
this.#save({
phase: 'rolled_back',
message,
error: message,
progressPercent: 100,
release: this.#state.release,
pendingCommit: '',
pendingAction: '',
});
}
#fail(message: string): void {
this.#save({
phase: 'failed',
message,
error: message,
progressPercent: this.#state.progressPercent,
release: this.#state.release,
pendingCommit: '',
pendingAction: '',
});
}
/**
* A restarted process has no memory of the install. When the recorded target commit is now
* HEAD, the update landed, and that is the confirmation the UI has been waiting for.
*/
async #reconcile(): Promise<void> {
if (this.#reconciled) return;
this.#reconciled = true;
const state = this.#state;
if (!state.pendingAction || !BUSY_UPDATE_PHASES.has(state.phase)) return;
const head = await this.#localHead();
if (!head) return;
if (state.pendingAction === 'install' && head !== state.pendingCommit) {
this.#save({
...state,
phase: 'failed',
message: '重启后未看到目标版本,安装可能未完成。',
error: '重启后未看到目标版本,请查看服务日志。',
pendingCommit: '',
pendingAction: '',
});
return;
}
this.#save({
...state,
phase: 'up_to_date',
message:
state.pendingAction === 'install'
? `已更新到 ${head.slice(0, 7)} 并完成重启。`
: '服务已重启完成。',
error: '',
progressPercent: 100,
release: state.release ? { ...state.release, status: 'latest', currentCommit: head } : null,
pendingCommit: '',
pendingAction: '',
});
}
#view(): UpdateStatus {
const git = this.#mode === 'git';
return {
phase: this.#state.phase,
message: this.#state.message,
error: this.#state.error,
progressPercent: this.#state.progressPercent,
checkedAt: this.#state.checkedAt,
deploymentMode: this.#mode === 'unknown' ? 'git' : this.#mode,
installSupported: git,
restartSupported: this.#launcher.supported,
release: this.#state.release,
};
}
#emptyState(message: string): PersistedState {
return {
phase: 'idle',
message,
error: '',
progressPercent: 0,
checkedAt: '',
release: null,
pendingCommit: '',
pendingAction: '',
};
}
#read(): PersistedState {
let row: { value_json?: string | null } | undefined;
try {
row = this.#db.prepare('SELECT value_json FROM app_settings WHERE key = ?').get(STATE_KEY) as
| { value_json?: string | null }
| undefined;
} catch {
row = undefined;
}
if (!row?.value_json) return this.#emptyState('尚未检查更新。');
try {
const parsed: unknown = JSON.parse(row.value_json);
const source =
parsed && typeof parsed === 'object' && !Array.isArray(parsed)
? (parsed as Record<string, unknown>)
: {};
const pendingAction = source.pendingAction;
return {
phase: phaseOf(source.phase),
message: text(source.message, 256),
error: text(source.error, 256),
progressPercent: percent(source.progressPercent),
checkedAt: text(source.checkedAt, 64),
release: readRelease(source.release),
pendingCommit: text(source.pendingCommit, 40),
pendingAction:
pendingAction === 'install' || pendingAction === 'restart' ? pendingAction : '',
};
} catch {
return this.#emptyState('尚未检查更新。');
}
}
#save(input: Omit<PersistedState, 'checkedAt'>): void {
const now = this.#now().toISOString();
this.#state = { ...input, checkedAt: now };
try {
this.#db
.prepare(
`INSERT INTO app_settings (key,value_json,created_at,updated_at)
VALUES (?,?,?,?)
ON CONFLICT(key) DO UPDATE SET value_json = excluded.value_json,
updated_at = excluded.updated_at`,
)
.run(STATE_KEY, JSON.stringify(this.#state), now, now);
} catch {
// A read-only database must not break the update flow; the in-memory state still leads.
}
}
}
@@ -0,0 +1,123 @@
import Fastify from 'fastify';
import { afterEach, describe, expect, it } from 'vitest';
import {
UpdateError,
type ConsoleUpdateService,
type UpdateStatus,
} from '../../application/system/console-update-service.js';
import { registerUpdateRoutes } from './update-routes.js';
let app: ReturnType<typeof Fastify> | undefined;
function baseStatus(overrides: Partial<UpdateStatus> = {}): UpdateStatus {
return {
phase: 'update_available',
message: '发现新版本 1b2c3d4,可以下载更新包。',
error: '',
progressPercent: 100,
checkedAt: '2026-09-06T00:00:00.000Z',
deploymentMode: 'git',
installSupported: true,
restartSupported: true,
release: {
status: 'update_available',
remote: 'https://example.test/repo.git',
branch: 'main',
currentCommit: 'a'.repeat(40),
latestCommit: 'b'.repeat(40),
currentVersion: '0.1.0',
latestVersion: '',
ahead: 0,
changedFiles: 0,
changedLines: 0,
summary: '',
},
...overrides,
};
}
function mount(partial: Partial<Record<keyof ConsoleUpdateService, () => Promise<unknown>>>) {
const instance = Fastify();
registerUpdateRoutes(instance, {
updates: partial as unknown as ConsoleUpdateService,
});
app = instance;
return instance;
}
afterEach(async () => {
await app?.close();
app = undefined;
});
describe('update routes', () => {
it('exposes the four update commands and the restart', async () => {
const calls: string[] = [];
const updates = {
status: async () => {
calls.push('status');
return baseStatus();
},
check: async () => {
calls.push('check');
return baseStatus();
},
download: async () => {
calls.push('download');
return baseStatus({ phase: 'ready' });
},
install: async () => {
calls.push('install');
return baseStatus({ phase: 'install_queued' });
},
restart: async () => {
calls.push('restart');
return baseStatus({ phase: 'restarting' });
},
};
const instance = Fastify();
registerUpdateRoutes(instance, { updates: updates as unknown as ConsoleUpdateService });
app = instance;
const routes = [
['GET', '/api/v1/system/update'],
['POST', '/api/v1/system/update/check'],
['POST', '/api/v1/system/update/download'],
['POST', '/api/v1/system/update/install'],
['POST', '/api/v1/system/restart'],
] as const;
for (const [method, url] of routes) {
const response = await instance.inject({ method, url });
expect(response.statusCode).toBe(200);
expect(response.json().phase).toBeTruthy();
}
expect(calls).toEqual(['status', 'check', 'download', 'install', 'restart']);
});
it('maps an update error onto a problem document', async () => {
const instance = mount({
restart: async () => {
throw new UpdateError('NOT_SUPPORTED', '当前部署方式不支持在线重启。', 501);
},
});
const response = await instance.inject({ method: 'POST', url: '/api/v1/system/restart' });
expect(response.statusCode).toBe(501);
expect(response.headers['content-type']).toContain('application/problem+json');
expect(response.json()).toMatchObject({
code: 'UPDATE_NOT_SUPPORTED',
title: 'Not Implemented',
detail: '当前部署方式不支持在线重启。',
});
});
it('keeps an unexpected failure on the global error handler', async () => {
const instance = mount({
status: async () => {
throw new Error('boom');
},
});
const response = await instance.inject({ method: 'GET', url: '/api/v1/system/update' });
expect(response.statusCode).toBe(500);
});
});
@@ -0,0 +1,85 @@
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import {
UpdateError,
type ConsoleUpdateService,
} from '../../application/system/console-update-service.js';
export interface UpdateRoutesOptions {
readonly updates: ConsoleUpdateService;
}
function problem(
reply: FastifyReply,
request: FastifyRequest,
status: number,
code: string,
title: string,
detail: string,
) {
return reply.code(status).type('application/problem+json').send({
type: 'about:blank',
title,
status,
code,
detail,
requestId: request.id,
});
}
/**
* The console's own update surface, mirroring the Hub: check, download, install, restart.
* Every action answers with the full status so the client never has to guess the next phase.
*/
export function registerUpdateRoutes(app: FastifyInstance, options: UpdateRoutesOptions): void {
const wrap =
<T>(action: () => Promise<T>) =>
async (request: FastifyRequest, reply: FastifyReply) => {
try {
return await action();
} catch (error) {
if (error instanceof UpdateError) {
const title =
error.statusCode === 501
? 'Not Implemented'
: error.statusCode === 502
? 'Bad Gateway'
: error.statusCode === 500
? 'Internal Server Error'
: error.statusCode === 409
? 'Conflict'
: 'Bad Request';
return problem(
reply,
request,
error.statusCode,
`UPDATE_${error.code}`,
title,
error.message,
);
}
throw error;
}
};
app.get(
'/api/v1/system/update',
wrap(async () => options.updates.status()),
);
app.post(
'/api/v1/system/update/check',
wrap(() => options.updates.check()),
);
app.post(
'/api/v1/system/update/download',
wrap(() => options.updates.download()),
);
app.post(
'/api/v1/system/update/install',
wrap(() => options.updates.install()),
);
app.post(
'/api/v1/system/restart',
wrap(() => options.updates.restart()),
);
}
+15
View File
@@ -4,6 +4,10 @@ import {
type ControlPlaneApp, type ControlPlaneApp,
type SafeControlPlaneUpstream, type SafeControlPlaneUpstream,
} from './control-plane.js'; } from './control-plane.js';
import {
createCommandRunner,
createLaunchdRestartLauncher,
} from './application/system/console-update-service.js';
import { openDatabase } from './infrastructure/database/database.js'; import { openDatabase } from './infrastructure/database/database.js';
import { migrateDatabase } from './infrastructure/database/migrations.js'; import { migrateDatabase } from './infrastructure/database/migrations.js';
import { MacOSKeychainSecretStore } from './infrastructure/secrets/keychain-secret-store.js'; import { MacOSKeychainSecretStore } from './infrastructure/secrets/keychain-secret-store.js';
@@ -23,6 +27,8 @@ export interface ProductionControlPlaneOptions {
readonly upstreamGatewayCheck?: () => boolean | Promise<boolean>; readonly upstreamGatewayCheck?: () => boolean | Promise<boolean>;
/** Production keeps device online state fresh on its own; tests and canaries can opt out. */ /** Production keeps device online state fresh on its own; tests and canaries can opt out. */
readonly heartbeatEnabled?: boolean; readonly heartbeatEnabled?: boolean;
/** Environment used to find the launchd label that can restart this process. */
readonly env?: Readonly<Record<string, string | undefined>>;
} }
export function buildProductionControlPlane( export function buildProductionControlPlane(
@@ -39,6 +45,9 @@ export function buildProductionControlPlane(
try { try {
migrateDatabase(db); migrateDatabase(db);
const store = options.store ?? new MacOSKeychainSecretStore(); const store = options.store ?? new MacOSKeychainSecretStore();
const env = options.env ?? process.env;
const updateRoot = env.MULTI_SIMADMIN_UPDATE_ROOT ?? process.cwd();
const updateRunner = createCommandRunner({ cwd: updateRoot, timeoutMs: 120_000 });
const app = buildControlPlaneApp({ const app = buildControlPlaneApp({
db, db,
store, store,
@@ -46,6 +55,12 @@ export function buildProductionControlPlane(
runtimeVersion: options.runtimeVersion ?? '0.1.0', runtimeVersion: options.runtimeVersion ?? '0.1.0',
backupDirectory: join(dirname(options.databasePath), 'backups'), backupDirectory: join(dirname(options.databasePath), 'backups'),
heartbeatEnabled: options.heartbeatEnabled ?? true, heartbeatEnabled: options.heartbeatEnabled ?? true,
update: {
remote: env.MULTI_SIMADMIN_UPDATE_REMOTE ?? 'origin',
root: updateRoot,
runner: updateRunner,
launcher: createLaunchdRestartLauncher({ env, runner: updateRunner }),
},
// This route-local decision is safe only in this composition: when a token is // This route-local decision is safe only in this composition: when a token is
// configured, buildApp's global onRequest hook rejects the request first. // configured, buildApp's global onRequest hook rejects the request first.
// Keep all other control-plane compositions fail-closed by default. // Keep all other control-plane compositions fail-closed by default.