Defer OpenCode restarts for config mutations
Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>
This commit is contained in:
co-authored by
Serhii Dziupin
parent
824d1fbbf4
commit
775da6e9f4
@@ -52,6 +52,25 @@ afterEach(() => {
|
|||||||
const readJson = (filePath) => JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
const readJson = (filePath) => JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
||||||
|
|
||||||
describe('VS Code config bridge plugin parity', () => {
|
describe('VS Code config bridge plugin parity', () => {
|
||||||
|
test('explicit config reload restarts OpenCode', async () => {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-vscode-reload-'));
|
||||||
|
tempRoots.push(root);
|
||||||
|
const ctx = createCtx(root);
|
||||||
|
|
||||||
|
const reloaded = await handleConfigBridgeMessage({
|
||||||
|
id: 'reload',
|
||||||
|
type: 'api:config/reload',
|
||||||
|
}, ctx, deps);
|
||||||
|
|
||||||
|
expect(reloaded).toEqual({
|
||||||
|
id: 'reload',
|
||||||
|
type: 'api:config/reload',
|
||||||
|
success: true,
|
||||||
|
data: { restarted: true },
|
||||||
|
});
|
||||||
|
expect(ctx.restart).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
test('removes agent fields when update payload sends null', async () => {
|
test('removes agent fields when update payload sends null', async () => {
|
||||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-vscode-agent-null-'));
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-vscode-agent-null-'));
|
||||||
tempRoots.push(root);
|
tempRoots.push(root);
|
||||||
@@ -102,7 +121,14 @@ describe('VS Code config bridge plugin parity', () => {
|
|||||||
}, ctx, deps);
|
}, ctx, deps);
|
||||||
|
|
||||||
expect(created?.success).toBe(true);
|
expect(created?.success).toBe(true);
|
||||||
expect(ctx.restart).toHaveBeenCalledTimes(1);
|
expect(created?.data).toMatchObject({
|
||||||
|
success: true,
|
||||||
|
requiresReload: false,
|
||||||
|
requiresRestart: true,
|
||||||
|
restartDeferred: true,
|
||||||
|
message: 'Plugin entry changed. Restart OpenCode to apply.',
|
||||||
|
});
|
||||||
|
expect(ctx.restart).not.toHaveBeenCalled();
|
||||||
|
|
||||||
const listed = await handleConfigBridgeMessage({
|
const listed = await handleConfigBridgeMessage({
|
||||||
id: 'list',
|
id: 'list',
|
||||||
@@ -251,27 +277,36 @@ describe('VS Code config bridge plugin parity', () => {
|
|||||||
expect(fs.readFileSync(path.join(configDir, 'plugins', 'demo-plugin.ts'), 'utf8')).toBe('export default {}');
|
expect(fs.readFileSync(path.join(configDir, 'plugins', 'demo-plugin.ts'), 'utf8')).toBe('export default {}');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('reports plugin mutation success when restart fails after writing config', async () => {
|
test('creates MCP config with deferred restart when restart would fail', async () => {
|
||||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-vscode-plugin-restart-'));
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-vscode-mcp-deferred-'));
|
||||||
tempRoots.push(root);
|
tempRoots.push(root);
|
||||||
const ctx = createCtx(root, async () => {
|
const ctx = createCtx(root, async () => {
|
||||||
throw new Error('restart failed');
|
throw new Error('restart failed');
|
||||||
});
|
});
|
||||||
|
|
||||||
const created = await handleConfigBridgeMessage({
|
const created = await handleConfigBridgeMessage({
|
||||||
id: 'create-restart-failure',
|
id: 'create-mcp-deferred',
|
||||||
type: 'api:config/plugins',
|
type: 'api:config/mcp',
|
||||||
payload: {
|
payload: {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
target: 'entry',
|
name: 'mcp-deferred',
|
||||||
directory: root,
|
directory: root,
|
||||||
body: { scope: 'project', spec: 'plugin-restart' },
|
body: { scope: 'project', type: 'local', command: ['node', 'server.js'] },
|
||||||
},
|
},
|
||||||
}, ctx, deps);
|
}, ctx, deps);
|
||||||
|
|
||||||
expect(created?.success).toBe(true);
|
expect(created?.success).toBe(true);
|
||||||
expect(created?.data).toMatchObject({ success: true, requiresReload: false, reloadFailed: true });
|
expect(created?.data).toMatchObject({
|
||||||
expect(created?.data?.warning).toContain('restart failed');
|
success: true,
|
||||||
expect(readJson(path.join(root, '.opencode', 'opencode.json')).plugin).toEqual(['plugin-restart']);
|
requiresReload: false,
|
||||||
|
requiresRestart: true,
|
||||||
|
restartDeferred: true,
|
||||||
|
message: 'MCP server "mcp-deferred" created. Restart OpenCode to apply.',
|
||||||
|
});
|
||||||
|
expect(ctx.restart).not.toHaveBeenCalled();
|
||||||
|
expect(readJson(path.join(root, '.opencode', 'opencode.json')).mcp['mcp-deferred']).toMatchObject({
|
||||||
|
type: 'local',
|
||||||
|
command: ['node', 'server.js'],
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ import {
|
|||||||
installSkillsFromRepository as installSkillsFromGit,
|
installSkillsFromRepository as installSkillsFromGit,
|
||||||
type SkillsCatalogSourceConfig,
|
type SkillsCatalogSourceConfig,
|
||||||
} from './skillsCatalog';
|
} from './skillsCatalog';
|
||||||
|
import { buildDeferredRestartResponse } from './config-mutation-response';
|
||||||
import type { BridgeContext, BridgeResponse } from './bridge';
|
import type { BridgeContext, BridgeResponse } from './bridge';
|
||||||
|
|
||||||
type BridgeMessageInput = {
|
type BridgeMessageInput = {
|
||||||
@@ -84,31 +85,9 @@ const resolveWorkingDirectory = (ctx: BridgeContext | undefined, directory?: str
|
|||||||
: (ctx?.manager?.getWorkingDirectory() || vscode.workspace.workspaceFolders?.[0]?.uri.fsPath)
|
: (ctx?.manager?.getWorkingDirectory() || vscode.workspace.workspaceFolders?.[0]?.uri.fsPath)
|
||||||
);
|
);
|
||||||
|
|
||||||
const pluginMutationPayload = async (
|
const pluginMutationPayload = (label: string) => buildDeferredRestartResponse(
|
||||||
ctx: BridgeContext | undefined,
|
`${label}. Restart OpenCode to apply.`,
|
||||||
deps: ConfigRuntimeDeps,
|
);
|
||||||
label: string,
|
|
||||||
) => {
|
|
||||||
try {
|
|
||||||
await ctx?.manager?.restart();
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
requiresReload: true,
|
|
||||||
message: `${label}. Reloading interface…`,
|
|
||||||
reloadDelayMs: deps.clientReloadDelayMs,
|
|
||||||
reloadFailed: false,
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
requiresReload: false,
|
|
||||||
message: `${label}, but OpenCode reload failed.`,
|
|
||||||
reloadDelayMs: deps.clientReloadDelayMs,
|
|
||||||
reloadFailed: true,
|
|
||||||
warning: error instanceof Error ? error.message : String(error),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const parseSkillsCatalogSources = (settings: Record<string, unknown>): SkillsCatalogSourceConfig[] => {
|
const parseSkillsCatalogSources = (settings: Record<string, unknown>): SkillsCatalogSourceConfig[] => {
|
||||||
const rawCatalogs = (settings as { skillCatalogs?: unknown }).skillCatalogs;
|
const rawCatalogs = (settings as { skillCatalogs?: unknown }).skillCatalogs;
|
||||||
@@ -222,8 +201,12 @@ export async function handleConfigBridgeMessage(
|
|||||||
}
|
}
|
||||||
await fs.promises.mkdir(path.dirname(AGENTS_MD_PATH), { recursive: true });
|
await fs.promises.mkdir(path.dirname(AGENTS_MD_PATH), { recursive: true });
|
||||||
await fs.promises.writeFile(AGENTS_MD_PATH, content, 'utf8');
|
await fs.promises.writeFile(AGENTS_MD_PATH, content, 'utf8');
|
||||||
await ctx?.manager?.restart();
|
return {
|
||||||
return { id, type, success: true, data: { success: true } };
|
id,
|
||||||
|
type,
|
||||||
|
success: true,
|
||||||
|
data: buildDeferredRestartResponse('AGENTS.md saved. Restart OpenCode to apply.'),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'api:magic-prompts:get': {
|
case 'api:magic-prompts:get': {
|
||||||
@@ -295,33 +278,21 @@ export async function handleConfigBridgeMessage(
|
|||||||
const scopeValue = body?.scope as string | undefined;
|
const scopeValue = body?.scope as string | undefined;
|
||||||
const scope: AgentScope | undefined = scopeValue === 'project' ? AGENT_SCOPE.PROJECT : scopeValue === 'user' ? AGENT_SCOPE.USER : undefined;
|
const scope: AgentScope | undefined = scopeValue === 'project' ? AGENT_SCOPE.PROJECT : scopeValue === 'user' ? AGENT_SCOPE.USER : undefined;
|
||||||
createAgent(agentName, (body || {}) as Record<string, unknown>, workingDirectory, scope);
|
createAgent(agentName, (body || {}) as Record<string, unknown>, workingDirectory, scope);
|
||||||
await ctx?.manager?.restart();
|
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
type,
|
type,
|
||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: buildDeferredRestartResponse(`Agent ${agentName} created successfully. Restart OpenCode to apply.`),
|
||||||
success: true,
|
|
||||||
requiresReload: true,
|
|
||||||
message: `Agent ${agentName} created successfully. Reloading interface…`,
|
|
||||||
reloadDelayMs: deps.clientReloadDelayMs,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (normalizedMethod === 'PATCH') {
|
if (normalizedMethod === 'PATCH') {
|
||||||
updateAgent(agentName, (body || {}) as Record<string, unknown>, workingDirectory);
|
updateAgent(agentName, (body || {}) as Record<string, unknown>, workingDirectory);
|
||||||
await ctx?.manager?.restart();
|
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
type,
|
type,
|
||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: buildDeferredRestartResponse(`Agent ${agentName} updated successfully. Restart OpenCode to apply.`),
|
||||||
success: true,
|
|
||||||
requiresReload: true,
|
|
||||||
message: `Agent ${agentName} updated successfully. Reloading interface…`,
|
|
||||||
reloadDelayMs: deps.clientReloadDelayMs,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -329,17 +300,11 @@ export async function handleConfigBridgeMessage(
|
|||||||
const scopeValue = body?.scope as string | undefined;
|
const scopeValue = body?.scope as string | undefined;
|
||||||
const scope: AgentScope | undefined = scopeValue === 'project' ? AGENT_SCOPE.PROJECT : scopeValue === 'user' ? AGENT_SCOPE.USER : undefined;
|
const scope: AgentScope | undefined = scopeValue === 'project' ? AGENT_SCOPE.PROJECT : scopeValue === 'user' ? AGENT_SCOPE.USER : undefined;
|
||||||
deleteAgent(agentName, workingDirectory, scope);
|
deleteAgent(agentName, workingDirectory, scope);
|
||||||
await ctx?.manager?.restart();
|
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
type,
|
type,
|
||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: buildDeferredRestartResponse(`Agent ${agentName} deleted successfully. Restart OpenCode to apply.`),
|
||||||
success: true,
|
|
||||||
requiresReload: true,
|
|
||||||
message: `Agent ${agentName} deleted successfully. Reloading interface…`,
|
|
||||||
reloadDelayMs: deps.clientReloadDelayMs,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -378,49 +343,31 @@ export async function handleConfigBridgeMessage(
|
|||||||
const scopeValue = body?.scope as string | undefined;
|
const scopeValue = body?.scope as string | undefined;
|
||||||
const scope: CommandScope | undefined = scopeValue === 'project' ? COMMAND_SCOPE.PROJECT : scopeValue === 'user' ? COMMAND_SCOPE.USER : undefined;
|
const scope: CommandScope | undefined = scopeValue === 'project' ? COMMAND_SCOPE.PROJECT : scopeValue === 'user' ? COMMAND_SCOPE.USER : undefined;
|
||||||
createCommand(commandName, (body || {}) as Record<string, unknown>, workingDirectory, scope);
|
createCommand(commandName, (body || {}) as Record<string, unknown>, workingDirectory, scope);
|
||||||
await ctx?.manager?.restart();
|
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
type,
|
type,
|
||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: buildDeferredRestartResponse(`Command ${commandName} created successfully. Restart OpenCode to apply.`),
|
||||||
success: true,
|
|
||||||
requiresReload: true,
|
|
||||||
message: `Command ${commandName} created successfully. Reloading interface…`,
|
|
||||||
reloadDelayMs: deps.clientReloadDelayMs,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (normalizedMethod === 'PATCH') {
|
if (normalizedMethod === 'PATCH') {
|
||||||
updateCommand(commandName, (body || {}) as Record<string, unknown>, workingDirectory);
|
updateCommand(commandName, (body || {}) as Record<string, unknown>, workingDirectory);
|
||||||
await ctx?.manager?.restart();
|
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
type,
|
type,
|
||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: buildDeferredRestartResponse(`Command ${commandName} updated successfully. Restart OpenCode to apply.`),
|
||||||
success: true,
|
|
||||||
requiresReload: true,
|
|
||||||
message: `Command ${commandName} updated successfully. Reloading interface…`,
|
|
||||||
reloadDelayMs: deps.clientReloadDelayMs,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (normalizedMethod === 'DELETE') {
|
if (normalizedMethod === 'DELETE') {
|
||||||
deleteCommand(commandName, workingDirectory);
|
deleteCommand(commandName, workingDirectory);
|
||||||
await ctx?.manager?.restart();
|
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
type,
|
type,
|
||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: buildDeferredRestartResponse(`Command ${commandName} deleted successfully. Restart OpenCode to apply.`),
|
||||||
success: true,
|
|
||||||
requiresReload: true,
|
|
||||||
message: `Command ${commandName} deleted successfully. Reloading interface…`,
|
|
||||||
reloadDelayMs: deps.clientReloadDelayMs,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -458,49 +405,31 @@ export async function handleConfigBridgeMessage(
|
|||||||
if (normalizedMethod === 'POST') {
|
if (normalizedMethod === 'POST') {
|
||||||
const scope = body?.scope as 'user' | 'project' | undefined;
|
const scope = body?.scope as 'user' | 'project' | undefined;
|
||||||
createMcpConfig(mcpName, (body || {}) as Record<string, unknown>, workingDirectory, scope);
|
createMcpConfig(mcpName, (body || {}) as Record<string, unknown>, workingDirectory, scope);
|
||||||
await ctx?.manager?.restart();
|
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
type,
|
type,
|
||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: buildDeferredRestartResponse(`MCP server "${mcpName}" created. Restart OpenCode to apply.`),
|
||||||
success: true,
|
|
||||||
requiresReload: true,
|
|
||||||
message: `MCP server "${mcpName}" created. Reloading interface…`,
|
|
||||||
reloadDelayMs: deps.clientReloadDelayMs,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (normalizedMethod === 'PATCH') {
|
if (normalizedMethod === 'PATCH') {
|
||||||
updateMcpConfig(mcpName, (body || {}) as Record<string, unknown>, workingDirectory);
|
updateMcpConfig(mcpName, (body || {}) as Record<string, unknown>, workingDirectory);
|
||||||
await ctx?.manager?.restart();
|
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
type,
|
type,
|
||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: buildDeferredRestartResponse(`MCP server "${mcpName}" updated. Restart OpenCode to apply.`),
|
||||||
success: true,
|
|
||||||
requiresReload: true,
|
|
||||||
message: `MCP server "${mcpName}" updated. Reloading interface…`,
|
|
||||||
reloadDelayMs: deps.clientReloadDelayMs,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (normalizedMethod === 'DELETE') {
|
if (normalizedMethod === 'DELETE') {
|
||||||
deleteMcpConfig(mcpName, workingDirectory);
|
deleteMcpConfig(mcpName, workingDirectory);
|
||||||
await ctx?.manager?.restart();
|
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
type,
|
type,
|
||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: buildDeferredRestartResponse(`MCP server "${mcpName}" deleted. Restart OpenCode to apply.`),
|
||||||
success: true,
|
|
||||||
requiresReload: true,
|
|
||||||
message: `MCP server "${mcpName}" deleted. Reloading interface…`,
|
|
||||||
reloadDelayMs: deps.clientReloadDelayMs,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -562,7 +491,7 @@ export async function handleConfigBridgeMessage(
|
|||||||
id,
|
id,
|
||||||
type,
|
type,
|
||||||
success: true,
|
success: true,
|
||||||
data: await pluginMutationPayload(ctx, deps, 'Plugin entry changed'),
|
data: pluginMutationPayload('Plugin entry changed'),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -590,7 +519,7 @@ export async function handleConfigBridgeMessage(
|
|||||||
id,
|
id,
|
||||||
type,
|
type,
|
||||||
success: true,
|
success: true,
|
||||||
data: await pluginMutationPayload(ctx, deps, 'Plugin file changed'),
|
data: pluginMutationPayload('Plugin file changed'),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -678,49 +607,31 @@ export async function handleConfigBridgeMessage(
|
|||||||
const scope: SkillScope | undefined = scopeValue === 'project' ? SKILL_SCOPE.PROJECT : scopeValue === 'user' ? SKILL_SCOPE.USER : undefined;
|
const scope: SkillScope | undefined = scopeValue === 'project' ? SKILL_SCOPE.PROJECT : scopeValue === 'user' ? SKILL_SCOPE.USER : undefined;
|
||||||
const normalizedSource = sourceValue === 'agents' ? 'agents' : 'opencode';
|
const normalizedSource = sourceValue === 'agents' ? 'agents' : 'opencode';
|
||||||
createSkill(skillName, { ...(body || {}), source: normalizedSource } as Record<string, unknown>, workingDirectory, scope);
|
createSkill(skillName, { ...(body || {}), source: normalizedSource } as Record<string, unknown>, workingDirectory, scope);
|
||||||
await ctx?.manager?.restart();
|
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
type,
|
type,
|
||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: buildDeferredRestartResponse(`Skill ${skillName} created successfully. Restart OpenCode to apply.`),
|
||||||
success: true,
|
|
||||||
requiresReload: true,
|
|
||||||
message: `Skill ${skillName} created successfully. Reloading interface…`,
|
|
||||||
reloadDelayMs: deps.clientReloadDelayMs,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (normalizedMethod === 'PATCH') {
|
if (normalizedMethod === 'PATCH') {
|
||||||
updateSkill(skillName, (body || {}) as Record<string, unknown>, workingDirectory);
|
updateSkill(skillName, (body || {}) as Record<string, unknown>, workingDirectory);
|
||||||
await ctx?.manager?.restart();
|
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
type,
|
type,
|
||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: buildDeferredRestartResponse(`Skill ${skillName} updated successfully. Restart OpenCode to apply.`),
|
||||||
success: true,
|
|
||||||
requiresReload: true,
|
|
||||||
message: `Skill ${skillName} updated successfully. Reloading interface…`,
|
|
||||||
reloadDelayMs: deps.clientReloadDelayMs,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (normalizedMethod === 'DELETE') {
|
if (normalizedMethod === 'DELETE') {
|
||||||
deleteSkill(skillName, workingDirectory);
|
deleteSkill(skillName, workingDirectory);
|
||||||
await ctx?.manager?.restart();
|
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
type,
|
type,
|
||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: buildDeferredRestartResponse(`Skill ${skillName} deleted successfully. Restart OpenCode to apply.`),
|
||||||
success: true,
|
|
||||||
requiresReload: true,
|
|
||||||
message: `Skill ${skillName} deleted successfully. Reloading interface…`,
|
|
||||||
reloadDelayMs: deps.clientReloadDelayMs,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -773,11 +684,7 @@ export async function handleConfigBridgeMessage(
|
|||||||
if (data.ok) {
|
if (data.ok) {
|
||||||
const installed = data.installed || [];
|
const installed = data.installed || [];
|
||||||
const skipped = data.skipped || [];
|
const skipped = data.skipped || [];
|
||||||
const requiresReload = installed.length > 0;
|
const requiresRestart = installed.length > 0;
|
||||||
|
|
||||||
if (requiresReload) {
|
|
||||||
await ctx?.manager?.restart();
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
@@ -787,9 +694,12 @@ export async function handleConfigBridgeMessage(
|
|||||||
ok: true,
|
ok: true,
|
||||||
installed,
|
installed,
|
||||||
skipped,
|
skipped,
|
||||||
requiresReload,
|
...(requiresRestart
|
||||||
message: requiresReload ? 'Skills installed successfully. Reloading interface…' : 'No skills were installed',
|
? buildDeferredRestartResponse('Skills installed successfully. Restart OpenCode to apply.')
|
||||||
reloadDelayMs: requiresReload ? deps.clientReloadDelayMs : undefined,
|
: {
|
||||||
|
requiresReload: false,
|
||||||
|
message: 'No skills were installed',
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { fetchOpenCodeGoUsage } from './opencodeGoQuota';
|
|||||||
import { credentialStatus, deleteCredential, importCursorCredential, normalizeCredential, readCredential, validateCredential, writeCredential, type ManagedProvider } from './quotaCredentials';
|
import { credentialStatus, deleteCredential, importCursorCredential, normalizeCredential, readCredential, validateCredential, writeCredential, type ManagedProvider } from './quotaCredentials';
|
||||||
import { getSessionActivitySnapshot } from './sessionActivityWatcher';
|
import { getSessionActivitySnapshot } from './sessionActivityWatcher';
|
||||||
import { getOpenCodeUpgradeStatus, upgradeManagedOpenCode } from './opencode-upgrade-runtime';
|
import { getOpenCodeUpgradeStatus, upgradeManagedOpenCode } from './opencode-upgrade-runtime';
|
||||||
|
import { buildDeferredRestartResponse } from './config-mutation-response';
|
||||||
import type { BridgeContext, BridgeResponse } from './bridge';
|
import type { BridgeContext, BridgeResponse } from './bridge';
|
||||||
|
|
||||||
type BridgeMessageInput = {
|
type BridgeMessageInput = {
|
||||||
@@ -443,21 +444,19 @@ export async function handleSystemBridgeMessage(
|
|||||||
return { id, type, success: false, error: 'Invalid scope' };
|
return { id, type, success: false, error: 'Invalid scope' };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (removed) {
|
|
||||||
await ctx?.manager?.restart();
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
type,
|
type,
|
||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: {
|
||||||
success: true,
|
|
||||||
removed,
|
removed,
|
||||||
requiresReload: removed,
|
...(removed
|
||||||
message: removed
|
? buildDeferredRestartResponse(`Provider ${providerId} disconnected successfully. Restart OpenCode to apply.`)
|
||||||
? `Provider ${providerId} disconnected successfully. Reloading interface…`
|
: {
|
||||||
: `Provider ${providerId} was not configured.`,
|
success: true,
|
||||||
reloadDelayMs: removed ? deps.clientReloadDelayMs : undefined,
|
requiresReload: false,
|
||||||
|
message: `Provider ${providerId} was not configured.`,
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
/**
|
||||||
|
* Shared response shape for OpenCode config mutations.
|
||||||
|
*
|
||||||
|
* Settings writes persist to disk immediately but defer the OpenCode restart
|
||||||
|
* so the UI can accumulate pending changes and apply them once via
|
||||||
|
* api:config/reload ("Apply & Restart OpenCode").
|
||||||
|
*/
|
||||||
|
export function buildDeferredRestartResponse(message: string) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
requiresReload: false,
|
||||||
|
requiresRestart: true,
|
||||||
|
restartDeferred: true,
|
||||||
|
message,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
|
import { buildDeferredRestartResponse } from './config-mutation-response.js';
|
||||||
|
|
||||||
export const registerConfigEntityRoutes = (app, dependencies) => {
|
export const registerConfigEntityRoutes = (app, dependencies) => {
|
||||||
const {
|
const {
|
||||||
resolveProjectDirectory,
|
resolveProjectDirectory,
|
||||||
resolveOptionalProjectDirectory,
|
resolveOptionalProjectDirectory,
|
||||||
refreshOpenCodeAfterConfigChange,
|
|
||||||
clientReloadDelayMs,
|
|
||||||
getAgentSources,
|
getAgentSources,
|
||||||
getAgentConfig,
|
getAgentConfig,
|
||||||
createAgent,
|
createAgent,
|
||||||
@@ -26,51 +26,14 @@ export const registerConfigEntityRoutes = (app, dependencies) => {
|
|||||||
expandSnippets,
|
expandSnippets,
|
||||||
} = dependencies;
|
} = dependencies;
|
||||||
|
|
||||||
// Build the response for a config mutation based on whether OpenCode actually
|
// Persist to disk immediately; OpenCode restart is deferred to an explicit
|
||||||
// reloaded the change. When connected to an external OpenCode server that
|
// Apply & Restart so settings edits do not interrupt live sessions.
|
||||||
// OpenChamber cannot restart, the change is persisted to disk but the running
|
|
||||||
// server will not serve it until the user restarts that server. We must not
|
|
||||||
// report a clean "reloading" success in that case, otherwise the UI silently
|
|
||||||
// reverts the edit to the stale value on the next refresh.
|
|
||||||
const buildConfigMutationResponse = (refreshResult, { liveMessage, manualRestartMessage }) => {
|
|
||||||
if (refreshResult && refreshResult.external) {
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
requiresReload: false,
|
|
||||||
requiresManualRestart: true,
|
|
||||||
message: manualRestartMessage,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
requiresReload: true,
|
|
||||||
message: liveMessage,
|
|
||||||
reloadDelayMs: clientReloadDelayMs,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const completeMcpMutation = async (res, action, name, applyChange) => {
|
const completeMcpMutation = async (res, action, name, applyChange) => {
|
||||||
applyChange();
|
applyChange();
|
||||||
|
const past = action === 'delete' ? 'deleted' : `${action}d`;
|
||||||
try {
|
return res.json(buildDeferredRestartResponse(
|
||||||
await refreshOpenCodeAfterConfigChange(`mcp ${action}`);
|
`MCP server "${name}" ${past}. Restart OpenCode to apply.`,
|
||||||
return res.json({
|
));
|
||||||
success: true,
|
|
||||||
requiresReload: true,
|
|
||||||
message: `MCP server "${name}" ${action}d. Reloading interface…`,
|
|
||||||
reloadDelayMs: clientReloadDelayMs,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`[API:MCP ${action}] Reload failed after config write:`, error);
|
|
||||||
return res.json({
|
|
||||||
success: true,
|
|
||||||
requiresReload: false,
|
|
||||||
reloadFailed: true,
|
|
||||||
message: `MCP server "${name}" ${action}d, but OpenCode reload failed.`,
|
|
||||||
warning: error.message || 'OpenCode reload failed after the MCP configuration changed',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
app.get('/api/config/agents/:name', async (req, res) => {
|
app.get('/api/config/agents/:name', async (req, res) => {
|
||||||
@@ -128,14 +91,9 @@ export const registerConfigEntityRoutes = (app, dependencies) => {
|
|||||||
console.log('[Server] Scope:', scope, 'Working directory:', directory);
|
console.log('[Server] Scope:', scope, 'Working directory:', directory);
|
||||||
|
|
||||||
createAgent(agentName, config, directory, scope);
|
createAgent(agentName, config, directory, scope);
|
||||||
const refreshResult = await refreshOpenCodeAfterConfigChange('agent creation', {
|
res.json(buildDeferredRestartResponse(
|
||||||
agentName
|
`Agent ${agentName} created successfully. Restart OpenCode to apply.`,
|
||||||
});
|
));
|
||||||
|
|
||||||
res.json(buildConfigMutationResponse(refreshResult, {
|
|
||||||
liveMessage: `Agent ${agentName} created successfully. Reloading interface…`,
|
|
||||||
manualRestartMessage: `Agent ${agentName} saved. Restart your connected OpenCode server to apply the change.`,
|
|
||||||
}));
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to create agent:', error);
|
console.error('Failed to create agent:', error);
|
||||||
res.status(500).json({ error: error.message || 'Failed to create agent' });
|
res.status(500).json({ error: error.message || 'Failed to create agent' });
|
||||||
@@ -156,14 +114,12 @@ export const registerConfigEntityRoutes = (app, dependencies) => {
|
|||||||
console.log('[Server] Working directory:', directory);
|
console.log('[Server] Working directory:', directory);
|
||||||
|
|
||||||
updateAgent(agentName, updates, directory);
|
updateAgent(agentName, updates, directory);
|
||||||
const refreshResult = await refreshOpenCodeAfterConfigChange('agent update');
|
|
||||||
|
|
||||||
console.log(`[Server] Agent ${agentName} updated successfully`);
|
console.log(`[Server] Agent ${agentName} updated successfully`);
|
||||||
|
|
||||||
res.json(buildConfigMutationResponse(refreshResult, {
|
res.json(buildDeferredRestartResponse(
|
||||||
liveMessage: `Agent ${agentName} updated successfully. Reloading interface…`,
|
`Agent ${agentName} updated successfully. Restart OpenCode to apply.`,
|
||||||
manualRestartMessage: `Agent ${agentName} saved. Restart your connected OpenCode server to apply the change.`,
|
));
|
||||||
}));
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[Server] Failed to update agent:', error);
|
console.error('[Server] Failed to update agent:', error);
|
||||||
console.error('[Server] Error stack:', error.stack);
|
console.error('[Server] Error stack:', error.stack);
|
||||||
@@ -181,12 +137,9 @@ export const registerConfigEntityRoutes = (app, dependencies) => {
|
|||||||
|
|
||||||
const scope = req.body?.scope;
|
const scope = req.body?.scope;
|
||||||
deleteAgent(agentName, directory, scope);
|
deleteAgent(agentName, directory, scope);
|
||||||
const refreshResult = await refreshOpenCodeAfterConfigChange('agent deletion');
|
res.json(buildDeferredRestartResponse(
|
||||||
|
`Agent ${agentName} deleted successfully. Restart OpenCode to apply.`,
|
||||||
res.json(buildConfigMutationResponse(refreshResult, {
|
));
|
||||||
liveMessage: `Agent ${agentName} deleted successfully. Reloading interface…`,
|
|
||||||
manualRestartMessage: `Agent ${agentName} deleted. Restart your connected OpenCode server to apply the change.`,
|
|
||||||
}));
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to delete agent:', error);
|
console.error('Failed to delete agent:', error);
|
||||||
res.status(500).json({ error: error.message || 'Failed to delete agent' });
|
res.status(500).json({ error: error.message || 'Failed to delete agent' });
|
||||||
@@ -323,16 +276,9 @@ export const registerConfigEntityRoutes = (app, dependencies) => {
|
|||||||
console.log('[Server] Scope:', scope, 'Working directory:', directory);
|
console.log('[Server] Scope:', scope, 'Working directory:', directory);
|
||||||
|
|
||||||
createCommand(commandName, config, directory, scope);
|
createCommand(commandName, config, directory, scope);
|
||||||
await refreshOpenCodeAfterConfigChange('command creation', {
|
res.json(buildDeferredRestartResponse(
|
||||||
commandName
|
`Command ${commandName} created successfully. Restart OpenCode to apply.`,
|
||||||
});
|
));
|
||||||
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
requiresReload: true,
|
|
||||||
message: `Command ${commandName} created successfully. Reloading interface…`,
|
|
||||||
reloadDelayMs: clientReloadDelayMs,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to create command:', error);
|
console.error('Failed to create command:', error);
|
||||||
res.status(500).json({ error: error.message || 'Failed to create command' });
|
res.status(500).json({ error: error.message || 'Failed to create command' });
|
||||||
@@ -353,16 +299,12 @@ export const registerConfigEntityRoutes = (app, dependencies) => {
|
|||||||
console.log('[Server] Working directory:', directory);
|
console.log('[Server] Working directory:', directory);
|
||||||
|
|
||||||
updateCommand(commandName, updates, directory);
|
updateCommand(commandName, updates, directory);
|
||||||
await refreshOpenCodeAfterConfigChange('command update');
|
|
||||||
|
|
||||||
console.log(`[Server] Command ${commandName} updated successfully`);
|
console.log(`[Server] Command ${commandName} updated successfully`);
|
||||||
|
|
||||||
res.json({
|
res.json(buildDeferredRestartResponse(
|
||||||
success: true,
|
`Command ${commandName} updated successfully. Restart OpenCode to apply.`,
|
||||||
requiresReload: true,
|
));
|
||||||
message: `Command ${commandName} updated successfully. Reloading interface…`,
|
|
||||||
reloadDelayMs: clientReloadDelayMs,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[Server] Failed to update command:', error);
|
console.error('[Server] Failed to update command:', error);
|
||||||
console.error('[Server] Error stack:', error.stack);
|
console.error('[Server] Error stack:', error.stack);
|
||||||
@@ -379,14 +321,9 @@ export const registerConfigEntityRoutes = (app, dependencies) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
deleteCommand(commandName, directory);
|
deleteCommand(commandName, directory);
|
||||||
await refreshOpenCodeAfterConfigChange('command deletion');
|
res.json(buildDeferredRestartResponse(
|
||||||
|
`Command ${commandName} deleted successfully. Restart OpenCode to apply.`,
|
||||||
res.json({
|
));
|
||||||
success: true,
|
|
||||||
requiresReload: true,
|
|
||||||
message: `Command ${commandName} deleted successfully. Reloading interface…`,
|
|
||||||
reloadDelayMs: clientReloadDelayMs,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to delete command:', error);
|
console.error('Failed to delete command:', error);
|
||||||
res.status(500).json({ error: error.message || 'Failed to delete command' });
|
res.status(500).json({ error: error.message || 'Failed to delete command' });
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
/**
|
||||||
|
* Shared response shapes for OpenCode config mutations.
|
||||||
|
*
|
||||||
|
* Settings writes persist to disk immediately but defer the OpenCode restart
|
||||||
|
* so the UI can accumulate pending changes and apply them once via
|
||||||
|
* POST /api/config/reload ("Apply & Restart OpenCode").
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function buildDeferredRestartResponse(message) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
requiresReload: false,
|
||||||
|
requiresRestart: true,
|
||||||
|
restartDeferred: true,
|
||||||
|
message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildExternalManualRestartResponse(message) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
requiresReload: false,
|
||||||
|
requiresManualRestart: true,
|
||||||
|
message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildConfigMutationResponse(refreshResult, { liveMessage, manualRestartMessage, deferredMessage }) {
|
||||||
|
if (refreshResult && refreshResult.external) {
|
||||||
|
return buildExternalManualRestartResponse(manualRestartMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
// When callers skip the live refresh (deferred apply flow), report a pending restart.
|
||||||
|
if (!refreshResult || refreshResult.deferred === true) {
|
||||||
|
return buildDeferredRestartResponse(deferredMessage || liveMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
requiresReload: true,
|
||||||
|
message: liveMessage,
|
||||||
|
reloadDelayMs: refreshResult.reloadDelayMs,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { buildExternalManualRestartResponse } from './config-mutation-response.js';
|
||||||
|
|
||||||
const parseLoopbackUrl = (rawUrl) => {
|
const parseLoopbackUrl = (rawUrl) => {
|
||||||
if (typeof rawUrl !== 'string') {
|
if (typeof rawUrl !== 'string') {
|
||||||
return null;
|
return null;
|
||||||
@@ -1024,7 +1026,13 @@ export const registerSettingsUtilityRoutes = (app, dependencies) => {
|
|||||||
try {
|
try {
|
||||||
console.log('[Server] Manual configuration reload requested');
|
console.log('[Server] Manual configuration reload requested');
|
||||||
|
|
||||||
await refreshOpenCodeAfterConfigChange('manual configuration reload');
|
const refreshResult = await refreshOpenCodeAfterConfigChange('manual configuration reload');
|
||||||
|
|
||||||
|
if (refreshResult?.external) {
|
||||||
|
return res.json(buildExternalManualRestartResponse(
|
||||||
|
'Configuration is saved on disk. Restart your connected OpenCode server to apply the changes.',
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import os from 'os';
|
|||||||
|
|
||||||
import { getNpmInfo as defaultGetNpmInfo } from './npm-registry.js';
|
import { getNpmInfo as defaultGetNpmInfo } from './npm-registry.js';
|
||||||
import { isExactSemver as defaultIsExactSemver, isPathSpec as defaultIsPathSpec, parseNpmSpec as defaultParseNpmSpec, parsePathSpec as defaultParsePathSpec } from './plugin-spec.js';
|
import { isExactSemver as defaultIsExactSemver, isPathSpec as defaultIsPathSpec, parseNpmSpec as defaultParseNpmSpec, parsePathSpec as defaultParsePathSpec } from './plugin-spec.js';
|
||||||
|
import { buildDeferredRestartResponse } from './config-mutation-response.js';
|
||||||
|
|
||||||
const ENTRY_EXISTS_CODES = new Set(['ENTRY_EXISTS', 'EEXIST']);
|
const ENTRY_EXISTS_CODES = new Set(['ENTRY_EXISTS', 'EEXIST']);
|
||||||
const FILE_EXISTS_CODES = new Set(['FILE_EXISTS', 'EEXIST']);
|
const FILE_EXISTS_CODES = new Set(['FILE_EXISTS', 'EEXIST']);
|
||||||
@@ -12,8 +13,6 @@ const BAD_REQUEST_CODES = new Set(['INVALID_FILENAME', 'INVALID_SCOPE', 'INVALID
|
|||||||
export const registerPluginRoutes = (app, dependencies) => {
|
export const registerPluginRoutes = (app, dependencies) => {
|
||||||
const {
|
const {
|
||||||
resolveOptionalProjectDirectory,
|
resolveOptionalProjectDirectory,
|
||||||
refreshOpenCodeAfterConfigChange,
|
|
||||||
clientReloadDelayMs,
|
|
||||||
listPluginEntries,
|
listPluginEntries,
|
||||||
getPluginEntry,
|
getPluginEntry,
|
||||||
createPluginEntry,
|
createPluginEntry,
|
||||||
@@ -43,34 +42,13 @@ export const registerPluginRoutes = (app, dependencies) => {
|
|||||||
return directory || null;
|
return directory || null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const successPayload = (message) => ({
|
|
||||||
success: true,
|
|
||||||
requiresReload: true,
|
|
||||||
message,
|
|
||||||
reloadDelayMs: clientReloadDelayMs,
|
|
||||||
reloadFailed: false,
|
|
||||||
warning: undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
const completePluginMutation = async (res, operation, _noun, applyChange) => {
|
const completePluginMutation = async (res, operation, _noun, applyChange) => {
|
||||||
applyChange();
|
applyChange();
|
||||||
|
|
||||||
const pastTense = operation.replace(/ion$/, 'ed').replace(/update$/, 'updated');
|
const pastTense = operation.replace(/ion$/, 'ed').replace(/update$/, 'updated');
|
||||||
|
return res.json(buildDeferredRestartResponse(
|
||||||
try {
|
`Plugin ${pastTense}. Restart OpenCode to apply.`,
|
||||||
await refreshOpenCodeAfterConfigChange(`plugin ${operation}`);
|
));
|
||||||
return res.json(successPayload(`Plugin ${pastTense}. Reloading interface…`));
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`[API:plugin ${operation}] Reload failed after config write:`, error);
|
|
||||||
return res.json({
|
|
||||||
success: true,
|
|
||||||
requiresReload: false,
|
|
||||||
message: `Plugin ${pastTense}, but OpenCode reload failed.`,
|
|
||||||
reloadDelayMs: clientReloadDelayMs,
|
|
||||||
reloadFailed: true,
|
|
||||||
warning: error.message || 'OpenCode reload failed after plugin config changed',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const validateEntryId = (id) => {
|
const validateEntryId = (id) => {
|
||||||
|
|||||||
@@ -263,11 +263,17 @@ describe('opencode plugin routes', () => {
|
|||||||
expect(response.body.results[0]).toMatchObject({ kind: 'npm-ok', spec: '@scope/foo@1.0.0', name: '@scope/foo' });
|
expect(response.body.results[0]).toMatchObject({ kind: 'npm-ok', spec: '@scope/foo@1.0.0', name: '@scope/foo' });
|
||||||
});
|
});
|
||||||
|
|
||||||
test('POST /entry creates entry and requires reload', async () => {
|
test('POST /entry creates entry and defers restart', async () => {
|
||||||
const response = await createEntry('a');
|
const response = await createEntry('a');
|
||||||
|
|
||||||
expect(response.body).toMatchObject({ success: true, requiresReload: true, reloadDelayMs: 25 });
|
expect(response.body).toMatchObject({
|
||||||
expect(refreshOpenCodeAfterConfigChange).toHaveBeenCalledWith('plugin entry creation');
|
success: true,
|
||||||
|
requiresReload: false,
|
||||||
|
requiresRestart: true,
|
||||||
|
restartDeferred: true,
|
||||||
|
message: 'Plugin entry created. Restart OpenCode to apply.',
|
||||||
|
});
|
||||||
|
expect(refreshOpenCodeAfterConfigChange).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('GET after POST returns created entry', async () => {
|
test('GET after POST returns created entry', async () => {
|
||||||
@@ -300,9 +306,15 @@ describe('opencode plugin routes', () => {
|
|||||||
.expect(200);
|
.expect(200);
|
||||||
|
|
||||||
expect(response.body.success).toBe(true);
|
expect(response.body.success).toBe(true);
|
||||||
|
expect(response.body).toMatchObject({
|
||||||
|
requiresReload: false,
|
||||||
|
requiresRestart: true,
|
||||||
|
restartDeferred: true,
|
||||||
|
message: 'Plugin entry updated. Restart OpenCode to apply.',
|
||||||
|
});
|
||||||
const after = await request(app).get('/api/config/plugins').expect(200);
|
const after = await request(app).get('/api/config/plugins').expect(200);
|
||||||
expect(after.body.entries[0]).toEqual(expect.objectContaining({ spec: 'b', scope: 'user' }));
|
expect(after.body.entries[0]).toEqual(expect.objectContaining({ spec: 'b', scope: 'user' }));
|
||||||
expect(refreshOpenCodeAfterConfigChange).toHaveBeenCalledWith('plugin entry update');
|
expect(refreshOpenCodeAfterConfigChange).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('DELETE /entry/:id removes entry and prunes plugin key', async () => {
|
test('DELETE /entry/:id removes entry and prunes plugin key', async () => {
|
||||||
@@ -310,20 +322,33 @@ describe('opencode plugin routes', () => {
|
|||||||
const listed = await request(app).get('/api/config/plugins').expect(200);
|
const listed = await request(app).get('/api/config/plugins').expect(200);
|
||||||
const id = listed.body.entries[0].id;
|
const id = listed.body.entries[0].id;
|
||||||
|
|
||||||
await request(app).delete(`/api/config/plugins/entry/${encodeURIComponent(id)}`).expect(200);
|
const response = await request(app).delete(`/api/config/plugins/entry/${encodeURIComponent(id)}`).expect(200);
|
||||||
|
|
||||||
const after = await request(app).get('/api/config/plugins').expect(200);
|
const after = await request(app).get('/api/config/plugins').expect(200);
|
||||||
expect(after.body.entries).toEqual([]);
|
expect(after.body.entries).toEqual([]);
|
||||||
expect(readJson(userConfigPath).plugin).toBeUndefined();
|
expect(readJson(userConfigPath).plugin).toBeUndefined();
|
||||||
expect(refreshOpenCodeAfterConfigChange).toHaveBeenCalledWith('plugin entry deletion');
|
expect(response.body).toMatchObject({
|
||||||
|
success: true,
|
||||||
|
requiresReload: false,
|
||||||
|
requiresRestart: true,
|
||||||
|
restartDeferred: true,
|
||||||
|
message: 'Plugin entry deleted. Restart OpenCode to apply.',
|
||||||
|
});
|
||||||
|
expect(refreshOpenCodeAfterConfigChange).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('POST /file writes plugin dir file', async () => {
|
test('POST /file writes plugin dir file', async () => {
|
||||||
const response = await createFile('test.js', '//x');
|
const response = await createFile('test.js', '//x');
|
||||||
|
|
||||||
expect(response.body).toMatchObject({ success: true, requiresReload: true });
|
expect(response.body).toMatchObject({
|
||||||
|
success: true,
|
||||||
|
requiresReload: false,
|
||||||
|
requiresRestart: true,
|
||||||
|
restartDeferred: true,
|
||||||
|
message: 'Plugin file created. Restart OpenCode to apply.',
|
||||||
|
});
|
||||||
expect(fs.readFileSync(path.join(rootDir, 'plugins', 'test.js'), 'utf8')).toBe('//x');
|
expect(fs.readFileSync(path.join(rootDir, 'plugins', 'test.js'), 'utf8')).toBe('//x');
|
||||||
expect(refreshOpenCodeAfterConfigChange).toHaveBeenCalledWith('plugin file creation');
|
expect(refreshOpenCodeAfterConfigChange).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('POST duplicate file returns 409', async () => {
|
test('POST duplicate file returns 409', async () => {
|
||||||
@@ -342,13 +367,20 @@ describe('opencode plugin routes', () => {
|
|||||||
const listed = await request(app).get('/api/config/plugins').expect(200);
|
const listed = await request(app).get('/api/config/plugins').expect(200);
|
||||||
const id = listed.body.files[0].id;
|
const id = listed.body.files[0].id;
|
||||||
|
|
||||||
await request(app)
|
const response = await request(app)
|
||||||
.put(`/api/config/plugins/file/${encodeURIComponent(id)}`)
|
.put(`/api/config/plugins/file/${encodeURIComponent(id)}`)
|
||||||
.send({ content: '//y' })
|
.send({ content: '//y' })
|
||||||
.expect(200);
|
.expect(200);
|
||||||
|
|
||||||
expect(fs.readFileSync(path.join(rootDir, 'plugins', 'test.js'), 'utf8')).toBe('//y');
|
expect(fs.readFileSync(path.join(rootDir, 'plugins', 'test.js'), 'utf8')).toBe('//y');
|
||||||
expect(refreshOpenCodeAfterConfigChange).toHaveBeenCalledWith('plugin file update');
|
expect(response.body).toMatchObject({
|
||||||
|
success: true,
|
||||||
|
requiresReload: false,
|
||||||
|
requiresRestart: true,
|
||||||
|
restartDeferred: true,
|
||||||
|
message: 'Plugin file updated. Restart OpenCode to apply.',
|
||||||
|
});
|
||||||
|
expect(refreshOpenCodeAfterConfigChange).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('DELETE /file/:id unlinks file', async () => {
|
test('DELETE /file/:id unlinks file', async () => {
|
||||||
@@ -356,10 +388,17 @@ describe('opencode plugin routes', () => {
|
|||||||
const listed = await request(app).get('/api/config/plugins').expect(200);
|
const listed = await request(app).get('/api/config/plugins').expect(200);
|
||||||
const id = listed.body.files[0].id;
|
const id = listed.body.files[0].id;
|
||||||
|
|
||||||
await request(app).delete(`/api/config/plugins/file/${encodeURIComponent(id)}`).expect(200);
|
const response = await request(app).delete(`/api/config/plugins/file/${encodeURIComponent(id)}`).expect(200);
|
||||||
|
|
||||||
expect(fs.existsSync(path.join(rootDir, 'plugins', 'test.js'))).toBe(false);
|
expect(fs.existsSync(path.join(rootDir, 'plugins', 'test.js'))).toBe(false);
|
||||||
expect(refreshOpenCodeAfterConfigChange).toHaveBeenCalledWith('plugin file deletion');
|
expect(response.body).toMatchObject({
|
||||||
|
success: true,
|
||||||
|
requiresReload: false,
|
||||||
|
requiresRestart: true,
|
||||||
|
restartDeferred: true,
|
||||||
|
message: 'Plugin file deleted. Restart OpenCode to apply.',
|
||||||
|
});
|
||||||
|
expect(refreshOpenCodeAfterConfigChange).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('PATCH unknown entry id returns 404', async () => {
|
test('PATCH unknown entry id returns 404', async () => {
|
||||||
|
|||||||
@@ -2,11 +2,13 @@ import { createProjectIdFromPath } from '../projects/project-id.js';
|
|||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import os from 'os';
|
import os from 'os';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
import {
|
||||||
|
buildDeferredRestartResponse,
|
||||||
|
} from './config-mutation-response.js';
|
||||||
|
|
||||||
export const registerOpenCodeRoutes = (app, dependencies) => {
|
export const registerOpenCodeRoutes = (app, dependencies) => {
|
||||||
const {
|
const {
|
||||||
crypto,
|
crypto,
|
||||||
clientReloadDelayMs,
|
|
||||||
getOpenCodeResolutionSnapshot,
|
getOpenCodeResolutionSnapshot,
|
||||||
getOpenCodeUpgradeCapability,
|
getOpenCodeUpgradeCapability,
|
||||||
formatSettingsResponse,
|
formatSettingsResponse,
|
||||||
@@ -489,15 +491,18 @@ export const registerOpenCodeRoutes = (app, dependencies) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (removed) {
|
if (removed) {
|
||||||
await refreshOpenCodeAfterConfigChange(`provider ${providerId} disconnected (${scope})`);
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
removed,
|
||||||
|
...buildDeferredRestartResponse('Provider disconnected successfully. Restart OpenCode to apply.'),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return res.json({
|
return res.json({
|
||||||
success: true,
|
success: true,
|
||||||
removed,
|
removed,
|
||||||
requiresReload: removed,
|
requiresReload: false,
|
||||||
message: removed ? 'Provider disconnected successfully' : 'Provider was not connected',
|
message: 'Provider was not connected',
|
||||||
reloadDelayMs: removed ? clientReloadDelayMs : undefined,
|
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to disconnect provider:', error);
|
console.error('Failed to disconnect provider:', error);
|
||||||
@@ -591,14 +596,9 @@ export const registerOpenCodeRoutes = (app, dependencies) => {
|
|||||||
|
|
||||||
await fs.promises.writeFile(AGENTS_MD_PATH, content, 'utf8');
|
await fs.promises.writeFile(AGENTS_MD_PATH, content, 'utf8');
|
||||||
|
|
||||||
// Refresh OpenCode so it picks up the new AGENTS.md without a full restart
|
return res.json(buildDeferredRestartResponse(
|
||||||
try {
|
'AGENTS.md saved. Restart OpenCode to apply.',
|
||||||
await refreshOpenCodeAfterConfigChange('global behavior (AGENTS.md) updated');
|
));
|
||||||
} catch {
|
|
||||||
// Non-fatal: file was written successfully
|
|
||||||
}
|
|
||||||
|
|
||||||
return res.json({ success: true });
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to write AGENTS.md:', error);
|
console.error('Failed to write AGENTS.md:', error);
|
||||||
return res.status(500).json({ error: error.message || 'Failed to write AGENTS.md' });
|
return res.status(500).json({ error: error.message || 'Failed to write AGENTS.md' });
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { createOpencodeClient } from '@opencode-ai/sdk/v2';
|
import { createOpencodeClient } from '@opencode-ai/sdk/v2';
|
||||||
|
import { buildDeferredRestartResponse } from './config-mutation-response.js';
|
||||||
|
|
||||||
export const registerSkillRoutes = (app, dependencies) => {
|
export const registerSkillRoutes = (app, dependencies) => {
|
||||||
const {
|
const {
|
||||||
@@ -10,9 +11,8 @@ export const registerSkillRoutes = (app, dependencies) => {
|
|||||||
readSettingsFromDisk,
|
readSettingsFromDisk,
|
||||||
sanitizeSkillCatalogs,
|
sanitizeSkillCatalogs,
|
||||||
isUnsafeSkillRelativePath,
|
isUnsafeSkillRelativePath,
|
||||||
refreshOpenCodeAfterConfigChange,
|
|
||||||
clientReloadDelayMs,
|
|
||||||
buildOpenCodeUrl,
|
buildOpenCodeUrl,
|
||||||
|
|
||||||
getOpenCodeAuthHeaders,
|
getOpenCodeAuthHeaders,
|
||||||
getOpenCodePort,
|
getOpenCodePort,
|
||||||
getSkillSources,
|
getSkillSources,
|
||||||
@@ -444,19 +444,18 @@ export const registerSkillRoutes = (app, dependencies) => {
|
|||||||
|
|
||||||
const installed = result.installed || [];
|
const installed = result.installed || [];
|
||||||
const skipped = result.skipped || [];
|
const skipped = result.skipped || [];
|
||||||
const requiresReload = installed.length > 0;
|
const requiresRestart = installed.length > 0;
|
||||||
|
|
||||||
if (requiresReload) {
|
|
||||||
await refreshOpenCodeAfterConfigChange('skills install');
|
|
||||||
}
|
|
||||||
|
|
||||||
return res.json({
|
return res.json({
|
||||||
ok: true,
|
ok: true,
|
||||||
installed,
|
installed,
|
||||||
skipped,
|
skipped,
|
||||||
requiresReload,
|
...(requiresRestart
|
||||||
message: requiresReload ? 'Skills installed successfully. Reloading interface…' : 'No skills were installed',
|
? buildDeferredRestartResponse('Skills installed successfully. Restart OpenCode to apply.')
|
||||||
reloadDelayMs: requiresReload ? clientReloadDelayMs : undefined,
|
: {
|
||||||
|
requiresReload: false,
|
||||||
|
message: 'No skills were installed',
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -495,19 +494,18 @@ export const registerSkillRoutes = (app, dependencies) => {
|
|||||||
|
|
||||||
const installed = result.installed || [];
|
const installed = result.installed || [];
|
||||||
const skipped = result.skipped || [];
|
const skipped = result.skipped || [];
|
||||||
const requiresReload = installed.length > 0;
|
const requiresRestart = installed.length > 0;
|
||||||
|
|
||||||
if (requiresReload) {
|
|
||||||
await refreshOpenCodeAfterConfigChange('skills install');
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
ok: true,
|
ok: true,
|
||||||
installed,
|
installed,
|
||||||
skipped,
|
skipped,
|
||||||
requiresReload,
|
...(requiresRestart
|
||||||
message: requiresReload ? 'Skills installed successfully. Reloading interface…' : 'No skills were installed',
|
? buildDeferredRestartResponse('Skills installed successfully. Restart OpenCode to apply.')
|
||||||
reloadDelayMs: requiresReload ? clientReloadDelayMs : undefined,
|
: {
|
||||||
|
requiresReload: false,
|
||||||
|
message: 'No skills were installed',
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to install skills:', error);
|
console.error('Failed to install skills:', error);
|
||||||
@@ -588,14 +586,9 @@ export const registerSkillRoutes = (app, dependencies) => {
|
|||||||
console.log('[Server] Scope:', scope, 'Working directory:', directory);
|
console.log('[Server] Scope:', scope, 'Working directory:', directory);
|
||||||
|
|
||||||
createSkill(skillName, { ...config, source: skillSource }, directory, scope);
|
createSkill(skillName, { ...config, source: skillSource }, directory, scope);
|
||||||
await refreshOpenCodeAfterConfigChange('skill creation');
|
res.json(buildDeferredRestartResponse(
|
||||||
|
`Skill ${skillName} created successfully. Restart OpenCode to apply.`,
|
||||||
res.json({
|
));
|
||||||
success: true,
|
|
||||||
requiresReload: true,
|
|
||||||
message: `Skill ${skillName} created successfully. Reloading interface…`,
|
|
||||||
reloadDelayMs: clientReloadDelayMs,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to create skill:', error);
|
console.error('Failed to create skill:', error);
|
||||||
res.status(500).json({ error: error.message || 'Failed to create skill' });
|
res.status(500).json({ error: error.message || 'Failed to create skill' });
|
||||||
@@ -615,14 +608,9 @@ export const registerSkillRoutes = (app, dependencies) => {
|
|||||||
console.log('[Server] Working directory:', directory);
|
console.log('[Server] Working directory:', directory);
|
||||||
|
|
||||||
updateSkill(skillName, updates, directory, updates?.targetPath);
|
updateSkill(skillName, updates, directory, updates?.targetPath);
|
||||||
await refreshOpenCodeAfterConfigChange('skill update');
|
res.json(buildDeferredRestartResponse(
|
||||||
|
`Skill ${skillName} updated successfully. Restart OpenCode to apply.`,
|
||||||
res.json({
|
));
|
||||||
success: true,
|
|
||||||
requiresReload: true,
|
|
||||||
message: `Skill ${skillName} updated successfully. Reloading interface…`,
|
|
||||||
reloadDelayMs: clientReloadDelayMs,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[Server] Failed to update skill:', error);
|
console.error('[Server] Failed to update skill:', error);
|
||||||
res.status(500).json({ error: error.message || 'Failed to update skill' });
|
res.status(500).json({ error: error.message || 'Failed to update skill' });
|
||||||
@@ -707,14 +695,9 @@ export const registerSkillRoutes = (app, dependencies) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
deleteSkill(skillName, directory);
|
deleteSkill(skillName, directory);
|
||||||
await refreshOpenCodeAfterConfigChange('skill deletion');
|
res.json(buildDeferredRestartResponse(
|
||||||
|
`Skill ${skillName} deleted successfully. Restart OpenCode to apply.`,
|
||||||
res.json({
|
));
|
||||||
success: true,
|
|
||||||
requiresReload: true,
|
|
||||||
message: `Skill ${skillName} deleted successfully. Reloading interface…`,
|
|
||||||
reloadDelayMs: clientReloadDelayMs,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to delete skill:', error);
|
console.error('Failed to delete skill:', error);
|
||||||
res.status(500).json({ error: error.message || 'Failed to delete skill' });
|
res.status(500).json({ error: error.message || 'Failed to delete skill' });
|
||||||
|
|||||||
Reference in New Issue
Block a user