Files
openchamber/packages/web/server/lib/opencode/routes.js
T
Bohdan Triapitsyn 4901cf60b8 fix(projects): deterministic project identity + safe per-project persistence
Derive project.id from project.path (path_<base64url(path)>), shared helper on
both server (lib/projects/project-id.js) and client (lib/projectId.ts). Replace
random UUIDs so icons, notes, todos, actions, setup-worktree, plans and
scheduledTasks share one id across restarts and reinstalls.

Fix read-then-overwrite clobber in project-config.js: scheduled-task writes now
merge with the existing project json instead of replacing it, preserving
client-written fields that live in the same file.

On settings load migrate legacy UUID ids to canonical path ids, moving config
json, storage dir contents and icon files, and remap activeProjectId. Scan for
orphan non-path_* project configs and merge them into the canonical project
when a \$ROOT_PROJECT_PATH/<file> reference resolves on disk, logging any that
can't be matched.

Client openchamberConfig.writeOpenChamberConfig re-asserts server-owned keys
(version, scheduledTasks) on write to defeat the symmetric race. Stores and
persistence derive ids from path consistently.
2026-04-18 13:46:38 +03:00

209 lines
7.4 KiB
JavaScript

import { createProjectIdFromPath } from '../projects/project-id.js';
export const registerOpenCodeRoutes = (app, dependencies) => {
const {
crypto,
clientReloadDelayMs,
getOpenCodeResolutionSnapshot,
formatSettingsResponse,
readSettingsFromDisk,
readSettingsFromDiskMigrated,
persistSettings,
sanitizeProjects,
validateDirectoryPath,
resolveProjectDirectory,
getProviderSources,
removeProviderConfig,
refreshOpenCodeAfterConfigChange,
} = dependencies;
let authLibrary = null;
const getAuthLibrary = async () => {
if (!authLibrary) {
authLibrary = await import('./auth.js');
}
return authLibrary;
};
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' });
}
});
app.put('/api/config/settings', async (req, res) => {
console.log('[API:PUT /api/config/settings] Received request');
try {
const updated = await persistSettings(req.body ?? {});
console.log(`[API:PUT /api/config/settings] Success, returning ${updated.projects?.length || 0} projects`);
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' });
}
});
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,
{
id: createProjectIdFromPath(resolvedPath),
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' });
}
});
};