diff --git a/packages/ui/src/lib/openchamberConfig.ts b/packages/ui/src/lib/openchamberConfig.ts index b9889023..727c9174 100644 --- a/packages/ui/src/lib/openchamberConfig.ts +++ b/packages/ui/src/lib/openchamberConfig.ts @@ -7,41 +7,14 @@ import type { FilesAPI, RuntimeAPIs } from './api/types'; import { getDesktopHomeDirectory } from './desktop'; import { isVSCodeRuntime } from './desktop'; +import { createProjectIdFromPath } from './projectId'; 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). @@ -56,9 +29,11 @@ function getRuntimeFilesAPI(): FilesAPI | null { } export interface OpenChamberConfig { + projectPath?: string; 'setup-worktree'?: string[]; projectNotes?: string; projectTodos?: OpenChamberProjectTodoItem[]; + projectPlanFiles?: OpenChamberProjectPlanFileLink[]; projectActions?: OpenChamberProjectAction[]; projectActionsPrimaryId?: string; } @@ -88,17 +63,35 @@ export interface OpenChamberProjectTodoItem { createdAt: number; } +export interface OpenChamberProjectPlanFileLink { + id: string; + path: string; + createdAt: number; +} + +export interface OpenChamberProjectPlanFile { + title: string; + body: string; + raw: string; + path: string; +} + export interface OpenChamberProjectNotesTodos { notes: string; todos: OpenChamberProjectTodoItem[]; } +export interface OpenChamberProjectContextData extends OpenChamberProjectNotesTodos { + plans: OpenChamberProjectPlanFileLink[]; +} + export const OPENCHAMBER_PROJECT_NOTES_MAX_LENGTH = 1000; export const OPENCHAMBER_PROJECT_TODO_TEXT_MAX_LENGTH = 120; export const OPENCHAMBER_PROJECT_ACTION_NAME_MAX_LENGTH = 80; export const OPENCHAMBER_PROJECT_ACTION_COMMAND_MAX_LENGTH = 4000; export const OPENCHAMBER_PROJECT_ACTION_OPEN_URL_MAX_LENGTH = 2000; export const OPENCHAMBER_PROJECT_ACTION_DESKTOP_FORWARD_MAX_LENGTH = 300; +export const OPENCHAMBER_PROJECT_PLAN_TITLE_MAX_LENGTH = 160; const OPENCHAMBER_ACTION_PLATFORM_SET = new Set(['macos', 'linux', 'windows']); @@ -226,14 +219,6 @@ const resolveHomeDirectory = async (): Promise => { } }; -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) { @@ -242,63 +227,11 @@ const getUserProjectsDirectory = async (): Promise => { 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 resolveConfigProjectId = (project: ProjectRef): string | null => { 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; + if (!normalizedProject) return null; + return createProjectIdFromPath(normalizedProject) || null; }; const getUserConfigPath = async (project: ProjectRef): Promise => { @@ -306,7 +239,7 @@ const getUserConfigPath = async (project: ProjectRef): Promise => if (!base) { return null; } - const safeId = await resolveConfigProjectId(project); + const safeId = resolveConfigProjectId(project); if (!safeId) { return null; } @@ -370,6 +303,43 @@ const sanitizeProjectTodoItems = (value: unknown): OpenChamberProjectTodoItem[] return sanitized; }; +const sanitizeProjectPlanFileLinks = (value: unknown): OpenChamberProjectPlanFileLink[] => { + if (!Array.isArray(value)) { + return []; + } + + const sanitized: OpenChamberProjectPlanFileLink[] = []; + const seenIds = new Set(); + + for (const entry of value) { + if (!entry || typeof entry !== 'object') { + continue; + } + + const record = entry as { + id?: unknown; + path?: unknown; + createdAt?: unknown; + }; + + const id = typeof record.id === 'string' ? record.id.trim() : ''; + const path = typeof record.path === 'string' ? record.path.trim() : ''; + const createdAt = + typeof record.createdAt === 'number' && Number.isFinite(record.createdAt) && record.createdAt >= 0 + ? record.createdAt + : Date.now(); + + if (!id || !path || seenIds.has(id)) { + continue; + } + + seenIds.add(id); + sanitized.push({ id, path, createdAt }); + } + + return sanitized.sort((a, b) => b.createdAt - a.createdAt); +}; + const sanitizeProjectActionPlatforms = (value: unknown): OpenChamberProjectActionPlatform[] => { if (!Array.isArray(value)) { return []; @@ -479,6 +449,87 @@ const sanitizeProjectNotesAndTodos = (value: { }; }; +const sanitizeProjectContextData = (value: { + notes?: unknown; + todos?: unknown; + plans?: unknown; +} | null | undefined): OpenChamberProjectContextData => { + const notesAndTodos = sanitizeProjectNotesAndTodos(value); + return { + ...notesAndTodos, + plans: sanitizeProjectPlanFileLinks(value?.plans), + }; +}; + +const slugifyPlanTitle = (value: string): string => { + const normalized = value + .trim() + .toLowerCase() + .replace(/[`*_#>[\](){}.!?,:;"']/g, '') + .replace(/\s+/g, '-') + .replace(/[^a-z0-9-]/g, '-') + .replace(/-+/g, '-') + .replace(/^-+|-+$/g, ''); + + return normalized || 'plan'; +}; + +const sanitizePlanTitle = (value: string): string => { + return trimToMaxLength(value.trim(), OPENCHAMBER_PROJECT_PLAN_TITLE_MAX_LENGTH); +}; + +const createProjectPlanId = (): string => { + if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { + return crypto.randomUUID(); + } + return `plan_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; +}; + +const getProjectStorageDirectory = async (project: ProjectRef): Promise => { + const base = await getUserProjectsDirectory(); + const safeId = resolveConfigProjectId(project); + if (!base || !safeId) { + return null; + } + return joinPath(base, safeId); +}; + +const getProjectPlansDirectory = async (project: ProjectRef): Promise => { + const projectDirectory = await getProjectStorageDirectory(project); + if (!projectDirectory) { + return null; + } + return joinPath(projectDirectory, 'plans'); +}; + +export const formatProjectPlanMarkdown = (title: string, body: string): string => { + const normalizedTitle = sanitizePlanTitle(title) || 'Plan'; + const normalizedBody = body.trim(); + return normalizedBody + ? `# ${normalizedTitle}\n\n${normalizedBody}` + : `# ${normalizedTitle}\n`; +}; + +export const parseProjectPlanMarkdown = (raw: string): { title: string; body: string } => { + const text = typeof raw === 'string' ? raw : ''; + const normalized = text.replace(/\r\n?/g, '\n'); + const match = normalized.match(/^\s*#\s+(.+?)\s*(?:\n+|$)/); + if (match) { + const title = sanitizePlanTitle(match[1]); + const body = normalized.slice(match[0].length).replace(/^\n+/, ''); + return { + title: title || 'Plan', + body, + }; + } + + const firstNonEmptyLine = normalized.split('\n').map((line) => line.trim()).find(Boolean) || 'Plan'; + return { + title: sanitizePlanTitle(firstNonEmptyLine.replace(/^#+\s*/, '')) || 'Plan', + body: normalized.trim(), + }; +}; + /** * Read the config for a project. * Returns null if file doesn't exist or is invalid. @@ -550,6 +601,10 @@ export async function readOpenChamberConfig(project: ProjectRef): Promise = {}; + if (typeof existingRaw === 'string' && existingRaw.trim()) { + try { + const parsed = JSON.parse(existingRaw); + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + existing = parsed as Record; + } + } catch { + existing = {}; + } + } + + const serverOwned: Record = {}; + if (existing.version !== undefined) serverOwned.version = existing.version; + if (existing.scheduledTasks !== undefined) serverOwned.scheduledTasks = existing.scheduledTasks; + + const content = JSON.stringify({ + ...existing, + ...config, + ...serverOwned, + projectPath: normalize(projectDirectory), + }, null, 2); return await writeTextFile(configPath, content); } catch (error) { console.error('Failed to write openchamber config:', error); @@ -629,6 +705,90 @@ export async function saveProjectNotesAndTodos( }); } +export async function getProjectContextData(project: ProjectRef): Promise { + const config = await readOpenChamberConfig(project); + return sanitizeProjectContextData({ + notes: config?.projectNotes, + todos: config?.projectTodos, + plans: config?.projectPlanFiles, + }); +} + +export async function getProjectPlanFiles(project: ProjectRef): Promise { + const config = await readOpenChamberConfig(project); + return sanitizeProjectPlanFileLinks(config?.projectPlanFiles); +} + +export async function saveProjectPlanFiles( + project: ProjectRef, + value: OpenChamberProjectPlanFileLink[] +): Promise { + const sanitized = sanitizeProjectPlanFileLinks(value); + return updateOpenChamberConfig(project, { + projectPlanFiles: sanitized, + }); +} + +export async function readProjectPlanFile(path: string): Promise { + const trimmedPath = typeof path === 'string' ? path.trim() : ''; + if (!trimmedPath) { + return null; + } + + const raw = await readTextFile(trimmedPath); + if (raw === null) { + return null; + } + + const parsed = parseProjectPlanMarkdown(raw); + return { + title: parsed.title, + body: parsed.body, + raw, + path: trimmedPath, + }; +} + +export async function createProjectPlanFile( + project: ProjectRef, + value: { title: string; body: string } +): Promise { + const plansDirectory = await getProjectPlansDirectory(project); + if (!plansDirectory) { + return null; + } + + const title = sanitizePlanTitle(value.title) || 'Plan'; + const createdAt = Date.now(); + const id = createProjectPlanId(); + const filePath = joinPath(plansDirectory, `${createdAt}-${slugifyPlanTitle(title)}.md`); + + const projectDirectory = await getProjectStorageDirectory(project); + if (!projectDirectory) { + return null; + } + + const createdProjectDir = await mkdirp(projectDirectory); + const createdPlansDir = createdProjectDir ? await mkdirp(plansDirectory) : false; + if (!createdProjectDir || !createdPlansDir) { + return null; + } + + const wrote = await writeTextFile(filePath, formatProjectPlanMarkdown(title, value.body)); + if (!wrote) { + return null; + } + + const existing = await getProjectPlanFiles(project); + const nextEntry = { id, path: filePath, createdAt }; + const saved = await saveProjectPlanFiles(project, [nextEntry, ...existing]); + if (!saved) { + return null; + } + + return nextEntry; +} + export async function getProjectActionsState(project: ProjectRef): Promise { const config = await readOpenChamberConfig(project); return sanitizeProjectActionsState({ diff --git a/packages/ui/src/lib/persistence.ts b/packages/ui/src/lib/persistence.ts index 6db50f1c..620f21d3 100644 --- a/packages/ui/src/lib/persistence.ts +++ b/packages/ui/src/lib/persistence.ts @@ -1,4 +1,5 @@ import type { DesktopSettings } from '@/lib/desktop'; +import { createProjectIdFromPath } from '@/lib/projectId'; import { useUIStore } from '@/stores/useUIStore'; import { useMessageQueueStore } from '@/stores/messageQueueStore'; import { setDirectoryShowHidden } from '@/lib/directoryShowHidden'; @@ -149,13 +150,15 @@ const sanitizeProjects = (value: unknown): DesktopSettings['projects'] | undefin if (!entry || typeof entry !== 'object') continue; const candidate = entry as Record; - const id = typeof candidate.id === 'string' ? candidate.id.trim() : ''; const rawPath = typeof candidate.path === 'string' ? candidate.path.trim() : ''; - if (!id || !rawPath) continue; + if (!rawPath) continue; const normalizedPath = rawPath === '/' ? rawPath : rawPath.replace(/\\/g, '/').replace(/\/+$/, ''); if (!normalizedPath) continue; + const id = createProjectIdFromPath(normalizedPath); + if (!id) continue; + if (seenIds.has(id) || seenPaths.has(normalizedPath)) continue; seenIds.add(id); seenPaths.add(normalizedPath); diff --git a/packages/ui/src/lib/projectId.ts b/packages/ui/src/lib/projectId.ts new file mode 100644 index 00000000..f6070c3b --- /dev/null +++ b/packages/ui/src/lib/projectId.ts @@ -0,0 +1,18 @@ +export const createProjectIdFromPath = (projectPath: string): string => { + const normalized = projectPath.replace(/\\/g, '/').replace(/\/+$/g, '').trim(); + if (!normalized) { + return ''; + } + + const data = new TextEncoder().encode(normalized); + let binary = ''; + for (const byte of data) { + binary += String.fromCharCode(byte); + } + + const encoded = typeof btoa === 'function' + ? btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '') + : normalized.replace(/[^A-Za-z0-9._-]+/g, '_'); + + return `path_${encoded}`; +}; diff --git a/packages/ui/src/stores/useProjectsStore.ts b/packages/ui/src/stores/useProjectsStore.ts index 4980767f..cd079f88 100644 --- a/packages/ui/src/stores/useProjectsStore.ts +++ b/packages/ui/src/stores/useProjectsStore.ts @@ -4,6 +4,7 @@ import { opencodeClient } from '@/lib/opencode/client'; import type { ProjectEntry } from '@/lib/api/types'; import type { DesktopSettings } from '@/lib/desktop'; import { updateDesktopSettings } from '@/lib/persistence'; +import { createProjectIdFromPath } from '@/lib/projectId'; import { getSafeStorage } from './utils/safeStorage'; import { useDirectoryStore } from './useDirectoryStore'; import { streamDebugEnabled } from '@/stores/utils/streamDebug'; @@ -112,13 +113,6 @@ const deriveProjectLabel = (path: string): string => { return raw.replace(/[-_]/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()); }; -const createProjectId = (): string => { - if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { - return crypto.randomUUID(); - } - return `proj_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; -}; - const sanitizeProjectIconImage = (value: unknown): ProjectEntry['iconImage'] | undefined => { if (!value || typeof value !== 'object') { return undefined; @@ -185,13 +179,15 @@ const sanitizeProjects = (value: unknown): ProjectEntry[] => { if (!entry || typeof entry !== 'object') continue; const candidate = entry as Record; - const id = typeof candidate.id === 'string' ? candidate.id.trim() : ''; const rawPath = typeof candidate.path === 'string' ? candidate.path.trim() : ''; - if (!id || !rawPath) continue; + if (!rawPath) continue; const normalizedPath = normalizeProjectPath(rawPath); if (!normalizedPath) continue; + const id = createProjectIdFromPath(normalizedPath); + if (!id) continue; + if (seenIds.has(id) || seenPaths.has(normalizedPath)) continue; seenIds.add(id); seenPaths.add(normalizedPath); @@ -310,7 +306,7 @@ const getVSCodeWorkspaceProject = (): { projects: ProjectEntry[]; activeProjectI return null; } - const id = `vscode:${normalizedPath}`; + const id = createProjectIdFromPath(normalizedPath); const entry: ProjectEntry = { id, path: normalizedPath, @@ -330,10 +326,10 @@ const getVSCodeWorkspaceProject = (): { projects: ProjectEntry[]; activeProjectI // Always prefer the workspace project over any persisted multi-project registry. const vscodeWorkspace = getVSCodeWorkspaceProject(); const effectiveInitialProjects = vscodeWorkspace?.projects ?? initialProjects; -const initialActiveProjectId = vscodeWorkspace?.activeProjectId - ?? readPersistedActiveProjectId() - ?? effectiveInitialProjects[0]?.id - ?? null; +const persistedInitialActiveProjectId = vscodeWorkspace?.activeProjectId ?? readPersistedActiveProjectId(); +const initialActiveProjectId = effectiveInitialProjects.some((project) => project.id === persistedInitialActiveProjectId) + ? persistedInitialActiveProjectId + : effectiveInitialProjects[0]?.id ?? null; if (vscodeWorkspace) { cacheProjects(effectiveInitialProjects, initialActiveProjectId); @@ -376,10 +372,7 @@ export const useProjectsStore = create()( const now = Date.now(); const label = options?.label?.trim() || deriveProjectLabel(normalizedPath); - const candidateId = options?.id?.trim(); - const id = candidateId && !get().projects.some((project) => project.id === candidateId) - ? candidateId - : createProjectId(); + const id = createProjectIdFromPath(normalizedPath); const entry: ProjectEntry = { id, path: normalizedPath, diff --git a/packages/web/server/lib/opencode/routes.js b/packages/web/server/lib/opencode/routes.js index 3dde098e..8b964ea3 100644 --- a/packages/web/server/lib/opencode/routes.js +++ b/packages/web/server/lib/opencode/routes.js @@ -1,3 +1,5 @@ +import { createProjectIdFromPath } from '../projects/project-id.js'; + export const registerOpenCodeRoutes = (app, dependencies) => { const { crypto, @@ -177,7 +179,7 @@ export const registerOpenCodeRoutes = (app, dependencies) => { : [ ...existingProjects, { - id: crypto.randomUUID(), + id: createProjectIdFromPath(resolvedPath), path: resolvedPath, addedAt: Date.now(), lastOpenedAt: Date.now(), diff --git a/packages/web/server/lib/opencode/settings-runtime.js b/packages/web/server/lib/opencode/settings-runtime.js index a57124ec..b9ff7126 100644 --- a/packages/web/server/lib/opencode/settings-runtime.js +++ b/packages/web/server/lib/opencode/settings-runtime.js @@ -1,3 +1,5 @@ +import { createProjectIdFromPath } from '../projects/project-id.js'; + const DEFAULT_NOTIFICATION_TEMPLATES = { completion: { title: '{agent_name} is ready', message: '{model_name} completed the task' }, error: { title: 'Tool error', message: '{last_message}' }, @@ -46,6 +48,367 @@ export const createSettingsRuntime = (deps) => { let persistSettingsLock = Promise.resolve(); + const PROJECTS_ROOT_DIR = path.join(path.dirname(SETTINGS_FILE_PATH), 'projects'); + const PROJECT_ICONS_DIR = path.join(path.dirname(SETTINGS_FILE_PATH), 'project-icons'); + + const sha1Hex = (value) => crypto.createHash('sha1').update(value).digest('hex'); + const projectIconBaseName = (projectId) => `project-${sha1Hex(projectId)}`; + const PROJECT_ICON_EXTENSIONS = ['png', 'jpg', 'svg', 'webp', 'ico']; + + const readJsonFile = async (filePath) => { + try { + const raw = await fsPromises.readFile(filePath, 'utf8'); + const parsed = JSON.parse(raw); + return parsed && typeof parsed === 'object' ? parsed : null; + } catch (error) { + if (error && typeof error === 'object' && error.code === 'ENOENT') { + return null; + } + throw error; + } + }; + + const writeJsonFile = async (filePath, value) => { + await fsPromises.mkdir(path.dirname(filePath), { recursive: true }); + await fsPromises.writeFile(filePath, JSON.stringify(value, null, 2), 'utf8'); + }; + + const uniqueStrings = (values) => Array.from(new Set(values.filter((value) => typeof value === 'string' && value.trim().length > 0))); + + const mergeByKey = (oldItems, newItems, getKey) => { + const result = []; + const seen = new Set(); + for (const item of [...(Array.isArray(newItems) ? newItems : []), ...(Array.isArray(oldItems) ? oldItems : [])]) { + if (!item || typeof item !== 'object') continue; + const key = getKey(item); + if (!key || seen.has(key)) continue; + seen.add(key); + result.push(item); + } + return result; + }; + + const remapPlanPaths = (entries, fromDir, toDir) => { + if (!Array.isArray(entries) || !fromDir || !toDir || fromDir === toDir) { + return Array.isArray(entries) ? entries : []; + } + return entries.map((entry) => { + if (!entry || typeof entry !== 'object' || typeof entry.path !== 'string') { + return entry; + } + const trimmedPath = entry.path.trim(); + if (!trimmedPath.startsWith(fromDir)) { + return entry; + } + return { + ...entry, + path: `${toDir}${trimmedPath.slice(fromDir.length)}`, + }; + }); + }; + + const mergeProjectConfigData = ({ oldConfig, newConfig, oldStorageDir, newStorageDir, projectPath }) => { + const oldValue = oldConfig && typeof oldConfig === 'object' ? oldConfig : {}; + const newValue = newConfig && typeof newConfig === 'object' ? newConfig : {}; + const oldPlanFiles = remapPlanPaths(oldValue.projectPlanFiles, oldStorageDir, newStorageDir); + const newPlanFiles = remapPlanPaths(newValue.projectPlanFiles, oldStorageDir, newStorageDir); + const oldNotes = typeof oldValue.projectNotes === 'string' ? oldValue.projectNotes : ''; + const newNotes = typeof newValue.projectNotes === 'string' ? newValue.projectNotes : ''; + + return { + ...oldValue, + ...newValue, + ...(typeof projectPath === 'string' && projectPath.trim().length > 0 ? { projectPath } : {}), + ...(uniqueStrings([...(Array.isArray(oldValue['setup-worktree']) ? oldValue['setup-worktree'] : []), ...(Array.isArray(newValue['setup-worktree']) ? newValue['setup-worktree'] : [])]).length > 0 + ? { 'setup-worktree': uniqueStrings([...(Array.isArray(oldValue['setup-worktree']) ? oldValue['setup-worktree'] : []), ...(Array.isArray(newValue['setup-worktree']) ? newValue['setup-worktree'] : [])]) } + : {}), + ...(oldNotes || newNotes ? { projectNotes: newNotes || oldNotes } : {}), + ...(mergeByKey(oldValue.projectTodos, newValue.projectTodos, (item) => item.id).length > 0 + ? { projectTodos: mergeByKey(oldValue.projectTodos, newValue.projectTodos, (item) => item.id) } + : {}), + ...(mergeByKey(oldValue.projectActions, newValue.projectActions, (item) => item.id).length > 0 + ? { projectActions: mergeByKey(oldValue.projectActions, newValue.projectActions, (item) => item.id) } + : {}), + ...(mergeByKey(oldValue.scheduledTasks, newValue.scheduledTasks, (item) => item.id).length > 0 + ? { scheduledTasks: mergeByKey(oldValue.scheduledTasks, newValue.scheduledTasks, (item) => item.id) } + : {}), + ...(mergeByKey(oldPlanFiles, newPlanFiles, (item) => item.id || item.path).length > 0 + ? { projectPlanFiles: mergeByKey(oldPlanFiles, newPlanFiles, (item) => item.id || item.path) } + : {}), + ...(typeof newValue.projectActionsPrimaryId === 'string' && newValue.projectActionsPrimaryId.trim().length > 0 + ? { projectActionsPrimaryId: newValue.projectActionsPrimaryId } + : typeof oldValue.projectActionsPrimaryId === 'string' && oldValue.projectActionsPrimaryId.trim().length > 0 + ? { projectActionsPrimaryId: oldValue.projectActionsPrimaryId } + : {}), + }; + }; + + const moveDirectoryContents = async (fromDir, toDir) => { + try { + const entries = await fsPromises.readdir(fromDir, { withFileTypes: true }); + await fsPromises.mkdir(toDir, { recursive: true }); + + for (const entry of entries) { + const fromPath = path.join(fromDir, entry.name); + const toPath = path.join(toDir, entry.name); + if (entry.isDirectory()) { + await moveDirectoryContents(fromPath, toPath); + continue; + } + try { + await fsPromises.access(toPath); + } catch { + await fsPromises.rename(fromPath, toPath); + } + } + + await fsPromises.rm(fromDir, { recursive: true, force: true }); + } catch (error) { + if (!(error && typeof error === 'object' && error.code === 'ENOENT')) { + throw error; + } + } + }; + + const migrateProjectIconFiles = async ({ oldId, newId }) => { + if (!oldId || !newId || oldId === newId) { + return; + } + + const oldBase = projectIconBaseName(oldId); + const newBase = projectIconBaseName(newId); + + await fsPromises.mkdir(PROJECT_ICONS_DIR, { recursive: true }); + + for (const ext of PROJECT_ICON_EXTENSIONS) { + const oldPath = path.join(PROJECT_ICONS_DIR, `${oldBase}.${ext}`); + const newPath = path.join(PROJECT_ICONS_DIR, `${newBase}.${ext}`); + try { + await fsPromises.access(oldPath); + } catch (error) { + if (error && typeof error === 'object' && error.code === 'ENOENT') { + continue; + } + throw error; + } + + try { + await fsPromises.access(newPath); + } catch { + await fsPromises.rename(oldPath, newPath); + continue; + } + + await fsPromises.rm(oldPath, { force: true }); + } + }; + + const migrateProjectScopedStorage = async ({ oldId, newId, projectPath }) => { + if (!oldId || !newId || oldId === newId) { + return; + } + + const oldConfigPath = path.join(PROJECTS_ROOT_DIR, `${oldId}.json`); + const newConfigPath = path.join(PROJECTS_ROOT_DIR, `${newId}.json`); + const oldStorageDir = path.join(PROJECTS_ROOT_DIR, oldId); + const newStorageDir = path.join(PROJECTS_ROOT_DIR, newId); + + const [oldConfig, newConfig] = await Promise.all([ + readJsonFile(oldConfigPath), + readJsonFile(newConfigPath), + ]); + + if (oldConfig || newConfig) { + const merged = mergeProjectConfigData({ oldConfig, newConfig, oldStorageDir, newStorageDir, projectPath }); + await writeJsonFile(newConfigPath, merged); + } + + await moveDirectoryContents(oldStorageDir, newStorageDir); + await fsPromises.rm(oldConfigPath, { force: true }); + }; + + const migrateSettingsToDeterministicProjectIds = async (current) => { + const settings = current && typeof current === 'object' ? current : {}; + const projects = sanitizeProjects(settings.projects) || []; + if (projects.length === 0) { + return { settings, changed: false }; + } + + let changed = false; + const projectIdMap = new Map(); + const nextProjects = []; + + for (const project of projects) { + const canonicalId = createProjectIdFromPath(project.path); + const nextId = canonicalId || project.id; + projectIdMap.set(project.id, nextId); + if (nextId !== project.id) { + changed = true; + await migrateProjectScopedStorage({ oldId: project.id, newId: nextId, projectPath: project.path }); + await migrateProjectIconFiles({ oldId: project.id, newId: nextId }); + } + nextProjects.push({ ...project, id: nextId }); + } + + try { + await recoverOrphanProjectFiles(nextProjects); + } catch (error) { + console.warn('[projects] Orphan recovery failed, continuing startup:', error); + } + + if (!changed) { + return { settings, changed: false }; + } + + const currentActiveId = typeof settings.activeProjectId === 'string' ? settings.activeProjectId : ''; + const nextActiveProjectId = projectIdMap.get(currentActiveId) || currentActiveId || nextProjects[0]?.id; + + return { + settings: { + ...settings, + projects: nextProjects, + ...(nextActiveProjectId ? { activeProjectId: nextActiveProjectId } : {}), + }, + changed: true, + }; + }; + + // Orphan files are project jsons left behind from earlier random-UUID project + // ids (they have no projectPath field and are not referenced by settings). + // For each canonical project whose current config is empty (lost during the + // earlier id churn), try to find a single orphan whose setup-worktree command + // patterns uniquely match the project's basename and merge it in. + const recoverOrphanProjectFiles = async (canonicalProjects) => { + let entries; + try { + entries = await fsPromises.readdir(PROJECTS_ROOT_DIR, { withFileTypes: true }); + } catch (error) { + if (error && typeof error === 'object' && error.code === 'ENOENT') return; + throw error; + } + + const canonicalIds = new Set(canonicalProjects.map((project) => project.id)); + const orphanFiles = entries + .filter((entry) => entry.isFile() && entry.name.endsWith('.json')) + .map((entry) => entry.name.replace(/\.json$/, '')) + .filter((id) => id && !id.startsWith('path_') && !canonicalIds.has(id)); + + if (orphanFiles.length === 0) return; + + console.warn(`[projects] Found ${orphanFiles.length} orphan project config(s) without projectPath.`); + + const orphans = []; + for (const orphanId of orphanFiles) { + const filePath = path.join(PROJECTS_ROOT_DIR, `${orphanId}.json`); + const content = await readJsonFile(filePath); + if (!content) continue; + const hasContent = [ + typeof content.projectNotes === 'string' && content.projectNotes.trim().length > 0, + Array.isArray(content.projectTodos) && content.projectTodos.length > 0, + Array.isArray(content.projectActions) && content.projectActions.length > 0, + Array.isArray(content['setup-worktree']) && content['setup-worktree'].length > 0, + Array.isArray(content.projectPlanFiles) && content.projectPlanFiles.length > 0, + ].some(Boolean); + if (!hasContent) continue; + orphans.push({ orphanId, filePath, content }); + } + + if (orphans.length === 0) return; + + const basenameOf = (projectPath) => { + if (typeof projectPath !== 'string') return ''; + const normalized = projectPath.replace(/\\/g, '/').replace(/\/+$/g, ''); + const idx = normalized.lastIndexOf('/'); + return (idx >= 0 ? normalized.slice(idx + 1) : normalized).toLowerCase(); + }; + + const extractRootRelPaths = (orphan) => { + const commands = [ + ...(Array.isArray(orphan.content['setup-worktree']) ? orphan.content['setup-worktree'] : []), + ...(Array.isArray(orphan.content.projectActions) ? orphan.content.projectActions.map((a) => typeof a?.command === 'string' ? a.command : '') : []), + ].filter((s) => typeof s === 'string'); + const results = new Set(); + const re = /\$(?:\{)?ROOT_(?:PROJECT|WORKTREE)_PATH\}?\/([A-Za-z0-9._/-]+)/g; + for (const cmd of commands) { + let match; + while ((match = re.exec(cmd)) !== null) { + results.add(match[1]); + } + } + return Array.from(results); + }; + + const fileExistsInProject = async (projectPath, relPath) => { + try { + await fsPromises.access(path.join(projectPath, relPath)); + return true; + } catch { + return false; + } + }; + + const orphanMatchesProject = async (orphan, project) => { + if (typeof project.path !== 'string' || !project.path.trim()) return false; + const rels = extractRootRelPaths(orphan); + for (const rel of rels) { + if (await fileExistsInProject(project.path, rel)) { + return true; + } + } + const name = basenameOf(project.path); + if (!name) return false; + const haystacks = [ + ...(Array.isArray(orphan.content['setup-worktree']) ? orphan.content['setup-worktree'] : []), + ...(Array.isArray(orphan.content.projectActions) ? orphan.content.projectActions.map((a) => `${a?.name || ''} ${a?.command || ''}`) : []), + ].join(' ').toLowerCase(); + return haystacks.includes(name); + }; + + const matches = new Map(); + for (const orphan of orphans) { + const matchedProjects = []; + for (const project of canonicalProjects) { + if (await orphanMatchesProject(orphan, project)) { + matchedProjects.push(project); + } + } + if (matchedProjects.length === 1) { + const project = matchedProjects[0]; + const list = matches.get(project.id) || []; + list.push(orphan); + matches.set(project.id, list); + } + } + + const orphansConsumed = new Set(); + for (const [projectId, orphansForProject] of matches.entries()) { + const project = canonicalProjects.find((p) => p.id === projectId); + if (!project) continue; + const targetPath = path.join(PROJECTS_ROOT_DIR, `${project.id}.json`); + + for (const orphan of orphansForProject) { + const targetExisting = (await readJsonFile(targetPath)) || {}; + const merged = mergeProjectConfigData({ + oldConfig: orphan.content, + newConfig: targetExisting, + oldStorageDir: path.join(PROJECTS_ROOT_DIR, orphan.orphanId), + newStorageDir: path.join(PROJECTS_ROOT_DIR, project.id), + projectPath: project.path, + }); + await writeJsonFile(targetPath, merged); + await moveDirectoryContents(path.join(PROJECTS_ROOT_DIR, orphan.orphanId), path.join(PROJECTS_ROOT_DIR, project.id)); + await fsPromises.rm(orphan.filePath, { force: true }); + orphansConsumed.add(orphan.orphanId); + console.log(`[projects] Recovered orphan ${orphan.orphanId} -> ${project.id} (${project.path})`); + } + } + + const remaining = orphans.filter((orphan) => !orphansConsumed.has(orphan.orphanId)); + if (remaining.length > 0) { + console.warn(`[projects] ${remaining.length} orphan project file(s) could not be auto-matched: ${remaining.map((o) => o.orphanId).join(', ')}`); + } + }; + const readSettingsFromDisk = async () => { try { const raw = await fsPromises.readFile(SETTINGS_FILE_PATH, 'utf8'); @@ -130,7 +493,7 @@ export const createSettingsRuntime = (deps) => { try { const stats = await fsPromises.stat(candidate); if (stats.isDirectory()) { - const id = crypto.randomUUID(); + const id = createProjectIdFromPath(candidate); nextProjects = [ { id, @@ -358,10 +721,11 @@ export const createSettingsRuntime = (deps) => { const migration4 = await migrateSettingsNotificationDefaults(migration3.settings); const migration5 = await migrateSettingsFromLegacyNamedTunnelKeys(migration4.settings); const migration6 = normalizeSettingsPaths(migration5.settings); - if (migration1.changed || migration2.changed || migration3.changed || migration4.changed || migration5.changed || migration6.changed) { - await writeSettingsToDisk(migration6.settings); + const migration7 = await migrateSettingsToDeterministicProjectIds(migration6.settings); + if (migration1.changed || migration2.changed || migration3.changed || migration4.changed || migration5.changed || migration6.changed || migration7.changed) { + await writeSettingsToDisk(migration7.settings); } - return migration6.settings; + return migration7.settings; }; const persistSettings = async (changes) => { @@ -377,6 +741,11 @@ export const createSettingsRuntime = (deps) => { next = normalizedState.settings; } + const deterministicProjectIdMigration = await migrateSettingsToDeterministicProjectIds(next); + if (deterministicProjectIdMigration.changed) { + next = deterministicProjectIdMigration.settings; + } + if (Array.isArray(next.projects)) { console.log(`[persistSettings] Validating ${next.projects.length} projects...`); const validated = await validateProjectEntries(next.projects); diff --git a/packages/web/server/lib/projects/project-config.js b/packages/web/server/lib/projects/project-config.js index 0c8c68b1..cbc2df77 100644 --- a/packages/web/server/lib/projects/project-config.js +++ b/packages/web/server/lib/projects/project-config.js @@ -360,50 +360,57 @@ export const createProjectConfigRuntime = (deps) => { return path.join(projectsDirPath, `${safeProjectID}.json`); }; - const readProjectConfigFromDisk = async (projectID) => { + const readRawProjectConfigFromDisk = async (projectID) => { const filePath = resolveProjectConfigPath(projectID); - try { const raw = await fsPromises.readFile(filePath, 'utf8'); const parsed = JSON.parse(raw); - if (!parsed || typeof parsed !== 'object') { - return createEmptyProjectConfig(); - } - const tasksRaw = Array.isArray(parsed.scheduledTasks) ? parsed.scheduledTasks : []; - const now = Date.now(); - const scheduledTasks = []; - for (const task of tasksRaw) { - try { - const normalized = normalizeTaskForStorage(task, { - now, - createId: taskIDFactory, - existingTask: null, - allowCreate: true, - }); - scheduledTasks.push(normalized); - } catch { - } - } - - return { - version: PROJECT_CONFIG_VERSION, - scheduledTasks, - }; + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {}; } catch (error) { if (error && typeof error === 'object' && error.code === 'ENOENT') { - return createEmptyProjectConfig(); + return {}; } throw error; } }; + const readProjectConfigFromDisk = async (projectID) => { + const parsed = await readRawProjectConfigFromDisk(projectID); + const tasksRaw = Array.isArray(parsed.scheduledTasks) ? parsed.scheduledTasks : []; + const now = Date.now(); + const scheduledTasks = []; + for (const task of tasksRaw) { + try { + const normalized = normalizeTaskForStorage(task, { + now, + createId: taskIDFactory, + existingTask: null, + allowCreate: true, + }); + scheduledTasks.push(normalized); + } catch { + } + } + return { + version: PROJECT_CONFIG_VERSION, + scheduledTasks, + }; + }; + const writeProjectConfigToDisk = async (projectID, config) => { const filePath = resolveProjectConfigPath(projectID); const parentDirectory = path.dirname(filePath); const temporaryPath = `${filePath}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`; + const existing = await readRawProjectConfigFromDisk(projectID); + const merged = { + ...existing, + version: PROJECT_CONFIG_VERSION, + scheduledTasks: Array.isArray(config?.scheduledTasks) ? config.scheduledTasks : [], + }; + await fsPromises.mkdir(parentDirectory, { recursive: true }); - await fsPromises.writeFile(temporaryPath, JSON.stringify(config, null, 2), 'utf8'); + await fsPromises.writeFile(temporaryPath, JSON.stringify(merged, null, 2), 'utf8'); await fsPromises.rename(temporaryPath, filePath); }; diff --git a/packages/web/server/lib/projects/project-config.test.js b/packages/web/server/lib/projects/project-config.test.js index cc2809c1..6d53e2a4 100644 --- a/packages/web/server/lib/projects/project-config.test.js +++ b/packages/web/server/lib/projects/project-config.test.js @@ -1,7 +1,7 @@ import { describe, expect, it } from 'bun:test'; import os from 'os'; import path from 'path'; -import { mkdtemp, rm } from 'fs/promises'; +import { mkdtemp, rm, readFile, writeFile } from 'fs/promises'; import { createProjectConfigRuntime } from './project-config.js'; const createRuntime = async () => { @@ -73,6 +73,47 @@ describe('project-config runtime', () => { } }); + it('preserves unknown project config keys when writing scheduled tasks', async () => { + const { runtime, cleanup } = await createRuntime(); + try { + const projectID = 'path_preserve'; + const filePath = path.join(runtime.resolveProjectConfigPath(projectID)); + await writeFile( + filePath, + JSON.stringify({ + projectNotes: 'hello notes', + projectTodos: [{ id: 't1', text: 'buy milk', completed: false, createdAt: 1 }], + projectActions: [{ id: 'a1', name: 'Run', command: 'bun run dev' }], + projectActionsPrimaryId: 'a1', + 'setup-worktree': ['bun install'], + projectPlanFiles: [{ id: 'p1', path: '/tmp/plans/p1.md', createdAt: 2 }], + projectPath: '/tmp/demo', + }, null, 2), + 'utf8', + ); + + await runtime.upsertScheduledTask(projectID, { + name: 'nightly', + enabled: true, + schedule: { kind: 'daily', time: '09:00', timezone: 'UTC' }, + execution: { prompt: 'run', providerID: 'openai', modelID: 'gpt-4.1' }, + }); + + const raw = JSON.parse(await readFile(filePath, 'utf8')); + expect(raw.projectNotes).toBe('hello notes'); + expect(raw.projectTodos).toEqual([{ id: 't1', text: 'buy milk', completed: false, createdAt: 1 }]); + expect(raw.projectActions).toHaveLength(1); + expect(raw.projectActionsPrimaryId).toBe('a1'); + expect(raw['setup-worktree']).toEqual(['bun install']); + expect(raw.projectPlanFiles).toEqual([{ id: 'p1', path: '/tmp/plans/p1.md', createdAt: 2 }]); + expect(raw.projectPath).toBe('/tmp/demo'); + expect(raw.scheduledTasks).toHaveLength(1); + expect(raw.version).toBe(1); + } finally { + await cleanup(); + } + }); + it('accepts one-time schedule with date and time', async () => { const { runtime, cleanup } = await createRuntime(); try { diff --git a/packages/web/server/lib/projects/project-id.js b/packages/web/server/lib/projects/project-id.js new file mode 100644 index 00000000..e0a3e1cb --- /dev/null +++ b/packages/web/server/lib/projects/project-id.js @@ -0,0 +1,13 @@ +const normalizeProjectPathForId = (value) => { + if (typeof value !== 'string') return ''; + return value.replace(/\\/g, '/').replace(/\/+$/g, '') || value; +}; + +export const createProjectIdFromPath = (projectPath) => { + const normalized = normalizeProjectPathForId(projectPath).trim(); + if (!normalized) { + return ''; + } + + return `path_${Buffer.from(normalized, 'utf8').toString('base64url')}`; +};