2026-04-18 13:46:38 +03:00
|
|
|
import { createProjectIdFromPath } from '../projects/project-id.js';
|
2026-04-30 14:39:55 -07:00
|
|
|
import fs from 'fs';
|
|
|
|
|
import os from 'os';
|
|
|
|
|
import path from 'path';
|
2026-04-18 13:46:38 +03:00
|
|
|
|
2026-03-31 18:47:00 +03:00
|
|
|
export const registerOpenCodeRoutes = (app, dependencies) => {
|
|
|
|
|
const {
|
|
|
|
|
crypto,
|
|
|
|
|
clientReloadDelayMs,
|
|
|
|
|
getOpenCodeResolutionSnapshot,
|
|
|
|
|
formatSettingsResponse,
|
|
|
|
|
readSettingsFromDisk,
|
|
|
|
|
readSettingsFromDiskMigrated,
|
|
|
|
|
persistSettings,
|
|
|
|
|
sanitizeProjects,
|
|
|
|
|
validateDirectoryPath,
|
|
|
|
|
resolveProjectDirectory,
|
|
|
|
|
getProviderSources,
|
|
|
|
|
removeProviderConfig,
|
|
|
|
|
refreshOpenCodeAfterConfigChange,
|
2026-05-14 14:45:04 +03:00
|
|
|
buildOpenCodeUrl,
|
|
|
|
|
getOpenCodeAuthHeaders,
|
2026-03-31 18:47:00 +03:00
|
|
|
} = dependencies;
|
|
|
|
|
|
|
|
|
|
let authLibrary = null;
|
2026-04-21 11:46:51 -06:00
|
|
|
const pendingMcpAuthContextByState = new Map();
|
|
|
|
|
const PENDING_MCP_AUTH_TTL_MS = 30 * 60 * 1000;
|
2026-03-31 18:47:00 +03:00
|
|
|
const getAuthLibrary = async () => {
|
|
|
|
|
if (!authLibrary) {
|
|
|
|
|
authLibrary = await import('./auth.js');
|
|
|
|
|
}
|
|
|
|
|
return authLibrary;
|
|
|
|
|
};
|
|
|
|
|
|
2026-04-21 11:46:51 -06:00
|
|
|
const normalizePendingString = (value) => {
|
|
|
|
|
if (typeof value !== 'string') {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const trimmed = value.trim();
|
|
|
|
|
return trimmed || null;
|
|
|
|
|
};
|
|
|
|
|
|
2026-07-02 17:43:33 +03:00
|
|
|
const isBundledOpenCodeBinaryActive = async () => {
|
|
|
|
|
const settings = await readSettingsFromDiskMigrated();
|
|
|
|
|
const resolution = await getOpenCodeResolutionSnapshot(settings);
|
|
|
|
|
return resolution?.source === 'bundled' || resolution?.detectedSourceNow === 'bundled';
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const readOpenCodeCurrentVersion = async () => {
|
|
|
|
|
const healthResponse = await fetch(buildOpenCodeUrl('/global/health', ''), {
|
|
|
|
|
method: 'GET',
|
|
|
|
|
headers: { Accept: 'application/json', ...getOpenCodeAuthHeaders() },
|
|
|
|
|
});
|
|
|
|
|
const health = await healthResponse.json().catch(() => null);
|
|
|
|
|
if (!healthResponse.ok) {
|
|
|
|
|
return { ok: false, status: healthResponse.status, error: health?.error || healthResponse.statusText };
|
|
|
|
|
}
|
|
|
|
|
const currentVersion = typeof health?.version === 'string' ? health.version.replace(/^v/, '') : null;
|
|
|
|
|
return { ok: true, currentVersion };
|
|
|
|
|
};
|
|
|
|
|
|
2026-05-14 14:45:04 +03:00
|
|
|
const parseVersionForComparison = (value) => {
|
|
|
|
|
const normalized = String(value || '').replace(/^v/, '').split('+')[0];
|
|
|
|
|
const prereleaseIndex = normalized.indexOf('-');
|
|
|
|
|
const core = prereleaseIndex >= 0 ? normalized.slice(0, prereleaseIndex) : normalized;
|
|
|
|
|
const parts = core.split('.').map((part) => {
|
|
|
|
|
const parsed = Number.parseInt(part || '0', 10);
|
|
|
|
|
return Number.isFinite(parsed) ? parsed : 0;
|
|
|
|
|
});
|
|
|
|
|
return { parts, prerelease: prereleaseIndex >= 0 };
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const compareVersions = (left, right) => {
|
|
|
|
|
const a = parseVersionForComparison(left);
|
|
|
|
|
const b = parseVersionForComparison(right);
|
|
|
|
|
const length = Math.max(a.parts.length, b.parts.length);
|
|
|
|
|
for (let index = 0; index < length; index += 1) {
|
|
|
|
|
const diff = (a.parts[index] || 0) - (b.parts[index] || 0);
|
|
|
|
|
if (diff !== 0) return diff;
|
|
|
|
|
}
|
|
|
|
|
if (a.prerelease !== b.prerelease) return a.prerelease ? -1 : 1;
|
|
|
|
|
return 0;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const fetchLatestOpenCodeVersionFromGithub = async () => {
|
|
|
|
|
const response = await fetch('https://api.github.com/repos/anomalyco/opencode/releases/latest', {
|
|
|
|
|
headers: { Accept: 'application/json' },
|
|
|
|
|
signal: AbortSignal.timeout(10_000),
|
|
|
|
|
});
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
throw new Error(`OpenCode releases responded with ${response.status}`);
|
|
|
|
|
}
|
|
|
|
|
const payload = await response.json();
|
|
|
|
|
const tag = typeof payload?.tag_name === 'string' ? payload.tag_name.trim() : '';
|
|
|
|
|
return tag.replace(/^v/, '');
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const fetchLatestOpenCodeVersionFromNpm = async () => {
|
|
|
|
|
const response = await fetch('https://registry.npmjs.org/opencode-ai/latest', {
|
|
|
|
|
headers: { Accept: 'application/json' },
|
|
|
|
|
signal: AbortSignal.timeout(10_000),
|
|
|
|
|
});
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
throw new Error(`OpenCode npm registry responded with ${response.status}`);
|
|
|
|
|
}
|
|
|
|
|
const payload = await response.json();
|
|
|
|
|
return typeof payload?.version === 'string' ? payload.version.trim().replace(/^v/, '') : '';
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const fetchLatestOpenCodeVersion = async () => {
|
|
|
|
|
const results = await Promise.allSettled([
|
|
|
|
|
fetchLatestOpenCodeVersionFromNpm(),
|
|
|
|
|
fetchLatestOpenCodeVersionFromGithub(),
|
|
|
|
|
]);
|
|
|
|
|
const versions = results
|
|
|
|
|
.filter((result) => result.status === 'fulfilled' && result.value)
|
|
|
|
|
.map((result) => result.value);
|
|
|
|
|
if (versions.length === 0) {
|
|
|
|
|
const failure = results.find((result) => result.status === 'rejected');
|
|
|
|
|
throw failure?.reason instanceof Error ? failure.reason : new Error('Failed to resolve latest OpenCode version');
|
|
|
|
|
}
|
|
|
|
|
return versions.sort((left, right) => compareVersions(right, left))[0];
|
|
|
|
|
};
|
|
|
|
|
|
2026-04-21 11:46:51 -06:00
|
|
|
const pruneExpiredPendingMcpAuthContexts = () => {
|
|
|
|
|
const now = Date.now();
|
|
|
|
|
for (const [state, entry] of pendingMcpAuthContextByState.entries()) {
|
|
|
|
|
if (!entry || typeof entry.expiresAt !== 'number' || entry.expiresAt <= now) {
|
|
|
|
|
pendingMcpAuthContextByState.delete(state);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-03-31 18:47:00 +03:00
|
|
|
app.get('/api/config/settings', async (_req, res) => {
|
|
|
|
|
try {
|
|
|
|
|
const settings = await readSettingsFromDiskMigrated();
|
|
|
|
|
res.json(formatSettingsResponse(settings));
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('Failed to read settings:', error);
|
|
|
|
|
res.status(500).json({ error: 'Failed to read settings' });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
app.get('/api/config/opencode-resolution', async (_req, res) => {
|
|
|
|
|
try {
|
|
|
|
|
const settings = await readSettingsFromDiskMigrated();
|
|
|
|
|
const resolution = await getOpenCodeResolutionSnapshot(settings);
|
|
|
|
|
res.json(resolution);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('Failed to resolve OpenCode binary:', error);
|
|
|
|
|
res.status(500).json({ error: 'Failed to resolve OpenCode binary' });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-14 14:45:04 +03:00
|
|
|
app.post('/api/opencode/upgrade', async (req, res) => {
|
|
|
|
|
try {
|
2026-07-02 17:43:33 +03:00
|
|
|
if (await isBundledOpenCodeBinaryActive()) {
|
|
|
|
|
return res.status(409).json({
|
|
|
|
|
success: false,
|
|
|
|
|
error: 'OpenCode is bundled with OpenChamber Desktop and cannot be upgraded separately.',
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-14 14:45:04 +03:00
|
|
|
const target = typeof req.body?.target === 'string' && req.body.target.trim().length > 0
|
|
|
|
|
? req.body.target.trim()
|
|
|
|
|
: undefined;
|
|
|
|
|
const response = await fetch(buildOpenCodeUrl('/global/upgrade', ''), {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
headers: {
|
|
|
|
|
'Content-Type': 'application/json',
|
|
|
|
|
Accept: 'application/json',
|
|
|
|
|
...getOpenCodeAuthHeaders(),
|
|
|
|
|
},
|
|
|
|
|
body: JSON.stringify(target ? { target } : {}),
|
|
|
|
|
});
|
|
|
|
|
const payload = await response.json().catch(() => null);
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
return res.status(response.status).json({
|
|
|
|
|
success: false,
|
|
|
|
|
error: payload?.error || response.statusText || 'Failed to upgrade OpenCode',
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-05-20 17:29:00 +03:00
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
await refreshOpenCodeAfterConfigChange('OpenCode upgrade');
|
|
|
|
|
} catch (restartError) {
|
|
|
|
|
return res.status(500).json({
|
|
|
|
|
success: false,
|
|
|
|
|
upgraded: true,
|
|
|
|
|
error: restartError instanceof Error
|
|
|
|
|
? `OpenCode upgraded, but restart failed: ${restartError.message}`
|
|
|
|
|
: 'OpenCode upgraded, but restart failed',
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return res.json({ ...(payload ?? { success: true }), restarted: true });
|
2026-05-14 14:45:04 +03:00
|
|
|
} catch (error) {
|
|
|
|
|
console.error('Failed to upgrade OpenCode:', error);
|
|
|
|
|
return res.status(500).json({
|
|
|
|
|
success: false,
|
|
|
|
|
error: error instanceof Error ? error.message : 'Failed to upgrade OpenCode',
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
app.get('/api/opencode/upgrade-status', async (_req, res) => {
|
|
|
|
|
try {
|
2026-07-02 17:43:33 +03:00
|
|
|
if (await isBundledOpenCodeBinaryActive()) {
|
|
|
|
|
const current = await readOpenCodeCurrentVersion().catch(() => ({ ok: false, currentVersion: null }));
|
|
|
|
|
return res.json({
|
|
|
|
|
available: false,
|
|
|
|
|
currentVersion: current.ok ? current.currentVersion : null,
|
|
|
|
|
latestVersion: null,
|
|
|
|
|
source: 'bundled',
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-14 14:45:04 +03:00
|
|
|
const [healthResponse, latestVersion] = await Promise.all([
|
|
|
|
|
fetch(buildOpenCodeUrl('/global/health', ''), {
|
|
|
|
|
method: 'GET',
|
|
|
|
|
headers: { Accept: 'application/json', ...getOpenCodeAuthHeaders() },
|
|
|
|
|
}),
|
|
|
|
|
fetchLatestOpenCodeVersion(),
|
|
|
|
|
]);
|
|
|
|
|
const health = await healthResponse.json().catch(() => null);
|
|
|
|
|
if (!healthResponse.ok) {
|
|
|
|
|
return res.status(healthResponse.status).json({
|
|
|
|
|
available: null,
|
|
|
|
|
error: health?.error || healthResponse.statusText || 'Failed to read OpenCode version',
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
const currentVersion = typeof health?.version === 'string' ? health.version.replace(/^v/, '') : null;
|
|
|
|
|
if (!currentVersion || !latestVersion) {
|
|
|
|
|
return res.json({ available: null, currentVersion, latestVersion: latestVersion || null });
|
|
|
|
|
}
|
|
|
|
|
const available = compareVersions(latestVersion, currentVersion) > 0;
|
|
|
|
|
return res.json({
|
|
|
|
|
available,
|
|
|
|
|
currentVersion,
|
|
|
|
|
latestVersion,
|
|
|
|
|
});
|
|
|
|
|
} catch (error) {
|
|
|
|
|
return res.status(500).json({
|
|
|
|
|
available: null,
|
|
|
|
|
error: error instanceof Error ? error.message : 'Failed to check OpenCode upgrade status',
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-06-05 15:09:24 +03:00
|
|
|
app.get('/api/opencode/health', async (_req, res) => {
|
|
|
|
|
try {
|
2026-06-05 18:43:13 +03:00
|
|
|
const healthResponse = await fetch(buildOpenCodeUrl('/global/health', ''), {
|
2026-06-05 15:09:24 +03:00
|
|
|
method: 'GET',
|
|
|
|
|
headers: { Accept: 'application/json', ...getOpenCodeAuthHeaders() },
|
|
|
|
|
});
|
|
|
|
|
const health = await healthResponse.json().catch(() => null);
|
|
|
|
|
if (!healthResponse.ok) {
|
|
|
|
|
return res.status(healthResponse.status).json({
|
|
|
|
|
healthy: false,
|
|
|
|
|
error: health?.error || healthResponse.statusText || 'OpenCode health check failed',
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
return res.json({ healthy: health?.healthy === true });
|
|
|
|
|
} catch (error) {
|
|
|
|
|
return res.status(503).json({
|
|
|
|
|
healthy: false,
|
|
|
|
|
error: error instanceof Error ? error.message : 'OpenCode health check failed',
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
app.get('/api/opencode/version', async (_req, res) => {
|
|
|
|
|
try {
|
|
|
|
|
const healthResponse = await fetch(buildOpenCodeUrl('/global/health', ''), {
|
|
|
|
|
method: 'GET',
|
|
|
|
|
headers: { Accept: 'application/json', ...getOpenCodeAuthHeaders() },
|
|
|
|
|
});
|
|
|
|
|
const health = await healthResponse.json().catch(() => null);
|
|
|
|
|
if (!healthResponse.ok) {
|
|
|
|
|
return res.status(healthResponse.status).json({
|
|
|
|
|
version: null,
|
|
|
|
|
error: health?.error || healthResponse.statusText || 'Failed to read OpenCode version',
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
const version = typeof health?.version === 'string' ? health.version.replace(/^v/, '') : null;
|
|
|
|
|
return res.json({ version });
|
|
|
|
|
} catch (error) {
|
|
|
|
|
return res.status(500).json({
|
|
|
|
|
version: null,
|
|
|
|
|
error: error instanceof Error ? error.message : 'Failed to read OpenCode version',
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-03-31 18:47:00 +03:00
|
|
|
app.put('/api/config/settings', async (req, res) => {
|
|
|
|
|
try {
|
|
|
|
|
const updated = await persistSettings(req.body ?? {});
|
|
|
|
|
res.json(updated);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('[API:PUT /api/config/settings] Failed to save settings:', error);
|
|
|
|
|
console.error('[API:PUT /api/config/settings] Error stack:', error.stack);
|
|
|
|
|
res.status(500).json({ error: 'Failed to save settings' });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-21 11:46:51 -06:00
|
|
|
app.post('/api/mcp/auth/pending', async (req, res) => {
|
|
|
|
|
try {
|
|
|
|
|
pruneExpiredPendingMcpAuthContexts();
|
|
|
|
|
|
|
|
|
|
const state = normalizePendingString(req.body?.state);
|
|
|
|
|
if (!state) {
|
|
|
|
|
return res.json({ success: true, context: null });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const name = normalizePendingString(req.body?.name);
|
|
|
|
|
if (!name) {
|
|
|
|
|
return res.status(400).json({ error: 'MCP server name is required' });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const entry = {
|
|
|
|
|
name,
|
|
|
|
|
directory: normalizePendingString(req.body?.directory),
|
|
|
|
|
expiresAt: Date.now() + PENDING_MCP_AUTH_TTL_MS,
|
|
|
|
|
};
|
|
|
|
|
pendingMcpAuthContextByState.set(state, entry);
|
|
|
|
|
|
|
|
|
|
return res.json({
|
|
|
|
|
success: true,
|
|
|
|
|
context: {
|
|
|
|
|
name: entry.name,
|
|
|
|
|
directory: entry.directory,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('Failed to store pending MCP auth context:', error);
|
|
|
|
|
return res.status(500).json({ error: error.message || 'Failed to store pending MCP auth context' });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
app.get('/api/mcp/auth/pending', async (req, res) => {
|
|
|
|
|
try {
|
|
|
|
|
pruneExpiredPendingMcpAuthContexts();
|
|
|
|
|
|
|
|
|
|
const state = normalizePendingString(Array.isArray(req.query?.state) ? req.query.state[0] : req.query?.state);
|
|
|
|
|
if (!state) {
|
|
|
|
|
return res.json(null);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const pendingMcpAuthContext = pendingMcpAuthContextByState.get(state) ?? null;
|
|
|
|
|
if (!pendingMcpAuthContext) {
|
|
|
|
|
return res.status(404).json({ error: 'No pending MCP auth context' });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return res.json(pendingMcpAuthContext);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('Failed to read pending MCP auth context:', error);
|
|
|
|
|
return res.status(500).json({ error: error.message || 'Failed to read pending MCP auth context' });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
app.delete('/api/mcp/auth/pending', async (req, res) => {
|
|
|
|
|
try {
|
|
|
|
|
const state = normalizePendingString(Array.isArray(req.query?.state) ? req.query.state[0] : req.query?.state);
|
|
|
|
|
if (!state) {
|
|
|
|
|
return res.json({ success: true });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pendingMcpAuthContextByState.delete(state);
|
|
|
|
|
return res.json({ success: true });
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('Failed to clear pending MCP auth context:', error);
|
|
|
|
|
return res.status(500).json({ error: error.message || 'Failed to clear pending MCP auth context' });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-03-31 18:47:00 +03:00
|
|
|
app.get('/api/provider/:providerId/source', async (req, res) => {
|
|
|
|
|
try {
|
|
|
|
|
const { providerId } = req.params;
|
|
|
|
|
if (!providerId) {
|
|
|
|
|
return res.status(400).json({ error: 'Provider ID is required' });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const headerDirectory = typeof req.get === 'function' ? req.get('x-opencode-directory') : null;
|
|
|
|
|
const queryDirectory = Array.isArray(req.query?.directory)
|
|
|
|
|
? req.query.directory[0]
|
|
|
|
|
: req.query?.directory;
|
|
|
|
|
const requestedDirectory = headerDirectory || queryDirectory || null;
|
|
|
|
|
|
|
|
|
|
let directory = null;
|
|
|
|
|
const resolved = await resolveProjectDirectory(req);
|
|
|
|
|
if (resolved.directory) {
|
|
|
|
|
directory = resolved.directory;
|
|
|
|
|
} else if (requestedDirectory) {
|
|
|
|
|
return res.status(400).json({ error: resolved.error });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const sources = getProviderSources(providerId, directory);
|
|
|
|
|
const { getProviderAuth } = await getAuthLibrary();
|
|
|
|
|
const auth = getProviderAuth(providerId);
|
|
|
|
|
sources.sources.auth.exists = Boolean(auth);
|
|
|
|
|
|
|
|
|
|
return res.json({
|
|
|
|
|
providerId,
|
|
|
|
|
sources: sources.sources,
|
|
|
|
|
});
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('Failed to get provider sources:', error);
|
|
|
|
|
return res.status(500).json({ error: error.message || 'Failed to get provider sources' });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
app.delete('/api/provider/:providerId/auth', async (req, res) => {
|
|
|
|
|
try {
|
|
|
|
|
const { providerId } = req.params;
|
|
|
|
|
if (!providerId) {
|
|
|
|
|
return res.status(400).json({ error: 'Provider ID is required' });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const scope = typeof req.query?.scope === 'string' ? req.query.scope : 'auth';
|
|
|
|
|
const headerDirectory = typeof req.get === 'function' ? req.get('x-opencode-directory') : null;
|
|
|
|
|
const queryDirectory = Array.isArray(req.query?.directory)
|
|
|
|
|
? req.query.directory[0]
|
|
|
|
|
: req.query?.directory;
|
|
|
|
|
const requestedDirectory = headerDirectory || queryDirectory || null;
|
|
|
|
|
let directory = null;
|
|
|
|
|
|
|
|
|
|
if (scope === 'project' || requestedDirectory) {
|
|
|
|
|
const resolved = await resolveProjectDirectory(req);
|
|
|
|
|
if (!resolved.directory) {
|
|
|
|
|
return res.status(400).json({ error: resolved.error });
|
|
|
|
|
}
|
|
|
|
|
directory = resolved.directory;
|
|
|
|
|
} else {
|
|
|
|
|
const resolved = await resolveProjectDirectory(req);
|
|
|
|
|
if (resolved.directory) {
|
|
|
|
|
directory = resolved.directory;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let removed = false;
|
|
|
|
|
if (scope === 'auth') {
|
|
|
|
|
const { removeProviderAuth } = await getAuthLibrary();
|
|
|
|
|
removed = removeProviderAuth(providerId);
|
|
|
|
|
} else if (scope === 'user' || scope === 'project' || scope === 'custom') {
|
|
|
|
|
removed = removeProviderConfig(providerId, directory, scope);
|
|
|
|
|
} else if (scope === 'all') {
|
|
|
|
|
const { removeProviderAuth } = await getAuthLibrary();
|
|
|
|
|
const authRemoved = removeProviderAuth(providerId);
|
|
|
|
|
const userRemoved = removeProviderConfig(providerId, directory, 'user');
|
|
|
|
|
const projectRemoved = directory ? removeProviderConfig(providerId, directory, 'project') : false;
|
|
|
|
|
const customRemoved = removeProviderConfig(providerId, directory, 'custom');
|
|
|
|
|
removed = authRemoved || userRemoved || projectRemoved || customRemoved;
|
|
|
|
|
} else {
|
|
|
|
|
return res.status(400).json({ error: 'Invalid scope' });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (removed) {
|
|
|
|
|
await refreshOpenCodeAfterConfigChange(`provider ${providerId} disconnected (${scope})`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return res.json({
|
|
|
|
|
success: true,
|
|
|
|
|
removed,
|
|
|
|
|
requiresReload: removed,
|
|
|
|
|
message: removed ? 'Provider disconnected successfully' : 'Provider was not connected',
|
|
|
|
|
reloadDelayMs: removed ? clientReloadDelayMs : undefined,
|
|
|
|
|
});
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('Failed to disconnect provider:', error);
|
|
|
|
|
return res.status(500).json({ error: error.message || 'Failed to disconnect provider' });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
app.post('/api/opencode/directory', async (req, res) => {
|
|
|
|
|
try {
|
|
|
|
|
const requestedPath = typeof req.body?.path === 'string' ? req.body.path.trim() : '';
|
|
|
|
|
if (!requestedPath) {
|
|
|
|
|
return res.status(400).json({ error: 'Path is required' });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const validated = await validateDirectoryPath(requestedPath);
|
|
|
|
|
if (!validated.ok) {
|
|
|
|
|
return res.status(400).json({ error: validated.error });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const resolvedPath = validated.directory;
|
|
|
|
|
const currentSettings = await readSettingsFromDisk();
|
|
|
|
|
const existingProjects = sanitizeProjects(currentSettings.projects) || [];
|
|
|
|
|
const existing = existingProjects.find((project) => project.path === resolvedPath) || null;
|
|
|
|
|
|
|
|
|
|
const nextProjects = existing
|
|
|
|
|
? existingProjects
|
|
|
|
|
: [
|
|
|
|
|
...existingProjects,
|
|
|
|
|
{
|
2026-04-18 13:46:38 +03:00
|
|
|
id: createProjectIdFromPath(resolvedPath),
|
2026-03-31 18:47:00 +03:00
|
|
|
path: resolvedPath,
|
|
|
|
|
addedAt: Date.now(),
|
|
|
|
|
lastOpenedAt: Date.now(),
|
|
|
|
|
},
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
const activeProjectId = existing ? existing.id : nextProjects[nextProjects.length - 1].id;
|
|
|
|
|
|
|
|
|
|
const updated = await persistSettings({
|
|
|
|
|
projects: nextProjects,
|
|
|
|
|
activeProjectId,
|
|
|
|
|
lastDirectory: resolvedPath,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return res.json({
|
|
|
|
|
success: true,
|
|
|
|
|
restarted: false,
|
|
|
|
|
path: resolvedPath,
|
|
|
|
|
settings: updated,
|
|
|
|
|
});
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('Failed to update OpenCode working directory:', error);
|
|
|
|
|
return res.status(500).json({ error: error.message || 'Failed to update working directory' });
|
|
|
|
|
}
|
|
|
|
|
});
|
2026-04-30 14:39:55 -07:00
|
|
|
|
|
|
|
|
// Behavior / Global AGENTS.md endpoints
|
|
|
|
|
const AGENTS_MD_PATH = path.join(os.homedir(), '.config', 'opencode', 'AGENTS.md');
|
|
|
|
|
const MAX_BEHAVIOR_PROMPT_SIZE = 1024 * 1024; // 1 MB
|
|
|
|
|
|
|
|
|
|
app.get('/api/behavior/agents-md', async (_req, res) => {
|
|
|
|
|
try {
|
|
|
|
|
try {
|
|
|
|
|
await fs.promises.access(AGENTS_MD_PATH);
|
|
|
|
|
} catch {
|
|
|
|
|
return res.json({ content: '', exists: false });
|
|
|
|
|
}
|
|
|
|
|
const content = await fs.promises.readFile(AGENTS_MD_PATH, 'utf8');
|
|
|
|
|
return res.json({ content, exists: true });
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('Failed to read AGENTS.md:', error);
|
|
|
|
|
return res.status(500).json({ error: 'Failed to read AGENTS.md' });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
app.put('/api/behavior/agents-md', async (req, res) => {
|
|
|
|
|
try {
|
|
|
|
|
const content = typeof req.body?.content === 'string' ? req.body.content : '';
|
|
|
|
|
|
|
|
|
|
if (content.length > MAX_BEHAVIOR_PROMPT_SIZE) {
|
|
|
|
|
return res.status(413).json({ error: `Content exceeds maximum size of ${MAX_BEHAVIOR_PROMPT_SIZE} bytes` });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Ensure parent directory exists
|
|
|
|
|
const parentDir = path.dirname(AGENTS_MD_PATH);
|
|
|
|
|
try {
|
|
|
|
|
await fs.promises.access(parentDir);
|
|
|
|
|
} catch {
|
|
|
|
|
await fs.promises.mkdir(parentDir, { recursive: true });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await fs.promises.writeFile(AGENTS_MD_PATH, content, 'utf8');
|
|
|
|
|
|
|
|
|
|
// Refresh OpenCode so it picks up the new AGENTS.md without a full restart
|
|
|
|
|
try {
|
|
|
|
|
await refreshOpenCodeAfterConfigChange('global behavior (AGENTS.md) updated');
|
|
|
|
|
} catch {
|
|
|
|
|
// Non-fatal: file was written successfully
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return res.json({ success: true });
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('Failed to write AGENTS.md:', error);
|
|
|
|
|
return res.status(500).json({ error: error.message || 'Failed to write AGENTS.md' });
|
|
|
|
|
}
|
|
|
|
|
});
|
2026-03-31 18:47:00 +03:00
|
|
|
};
|