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('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 }); }); });