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 ControlPlaneApp,
|
||||||
type SafeControlPlaneUpstream,
|
type SafeControlPlaneUpstream,
|
||||||
} from './control-plane.js';
|
} from './control-plane.js';
|
||||||
|
import {
|
||||||
|
createCommandRunner,
|
||||||
|
createLaunchdRestartLauncher,
|
||||||
|
} from './application/system/console-update-service.js';
|
||||||
import { openDatabase } from './infrastructure/database/database.js';
|
import { openDatabase } from './infrastructure/database/database.js';
|
||||||
import { migrateDatabase } from './infrastructure/database/migrations.js';
|
import { migrateDatabase } from './infrastructure/database/migrations.js';
|
||||||
import { MacOSKeychainSecretStore } from './infrastructure/secrets/keychain-secret-store.js';
|
import { MacOSKeychainSecretStore } from './infrastructure/secrets/keychain-secret-store.js';
|
||||||
@@ -23,6 +27,8 @@ export interface ProductionControlPlaneOptions {
|
|||||||
readonly upstreamGatewayCheck?: () => boolean | Promise<boolean>;
|
readonly upstreamGatewayCheck?: () => boolean | Promise<boolean>;
|
||||||
/** Production keeps device online state fresh on its own; tests and canaries can opt out. */
|
/** Production keeps device online state fresh on its own; tests and canaries can opt out. */
|
||||||
readonly heartbeatEnabled?: boolean;
|
readonly heartbeatEnabled?: boolean;
|
||||||
|
/** Environment used to find the launchd label that can restart this process. */
|
||||||
|
readonly env?: Readonly<Record<string, string | undefined>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildProductionControlPlane(
|
export function buildProductionControlPlane(
|
||||||
@@ -39,6 +45,9 @@ export function buildProductionControlPlane(
|
|||||||
try {
|
try {
|
||||||
migrateDatabase(db);
|
migrateDatabase(db);
|
||||||
const store = options.store ?? new MacOSKeychainSecretStore();
|
const store = options.store ?? new MacOSKeychainSecretStore();
|
||||||
|
const env = options.env ?? process.env;
|
||||||
|
const updateRoot = env.MULTI_SIMADMIN_UPDATE_ROOT ?? process.cwd();
|
||||||
|
const updateRunner = createCommandRunner({ cwd: updateRoot, timeoutMs: 120_000 });
|
||||||
const app = buildControlPlaneApp({
|
const app = buildControlPlaneApp({
|
||||||
db,
|
db,
|
||||||
store,
|
store,
|
||||||
@@ -46,6 +55,12 @@ export function buildProductionControlPlane(
|
|||||||
runtimeVersion: options.runtimeVersion ?? '0.1.0',
|
runtimeVersion: options.runtimeVersion ?? '0.1.0',
|
||||||
backupDirectory: join(dirname(options.databasePath), 'backups'),
|
backupDirectory: join(dirname(options.databasePath), 'backups'),
|
||||||
heartbeatEnabled: options.heartbeatEnabled ?? true,
|
heartbeatEnabled: options.heartbeatEnabled ?? true,
|
||||||
|
update: {
|
||||||
|
remote: env.MULTI_SIMADMIN_UPDATE_REMOTE ?? 'origin',
|
||||||
|
root: updateRoot,
|
||||||
|
runner: updateRunner,
|
||||||
|
launcher: createLaunchdRestartLauncher({ env, runner: updateRunner }),
|
||||||
|
},
|
||||||
// This route-local decision is safe only in this composition: when a token is
|
// This route-local decision is safe only in this composition: when a token is
|
||||||
// configured, buildApp's global onRequest hook rejects the request first.
|
// configured, buildApp's global onRequest hook rejects the request first.
|
||||||
// Keep all other control-plane compositions fail-closed by default.
|
// Keep all other control-plane compositions fail-closed by default.
|
||||||
|
|||||||
Reference in New Issue
Block a user