/** * OpenChamber project-level configuration service. * Stores per-project settings in ~/.config/openchamber/.json. * Migrates from legacy /.openchamber/openchamber.json. */ import type { FilesAPI, RuntimeAPIs } from './api/types'; import { getDesktopHomeDirectory } from './desktop'; import { isVSCodeRuntime } from './desktop'; type ProjectRef = { id: string; path: string }; const CONFIG_FILENAME = 'openchamber.json'; // LEGACY_PROJECT_CONFIG: legacy per-project config root inside repo. const LEGACY_CONFIG_DIR = '.openchamber'; const USER_CONFIG_DIR_SEGMENTS = ['.config', 'openchamber']; const USER_PROJECTS_DIR_SEGMENTS = ['.config', 'openchamber', 'projects']; const SETTINGS_FILENAME = 'settings.json'; const projectIdCache = new Map(); const isSafeConfigFileId = (value: string): boolean => /^[A-Za-z0-9._-]+$/.test(value); const toHex = (bytes: Uint8Array): string => { let out = ''; for (const b of bytes) { out += b.toString(16).padStart(2, '0'); } return out; }; const sha1Hex = async (value: string): Promise => { try { if (typeof crypto === 'undefined' || !crypto.subtle) { return null; } const encoder = new TextEncoder(); const data = encoder.encode(value); const digest = await crypto.subtle.digest('SHA-1', data); return toHex(new Uint8Array(digest)); } catch { return null; } }; /** * Get the runtime Files API if available (Desktop/VSCode). */ function getRuntimeFilesAPI(): FilesAPI | null { if (typeof window === 'undefined') return null; const apis = (window as typeof window & { __OPENCHAMBER_RUNTIME_APIS__?: RuntimeAPIs }).__OPENCHAMBER_RUNTIME_APIS__; if (apis?.files) { return apis.files; } return null; } export interface OpenChamberConfig { 'setup-worktree'?: string[]; } const normalize = (value: string): string => { if (!value) return ''; const replaced = value.replace(/\\/g, '/'); return replaced === '/' ? '/' : replaced.replace(/\/+$/, ''); }; const joinPath = (base: string, segment: string): string => { const normalizedBase = normalize(base); const cleanSegment = segment.replace(/\\/g, '/').replace(/^\/+/, '').replace(/\/+$/, ''); if (!normalizedBase || normalizedBase === '/') { return `/${cleanSegment}`; } return `${normalizedBase}/${cleanSegment}`; }; const getLegacyConfigPath = (projectDirectory: string): string => { return joinPath(joinPath(projectDirectory, LEGACY_CONFIG_DIR), CONFIG_FILENAME); }; const getBaseUrl = (): string => { const defaultBaseUrl = import.meta.env.VITE_OPENCODE_URL || '/api'; if (defaultBaseUrl.startsWith('/')) { return defaultBaseUrl; } return defaultBaseUrl; }; const postJson = async (url: string, body: unknown): Promise<{ ok: boolean; data: T | null }> => { try { const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); if (!response.ok) { return { ok: false, data: null }; } const data = (await response.json().catch(() => null)) as T | null; return { ok: true, data }; } catch { return { ok: false, data: null }; } }; const mkdirp = async (path: string): Promise => { const runtimeFiles = getRuntimeFilesAPI(); if (runtimeFiles?.createDirectory) { try { const result = await runtimeFiles.createDirectory(path); if (result?.success) { return true; } } catch { // fall through } } const res = await postJson<{ success?: boolean }>(`${getBaseUrl()}/fs/mkdir`, { path }); return Boolean(res.ok); }; const readTextFile = async (path: string): Promise => { const runtimeFiles = getRuntimeFilesAPI(); if (runtimeFiles?.readFile) { try { const result = await runtimeFiles.readFile(path); const content = typeof result?.content === 'string' ? result.content : ''; return content; } catch { return null; } } try { const response = await fetch(`${getBaseUrl()}/fs/read?path=${encodeURIComponent(path)}`); if (!response.ok) { return null; } return await response.text(); } catch { return null; } }; const writeTextFile = async (path: string, content: string): Promise => { const runtimeFiles = getRuntimeFilesAPI(); if (runtimeFiles?.writeFile) { try { const result = await runtimeFiles.writeFile(path, content); if (result?.success) { return true; } } catch { // fall through } } const res = await postJson<{ success?: boolean }>(`${getBaseUrl()}/fs/write`, { path, content }); return Boolean(res.ok); }; const resolveHomeDirectory = async (): Promise => { // VSCode webview sets __OPENCHAMBER_HOME__ to workspace folder (not OS home). // For user config (~/.config/openchamber), always use /api/fs/home in VSCode. if (!isVSCodeRuntime()) { const desktopHome = await getDesktopHomeDirectory().catch(() => null); if (desktopHome && desktopHome.trim().length > 0) { return normalize(desktopHome); } } try { const response = await fetch(`${getBaseUrl()}/fs/home`); if (!response.ok) { return null; } const payload = await response.json().catch(() => null) as { home?: unknown } | null; const home = typeof payload?.home === 'string' ? payload.home.trim() : ''; return home ? normalize(home) : null; } catch { return null; } }; const getUserConfigRootDirectory = async (): Promise => { const home = await resolveHomeDirectory(); if (!home) { return null; } return USER_CONFIG_DIR_SEGMENTS.reduce((acc, segment) => joinPath(acc, segment), home); }; const getUserProjectsDirectory = async (): Promise => { const home = await resolveHomeDirectory(); if (!home) { return null; } return USER_PROJECTS_DIR_SEGMENTS.reduce((acc, segment) => joinPath(acc, segment), home); }; const getSettingsPath = async (): Promise => { const base = await getUserConfigRootDirectory(); if (!base) { return null; } return joinPath(base, SETTINGS_FILENAME); }; const resolveConfigProjectId = async (project: ProjectRef): Promise => { const projectDirectory = typeof project?.path === 'string' ? project.path.trim() : ''; const normalizedProject = projectDirectory ? normalize(projectDirectory) : ''; const explicitId = typeof project?.id === 'string' ? project.id.trim() : ''; if (explicitId && isSafeConfigFileId(explicitId)) { return explicitId; } if (normalizedProject) { const cached = projectIdCache.get(normalizedProject); if (cached) { return cached; } } // Best-effort map project directory -> persisted project id from settings.json. const settingsPath = await getSettingsPath(); if (settingsPath && normalizedProject) { const raw = await readTextFile(settingsPath); if (raw) { try { const parsed = JSON.parse(raw) as { projects?: unknown }; const projects = Array.isArray(parsed?.projects) ? parsed.projects : []; for (const entry of projects) { if (!entry || typeof entry !== 'object') continue; const record = entry as { id?: unknown; path?: unknown }; const id = typeof record.id === 'string' ? record.id.trim() : ''; const path = typeof record.path === 'string' ? normalize(record.path.trim()) : ''; if (id && isSafeConfigFileId(id) && path && path === normalizedProject) { projectIdCache.set(normalizedProject, id); return id; } } } catch { // ignore } } } // Fallback: stable id derived from path (used in VSCode when project isn't registered). if (normalizedProject) { const digest = await sha1Hex(normalizedProject); const fallback = digest ? `path_${digest}` : `path_${normalizedProject.replace(/[^A-Za-z0-9._-]+/g, '_')}`; projectIdCache.set(normalizedProject, fallback); return fallback; } return null; }; const getUserConfigPath = async (project: ProjectRef): Promise => { const base = await getUserProjectsDirectory(); if (!base) { return null; } const safeId = await resolveConfigProjectId(project); if (!safeId) { return null; } return joinPath(base, `${safeId}.json`); }; /** * Read the config for a project. * Returns null if file doesn't exist or is invalid. */ export async function readOpenChamberConfig(project: ProjectRef): Promise { const projectDirectory = typeof project?.path === 'string' ? project.path.trim() : ''; if (!projectDirectory) { return null; } const configPath = await getUserConfigPath(project); const readText = async (path: string): Promise => { // Keep behavior consistent with other helpers. const text = await readTextFile(path); if (text === null) { return null; } return text; }; const parseConfig = (text: string | null): OpenChamberConfig | null => { if (typeof text !== 'string') { return null; } const trimmed = text.trim(); if (!trimmed) { return null; } try { const parsed = JSON.parse(trimmed); if (!parsed || typeof parsed !== 'object') { return null; } return parsed as OpenChamberConfig; } catch { return null; } }; // 1) Prefer new per-user config. if (configPath) { const existing = parseConfig(await readText(configPath)); if (existing) { return existing; } } // 2) Migrate legacy /.openchamber/openchamber.json. // LEGACY_PROJECT_CONFIG: migrate project-local openchamber.json -> ~/.config/openchamber/projects/.json const legacyPath = getLegacyConfigPath(projectDirectory); const legacyConfig = parseConfig(await readText(legacyPath)); if (!legacyConfig) { return null; } // Best-effort write + delete legacy. try { const wrote = await writeOpenChamberConfig(project, legacyConfig); if (wrote) { await deleteLegacyOpenChamberConfig(projectDirectory); } } catch { // Ignore migration failures; still return legacy content. } return legacyConfig; } /** * Write the per-user config for a project. */ export async function writeOpenChamberConfig( project: ProjectRef, config: OpenChamberConfig ): Promise { const projectDirectory = typeof project?.path === 'string' ? project.path.trim() : ''; if (!projectDirectory) { return false; } const configDir = await getUserProjectsDirectory(); const configPath = await getUserConfigPath(project); if (!configDir || !configPath) { return false; } try { // Ensure user config directory exists. const okDir = await mkdirp(configDir); if (!okDir) { return false; } const content = JSON.stringify(config, null, 2); return await writeTextFile(configPath, content); } catch (error) { console.error('Failed to write openchamber config:', error); return false; } } /** * Update specific keys in the config, preserving other values. */ export async function updateOpenChamberConfig( project: ProjectRef, updates: Partial ): Promise { const existing = await readOpenChamberConfig(project) || {}; const merged = { ...existing, ...updates }; return writeOpenChamberConfig(project, merged); } /** * Get worktree setup commands from config. */ export async function getWorktreeSetupCommands(project: ProjectRef): Promise { const config = await readOpenChamberConfig(project); return config?.['setup-worktree'] ?? []; } export async function saveWorktreeSetupCommands(project: ProjectRef, commands: string[]): Promise { const filtered = commands.filter((cmd) => cmd.trim().length > 0); return updateOpenChamberConfig(project, { 'setup-worktree': filtered }); } /** * Substitute variables in a command string. * Supported variables: * - $ROOT_PROJECT_PATH: The root project directory path * - $ROOT_WORKTREE_PATH: Legacy alias for $ROOT_PROJECT_PATH */ export function substituteCommandVariables( command: string, variables: { rootWorktreePath: string } ): string { return command // New preferred name .replace(/\$ROOT_PROJECT_PATH/g, variables.rootWorktreePath) .replace(/\$\{ROOT_PROJECT_PATH\}/g, variables.rootWorktreePath) // Legacy .replace(/\$ROOT_WORKTREE_PATH/g, variables.rootWorktreePath) .replace(/\$\{ROOT_WORKTREE_PATH\}/g, variables.rootWorktreePath); } async function deleteLegacyOpenChamberConfig(projectDirectory: string): Promise { const legacyPath = getLegacyConfigPath(projectDirectory); const runtimeFiles = getRuntimeFilesAPI(); if (runtimeFiles?.delete) { try { await runtimeFiles.delete(legacyPath); return; } catch { // fall through } } try { await postJson(`${getBaseUrl()}/fs/delete`, { path: legacyPath }); } catch { // ignored } } export type { ProjectRef };