diff --git a/apps/api/src/application/system/console-update-service.test.ts b/apps/api/src/application/system/console-update-service.test.ts new file mode 100644 index 0000000..b5e0e59 --- /dev/null +++ b/apps/api/src/application/system/console-update-service.test.ts @@ -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; +} + +/** Matches a `command arg ...` key against the configured failing prefixes. */ +function fails(patterns: ReadonlySet, 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> = {}): FakeGit { + const state: FakeGit = { + calls: [], + head: overrides.head ?? LOCAL, + remoteHead: overrides.remoteHead ?? REMOTE, + dirty: '', + fastForwardable: true, + containsRemote: false, + failing: new Set(), + runner: { run: async () => ({ code: 0, stdout: '', stderr: '' }) }, + }; + const run = async (command: string, args: readonly string[]): Promise => { + 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; ` 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 }; + readonly restartDelayMs?: number; + readonly preInstallBackup?: (targetCommit: string) => Promise; + } = {}, +) { + 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((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 }); + }); +}); diff --git a/apps/api/src/application/system/console-update-service.ts b/apps/api/src/application/system/console-update-service.ts new file mode 100644 index 0000000..76aa8c0 --- /dev/null +++ b/apps/api/src/application/system/console-update-service.ts @@ -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 = new Set([ + '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; +} + +export interface RestartLauncher { + readonly supported: boolean; + restart(): Promise; +} + +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; + 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).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>; + 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; + /** 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) | undefined; + readonly #restartDelayMs: number; + #state: PersistedState; + #inFlight: Promise | 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 { + if (this.#mode === 'unknown') await this.#localHead(); + await this.#reconcile(); + return this.#view(); + } + + async check(): Promise { + 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 { + 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 { + 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 { + 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): Promise { + 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 { + 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 { + return this.#runner.run('git', args); + } + + /** True when HEAD already carries `commit`. A missing object reads as "not contained". */ + async #contains(commit: string): Promise { + 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 { + 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 { + 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 { + 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) + : {}; + 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): 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. + } + } +} diff --git a/apps/api/src/interface/http/update-routes.test.ts b/apps/api/src/interface/http/update-routes.test.ts new file mode 100644 index 0000000..1389aed --- /dev/null +++ b/apps/api/src/interface/http/update-routes.test.ts @@ -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 | undefined; + +function baseStatus(overrides: Partial = {}): 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 Promise>>) { + 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); + }); +}); diff --git a/apps/api/src/interface/http/update-routes.ts b/apps/api/src/interface/http/update-routes.ts new file mode 100644 index 0000000..e315e34 --- /dev/null +++ b/apps/api/src/interface/http/update-routes.ts @@ -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 = + (action: () => Promise) => + 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()), + ); +} diff --git a/apps/api/src/production-control-plane.ts b/apps/api/src/production-control-plane.ts index df1791c..dc9e87d 100644 --- a/apps/api/src/production-control-plane.ts +++ b/apps/api/src/production-control-plane.ts @@ -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; /** 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>; } 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.