975 lines
33 KiB
TypeScript
975 lines
33 KiB
TypeScript
import { EventEmitter } from 'node:events';
|
|
import {
|
|
existsSync,
|
|
mkdirSync,
|
|
mkdtempSync,
|
|
readFileSync,
|
|
readdirSync,
|
|
rmSync,
|
|
writeFileSync,
|
|
} from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { delimiter, dirname, join } from 'node:path';
|
|
import type { ChildProcess, SpawnOptionsWithoutStdio } from 'node:child_process';
|
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
import { OpencodeManager } from '@electron/opencode/manager';
|
|
import {
|
|
ensureBundledSuperpowersPlugin,
|
|
resolveBundledSuperpowersDir,
|
|
} from '@electron/opencode/superpowers';
|
|
import { logger } from '@electron/utils/logger';
|
|
|
|
type SpawnCall = {
|
|
command: string;
|
|
args: string[];
|
|
options: SpawnOptionsWithoutStdio;
|
|
};
|
|
|
|
class FakeChildProcess extends EventEmitter {
|
|
readonly stdout = new EventEmitter();
|
|
readonly stderr = new EventEmitter();
|
|
readonly pid = 4242;
|
|
readonly kill = vi.fn(() => true);
|
|
}
|
|
|
|
function createSpawnHarness() {
|
|
const children: FakeChildProcess[] = [];
|
|
const calls: SpawnCall[] = [];
|
|
const spawn = vi.fn(
|
|
(command: string, args: string[], options: SpawnOptionsWithoutStdio): ChildProcess => {
|
|
const child = new FakeChildProcess();
|
|
children.push(child);
|
|
calls.push({ command, args, options });
|
|
return child as unknown as ChildProcess;
|
|
},
|
|
);
|
|
|
|
return { children, calls, spawn };
|
|
}
|
|
|
|
function createBundledSuperpowersFixture(): string {
|
|
const sourceDir = mkdtempSync(join(tmpdir(), 'niancode-superpowers-source-'));
|
|
writeFileSync(
|
|
join(sourceDir, 'package.json'),
|
|
JSON.stringify({ name: 'superpowers', version: '5.0.7' }),
|
|
);
|
|
writeFileSync(join(sourceDir, 'LICENSE'), 'source-license');
|
|
const pluginDir = join(sourceDir, '.opencode', 'plugins');
|
|
const skillDir = join(sourceDir, 'skills', 'using-superpowers');
|
|
mkdirSync(pluginDir, { recursive: true });
|
|
mkdirSync(skillDir, { recursive: true });
|
|
writeFileSync(
|
|
join(pluginDir, 'superpowers.js'),
|
|
'export const SuperpowersPlugin = async () => ({});\n',
|
|
);
|
|
writeFileSync(join(skillDir, 'SKILL.md'), '---\nname: using-superpowers\n---\n');
|
|
return sourceDir;
|
|
}
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
describe('OpencodeManager', () => {
|
|
it('resolves the packaged Superpowers source without depending on the managed destination name', () => {
|
|
const resourcesPath = 'C:\\Program Files\\NianCode\\resources';
|
|
|
|
expect(resolveBundledSuperpowersDir({
|
|
isPackaged: true,
|
|
resourcesPath,
|
|
appPath: 'C:\\Program Files\\NianCode\\resources\\app.asar',
|
|
})).toBe(join(resourcesPath, 'resources', 'skills', 'superpowers'));
|
|
});
|
|
|
|
it('spawns the bundled opencode server and becomes running after listening output', async () => {
|
|
const { children, calls, spawn } = createSpawnHarness();
|
|
const manager = new OpencodeManager({
|
|
port: 4321,
|
|
binPath: 'C:\\NianCode\\opencode.exe',
|
|
spawn,
|
|
});
|
|
|
|
const startPromise = manager.start();
|
|
children[0].stdout.emit(
|
|
'data',
|
|
Buffer.from('opencode server listening on http://127.0.0.1:4321\n'),
|
|
);
|
|
const status = await startPromise;
|
|
|
|
expect(spawn).toHaveBeenCalledOnce();
|
|
expect(calls[0]).toMatchObject({
|
|
command: 'C:\\NianCode\\opencode.exe',
|
|
args: ['serve', '--hostname=127.0.0.1', '--port=4321'],
|
|
options: {
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
windowsHide: true,
|
|
},
|
|
});
|
|
expect(status).toMatchObject({
|
|
state: 'running',
|
|
port: 4321,
|
|
url: 'http://127.0.0.1:4321',
|
|
pid: 4242,
|
|
});
|
|
expect(manager.getStatus()).toEqual(status);
|
|
expect(manager.getStatus()).not.toBe(status);
|
|
});
|
|
|
|
it('passes configProvider JSON through OPENCODE_CONFIG_CONTENT', async () => {
|
|
const { children, calls, spawn } = createSpawnHarness();
|
|
const config = { provider: { anthropic: { models: ['claude'] } } };
|
|
const manager = new OpencodeManager({
|
|
port: 4322,
|
|
binPath: '/opt/opencode',
|
|
configProvider: () => config,
|
|
spawn,
|
|
});
|
|
|
|
const startPromise = manager.start();
|
|
children[0].stdout.emit(
|
|
'data',
|
|
Buffer.from('opencode server listening on http://127.0.0.1:4322\n'),
|
|
);
|
|
await startPromise;
|
|
|
|
expect(calls[0].options.env).toMatchObject({
|
|
OPENCODE_CONFIG_CONTENT: JSON.stringify(config),
|
|
});
|
|
});
|
|
|
|
it('runs extensionless opencode package scripts through Node on Windows', async () => {
|
|
const { children, calls, spawn } = createSpawnHarness();
|
|
const manager = new OpencodeManager({
|
|
port: 4325,
|
|
binPath: 'C:\\NianCode\\resources\\opencode-ai\\bin\\opencode',
|
|
spawn,
|
|
});
|
|
|
|
const startPromise = manager.start();
|
|
children[0].stdout.emit(
|
|
'data',
|
|
Buffer.from('opencode server listening on http://127.0.0.1:4325\n'),
|
|
);
|
|
await startPromise;
|
|
|
|
if (process.platform === 'win32') {
|
|
expect(calls[0].command).toBe(process.execPath);
|
|
expect(calls[0].args).toEqual([
|
|
'C:\\NianCode\\resources\\opencode-ai\\bin\\opencode',
|
|
'serve',
|
|
'--hostname=127.0.0.1',
|
|
'--port=4325',
|
|
]);
|
|
expect(calls[0].options.env).toMatchObject({
|
|
ELECTRON_RUN_AS_NODE: '1',
|
|
});
|
|
} else {
|
|
expect(calls[0].command).toBe('C:\\NianCode\\resources\\opencode-ai\\bin\\opencode');
|
|
}
|
|
});
|
|
|
|
it('passes runtime provider environment variables to the child process', async () => {
|
|
const { children, calls, spawn } = createSpawnHarness();
|
|
const manager = new OpencodeManager({
|
|
port: 4324,
|
|
binPath: '/opt/opencode',
|
|
runtimeConfigProvider: () => ({
|
|
config: {
|
|
provider: {
|
|
openai: {
|
|
options: {
|
|
apiKey: '{env:NIANCODE_OPENCODE_OPENAI_API_KEY}',
|
|
},
|
|
},
|
|
},
|
|
},
|
|
env: {
|
|
NIANCODE_OPENCODE_OPENAI_API_KEY: 'sk-openai',
|
|
},
|
|
}),
|
|
spawn,
|
|
});
|
|
|
|
const startPromise = manager.start();
|
|
children[0].stdout.emit(
|
|
'data',
|
|
Buffer.from('opencode server listening on http://127.0.0.1:4324\n'),
|
|
);
|
|
await startPromise;
|
|
|
|
expect(calls[0].options.env).toMatchObject({
|
|
OPENCODE_CONFIG_CONTENT: JSON.stringify({
|
|
provider: {
|
|
openai: {
|
|
options: {
|
|
apiKey: '{env:NIANCODE_OPENCODE_OPENAI_API_KEY}',
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
NIANCODE_OPENCODE_OPENAI_API_KEY: 'sk-openai',
|
|
});
|
|
});
|
|
|
|
it('passes the managed Python runtime only to the OpenCode child environment', async () => {
|
|
const { children, calls, spawn } = createSpawnHarness();
|
|
const userDataDir = mkdtempSync(join(tmpdir(), 'niancode-opencode-python-'));
|
|
const pythonExecutable = join(userDataDir, 'bundled-python', 'python.exe');
|
|
const originalPath = process.env.PATH ?? '';
|
|
try {
|
|
const manager = new OpencodeManager({
|
|
port: 4325,
|
|
binPath: '/opt/opencode',
|
|
userDataDir,
|
|
pythonRuntime: {
|
|
executable: pythonExecutable,
|
|
binDir: dirname(pythonExecutable),
|
|
source: 'bundled',
|
|
},
|
|
spawn,
|
|
});
|
|
|
|
const startPromise = manager.start();
|
|
children[0].stdout.emit('data', Buffer.from('opencode server listening on http://127.0.0.1:4325\n'));
|
|
await startPromise;
|
|
|
|
expect(calls[0].options.env).toMatchObject({ NIANCODE_PYTHON_PATH: pythonExecutable });
|
|
expect(String(calls[0].options.env?.PATH).split(delimiter)[0]).toBe(dirname(pythonExecutable));
|
|
expect(process.env.PATH ?? '').toBe(originalPath);
|
|
} finally {
|
|
rmSync(userDataDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('logs the sanitized runtime provider target before spawning', async () => {
|
|
const { children, spawn } = createSpawnHarness();
|
|
const logInfo = vi.spyOn(logger, 'info').mockImplementation(() => undefined);
|
|
const manager = new OpencodeManager({
|
|
port: 4330,
|
|
binPath: '/opt/opencode',
|
|
runtimeConfigProvider: () => ({
|
|
config: {
|
|
model: 'niancode-user-models/gpt-4.1-mini',
|
|
enabled_providers: ['niancode-user-models'],
|
|
provider: {
|
|
'niancode-user-models': {
|
|
npm: '@ai-sdk/openai-compatible',
|
|
options: {
|
|
apiKey: '{env:NIANCODE_OPENCODE_NIANCODE_USER_MODELS_API_KEY}',
|
|
baseURL: 'https://one-api.example.com/v1',
|
|
},
|
|
models: {
|
|
'gpt-4.1-mini': { name: 'gpt-4.1-mini' },
|
|
},
|
|
},
|
|
},
|
|
mcp: {
|
|
playwright: {
|
|
type: 'local',
|
|
command: ['node', 'node_modules/@playwright/mcp/cli.js'],
|
|
enabled: true,
|
|
env: {
|
|
ELECTRON_RUN_AS_NODE: '1',
|
|
},
|
|
},
|
|
},
|
|
},
|
|
env: {
|
|
NIANCODE_OPENCODE_NIANCODE_USER_MODELS_API_KEY: 'sk-user-token',
|
|
},
|
|
}),
|
|
spawn,
|
|
});
|
|
|
|
const startPromise = manager.start();
|
|
children[0].stdout.emit(
|
|
'data',
|
|
Buffer.from('opencode server listening on http://127.0.0.1:4330\n'),
|
|
);
|
|
await startPromise;
|
|
|
|
expect(logInfo).toHaveBeenCalledWith(
|
|
'[opencode-runtime] Starting runtime with provider config',
|
|
expect.objectContaining({
|
|
port: 4330,
|
|
model: 'niancode-user-models/gpt-4.1-mini',
|
|
enabledProviderIds: ['niancode-user-models'],
|
|
envKeys: ['NIANCODE_OPENCODE_NIANCODE_USER_MODELS_API_KEY'],
|
|
providers: [
|
|
expect.objectContaining({
|
|
id: 'niancode-user-models',
|
|
baseURL: 'https://one-api.example.com/v1',
|
|
hasApiKey: true,
|
|
apiKeyEnv: 'NIANCODE_OPENCODE_NIANCODE_USER_MODELS_API_KEY',
|
|
}),
|
|
],
|
|
mcpServerIds: ['playwright'],
|
|
mcpServers: [
|
|
expect.objectContaining({
|
|
id: 'playwright',
|
|
type: 'local',
|
|
enabled: true,
|
|
command: ['node', 'node_modules/@playwright/mcp/cli.js'],
|
|
envKeys: ['ELECTRON_RUN_AS_NODE'],
|
|
}),
|
|
],
|
|
}),
|
|
);
|
|
expect(JSON.stringify(logInfo.mock.calls)).not.toContain('sk-user-token');
|
|
});
|
|
|
|
it('attaches to a healthy opencode server when startup exits because the port is already in use', async () => {
|
|
const { children, spawn } = createSpawnHarness();
|
|
const logWarn = vi.spyOn(logger, 'warn').mockImplementation(() => undefined);
|
|
const fetch = vi.fn(async () => new Response(JSON.stringify({ healthy: true, version: '1.15.3' }), {
|
|
headers: { 'content-type': 'application/json' },
|
|
status: 200,
|
|
}));
|
|
vi.stubGlobal('fetch', fetch);
|
|
|
|
const manager = new OpencodeManager({
|
|
port: 4329,
|
|
binPath: '/opt/opencode',
|
|
spawn,
|
|
startupTimeoutMs: 1,
|
|
});
|
|
|
|
const startPromise = manager.start();
|
|
children[0].stderr.emit('data', Buffer.from('Failed to start server. Is port 4329 in use?\n'));
|
|
children[0].emit('exit', 1);
|
|
const status = await startPromise;
|
|
|
|
expect(fetch).toHaveBeenCalledWith(
|
|
'http://127.0.0.1:4329/global/health',
|
|
expect.objectContaining({ signal: expect.any(AbortSignal) }),
|
|
);
|
|
expect(spawn).toHaveBeenCalledOnce();
|
|
expect(status).toMatchObject({
|
|
state: 'running',
|
|
port: 4329,
|
|
url: 'http://127.0.0.1:4329',
|
|
});
|
|
expect(logWarn).toHaveBeenCalledWith(
|
|
'[opencode-runtime] Attached to an existing server; newly generated provider config may not be active',
|
|
expect.objectContaining({
|
|
port: 4329,
|
|
url: 'http://127.0.0.1:4329',
|
|
}),
|
|
);
|
|
expect(manager.getStatus()).toEqual(status);
|
|
});
|
|
|
|
it('stops a managed Unix attached runtime before spawning the new provider config', async () => {
|
|
const { children, calls, spawn } = createSpawnHarness();
|
|
const findPortOwner = vi.fn()
|
|
.mockResolvedValueOnce({
|
|
pid: 5150,
|
|
commandLine: '/opt/opencode serve --hostname=127.0.0.1 --port=4334',
|
|
})
|
|
.mockResolvedValueOnce(null);
|
|
const killProcess = vi.fn(() => true);
|
|
vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ healthy: true }), {
|
|
headers: { 'content-type': 'application/json' },
|
|
status: 200,
|
|
})));
|
|
const manager = new OpencodeManager({
|
|
port: 4334,
|
|
binPath: '/opt/opencode',
|
|
runtimeConfigProvider: () => ({
|
|
config: {},
|
|
env: {
|
|
NIANCODE_OPENCODE_NIANCODE_USER_MODELS_API_KEY: 'fresh-token',
|
|
},
|
|
}),
|
|
spawn,
|
|
findPortOwner,
|
|
killProcess,
|
|
startupTimeoutMs: 1_000,
|
|
});
|
|
|
|
const startPromise = manager.start();
|
|
children[0].stderr.emit('data', Buffer.from('Failed to start server. Is port 4334 in use?\n'));
|
|
children[0].emit('exit', 1);
|
|
await expect(startPromise).resolves.toMatchObject({
|
|
state: 'running',
|
|
port: 4334,
|
|
url: 'http://127.0.0.1:4334',
|
|
});
|
|
|
|
const restartPromise = manager.restart();
|
|
await vi.waitFor(() => expect(children).toHaveLength(2));
|
|
children[1].stdout.emit(
|
|
'data',
|
|
Buffer.from('opencode server listening on http://127.0.0.1:4334\n'),
|
|
);
|
|
const status = await restartPromise;
|
|
|
|
expect(killProcess).toHaveBeenCalledWith(5150);
|
|
expect(calls[0].options.env).toMatchObject({
|
|
NIANCODE_OPENCODE_NIANCODE_USER_MODELS_API_KEY: 'fresh-token',
|
|
});
|
|
expect(status).toMatchObject({
|
|
state: 'running',
|
|
port: 4334,
|
|
pid: 4242,
|
|
});
|
|
});
|
|
|
|
it('stops an attached bundled opencode server before restarting', async () => {
|
|
const { children, spawn } = createSpawnHarness();
|
|
const attachedOwner = {
|
|
pid: 5150,
|
|
executablePath: 'C:\\NianCode\\opencode.exe',
|
|
commandLine: 'C:\\NianCode\\opencode.exe serve --hostname=127.0.0.1 --port=4331',
|
|
};
|
|
const findPortOwner = vi.fn()
|
|
.mockResolvedValueOnce(attachedOwner)
|
|
.mockResolvedValueOnce(null);
|
|
const killProcess = vi.fn(() => true);
|
|
vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ healthy: true }), {
|
|
headers: { 'content-type': 'application/json' },
|
|
status: 200,
|
|
})));
|
|
const manager = new OpencodeManager({
|
|
port: 4331,
|
|
binPath: 'C:\\NianCode\\opencode.exe',
|
|
spawn,
|
|
findPortOwner,
|
|
killProcess,
|
|
startupTimeoutMs: 1_000,
|
|
});
|
|
|
|
const startPromise = manager.start();
|
|
children[0].stderr.emit('data', Buffer.from('Failed to start server. Is port 4331 in use?\n'));
|
|
children[0].emit('exit', 1);
|
|
await expect(startPromise).resolves.toMatchObject({
|
|
state: 'running',
|
|
port: 4331,
|
|
url: 'http://127.0.0.1:4331',
|
|
});
|
|
|
|
const restartPromise = manager.restart();
|
|
await vi.waitFor(() => expect(children).toHaveLength(2));
|
|
children[1].stdout.emit(
|
|
'data',
|
|
Buffer.from('opencode server listening on http://127.0.0.1:4331\n'),
|
|
);
|
|
const restartedStatus = await restartPromise;
|
|
|
|
expect(findPortOwner).toHaveBeenCalledWith(4331);
|
|
expect(killProcess).toHaveBeenCalledWith(5150);
|
|
expect(spawn).toHaveBeenCalledTimes(2);
|
|
expect(restartedStatus).toMatchObject({
|
|
state: 'running',
|
|
port: 4331,
|
|
url: 'http://127.0.0.1:4331',
|
|
pid: 4242,
|
|
});
|
|
});
|
|
|
|
it('does not kill an attached server when the port owner is not the bundled opencode binary', async () => {
|
|
const { children, spawn } = createSpawnHarness();
|
|
const findPortOwner = vi.fn(async () => ({
|
|
pid: 6161,
|
|
executablePath: 'C:\\OtherApp\\server.exe',
|
|
commandLine: 'C:\\OtherApp\\server.exe --port=4332',
|
|
}));
|
|
const killProcess = vi.fn(() => true);
|
|
vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ healthy: true }), {
|
|
headers: { 'content-type': 'application/json' },
|
|
status: 200,
|
|
})));
|
|
const manager = new OpencodeManager({
|
|
port: 4332,
|
|
binPath: 'C:\\NianCode\\opencode.exe',
|
|
spawn,
|
|
findPortOwner,
|
|
killProcess,
|
|
});
|
|
|
|
const startPromise = manager.start();
|
|
children[0].stderr.emit('data', Buffer.from('Failed to start server. Is port 4332 in use?\n'));
|
|
children[0].emit('exit', 1);
|
|
await startPromise;
|
|
|
|
await manager.stop();
|
|
|
|
expect(findPortOwner).toHaveBeenCalledWith(4332);
|
|
expect(killProcess).not.toHaveBeenCalled();
|
|
expect(manager.getStatus()).toEqual({ state: 'stopped', port: 4332 });
|
|
});
|
|
|
|
it('isolates opencode config, data, and cache under the app user data directory', async () => {
|
|
const userDataDir = mkdtempSync(join(tmpdir(), 'niancode-opencode-manager-'));
|
|
try {
|
|
const { children, calls, spawn } = createSpawnHarness();
|
|
const manager = new OpencodeManager({
|
|
port: 4326,
|
|
binPath: '/opt/opencode',
|
|
userDataDir,
|
|
runtimeConfigProvider: () => ({
|
|
config: {},
|
|
env: {
|
|
NIANCODE_OPENCODE_OPENAI_API_KEY: 'sk-openai',
|
|
},
|
|
}),
|
|
spawn,
|
|
});
|
|
|
|
const startPromise = manager.start();
|
|
children[0].stdout.emit(
|
|
'data',
|
|
Buffer.from('opencode server listening on http://127.0.0.1:4326\n'),
|
|
);
|
|
await startPromise;
|
|
|
|
const opencodeDir = join(userDataDir, 'opencode');
|
|
expect(calls[0].options.env).toMatchObject({
|
|
XDG_CONFIG_HOME: join(opencodeDir, 'config'),
|
|
XDG_DATA_HOME: join(opencodeDir, 'data'),
|
|
XDG_CACHE_HOME: join(opencodeDir, 'cache'),
|
|
NIANCODE_OPENCODE_OPENAI_API_KEY: 'sk-openai',
|
|
});
|
|
expect(existsSync(join(opencodeDir, 'config'))).toBe(true);
|
|
expect(existsSync(join(opencodeDir, 'data'))).toBe(true);
|
|
expect(existsSync(join(opencodeDir, 'cache'))).toBe(true);
|
|
} finally {
|
|
rmSync(userDataDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('disables external Skill discovery for the managed NianCode runtime', async () => {
|
|
const userDataDir = mkdtempSync(join(tmpdir(), 'niancode-opencode-manager-'));
|
|
try {
|
|
const { children, calls, spawn } = createSpawnHarness();
|
|
const manager = new OpencodeManager({
|
|
port: 4334,
|
|
binPath: '/opt/opencode',
|
|
userDataDir,
|
|
runtimeConfigProvider: () => ({
|
|
config: {},
|
|
env: {
|
|
OPENCODE_DISABLE_EXTERNAL_SKILLS: '0',
|
|
},
|
|
}),
|
|
spawn,
|
|
});
|
|
|
|
const startPromise = manager.start();
|
|
children[0].stdout.emit(
|
|
'data',
|
|
Buffer.from('opencode server listening on http://127.0.0.1:4334\n'),
|
|
);
|
|
await startPromise;
|
|
|
|
expect(calls[0].options.env).toMatchObject({
|
|
OPENCODE_DISABLE_EXTERNAL_SKILLS: '1',
|
|
});
|
|
} finally {
|
|
rmSync(userDataDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('does not pass Meowa credentials into the model runtime process', async () => {
|
|
const userDataDir = mkdtempSync(join(tmpdir(), 'niancode-opencode-manager-'));
|
|
const previousApiKey = process.env.MEOWART_API_KEY;
|
|
const previousDevKey = process.env.MEOWART_DEV_KEY;
|
|
process.env.MEOWART_API_KEY = 'meowa-secret';
|
|
process.env.MEOWART_DEV_KEY = 'meowa-dev-secret';
|
|
try {
|
|
const { children, calls, spawn } = createSpawnHarness();
|
|
const manager = new OpencodeManager({
|
|
port: 4335,
|
|
binPath: '/opt/opencode',
|
|
userDataDir,
|
|
runtimeConfigProvider: () => ({ config: {}, env: {} }),
|
|
spawn,
|
|
});
|
|
|
|
const startPromise = manager.start();
|
|
children[0].stdout.emit(
|
|
'data',
|
|
Buffer.from('opencode server listening on http://127.0.0.1:4335\n'),
|
|
);
|
|
await startPromise;
|
|
|
|
expect(calls[0].options.env).not.toHaveProperty('MEOWART_API_KEY');
|
|
expect(calls[0].options.env).not.toHaveProperty('MEOWART_DEV_KEY');
|
|
} finally {
|
|
if (previousApiKey === undefined) delete process.env.MEOWART_API_KEY;
|
|
else process.env.MEOWART_API_KEY = previousApiKey;
|
|
if (previousDevKey === undefined) delete process.env.MEOWART_DEV_KEY;
|
|
else process.env.MEOWART_DEV_KEY = previousDevKey;
|
|
rmSync(userDataDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('installs bundled Superpowers into the managed opencode config directory before spawning', async () => {
|
|
const userDataDir = mkdtempSync(join(tmpdir(), 'niancode-opencode-manager-'));
|
|
const bundledSuperpowersDir = createBundledSuperpowersFixture();
|
|
try {
|
|
const { children, calls, spawn } = createSpawnHarness();
|
|
const manager = new OpencodeManager({
|
|
port: 4328,
|
|
binPath: '/opt/opencode',
|
|
userDataDir,
|
|
bundledSuperpowersDir,
|
|
runtimeConfigProvider: () => ({ config: {}, env: {} }),
|
|
spawn,
|
|
});
|
|
|
|
const startPromise = manager.start();
|
|
children[0].stdout.emit(
|
|
'data',
|
|
Buffer.from('opencode server listening on http://127.0.0.1:4328\n'),
|
|
);
|
|
await startPromise;
|
|
|
|
const opencodeDir = join(userDataDir, 'opencode');
|
|
const managedConfigDir = join(opencodeDir, 'niancode-config');
|
|
const installedRepoDir = join(managedConfigDir, 'superpowers-bundles', '5.0.7');
|
|
const wrapperPath = join(managedConfigDir, 'plugins', 'superpowers-niancode.js');
|
|
|
|
expect(calls[0].options.env).toMatchObject({
|
|
OPENCODE_CONFIG_DIR: managedConfigDir,
|
|
});
|
|
expect(existsSync(join(installedRepoDir, 'skills', 'using-superpowers', 'SKILL.md'))).toBe(true);
|
|
expect(existsSync(join(installedRepoDir, '.opencode', 'plugins', 'superpowers.js'))).toBe(true);
|
|
expect(readFileSync(wrapperPath, 'utf8')).toContain(
|
|
"export { SuperpowersPlugin } from '../superpowers-bundles/5.0.7/.opencode/plugins/superpowers.js';",
|
|
);
|
|
expect(JSON.parse(readFileSync(
|
|
join(managedConfigDir, 'superpowers-active.json'),
|
|
'utf8',
|
|
))).toEqual({ version: '5.0.7', directory: '5.0.7' });
|
|
} finally {
|
|
rmSync(userDataDir, { recursive: true, force: true });
|
|
rmSync(bundledSuperpowersDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('migrates the wrapper without touching an unusable legacy destination under a non-ASCII user path', () => {
|
|
const managedRoot = mkdtempSync(join(tmpdir(), 'niancode-managed-config-'));
|
|
const managedConfigDir = join(
|
|
managedRoot,
|
|
'卢欢',
|
|
'AppData',
|
|
'Roaming',
|
|
'niancode',
|
|
'opencode',
|
|
'niancode-config',
|
|
);
|
|
mkdirSync(managedConfigDir, { recursive: true });
|
|
const bundledSuperpowersDir = createBundledSuperpowersFixture();
|
|
try {
|
|
const legacyPath = join(managedConfigDir, 'superpowers');
|
|
writeFileSync(legacyPath, 'legacy-path-must-remain-untouched');
|
|
const wrapperPath = join(managedConfigDir, 'plugins', 'superpowers-niancode.js');
|
|
mkdirSync(join(managedConfigDir, 'plugins'), { recursive: true });
|
|
writeFileSync(
|
|
wrapperPath,
|
|
"export { SuperpowersPlugin } from '../superpowers/.opencode/plugins/superpowers.js';\n",
|
|
);
|
|
|
|
expect(ensureBundledSuperpowersPlugin({
|
|
managedConfigDir,
|
|
sourceDir: bundledSuperpowersDir,
|
|
})).toBe(true);
|
|
|
|
expect(readFileSync(legacyPath, 'utf8')).toBe('legacy-path-must-remain-untouched');
|
|
expect(readFileSync(wrapperPath, 'utf8')).not.toContain('../superpowers/.opencode');
|
|
expect(readFileSync(wrapperPath, 'utf8')).toContain('../superpowers-bundles/5.0.7/.opencode');
|
|
expect(existsSync(join(
|
|
managedConfigDir,
|
|
'superpowers-bundles',
|
|
'5.0.7',
|
|
'skills',
|
|
'using-superpowers',
|
|
'SKILL.md',
|
|
))).toBe(true);
|
|
} finally {
|
|
rmSync(managedRoot, { recursive: true, force: true });
|
|
rmSync(bundledSuperpowersDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('reuses an installed Superpowers version without overwriting its files', () => {
|
|
const managedConfigDir = mkdtempSync(join(tmpdir(), 'niancode-managed-config-'));
|
|
const bundledSuperpowersDir = createBundledSuperpowersFixture();
|
|
try {
|
|
expect(ensureBundledSuperpowersPlugin({
|
|
managedConfigDir,
|
|
sourceDir: bundledSuperpowersDir,
|
|
})).toBe(true);
|
|
const installedLicense = join(
|
|
managedConfigDir,
|
|
'superpowers-bundles',
|
|
'5.0.7',
|
|
'LICENSE',
|
|
);
|
|
writeFileSync(installedLicense, 'installed-copy-must-not-be-overwritten');
|
|
|
|
expect(ensureBundledSuperpowersPlugin({
|
|
managedConfigDir,
|
|
sourceDir: bundledSuperpowersDir,
|
|
})).toBe(true);
|
|
expect(readFileSync(installedLicense, 'utf8')).toBe('installed-copy-must-not-be-overwritten');
|
|
} finally {
|
|
rmSync(managedConfigDir, { recursive: true, force: true });
|
|
rmSync(bundledSuperpowersDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('cleans an incomplete Superpowers staging directory', () => {
|
|
const managedConfigDir = mkdtempSync(join(tmpdir(), 'niancode-managed-config-'));
|
|
const bundledSuperpowersDir = createBundledSuperpowersFixture();
|
|
try {
|
|
rmSync(join(
|
|
bundledSuperpowersDir,
|
|
'skills',
|
|
'using-superpowers',
|
|
'SKILL.md',
|
|
));
|
|
|
|
expect(() => ensureBundledSuperpowersPlugin({
|
|
managedConfigDir,
|
|
sourceDir: bundledSuperpowersDir,
|
|
})).toThrow('Bundled Superpowers staging copy is incomplete');
|
|
|
|
const bundlesDir = join(managedConfigDir, 'superpowers-bundles');
|
|
expect(readdirSync(bundlesDir).some((entry) => entry.startsWith('.staging-'))).toBe(false);
|
|
} finally {
|
|
rmSync(managedConfigDir, { recursive: true, force: true });
|
|
rmSync(bundledSuperpowersDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('keeps the active bundle when a newer Superpowers bundle is incomplete', () => {
|
|
const managedConfigDir = mkdtempSync(join(tmpdir(), 'niancode-managed-config-'));
|
|
const installedSourceDir = createBundledSuperpowersFixture();
|
|
const brokenSourceDir = createBundledSuperpowersFixture();
|
|
try {
|
|
expect(ensureBundledSuperpowersPlugin({
|
|
managedConfigDir,
|
|
sourceDir: installedSourceDir,
|
|
})).toBe(true);
|
|
const manifestPath = join(managedConfigDir, 'superpowers-active.json');
|
|
const originalManifest = readFileSync(manifestPath, 'utf8');
|
|
|
|
writeFileSync(
|
|
join(brokenSourceDir, 'package.json'),
|
|
JSON.stringify({ name: 'superpowers', version: '5.0.8' }),
|
|
);
|
|
rmSync(join(brokenSourceDir, 'skills', 'using-superpowers', 'SKILL.md'));
|
|
|
|
expect(() => ensureBundledSuperpowersPlugin({
|
|
managedConfigDir,
|
|
sourceDir: brokenSourceDir,
|
|
})).toThrow('Bundled Superpowers staging copy is incomplete');
|
|
expect(readFileSync(manifestPath, 'utf8')).toBe(originalManifest);
|
|
expect(existsSync(join(
|
|
managedConfigDir,
|
|
'superpowers-bundles',
|
|
'5.0.7',
|
|
'.opencode',
|
|
'plugins',
|
|
'superpowers.js',
|
|
))).toBe(true);
|
|
} finally {
|
|
rmSync(managedConfigDir, { recursive: true, force: true });
|
|
rmSync(installedSourceDir, { recursive: true, force: true });
|
|
rmSync(brokenSourceDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('uses a fresh bundle directory when the preferred version path is unusable', () => {
|
|
const managedConfigDir = mkdtempSync(join(tmpdir(), 'niancode-managed-config-'));
|
|
const bundledSuperpowersDir = createBundledSuperpowersFixture();
|
|
try {
|
|
const bundlesDir = join(managedConfigDir, 'superpowers-bundles');
|
|
mkdirSync(bundlesDir, { recursive: true });
|
|
writeFileSync(join(bundlesDir, '5.0.7'), 'unusable-preferred-path');
|
|
|
|
expect(ensureBundledSuperpowersPlugin({
|
|
managedConfigDir,
|
|
sourceDir: bundledSuperpowersDir,
|
|
})).toBe(true);
|
|
|
|
const manifest = JSON.parse(readFileSync(
|
|
join(managedConfigDir, 'superpowers-active.json'),
|
|
'utf8',
|
|
)) as { version: string; directory: string };
|
|
expect(manifest.version).toBe('5.0.7');
|
|
expect(manifest.directory).toMatch(/^5\.0\.7-/);
|
|
expect(existsSync(join(
|
|
bundlesDir,
|
|
manifest.directory,
|
|
'.opencode',
|
|
'plugins',
|
|
'superpowers.js',
|
|
))).toBe(true);
|
|
} finally {
|
|
rmSync(managedConfigDir, { recursive: true, force: true });
|
|
rmSync(bundledSuperpowersDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('installs bundled course skills into the managed opencode config directory before spawning', async () => {
|
|
const userDataDir = mkdtempSync(join(tmpdir(), 'niancode-opencode-manager-'));
|
|
const bundledCourseSkillsDir = mkdtempSync(join(tmpdir(), 'niancode-course-skills-source-'));
|
|
try {
|
|
const skillDir = join(bundledCourseSkillsDir, 'pm-project-plan');
|
|
await import('node:fs/promises').then(async ({ mkdir }) => {
|
|
await mkdir(skillDir, { recursive: true });
|
|
});
|
|
writeFileSync(join(skillDir, 'SKILL.md'), '---\nname: pm-project-plan\ndescription: Plan projects.\n---\n');
|
|
|
|
const { children, spawn } = createSpawnHarness();
|
|
const manager = new OpencodeManager({
|
|
port: 4329,
|
|
binPath: '/opt/opencode',
|
|
userDataDir,
|
|
bundledCourseSkillsDir,
|
|
runtimeConfigProvider: () => ({ config: {}, env: {} }),
|
|
spawn,
|
|
});
|
|
|
|
const startPromise = manager.start();
|
|
children[0].stdout.emit(
|
|
'data',
|
|
Buffer.from('opencode server listening on http://127.0.0.1:4329\n'),
|
|
);
|
|
await startPromise;
|
|
|
|
const installedSkillPath = join(
|
|
userDataDir,
|
|
'opencode',
|
|
'niancode-config',
|
|
'skills',
|
|
'pm-project-plan',
|
|
'SKILL.md',
|
|
);
|
|
expect(existsSync(installedSkillPath)).toBe(true);
|
|
expect(readFileSync(installedSkillPath, 'utf8')).toContain('name: pm-project-plan');
|
|
} finally {
|
|
rmSync(userDataDir, { recursive: true, force: true });
|
|
rmSync(bundledCourseSkillsDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('removes retired bundled skills from the managed config before spawning', async () => {
|
|
const userDataDir = mkdtempSync(join(tmpdir(), 'niancode-opencode-manager-'));
|
|
const bundledCourseSkillsDir = mkdtempSync(join(tmpdir(), 'niancode-course-skills-source-'));
|
|
const retiredSkillDir = join(userDataDir, 'opencode', 'niancode-config', 'skills', 'student-growth-logger');
|
|
try {
|
|
mkdirSync(retiredSkillDir, { recursive: true });
|
|
writeFileSync(join(retiredSkillDir, 'SKILL.md'), '---\nname: student-growth-logger\n---\n');
|
|
|
|
const { children, spawn } = createSpawnHarness();
|
|
const manager = new OpencodeManager({
|
|
port: 4331,
|
|
binPath: '/opt/opencode',
|
|
userDataDir,
|
|
bundledCourseSkillsDir,
|
|
runtimeConfigProvider: () => ({ config: {}, env: {} }),
|
|
spawn,
|
|
});
|
|
|
|
const startPromise = manager.start();
|
|
children[0].stdout.emit(
|
|
'data',
|
|
Buffer.from('opencode server listening on http://127.0.0.1:4331\n'),
|
|
);
|
|
await startPromise;
|
|
|
|
expect(existsSync(retiredSkillDir)).toBe(false);
|
|
} finally {
|
|
rmSync(userDataDir, { recursive: true, force: true });
|
|
rmSync(bundledCourseSkillsDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('installs bundled course agents into the managed opencode config directory before spawning', async () => {
|
|
const userDataDir = mkdtempSync(join(tmpdir(), 'niancode-opencode-manager-'));
|
|
const bundledCourseAgentsDir = mkdtempSync(join(tmpdir(), 'niancode-course-agents-source-'));
|
|
try {
|
|
writeFileSync(
|
|
join(bundledCourseAgentsDir, 'reviewer.md'),
|
|
'---\ndescription: Review course stages.\nmode: all\n---\nReview course evidence.\n',
|
|
);
|
|
|
|
const { children, spawn } = createSpawnHarness();
|
|
const manager = new OpencodeManager({
|
|
port: 4333,
|
|
binPath: '/opt/opencode',
|
|
userDataDir,
|
|
bundledCourseAgentsDir,
|
|
runtimeConfigProvider: () => ({ config: {}, env: {} }),
|
|
spawn,
|
|
});
|
|
|
|
const startPromise = manager.start();
|
|
children[0].stdout.emit(
|
|
'data',
|
|
Buffer.from('opencode server listening on http://127.0.0.1:4333\n'),
|
|
);
|
|
await startPromise;
|
|
|
|
const installedAgentPath = join(
|
|
userDataDir,
|
|
'opencode',
|
|
'niancode-config',
|
|
'agent',
|
|
'reviewer.md',
|
|
);
|
|
expect(existsSync(installedAgentPath)).toBe(true);
|
|
expect(readFileSync(installedAgentPath, 'utf8')).toContain('Review course evidence.');
|
|
} finally {
|
|
rmSync(userDataDir, { recursive: true, force: true });
|
|
rmSync(bundledCourseAgentsDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('includes startup stderr in the failed runtime status', async () => {
|
|
const { children, spawn } = createSpawnHarness();
|
|
const manager = new OpencodeManager({
|
|
port: 4327,
|
|
binPath: '/opt/opencode',
|
|
spawn,
|
|
});
|
|
|
|
const startPromise = manager.start();
|
|
children[0].stderr.emit('data', Buffer.from('EEXIST: file already exists, mkdir .config/opencode\n'));
|
|
children[0].emit('exit', 1);
|
|
|
|
await expect(startPromise).rejects.toThrow('EEXIST');
|
|
expect(manager.getStatus()).toMatchObject({
|
|
state: 'error',
|
|
port: 4327,
|
|
error: expect.stringContaining('EEXIST'),
|
|
});
|
|
});
|
|
|
|
it('kills the process on stop and marks the runtime stopped', async () => {
|
|
const { children, spawn } = createSpawnHarness();
|
|
const manager = new OpencodeManager({
|
|
port: 4323,
|
|
binPath: '/opt/opencode',
|
|
spawn,
|
|
});
|
|
|
|
const startPromise = manager.start();
|
|
children[0].stdout.emit(
|
|
'data',
|
|
Buffer.from('opencode server listening on http://127.0.0.1:4323\n'),
|
|
);
|
|
await startPromise;
|
|
|
|
await manager.stop();
|
|
|
|
expect(children[0].kill).toHaveBeenCalledOnce();
|
|
expect(manager.getStatus()).toEqual({ state: 'stopped', port: 4323 });
|
|
});
|
|
});
|