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:
@@ -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()),
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,10 @@ import {
|
||||
type ControlPlaneApp,
|
||||
type SafeControlPlaneUpstream,
|
||||
} from './control-plane.js';
|
||||
import {
|
||||
createCommandRunner,
|
||||
createLaunchdRestartLauncher,
|
||||
} from './application/system/console-update-service.js';
|
||||
import { openDatabase } from './infrastructure/database/database.js';
|
||||
import { migrateDatabase } from './infrastructure/database/migrations.js';
|
||||
import { MacOSKeychainSecretStore } from './infrastructure/secrets/keychain-secret-store.js';
|
||||
@@ -23,6 +27,8 @@ export interface ProductionControlPlaneOptions {
|
||||
readonly upstreamGatewayCheck?: () => boolean | Promise<boolean>;
|
||||
/** Production keeps device online state fresh on its own; tests and canaries can opt out. */
|
||||
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(
|
||||
@@ -39,6 +45,9 @@ export function buildProductionControlPlane(
|
||||
try {
|
||||
migrateDatabase(db);
|
||||
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({
|
||||
db,
|
||||
store,
|
||||
@@ -46,6 +55,12 @@ export function buildProductionControlPlane(
|
||||
runtimeVersion: options.runtimeVersion ?? '0.1.0',
|
||||
backupDirectory: join(dirname(options.databasePath), 'backups'),
|
||||
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
|
||||
// configured, buildApp's global onRequest hook rejects the request first.
|
||||
// Keep all other control-plane compositions fail-closed by default.
|
||||
|
||||
Reference in New Issue
Block a user