From af4a62b69a2cffe168a1ffaed134a3a73c652006 Mon Sep 17 00:00:00 2001 From: BayesWang <827130441@qq.com> Date: Sun, 31 May 2026 12:47:45 +0800 Subject: [PATCH] Add configurable project locations (#2041) * add daemon project location support * wire project locations into web settings * localize project location settings * move default project location to settings * polish project location selection cards * fix project location i18n gaps * fix external project validation cleanup --- apps/daemon/src/app-config.ts | 72 +- apps/daemon/src/project-locations.ts | 130 ++++ apps/daemon/src/project-routes.ts | 332 ++++++++- apps/daemon/src/server.ts | 1 + apps/daemon/tests/app-config.test.ts | 183 ++++- apps/daemon/tests/projects-routes.test.ts | 640 +++++++++++++++++- apps/web/src/App.tsx | 1 + apps/web/src/components/EntryShell.tsx | 1 + apps/web/src/components/EntryView.tsx | 2 +- apps/web/src/components/NewProjectPanel.tsx | 16 +- .../components/ProjectLocationsSection.tsx | 239 +++++++ apps/web/src/components/SettingsDialog.tsx | 23 + apps/web/src/i18n/locales/ar.ts | 17 + apps/web/src/i18n/locales/de.ts | 17 + apps/web/src/i18n/locales/en.ts | 17 + apps/web/src/i18n/locales/es-ES.ts | 17 + apps/web/src/i18n/locales/fa.ts | 17 + apps/web/src/i18n/locales/fr.ts | 17 + apps/web/src/i18n/locales/hu.ts | 17 + apps/web/src/i18n/locales/id.ts | 17 + apps/web/src/i18n/locales/it.ts | 17 + apps/web/src/i18n/locales/ja.ts | 17 + apps/web/src/i18n/locales/ko.ts | 17 + apps/web/src/i18n/locales/pl.ts | 17 + apps/web/src/i18n/locales/pt-BR.ts | 17 + apps/web/src/i18n/locales/ru.ts | 17 + apps/web/src/i18n/locales/th.ts | 17 + apps/web/src/i18n/locales/tr.ts | 17 + apps/web/src/i18n/locales/uk.ts | 17 + apps/web/src/i18n/locales/zh-CN.ts | 17 + apps/web/src/i18n/locales/zh-TW.ts | 17 + apps/web/src/i18n/types.ts | 17 + apps/web/src/state/config.ts | 10 + apps/web/src/state/project-locations.ts | 55 ++ apps/web/src/state/projects.ts | 1 + .../web/src/styles/workspace/mention-home.css | 86 +++ apps/web/src/types.ts | 4 + packages/contracts/src/analytics/events.ts | 3 + packages/contracts/src/api/app-config.ts | 10 + packages/contracts/src/api/projects.ts | 38 ++ 40 files changed, 2143 insertions(+), 44 deletions(-) create mode 100644 apps/daemon/src/project-locations.ts create mode 100644 apps/web/src/components/ProjectLocationsSection.tsx create mode 100644 apps/web/src/state/project-locations.ts diff --git a/apps/daemon/src/app-config.ts b/apps/daemon/src/app-config.ts index c53522347..a9d83c195 100644 --- a/apps/daemon/src/app-config.ts +++ b/apps/daemon/src/app-config.ts @@ -13,8 +13,9 @@ // outside this machine. import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'; -import { randomBytes } from 'node:crypto'; +import { createHash, randomBytes } from 'node:crypto'; import path from 'node:path'; +import { expandHomePrefix } from './home-expansion.js'; import { readInstallationFile, @@ -85,6 +86,12 @@ export interface OrbitConfigPrefs { templateSkillId?: string | null; } +export interface ProjectLocationPrefs { + id: string; + name: string; + path: string; +} + export interface AppConfigPrefs { onboardingCompleted?: boolean; agentId?: string | null; @@ -99,6 +106,8 @@ export interface AppConfigPrefs { privacyDecisionAt?: number | null; orbit?: OrbitConfigPrefs; customInstructions?: string | null; + projectLocations?: ProjectLocationPrefs[]; + defaultProjectLocationId?: string | null; } const ALLOWED_KEYS: ReadonlySet = new Set([ @@ -115,6 +124,8 @@ const ALLOWED_KEYS: ReadonlySet = new Set([ 'privacyDecisionAt', 'orbit', 'customInstructions', + 'projectLocations', + 'defaultProjectLocationId', ] as const); function configFile(dataDir: string): string { @@ -245,6 +256,46 @@ function validateOrbit(raw: unknown): OrbitConfigPrefs | undefined { return orbit; } +function normalizeLocationId(raw: string, fallback: string): string { + const trimmed = raw.trim(); + if (/^[A-Za-z0-9._-]{1,128}$/.test(trimmed) && trimmed !== 'default') { + return trimmed; + } + return fallback; +} + +function autoProjectLocationId(pathKey: string): string { + return `loc_${createHash('sha256').update(pathKey).digest('base64url').slice(0, 16)}`; +} + +function validateProjectLocations(raw: unknown): ProjectLocationPrefs[] | undefined { + if (raw === undefined || raw === null) return undefined; + if (!Array.isArray(raw)) return undefined; + const result: ProjectLocationPrefs[] = []; + const seenIds = new Set(); + const seenPaths = new Set(); + for (const item of raw) { + if (!item || typeof item !== 'object' || Array.isArray(item)) continue; + const obj = item as Record; + if (typeof obj.path !== 'string') continue; + const expanded = expandHomePrefix(obj.path.trim()); + if (!expanded || !path.isAbsolute(expanded)) continue; + const normalizedPath = path.normalize(expanded); + const pathKey = process.platform === 'win32' ? normalizedPath.toLowerCase() : normalizedPath; + if (seenPaths.has(pathKey)) continue; + const id = normalizeLocationId( + typeof obj.id === 'string' ? obj.id : '', + autoProjectLocationId(pathKey), + ); + if (seenIds.has(id)) continue; + const rawName = typeof obj.name === 'string' ? obj.name.trim() : ''; + result.push({ id, name: rawName || path.basename(normalizedPath) || normalizedPath, path: normalizedPath }); + seenIds.add(id); + seenPaths.add(pathKey); + } + return result; +} + export function agentCliEnvForAgent( prefs: AgentCliEnvPrefs | undefined, agentId: string, @@ -330,6 +381,25 @@ function applyConfigValue( } return; } + if (key === 'projectLocations') { + const validated = validateProjectLocations(value); + if (validated !== undefined) { + target[key] = validated; + } else { + delete target[key]; + } + return; + } + if (key === 'defaultProjectLocationId') { + if (typeof value === 'string') { + target[key] = normalizeLocationId(value, 'default'); + } else if (value === null) { + target[key] = null; + } else { + delete target[key]; + } + return; + } } function filterAllowedKeys(obj: Record): AppConfigPrefs { diff --git a/apps/daemon/src/project-locations.ts b/apps/daemon/src/project-locations.ts new file mode 100644 index 000000000..0cd436752 --- /dev/null +++ b/apps/daemon/src/project-locations.ts @@ -0,0 +1,130 @@ +import { lstat, mkdir, readdir, readFile, realpath, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import type { ProjectLocationPrefs } from './app-config.js'; +import { expandHomePrefix } from './home-expansion.js'; +import { isSafeId } from './projects.js'; + +export const BUILT_IN_PROJECT_LOCATION_ID = 'default'; +export const PROJECT_MANIFEST_RELATIVE_PATH = path.join('.open-design', 'project.json'); + +export interface ProjectLocation extends ProjectLocationPrefs { + builtIn?: boolean; +} + +export interface ProjectManifest { + schemaVersion: 1; + id: string; + name: string; + createdAt: number; + updatedAt: number; + skillId?: string | null; + designSystemId?: string | null; +} + +export function builtInProjectLocation(projectsDir: string): ProjectLocation { + return { + id: BUILT_IN_PROJECT_LOCATION_ID, + name: 'Open Design projects', + path: projectsDir, + builtIn: true, + }; +} + +export function allProjectLocations(projectsDir: string, external: ProjectLocationPrefs[] | undefined): ProjectLocation[] { + return [builtInProjectLocation(projectsDir), ...(external ?? [])]; +} + +export function locationProjectDir(location: ProjectLocation, projectId: string): string { + if (!isSafeId(projectId)) throw new Error('invalid project id'); + return path.join(location.path, projectId); +} + +function assertInsideLocation(locationRoot: string, projectDir: string): void { + const relative = path.relative(locationRoot, projectDir); + if (relative === '' || relative.startsWith('..') || path.isAbsolute(relative)) { + throw new Error('project directory escapes project location'); + } +} + +export async function createLocationProjectDir(location: ProjectLocation, projectId: string): Promise { + const root = await realpath(location.path); + const target = locationProjectDir({ ...location, path: root }, projectId); + await mkdir(target, { recursive: false }); + const info = await lstat(target); + if (!info.isDirectory() || info.isSymbolicLink()) throw new Error('project directory must be a real directory'); + const canonical = await realpath(target); + assertInsideLocation(root, canonical); + return canonical; +} + +export async function canonicalLocationChildDir(location: ProjectLocation, childName: string): Promise { + const root = await realpath(location.path); + if (!isSafeId(childName)) throw new Error('invalid project directory name'); + const target = path.join(root, childName); + const info = await lstat(target); + if (!info.isDirectory() || info.isSymbolicLink()) throw new Error('project directory must be a real directory'); + const canonical = await realpath(target); + assertInsideLocation(root, canonical); + return canonical; +} + +export function manifestPath(projectDir: string): string { + return path.join(projectDir, PROJECT_MANIFEST_RELATIVE_PATH); +} + +export async function ensureProjectLocation(locationPath: string): Promise { + const expanded = expandHomePrefix(locationPath.trim()); + if (!path.isAbsolute(expanded)) throw new Error(`project location must be an absolute path: ${locationPath}`); + await mkdir(expanded, { recursive: true }); + return realpath(expanded); +} + +export async function writeProjectManifest(projectDir: string, manifest: ProjectManifest): Promise { + const file = manifestPath(projectDir); + await mkdir(path.dirname(file), { recursive: true }); + await writeFile(file, JSON.stringify(manifest, null, 2), 'utf8'); +} + +export async function readProjectManifest(projectDir: string): Promise { + try { + const raw = await readFile(manifestPath(projectDir), 'utf8'); + const parsed: unknown = JSON.parse(raw); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null; + const obj = parsed as Record; + if (obj.schemaVersion !== 1) return null; + if (typeof obj.id !== 'string' || !isSafeId(obj.id)) return null; + if (typeof obj.name !== 'string' || !obj.name.trim()) return null; + const createdAt = typeof obj.createdAt === 'number' && Number.isFinite(obj.createdAt) ? obj.createdAt : Date.now(); + const updatedAt = typeof obj.updatedAt === 'number' && Number.isFinite(obj.updatedAt) ? obj.updatedAt : createdAt; + return { + schemaVersion: 1, + id: obj.id, + name: obj.name.trim(), + createdAt, + updatedAt, + skillId: typeof obj.skillId === 'string' ? obj.skillId : null, + designSystemId: typeof obj.designSystemId === 'string' ? obj.designSystemId : null, + }; + } catch (err: unknown) { + const e = err as { code?: string; name?: string }; + if (e.code === 'ENOENT' || e.name === 'SyntaxError') return null; + throw err; + } +} + +export async function scanProjectLocation(location: ProjectLocation): Promise> { + const entries = await readdir(location.path, { withFileTypes: true }); + const found: Array<{ dir: string; manifest: ProjectManifest }> = []; + for (const entry of entries) { + if (!entry.isDirectory()) continue; + let dir: string; + try { + dir = await canonicalLocationChildDir(location, entry.name); + } catch { + continue; + } + const manifest = await readProjectManifest(dir); + if (manifest) found.push({ dir, manifest }); + } + return found; +} diff --git a/apps/daemon/src/project-routes.ts b/apps/daemon/src/project-routes.ts index c8bafd70e..fa5392d9e 100644 --- a/apps/daemon/src/project-routes.ts +++ b/apps/daemon/src/project-routes.ts @@ -1,4 +1,5 @@ import type { Express } from 'express'; +import { rm } from 'node:fs/promises'; import path from 'node:path'; import { defaultScenarioPluginIdForProjectMetadata, @@ -18,9 +19,18 @@ import { import { connectorService } from './connectors/service.js'; import type { RouteDeps } from './server-context.js'; import { listSkills } from './skills.js'; +import { isSafeId } from './projects.js'; +import { + BUILT_IN_PROJECT_LOCATION_ID, + allProjectLocations, + createLocationProjectDir, + ensureProjectLocation, + scanProjectLocation, + writeProjectManifest, +} from './project-locations.js'; import { auditDesignSystemPackage } from './tools-connectors-cli.js'; -export interface RegisterProjectRoutesDeps extends RouteDeps<'db' | 'design' | 'http' | 'paths' | 'projectStore' | 'projectFiles' | 'conversations' | 'templates' | 'status' | 'events' | 'ids' | 'telemetry' | 'validation'> {} +export interface RegisterProjectRoutesDeps extends RouteDeps<'db' | 'design' | 'http' | 'paths' | 'projectStore' | 'projectFiles' | 'conversations' | 'templates' | 'status' | 'events' | 'ids' | 'telemetry' | 'appConfig' | 'validation'> {} function projectDetailResolvedDir( projectsRoot: string, @@ -145,6 +155,7 @@ export function registerProjectRoutes(app: Express, ctx: RegisterProjectRoutesDe const { db, design } = ctx; const { sendApiError, createSseResponse } = ctx.http; const { DESIGN_SYSTEMS_DIR, PROJECTS_DIR, SKILLS_DIR } = ctx.paths; + const { readAppConfig, writeAppConfig } = ctx.appConfig; const { insertProject, validateLinkedDirs, getProject, updateProject, dbDeleteProject, removeProjectDir } = ctx.projectStore; const { writeProjectFile, readProjectFile, ensureProject, listFiles, listTabs, setTabs, resolveProjectDir } = ctx.projectFiles; const { insertConversation, getConversation, listConversations, updateConversation, deleteConversation, listMessages, upsertMessage, listPreviewComments, upsertPreviewComment, updatePreviewCommentStatus, deletePreviewComment } = ctx.conversations; @@ -202,8 +213,199 @@ export function registerProjectRoutes(app: Express, ctx: RegisterProjectRoutesDe return Array.from(byTaskKind.values()); } - app.get('/api/projects', (_req, res) => { + async function configuredProjectLocations() { + const config = await readAppConfig(ctx.paths.RUNTIME_DATA_DIR); + const all = allProjectLocations(PROJECTS_DIR, config.projectLocations); + const valid = all[0] ? [all[0]] : []; + for (const location of all.slice(1)) { + const validated = validateLinkedDirs([location.path]); + if (validated.error) continue; + const canonical = validated.dirs[0]; + if (!canonical) continue; + if (locationOverlapsDaemonData(canonical)) continue; + valid.push({ ...location, path: canonical }); + } + return valid; + } + + function locationOverlapsDaemonData(locationPath: string): boolean { + const runtimeDir = ctx.paths.RUNTIME_DATA_DIR_CANONICAL || ctx.paths.RUNTIME_DATA_DIR; + const projectsDir = path.join(runtimeDir, 'projects'); + const relativeToRuntime = pathRelative(runtimeDir, locationPath); + const runtimeInsideLocation = pathRelative(locationPath, runtimeDir); + const relativeToProjects = pathRelative(projectsDir, locationPath); + const projectsInsideLocation = pathRelative(locationPath, projectsDir); + return isInsideOrSame(relativeToRuntime) || isInsideOrSame(runtimeInsideLocation) + || isInsideOrSame(relativeToProjects) || isInsideOrSame(projectsInsideLocation); + } + + function pathRelative(from: string, to: string): string { + return path.relative(from, to); + } + + function isInsideOrSame(relative: string): boolean { + return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative)); + } + + function projectBelongsToLocation(project: any, location: { id: string; path: string }): boolean { + const metadata = project?.metadata; + if (typeof metadata?.baseDir !== 'string') return metadata?.projectLocationId === location.id; + const relative = path.relative(location.path, metadata.baseDir); + return isInsideOrSame(relative) && relative !== ''; + } + + function isProjectLocationProject(project: any): boolean { + const metadata = project?.metadata; + return metadata?.importedFrom === 'project-location' + || typeof metadata?.projectLocationId === 'string'; + } + + function projectVisibleForLocations( + project: any, + locations: Array<{ id: string; path: string; builtIn?: boolean }>, + ): boolean { + if (!isProjectLocationProject(project)) return true; + return locations.some((location) => !location.builtIn && projectBelongsToLocation(project, location)); + } + + async function resolveCreateProjectLocationId(explicitProjectLocationId: unknown): Promise { + if (typeof explicitProjectLocationId === 'string' && explicitProjectLocationId.trim()) { + return explicitProjectLocationId.trim(); + } + const config = await readAppConfig(ctx.paths.RUNTIME_DATA_DIR); + const configuredDefault = typeof config.defaultProjectLocationId === 'string' + ? config.defaultProjectLocationId.trim() + : ''; + if (!configuredDefault || configuredDefault === BUILT_IN_PROJECT_LOCATION_ID) { + return BUILT_IN_PROJECT_LOCATION_ID; + } + const locations = await configuredProjectLocations(); + return locations.some((location) => !location.builtIn && location.id === configuredDefault) + ? configuredDefault + : BUILT_IN_PROJECT_LOCATION_ID; + } + + function unregisterProjectsForRemovedLocations( + previousLocations: Array<{ id: string; path: string; builtIn?: boolean }>, + nextLocations: Array<{ id?: string; path: string }>, + ): string[] { + const nextIds = new Set(nextLocations.map((location) => location.id).filter(Boolean)); + const nextPaths = new Set(nextLocations.map((location) => location.path)); + const removed = previousLocations.filter( + (location) => !location.builtIn && !nextIds.has(location.id) && !nextPaths.has(location.path), + ); + if (removed.length === 0) return []; + return listProjects(db) + .filter((project: any) => removed.some((location) => projectBelongsToLocation(project, location))) + .map((project: any) => project.id); + } + + app.get('/api/project-locations', async (_req, res) => { try { + const locations = await configuredProjectLocations(); + /** @type {import('@open-design/contracts').ProjectLocationsResponse} */ + const body = { locations }; + res.json(body); + } catch (err: any) { + sendApiError(res, 500, 'INTERNAL_ERROR', String(err)); + } + }); + + app.put('/api/project-locations', async (req, res) => { + try { + const requested = Array.isArray(req.body?.locations) ? req.body.locations : null; + if (!requested) return sendApiError(res, 400, 'BAD_REQUEST', 'locations must be an array'); + const previousLocations = await configuredProjectLocations(); + const prepared = []; + for (const loc of requested) { + if (!loc || typeof loc !== 'object' || typeof loc.path !== 'string') continue; + const canonicalPath = await ensureProjectLocation(loc.path); + const validated = validateLinkedDirs([canonicalPath]); + if (validated.error) return sendApiError(res, 400, 'BAD_REQUEST', validated.error); + if (locationOverlapsDaemonData(canonicalPath)) { + return sendApiError(res, 400, 'BAD_REQUEST', 'project location cannot overlap daemon data'); + } + prepared.push({ + id: typeof loc.id === 'string' ? loc.id : undefined, + name: typeof loc.name === 'string' ? loc.name : undefined, + path: canonicalPath, + }); + } + const config = await writeAppConfig(ctx.paths.RUNTIME_DATA_DIR, { projectLocations: prepared }); + const locations = allProjectLocations(PROJECTS_DIR, config.projectLocations); + const removedProjectIds = unregisterProjectsForRemovedLocations(previousLocations, config.projectLocations ?? []); + /** @type {import('@open-design/contracts').ProjectLocationsResponse} */ + const body = { locations, removedProjectIds }; + res.json(body); + } catch (err: any) { + sendApiError(res, 400, 'BAD_REQUEST', String(err)); + } + }); + + app.post('/api/project-locations/scan', async (_req, res) => { + try { + const locations = (await configuredProjectLocations()).filter((loc: any) => !loc.builtIn); + const imported = []; + const existing: string[] = []; + const skipped: Array<{ path: string; reason: string }> = []; + let scanned = 0; + const now = Date.now(); + for (const location of locations) { + let found; + try { + found = await scanProjectLocation(location); + } catch (err: any) { + skipped.push({ path: location.path, reason: String(err?.message ?? err) }); + continue; + } + scanned += found.length; + for (const entry of found) { + const { manifest } = entry; + if (getProject(db, manifest.id)) { + existing.push(manifest.id); + continue; + } + try { + const project = insertProject(db, { + id: manifest.id, + name: manifest.name, + skillId: manifest.skillId ?? null, + designSystemId: manifest.designSystemId ?? null, + pendingPrompt: null, + metadata: { + kind: 'prototype', + baseDir: entry.dir, + importedFrom: 'project-location', + projectLocationId: location.id, + }, + customInstructions: null, + createdAt: manifest.createdAt, + updatedAt: manifest.updatedAt, + }); + insertConversation(db, { + id: randomId(), + projectId: manifest.id, + title: null, + createdAt: now, + updatedAt: now, + }); + if (project) imported.push(project); + } catch (err: any) { + skipped.push({ path: entry.dir, reason: String(err?.message ?? err) }); + } + } + } + /** @type {import('@open-design/contracts').ScanProjectLocationsResponse} */ + const body = { scanned, imported, existing, skipped }; + res.json(body); + } catch (err: any) { + sendApiError(res, 400, 'BAD_REQUEST', String(err)); + } + }); + + app.get('/api/projects', async (_req, res) => { + try { + const locations = await configuredProjectLocations(); const latestRunStatuses = listLatestProjectRunStatuses(db); const awaitingInputProjects = listProjectsAwaitingInput(db); const activeRunStatuses = new Map(); @@ -224,15 +426,17 @@ export function registerProjectRoutes(app: Express, ctx: RegisterProjectRoutesDe } /** @type {import('@open-design/contracts').ProjectsResponse} */ const body = { - projects: listProjects(db).map((project: any) => ({ - ...project, - status: composeProjectDisplayStatus( - activeRunStatuses.get(project.id) ?? - latestRunStatuses.get(project.id) ?? { value: 'not_started' }, - awaitingInputProjects, - project.id, - ), - })), + projects: listProjects(db) + .filter((project: any) => projectVisibleForLocations(project, locations)) + .map((project: any) => ({ + ...project, + status: composeProjectDisplayStatus( + activeRunStatuses.get(project.id) ?? + latestRunStatuses.get(project.id) ?? { value: 'not_started' }, + awaitingInputProjects, + project.id, + ), + })), }; res.json(body); } catch (err: any) { @@ -250,9 +454,9 @@ export function registerProjectRoutes(app: Express, ctx: RegisterProjectRoutesDe app.post('/api/projects', async (req, res) => { try { - const { id, name, skillId, designSystemId, pendingPrompt, metadata, customInstructions, skipDiscoveryBrief } = + const { id, name, projectLocationId, skillId, designSystemId, pendingPrompt, metadata, customInstructions, skipDiscoveryBrief } = req.body || {}; - if (typeof id !== 'string' || !/^[A-Za-z0-9._-]{1,128}$/.test(id)) { + if (typeof id !== 'string' || !isSafeId(id)) { return sendApiError(res, 400, 'BAD_REQUEST', 'invalid project id'); } if (typeof name !== 'string' || !name.trim()) { @@ -306,11 +510,30 @@ export function registerProjectRoutes(app: Express, ctx: RegisterProjectRoutesDe return sendApiError(res, 400, skillValidation.code, skillValidation.message); } const normalizedSkillId = skillValidation.id; + const selectedLocationId = await resolveCreateProjectLocationId(projectLocationId); + let externalProjectDir: string | null = null; + if (selectedLocationId !== BUILT_IN_PROJECT_LOCATION_ID) { + const location = (await configuredProjectLocations()).find((loc: any) => loc.id === selectedLocationId); + if (!location || location.builtIn) { + return sendApiError(res, 400, 'BAD_REQUEST', 'unknown project location'); + } + if (getProject(db, id)) { + return sendApiError(res, 400, 'BAD_REQUEST', 'project id already exists'); + } + externalProjectDir = await createLocationProjectDir(location, id); + } const projectMetadata = metadata && typeof metadata === 'object' ? { ...metadata, ...(skipDiscoveryBrief === true ? { skipDiscoveryBrief: true } : {}), + ...(externalProjectDir + ? { + baseDir: externalProjectDir, + importedFrom: 'project-location', + projectLocationId: selectedLocationId, + } + : {}), ...(Array.isArray(metadata.linkedDirs) ? (() => { const v = validateLinkedDirs(metadata.linkedDirs); @@ -319,23 +542,58 @@ export function registerProjectRoutes(app: Express, ctx: RegisterProjectRoutesDe : {}), } : skipDiscoveryBrief === true - ? { skipDiscoveryBrief: true } - : null; + ? { + skipDiscoveryBrief: true, + ...(externalProjectDir + ? { + baseDir: externalProjectDir, + importedFrom: 'project-location', + projectLocationId: selectedLocationId, + } + : {}), + } + : externalProjectDir + ? { + kind: 'prototype', + baseDir: externalProjectDir, + importedFrom: 'project-location', + projectLocationId: selectedLocationId, + } + : null; const now = Date.now(); - const project = insertProject(db, { - id, - name: name.trim(), - skillId: normalizedSkillId, - designSystemId: normalizedDesignSystemId, - pendingPrompt: pendingPrompt || null, - metadata: projectMetadata, - customInstructions: - typeof customInstructions === 'string' - ? customInstructions - : null, - createdAt: now, - updatedAt: now, - }); + let project; + try { + if (externalProjectDir) { + await writeProjectManifest(externalProjectDir, { + schemaVersion: 1, + id, + name: name.trim(), + createdAt: now, + updatedAt: now, + skillId: normalizedSkillId, + designSystemId: normalizedDesignSystemId, + }); + } + project = insertProject(db, { + id, + name: name.trim(), + skillId: normalizedSkillId, + designSystemId: normalizedDesignSystemId, + pendingPrompt: pendingPrompt || null, + metadata: projectMetadata, + customInstructions: + typeof customInstructions === 'string' + ? customInstructions + : null, + createdAt: now, + updatedAt: now, + }); + } catch (err) { + if (externalProjectDir) { + await rm(externalProjectDir, { recursive: true, force: true }).catch(() => {}); + } + throw err; + } // Seed a default conversation so the UI always has somewhere to write. const cid = randomId(); insertConversation(db, { @@ -345,7 +603,6 @@ export function registerProjectRoutes(app: Express, ctx: RegisterProjectRoutesDe createdAt: now, updatedAt: now, }); - const explicitPlugin = typeof req.body?.pluginId === 'string' && req.body.pluginId.trim().length > 0 ? true @@ -398,7 +655,7 @@ export function registerProjectRoutes(app: Express, ctx: RegisterProjectRoutesDe ) { const tpl = getTemplate(db, metadata.templateId); if (tpl && Array.isArray(tpl.files) && tpl.files.length > 0) { - await ensureProject(PROJECTS_DIR, id); + await ensureProject(PROJECTS_DIR, id, projectMetadata); for (const f of tpl.files) { if ( !f || @@ -413,6 +670,8 @@ export function registerProjectRoutes(app: Express, ctx: RegisterProjectRoutesDe id, f.name, Buffer.from(f.content, 'utf8'), + {}, + projectMetadata, ); } catch { // Skip individual file failures — the template snapshot is @@ -435,9 +694,10 @@ export function registerProjectRoutes(app: Express, ctx: RegisterProjectRoutesDe } }); - app.get('/api/projects/:id', (req, res) => { + app.get('/api/projects/:id', async (req, res) => { const project = getProject(db, req.params.id); - if (!project) + const locations = await configuredProjectLocations(); + if (!project || !projectVisibleForLocations(project, locations)) return sendApiError(res, 404, 'PROJECT_NOT_FOUND', 'not found'); const resolvedDir = projectDetailResolvedDir(PROJECTS_DIR, project, resolveProjectDir); /** @type {import('@open-design/contracts').ProjectResponse} */ @@ -484,6 +744,12 @@ export function registerProjectRoutes(app: Express, ctx: RegisterProjectRoutesDe ...(existingMeta.importedFrom === 'folder' ? { importedFrom: 'folder' } : {}), + ...(existingMeta.importedFrom === 'project-location' + ? { importedFrom: 'project-location' } + : {}), + ...(typeof existingMeta.projectLocationId === 'string' + ? { projectLocationId: existingMeta.projectLocationId } + : {}), ...(existingMeta.fromTrustedPicker === true ? { fromTrustedPicker: true as const } : {}), diff --git a/apps/daemon/src/server.ts b/apps/daemon/src/server.ts index b42733d65..6ea861454 100644 --- a/apps/daemon/src/server.ts +++ b/apps/daemon/src/server.ts @@ -5733,6 +5733,7 @@ export async function startServer({ events: projectEventDeps, ids: idDeps, telemetry: { reportFinalizedMessage }, + appConfig: appConfigDeps, validation: validationDeps, }); registerImportRoutes(app, { diff --git a/apps/daemon/tests/app-config.test.ts b/apps/daemon/tests/app-config.test.ts index 6de4af84b..2e8f20478 100644 --- a/apps/daemon/tests/app-config.test.ts +++ b/apps/daemon/tests/app-config.test.ts @@ -1,6 +1,6 @@ import http from 'node:http'; import { mkdtemp, rm, writeFile } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; +import { homedir, tmpdir } from 'node:os'; import path from 'node:path'; import express from 'express'; import { @@ -623,6 +623,187 @@ describe('app-config telemetry prefs', () => { }); }); +describe('app-config projectLocations', () => { + let dataDir: string; + + beforeEach(async () => { + dataDir = await mkdtemp(path.join(tmpdir(), 'od-projectLocations-')); + }); + + afterEach(async () => { + await rm(dataDir, { recursive: true, force: true }); + }); + + it('persists valid projectLocations and reads them back', async () => { + const locs = [ + { id: 'ext-one', name: 'One', path: '/tmp/od-loc-one' }, + { id: 'ext-two', name: 'Two', path: '/tmp/od-loc-two' }, + ]; + await writeAppConfig(dataDir, { projectLocations: locs }); + const cfg = await readAppConfig(dataDir); + expect(cfg.projectLocations).toEqual(locs); + }); + + it('normalizes ~/ paths via expandHomePrefix', async () => { + const home = homedir(); + const locs = [{ id: 'home-loc', name: 'Home', path: '~/od-projects' }]; + await writeAppConfig(dataDir, { projectLocations: locs }); + const cfg = await readAppConfig(dataDir); + expect(cfg.projectLocations).toHaveLength(1); + const first = cfg.projectLocations![0]!; + expect(first.path).toBe(path.join(home, 'od-projects')); + expect(path.isAbsolute(first.path)).toBe(true); + }); + + it('drops relative paths that cannot be resolved to absolute', async () => { + const locs = [ + { id: 'good', name: 'Good', path: '/tmp/od-good' }, + { id: 'bad-relative', name: 'Bad Rel', path: './relative/path' }, + ]; + await writeAppConfig(dataDir, { projectLocations: locs }); + const cfg = await readAppConfig(dataDir); + expect(cfg.projectLocations).toHaveLength(1); + const first = cfg.projectLocations![0]!; + expect(first.id).toBe('good'); + }); + + it('drops entries without a string path', async () => { + const locs = [ + { id: 'good', name: 'Good', path: '/tmp/od-good' }, + { id: 'no-path', name: 'No Path' }, + ]; + await writeAppConfig(dataDir, { projectLocations: locs as any }); + const cfg = await readAppConfig(dataDir); + expect(cfg.projectLocations).toHaveLength(1); + const first = cfg.projectLocations![0]!; + expect(first.id).toBe('good'); + }); + + it('deduplicates paths (case-sensitive on unix)', async () => { + const locs = [ + { id: 'first', name: 'First', path: '/tmp/od-same' }, + { id: 'second', name: 'Second', path: '/tmp/od-same' }, + ]; + await writeAppConfig(dataDir, { projectLocations: locs }); + const cfg = await readAppConfig(dataDir); + // Single canonical entry, second deduplicated + expect(cfg.projectLocations).toHaveLength(1); + const first = cfg.projectLocations![0]!; + expect(first.path).toBe(path.normalize('/tmp/od-same')); + }); + + it('deduplicates by resolved path after normalization', async () => { + const locs = [ + { id: 'first', name: 'First', path: '/tmp/od-dup/../od-dup' }, + { id: 'second', name: 'Second', path: '/tmp/od-dup' }, + ]; + await writeAppConfig(dataDir, { projectLocations: locs }); + const cfg = await readAppConfig(dataDir); + expect(cfg.projectLocations).toHaveLength(1); + const first = cfg.projectLocations![0]!; + expect(first.path).toBe(path.normalize('/tmp/od-dup')); + }); + + it('rejects reserved id "default" and falls back to auto-generated id', async () => { + const locs = [{ id: 'default', name: 'Hijack', path: '/tmp/od-hijack' }]; + await writeAppConfig(dataDir, { projectLocations: locs }); + const cfg = await readAppConfig(dataDir); + expect(cfg.projectLocations).toHaveLength(1); + // The stored id must NOT be 'default' + const first = cfg.projectLocations![0]!; + expect(first.id).not.toBe('default'); + // The auto-generated id follows the hash-backed base64url pattern + expect(first.id).toMatch(/^loc_[A-Za-z0-9_-]{1,16}$/); + expect(first.path).toBe(path.normalize('/tmp/od-hijack')); + }); + + it('generates distinct ids for sibling paths with long shared prefixes', async () => { + const locs = [ + { path: '/tmp/open-design-project-locations/shared-prefix-one' }, + { path: '/tmp/open-design-project-locations/shared-prefix-two' }, + ]; + await writeAppConfig(dataDir, { projectLocations: locs }); + const cfg = await readAppConfig(dataDir); + expect(cfg.projectLocations).toHaveLength(2); + const ids = cfg.projectLocations!.map((location) => location.id); + expect(new Set(ids).size).toBe(2); + expect(ids.every((id) => /^loc_[A-Za-z0-9_-]{1,16}$/.test(id))).toBe(true); + }); + + it('persists a defaultProjectLocationId preference', async () => { + await writeAppConfig(dataDir, { + projectLocations: [{ id: 'external-default', name: 'External', path: '/tmp/od-default-location' }], + defaultProjectLocationId: 'external-default', + }); + const cfg = await readAppConfig(dataDir); + expect(cfg.defaultProjectLocationId).toBe('external-default'); + }); + + it('normalizes invalid defaultProjectLocationId values', async () => { + await writeAppConfig(dataDir, { defaultProjectLocationId: '../bad' }); + let cfg = await readAppConfig(dataDir); + expect(cfg.defaultProjectLocationId).toBe('default'); + + await writeAppConfig(dataDir, { defaultProjectLocationId: null }); + cfg = await readAppConfig(dataDir); + expect(cfg.defaultProjectLocationId).toBeNull(); + }); + + it('drops invalid scalar projectLocations (not an array)', async () => { + await writeAppConfig(dataDir, { projectLocations: 'not-array' } as any); + const cfg = await readAppConfig(dataDir); + expect(cfg.projectLocations).toBeUndefined(); + }); + + it('clears projectLocations when empty array is sent', async () => { + await writeAppConfig(dataDir, { + projectLocations: [{ id: 'ext', name: 'ext', path: '/tmp/od-ext' }], + onboardingCompleted: true, + }); + expect((await readAppConfig(dataDir)).projectLocations).toHaveLength(1); + await writeAppConfig(dataDir, { projectLocations: [] }); + const cfg = await readAppConfig(dataDir); + expect(cfg.projectLocations).toEqual([]); + expect(cfg.onboardingCompleted).toBe(true); + }); + + it('clears projectLocations when null is sent', async () => { + await writeAppConfig(dataDir, { + projectLocations: [{ id: 'ext', name: 'ext', path: '/tmp/od-ext' }], + onboardingCompleted: true, + }); + expect((await readAppConfig(dataDir)).projectLocations).toHaveLength(1); + await writeAppConfig(dataDir, { projectLocations: null as any }); + const cfg = await readAppConfig(dataDir); + expect(cfg.projectLocations).toBeUndefined(); + expect(cfg.onboardingCompleted).toBe(true); + }); + + it('validates projectLocations on read (filters corrupted stored data)', async () => { + // Write raw JSON with invalid entries + await writeFile( + path.join(dataDir, 'app-config.json'), + JSON.stringify({ + projectLocations: [ + { id: 'good', name: 'Good', path: '/tmp/od-good' }, + { id: 'bad-relative', name: 'Bad', path: 'relative' }, + { id: 'no-path', name: 'No Path' }, + 'not-an-object', + null, + { id: 'good2', name: 'Dup Path', path: '/tmp/od-good' }, + { id: 'default', name: 'Reserved', path: '/tmp/od-reserved' }, + ], + }), + ); + const cfg = await readAppConfig(dataDir); + expect(cfg.projectLocations).toHaveLength(2); + const ids = cfg.projectLocations!.map((l) => l.id); + expect(ids).not.toContain('default'); + expect(ids).not.toContain('bad-relative'); + expect(ids).not.toContain('no-path'); + }); +}); + describe('app-config origin guard', () => { let server: http.Server; let port: number; diff --git a/apps/daemon/tests/projects-routes.test.ts b/apps/daemon/tests/projects-routes.test.ts index bf7c2b4e0..7654cd57f 100644 --- a/apps/daemon/tests/projects-routes.test.ts +++ b/apps/daemon/tests/projects-routes.test.ts @@ -13,7 +13,7 @@ */ import type http from 'node:http'; import { mkdtempSync, rmSync } from 'node:fs'; -import { writeFile } from 'node:fs/promises'; +import { mkdir, readdir, readFile, realpath, symlink, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'; @@ -299,6 +299,644 @@ describe('GET /api/projects/:id resolvedDir', () => { }); }); +// --------------------------------------------------------------------------- +// Project locations routes: GET, PUT, scan, and project creation under an +// external project location. +// --------------------------------------------------------------------------- +describe('project locations routes', () => { + let server: http.Server; + let baseUrl: string; + const tempDirs: string[] = []; + + beforeAll(async () => { + const started = (await startServer({ port: 0, returnServer: true })) as { + url: string; + server: http.Server; + }; + baseUrl = started.url; + server = started.server; + }); + + afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } + }); + + afterAll(() => { + return new Promise((resolve) => server.close(() => resolve())); + }); + + function makeTempDir(): string { + const d = mkdtempSync(path.join(tmpdir(), 'od-proj-loc-routes-')); + tempDirs.push(d); + return d; + } + + async function putProjectLocations( + locations: Array<{ id?: string; name?: string; path: string }>, + ): Promise { + return fetch(`${baseUrl}/api/project-locations`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ locations }), + }); + } + + async function putAppConfig(config: Record): Promise { + return fetch(`${baseUrl}/api/app-config`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(config), + }); + } + + it('GET /api/project-locations returns built-in default plus empty external', async () => { + const resp = await fetch(`${baseUrl}/api/project-locations`); + expect(resp.status).toBe(200); + const body = (await resp.json()) as { locations: Array<{ id: string; name: string; builtIn?: boolean; path: string }> }; + expect(body.locations).toHaveLength(1); // only default on fresh start + const loc0 = body.locations[0]!; + expect(loc0.id).toBe('default'); + expect(loc0.builtIn).toBe(true); + expect(loc0.name).toBe('Open Design projects'); + }); + + it('PUT /api/project-locations creates external roots and GET returns them alongside default', async () => { + const extDir = makeTempDir(); + const resp = await putProjectLocations([ + { id: 'ext-root', name: 'External', path: extDir }, + ]); + expect(resp.status).toBe(200); + const putBody = (await resp.json()) as { locations: Array<{ id: string; builtIn?: boolean; path: string }> }; + expect(putBody.locations).toHaveLength(2); + const putLoc0 = putBody.locations[0]!; + const putLoc1 = putBody.locations[1]!; + expect(putLoc0.id).toBe('default'); + expect(putLoc1.id).toBe('ext-root'); + expect(putLoc1.path).toBe(await realpath(extDir)); + + // GET returns the same + const getResp = await fetch(`${baseUrl}/api/project-locations`); + expect(getResp.status).toBe(200); + const getBody = (await getResp.json()) as { locations: Array<{ id: string; builtIn?: boolean; path: string }> }; + expect(getBody.locations).toHaveLength(2); + const getLoc0 = getBody.locations[0]!; + const getLoc1 = getBody.locations[1]!; + expect(getLoc0.id).toBe('default'); + expect(getLoc1.id).toBe('ext-root'); + }); + + it('POST /api/project-locations/scan returns empty result when no manifests found', async () => { + const extDir = makeTempDir(); + await putProjectLocations([{ id: 'empty-ext', name: 'Empty', path: extDir }]); + + const scanResp = await fetch(`${baseUrl}/api/project-locations/scan`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + }); + expect(scanResp.status).toBe(200); + const body = (await scanResp.json()) as { + scanned: number; + imported: unknown[]; + existing: string[]; + skipped: unknown[]; + }; + expect(body.scanned).toBe(0); + expect(body.imported).toEqual([]); + }); + + it('POST /api/project-locations/scan imports manifest-backed project and skips on re-scan', async () => { + const extDir = makeTempDir(); + // Create a project directory with a valid manifest + const projectDir = path.join(extDir, 'scan-test-proj'); + const odDir = path.join(projectDir, '.open-design'); + await mkdir(odDir, { recursive: true }); + const manifest = { + schemaVersion: 1 as const, + id: 'scan-test-proj', + name: 'Scanned Project', + createdAt: Date.now(), + updatedAt: Date.now(), + skillId: null, + designSystemId: null, + }; + await writeFile( + path.join(projectDir, '.open-design', 'project.json'), + JSON.stringify(manifest, null, 2), + 'utf8', + ); + + // Register the location + await putProjectLocations([{ id: 'scan-ext', name: 'Scan External', path: extDir }]); + + // First scan: should import + const scan1 = await fetch(`${baseUrl}/api/project-locations/scan`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + }); + expect(scan1.status).toBe(200); + const body1 = (await scan1.json()) as { + scanned: number; + imported: Array<{ id: string; name: string; metadata?: { baseDir?: string; importedFrom?: string } }>; + existing: string[]; + skipped: unknown[]; + }; + expect(body1.scanned).toBeGreaterThanOrEqual(1); + expect(body1.imported).toHaveLength(1); + const imported0 = body1.imported[0]!; + expect(imported0.id).toBe('scan-test-proj'); + expect(imported0.name).toBe('Scanned Project'); + // The imported project should have metadata pointing at the external dir + // (ensureProjectLocation calls realpath which resolves /var -> /private/var on macOS) + expect(imported0.metadata?.baseDir).toBe(await realpath(projectDir)); + expect(imported0.metadata?.importedFrom).toBe('project-location'); + expect(body1.existing).toEqual([]); + + // Second scan: project already exists, should be in "existing" + const scan2 = await fetch(`${baseUrl}/api/project-locations/scan`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + }); + expect(scan2.status).toBe(200); + const body2 = (await scan2.json()) as { + scanned: number; + imported: unknown[]; + existing: string[]; + }; + expect(body2.imported).toEqual([]); + expect(body2.existing).toEqual(['scan-test-proj']); + }); + + it('POST /api/projects with projectLocationId creates project under external root and writes .open-design/project.json', async () => { + const extDir = makeTempDir(); + // Register an external location + await putProjectLocations([{ id: 'create-ext', name: 'Create External', path: extDir }]); + + const projectId = `ext-proj-${Date.now()}`; + const createResp = await fetch(`${baseUrl}/api/projects`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + id: projectId, + name: 'External Project', + skillId: null, + designSystemId: null, + projectLocationId: 'create-ext', + }), + }); + expect(createResp.status).toBe(200); + const createBody = (await createResp.json()) as { + project: { id: string; metadata?: { baseDir?: string; importedFrom?: string; projectLocationId?: string } }; + }; + expect(createBody.project.id).toBe(projectId); + expect(createBody.project.metadata?.importedFrom).toBe('project-location'); + expect(createBody.project.metadata?.projectLocationId).toBe('create-ext'); + + // The project should be under / (ensureProjectLocation realpaths) + const expectedProjectDir = await realpath(path.join(extDir, projectId)); + expect(createBody.project.metadata?.baseDir).toBe(expectedProjectDir); + + // Verify .open-design/project.json was written + const manifestPath = path.join(expectedProjectDir, '.open-design', 'project.json'); + const manifestRaw = await import('node:fs/promises').then((m) => m.readFile(manifestPath, 'utf8')); + const manifest = JSON.parse(manifestRaw); + expect(manifest.schemaVersion).toBe(1); + expect(manifest.id).toBe(projectId); + expect(manifest.name).toBe('External Project'); + + // GET /api/projects/:id resolvedDir equals the external project dir + const detailResp = await fetch(`${baseUrl}/api/projects/${projectId}`); + expect(detailResp.status).toBe(200); + const detail = (await detailResp.json()) as { resolvedDir: string }; + expect(detail.resolvedDir).toBe(expectedProjectDir); + }); + + it('POST /api/projects uses the configured default project location when no location is supplied', async () => { + const extDir = makeTempDir(); + const locationId = 'default-create-location'; + await putProjectLocations([{ id: locationId, name: 'Default External', path: extDir }]); + const cfgResp = await putAppConfig({ defaultProjectLocationId: locationId }); + expect(cfgResp.status).toBe(200); + + const projectId = `default-location-project-${Date.now()}`; + const createResp = await fetch(`${baseUrl}/api/projects`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + id: projectId, + name: 'Default location project', + skillId: null, + designSystemId: null, + }), + }); + expect(createResp.status).toBe(200); + const body = (await createResp.json()) as { + project: { metadata?: { baseDir?: string; projectLocationId?: string; importedFrom?: string } }; + }; + expect(body.project.metadata?.projectLocationId).toBe(locationId); + expect(body.project.metadata?.importedFrom).toBe('project-location'); + expect(body.project.metadata?.baseDir).toBe(await realpath(path.join(extDir, projectId))); + + await putAppConfig({ defaultProjectLocationId: null }); + await putProjectLocations([]); + }); + + it('POST /api/projects falls back to built-in storage when configured default location is unavailable', async () => { + await putProjectLocations([]); + const cfgResp = await putAppConfig({ defaultProjectLocationId: 'missing-location' }); + expect(cfgResp.status).toBe(200); + + const projectId = `missing-default-project-${Date.now()}`; + const createResp = await fetch(`${baseUrl}/api/projects`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + id: projectId, + name: 'Missing default project', + skillId: null, + designSystemId: null, + }), + }); + expect(createResp.status).toBe(200); + const body = (await createResp.json()) as { + project: { metadata?: { baseDir?: string; projectLocationId?: string } }; + }; + expect(body.project.metadata?.baseDir).toBeUndefined(); + expect(body.project.metadata?.projectLocationId).toBeUndefined(); + + await putAppConfig({ defaultProjectLocationId: null }); + }); + + it('PATCH /api/projects/:id preserves project-location provenance with baseDir', async () => { + const extDir = makeTempDir(); + await putProjectLocations([{ id: 'patch-ext', name: 'Patch External', path: extDir }]); + + const projectId = `ext-patch-${Date.now()}`; + const createResp = await fetch(`${baseUrl}/api/projects`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + id: projectId, + name: 'Patch External Project', + projectLocationId: 'patch-ext', + }), + }); + expect(createResp.status).toBe(200); + const createBody = (await createResp.json()) as { + project: { metadata?: { baseDir?: string; importedFrom?: string; projectLocationId?: string } }; + }; + + const patchResp = await fetch(`${baseUrl}/api/projects/${projectId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ metadata: { kind: 'prototype', skipDiscoveryBrief: true } }), + }); + expect(patchResp.status).toBe(200); + const patchBody = (await patchResp.json()) as { + project: { metadata?: { baseDir?: string; importedFrom?: string; projectLocationId?: string; skipDiscoveryBrief?: boolean } }; + }; + expect(patchBody.project.metadata?.baseDir).toBe(createBody.project.metadata?.baseDir); + expect(patchBody.project.metadata?.importedFrom).toBe('project-location'); + expect(patchBody.project.metadata?.projectLocationId).toBe('patch-ext'); + expect(patchBody.project.metadata?.skipDiscoveryBrief).toBe(true); + }); + + it('POST /api/projects with unknown projectLocationId returns 400', async () => { + const projectId = `bad-loc-${Date.now()}`; + const resp = await fetch(`${baseUrl}/api/projects`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + id: projectId, + name: 'Bad Location Project', + projectLocationId: 'nonexistent-location-id', + }), + }); + expect(resp.status).toBe(400); + const body = (await resp.json()) as { error?: { code?: string; message?: string } }; + expect(body.error?.code).toBe('BAD_REQUEST'); + expect(body.error?.message).toMatch(/project location/i); + }); + + it('POST /api/projects with invalid designSystemId does not create external project directory', async () => { + const extDir = makeTempDir(); + await putProjectLocations([{ id: 'invalid-ds-ext', name: 'Invalid DS External', path: extDir }]); + + const projectId = `invalid-ds-${Date.now()}`; + const resp = await fetch(`${baseUrl}/api/projects`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + id: projectId, + name: 'Invalid design system project', + designSystemId: `missing-design-system-${Date.now()}`, + projectLocationId: 'invalid-ds-ext', + }), + }); + + expect(resp.status).toBe(400); + const body = (await resp.json()) as { error?: { code?: string } }; + expect(body.error?.code).toBe('DESIGN_SYSTEM_NOT_FOUND'); + await expect(readdir(extDir)).resolves.toEqual([]); + }); + + it('PUT /api/project-locations rejects non-array locations body', async () => { + const resp = await fetch(`${baseUrl}/api/project-locations`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ locations: 'not-an-array' }), + }); + expect(resp.status).toBe(400); + const body = (await resp.json()) as { error?: { code?: string } }; + expect(body.error?.code).toBe('BAD_REQUEST'); + }); + + // ----------------------------------------------------------------------- + // Security boundaries — see #451 (project-locations) for context. + // ----------------------------------------------------------------------- + + it('POST /api/projects with projectLocationId rejects unsafe id "."', async () => { + const extDir = makeTempDir(); + await putProjectLocations([{ id: 'sec-ext', name: 'Security External', path: extDir }]); + + const resp = await fetch(`${baseUrl}/api/projects`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + id: '.', + name: 'Dot Project', + projectLocationId: 'sec-ext', + }), + }); + expect(resp.status).toBe(400); + const body = (await resp.json()) as { error?: { code?: string; message?: string } }; + expect(body.error?.code).toBe('BAD_REQUEST'); + expect(body.error?.message).toMatch(/invalid project id/i); + }); + + it('POST /api/projects with projectLocationId rejects unsafe id ".."', async () => { + const extDir = makeTempDir(); + await putProjectLocations([{ id: 'sec-ext2', name: 'Security External 2', path: extDir }]); + + const resp = await fetch(`${baseUrl}/api/projects`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + id: '..', + name: 'DotDot Project', + projectLocationId: 'sec-ext2', + }), + }); + expect(resp.status).toBe(400); + const body = (await resp.json()) as { error?: { code?: string; message?: string } }; + expect(body.error?.code).toBe('BAD_REQUEST'); + expect(body.error?.message).toMatch(/invalid project id/i); + }); + + it('POST /api/projects with projectLocationId rejects when target path already exists as a symlink', async () => { + const extDir = makeTempDir(); + await putProjectLocations([{ id: 'sym-ext', name: 'Symlink External', path: extDir }]); + + const projectId = `symlink-proj-${Date.now()}`; + const realTargetDir = path.join(extDir, 'real-target'); + await mkdir(realTargetDir, { recursive: true }); + + // Pre-create a symlink at / pointing to another directory + const symlinkPath = path.join(extDir, projectId); + await symlink(realTargetDir, symlinkPath); + + const resp = await fetch(`${baseUrl}/api/projects`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + id: projectId, + name: 'Symlink Project', + projectLocationId: 'sym-ext', + }), + }); + expect(resp.status).toBe(400); + const body = (await resp.json()) as { error?: { code?: string; message?: string } }; + expect(body.error?.code).toBe('BAD_REQUEST'); + }); + + it('PUT /api/project-locations rejects a root overlapping the daemon projects dir', async () => { + const dataDir = process.env.OD_DATA_DIR; + if (!dataDir) throw new Error('OD_DATA_DIR required for daemon route tests'); + const projectsDir = path.join(dataDir, 'projects'); + + const canonicalProjectsDir = await realpath(projectsDir); + + const resp = await putProjectLocations([ + { id: 'overlap-projects', name: 'Overlap Projects', path: canonicalProjectsDir }, + ]); + + expect(resp.status).toBe(400); + const body = (await resp.json()) as { error?: { code?: string; message?: string } }; + expect(body.error?.code).toBe('BAD_REQUEST'); + expect(body.error?.message).toMatch(/cannot overlap|daemon data/i); + }); + + it('PUT /api/project-locations rejects filesystem root "/" via isBlocked check', async () => { + // isBlocked in linked-dirs.ts rejects the filesystem root. + const resp = await putProjectLocations([ + { id: 'root-loc', name: 'Root', path: '/' }, + ]); + expect(resp.status).toBe(400); + const body = (await resp.json()) as { error?: { code?: string; message?: string } }; + expect(body.error?.code).toBe('BAD_REQUEST'); + }); + + it('app-config bypass: PUT /api/app-config persists invalid path but GET /api/project-locations does not expose it', async () => { + // Persist a projectLocations entry with a system-protected path ('/') via + // the generic PUT /api/app-config route, which only validates format, not + // safety. The GET /api/project-locations route must filter it out because + // configuredProjectLocations() runs validateLinkedDirs + locationOverlapsDaemonData. + const appCfgResp = await fetch(`${baseUrl}/api/app-config`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + projectLocations: [ + { id: 'bad-root', name: 'Bad Root', path: '/' }, + ], + }), + }); + expect(appCfgResp.status).toBe(200); + + // Verify the persisted config (read back) contains the entry (format validation passed) + const readCfgResp = await fetch(`${baseUrl}/api/app-config`); + expect(readCfgResp.status).toBe(200); + const cfgBody = (await readCfgResp.json()) as { + config: { projectLocations?: Array<{ id: string; path: string }> }; + }; + // The entry was normalized and persisted + const locs = cfgBody.config.projectLocations; + expect(locs).toBeDefined(); + expect(locs!.length).toBeGreaterThanOrEqual(1); + + // But GET /api/project-locations must NOT expose it + const locResp = await fetch(`${baseUrl}/api/project-locations`); + expect(locResp.status).toBe(200); + const locBody = (await locResp.json()) as { + locations: Array<{ id: string }>; + }; + const ids = locBody.locations.map((l) => l.id); + expect(ids).toContain('default'); // built-in always present + // The invalid location must not appear + expect(ids).not.toContain('bad-root'); + + // Clean up: remove the invalid projectLocations + await fetch(`${baseUrl}/api/app-config`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ projectLocations: [] }), + }); + }); + + it('app-config bypass: POST /api/projects with invalid persisted root id returns 400 unknown project location', async () => { + // Persist a projectLocations entry with '/' via app-config. + // The auto-generated id follows the loc_ pattern. + const appCfgResp = await fetch(`${baseUrl}/api/app-config`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + projectLocations: [ + { id: 'evil-root', name: 'Evil Root', path: '/' }, + ], + }), + }); + expect(appCfgResp.status).toBe(200); + + // Try to create a project under this location id. Since configuredProjectLocations + // filters it, the lookup returns nothing → 400 "unknown project location". + const projectId = `evil-proj-${Date.now()}`; + const createResp = await fetch(`${baseUrl}/api/projects`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + id: projectId, + name: 'Evil Project', + projectLocationId: 'evil-root', + }), + }); + expect(createResp.status).toBe(400); + const body = (await createResp.json()) as { error?: { code?: string; message?: string } }; + expect(body.error?.code).toBe('BAD_REQUEST'); + expect(body.error?.message).toMatch(/unknown project location/i); + + // Clean up + await fetch(`${baseUrl}/api/app-config`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ projectLocations: [] }), + }); + }); + + it('removing an external location hides its projects but preserves DB history and disk files for re-scan', async () => { + const extDir = makeTempDir(); + const locationId = 'unreg-loc'; + await putProjectLocations([{ id: locationId, name: 'Unreg External', path: extDir }]); + + // Create a project under this external location + const projectId = `unreg-proj-${Date.now()}`; + const createResp = await fetch(`${baseUrl}/api/projects`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + id: projectId, + name: 'Project To Unregister', + skillId: null, + designSystemId: null, + projectLocationId: locationId, + }), + }); + expect(createResp.status).toBe(200); + const createBody = (await createResp.json()) as { + project: { id: string }; + conversationId: string; + }; + expect(createBody.project.id).toBe(projectId); + + const messageId = `msg-${Date.now()}`; + const messageResp = await fetch(`${baseUrl}/api/projects/${projectId}/conversations/${createBody.conversationId}/messages/${messageId}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + role: 'user', + content: 'restore this conversation after location re-add', + }), + }); + expect(messageResp.status).toBe(200); + + // Confirm the project is listed + const listBefore = await fetch(`${baseUrl}/api/projects`); + expect(listBefore.status).toBe(200); + const beforeBody = (await listBefore.json()) as { projects: Array<{ id: string }> }; + expect(beforeBody.projects.some((p) => p.id === projectId)).toBe(true); + + // The project directory and manifest should exist on disk + const expectedProjectDir = await realpath(path.join(extDir, projectId)); + const manifestPath = path.join(expectedProjectDir, '.open-design', 'project.json'); + const manifestBefore = await readFile(manifestPath, 'utf8'); + expect(JSON.parse(manifestBefore).id).toBe(projectId); + + // Remove the external location: PUT empty locations so the location is dropped. + // This is an unmount/hide operation, not a destructive project delete. + const removeResp = await putProjectLocations([]); + expect(removeResp.status).toBe(200); + const removeBody = (await removeResp.json()) as { + locations: Array<{ id: string }>; + removedProjectIds?: string[]; + }; + // The response must include removedProjectIds with our project + expect(removeBody.removedProjectIds).toBeDefined(); + expect(removeBody.removedProjectIds).toContain(projectId); + // Only the built-in default location should remain + expect(removeBody.locations).toHaveLength(1); + expect(removeBody.locations[0]!.id).toBe('default'); + + // The project should no longer appear in GET /api/projects + const listAfter = await fetch(`${baseUrl}/api/projects`); + expect(listAfter.status).toBe(200); + const afterBody = (await listAfter.json()) as { projects: Array<{ id: string }> }; + expect(afterBody.projects.some((p) => p.id === projectId)).toBe(false); + + // GET /api/projects/:id should return 404 while the location is unmounted. + const detailResp = await fetch(`${baseUrl}/api/projects/${projectId}`); + expect(detailResp.status).toBe(404); + + // The on-disk project directory and manifest must still be intact + const manifestAfter = await readFile(manifestPath, 'utf8'); + expect(JSON.parse(manifestAfter).id).toBe(projectId); + + // Re-add the same base and scan: the existing DB row should be revealed, + // not recreated from only the manifest, so conversation history survives. + await putProjectLocations([{ id: locationId, name: 'Unreg External', path: extDir }]); + const scanResp = await fetch(`${baseUrl}/api/project-locations/scan`, { method: 'POST' }); + expect(scanResp.status).toBe(200); + const scanBody = (await scanResp.json()) as { imported: Array<{ id: string }>; existing: string[] }; + expect(scanBody.imported.some((p) => p.id === projectId)).toBe(false); + expect(scanBody.existing).toContain(projectId); + + const listReadded = await fetch(`${baseUrl}/api/projects`); + expect(listReadded.status).toBe(200); + const readdedBody = (await listReadded.json()) as { projects: Array<{ id: string }> }; + expect(readdedBody.projects.some((p) => p.id === projectId)).toBe(true); + + const messagesResp = await fetch(`${baseUrl}/api/projects/${projectId}/conversations/${createBody.conversationId}/messages`); + expect(messagesResp.status).toBe(200); + const messagesBody = (await messagesResp.json()) as { messages: Array<{ id: string; content: string }> }; + expect(messagesBody.messages).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: messageId, + content: 'restore this conversation after location re-add', + }), + ]), + ); + }); +}); + async function withSandboxMode(run: () => Promise): Promise { const previous = process.env.OD_SANDBOX_MODE; process.env.OD_SANDBOX_MODE = '1'; diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index f3a7b6acb..f9d4c89b7 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -1622,6 +1622,7 @@ function AppInner() { daemonMediaProvidersFetchState={daemonMediaProvidersFetchState} mediaProvidersNotice={mediaProvidersNotice} onReloadMediaProviders={reloadMediaProvidersFromDaemon} + onProjectsRefresh={refreshProjects} onSkillsChanged={handleSkillsChanged} onDesignSystemsChanged={handleDesignSystemsChanged} providerModelsCache={providerModelsCache} diff --git a/apps/web/src/components/EntryShell.tsx b/apps/web/src/components/EntryShell.tsx index 4d0c4a3d9..83dc04d59 100644 --- a/apps/web/src/components/EntryShell.tsx +++ b/apps/web/src/components/EntryShell.tsx @@ -307,6 +307,7 @@ interface Props { | 'appearance' | 'notifications' | 'pet' + | 'projectLocations' | 'library' | 'about' | 'memory' diff --git a/apps/web/src/components/EntryView.tsx b/apps/web/src/components/EntryView.tsx index c92c53c92..c1c232f7d 100644 --- a/apps/web/src/components/EntryView.tsx +++ b/apps/web/src/components/EntryView.tsx @@ -130,7 +130,7 @@ interface Props { onOpenDesignSystem?: (id: string) => void; onDesignSystemsRefresh?: () => Promise | void; onPersistComposioKey: (composio: AppConfig['composio']) => Promise | void; - onOpenSettings: (section?: 'execution' | 'media' | 'composio' | 'orbit' | 'integrations' | 'mcpClient' | 'language' | 'appearance' | 'notifications' | 'pet' | 'library' | 'about' | 'memory' | 'designSystems') => void; + onOpenSettings: (section?: 'execution' | 'media' | 'composio' | 'orbit' | 'integrations' | 'mcpClient' | 'language' | 'appearance' | 'notifications' | 'pet' | 'projectLocations' | 'library' | 'about' | 'memory' | 'designSystems') => void; onCompleteOnboarding: () => void; } diff --git a/apps/web/src/components/NewProjectPanel.tsx b/apps/web/src/components/NewProjectPanel.tsx index 1397d9eef..7a8f5fdd5 100644 --- a/apps/web/src/components/NewProjectPanel.tsx +++ b/apps/web/src/components/NewProjectPanel.tsx @@ -847,13 +847,15 @@ export function NewProjectPanel({ ) : null} - setName(e.target.value)} - /> +
+ setName(e.target.value)} + /> +
{showDesignSystemPicker ? ( >; + onProjectsRefresh?: () => Promise | void; +} + +interface DraftLocation { + id?: string; + path: string; +} + +function locationLabel(locationPath: string): string { + return locationPath.split(/[\\/]/).filter(Boolean).pop() || locationPath; +} + +function externalLocations(locations: ProjectLocation[]): DraftLocation[] { + return locations + .filter((location) => !location.builtIn) + .map((location) => ({ id: location.id, path: location.path })); +} + +function toConfigLocations(locations: ProjectLocation[]): NonNullable { + return locations + .filter((location) => !location.builtIn) + .map((location) => ({ id: location.id, name: location.name, path: location.path })); +} + +export function ProjectLocationsSection({ cfg, setCfg, onProjectsRefresh }: Props) { + const { t } = useI18n(); + const [locations, setLocations] = useState([]); + const [drafts, setDrafts] = useState(cfg.projectLocations ?? []); + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + const [status, setStatus] = useState(null); + const [error, setError] = useState(null); + const draftsRef = useRef(drafts); + + useEffect(() => { + draftsRef.current = drafts; + }, [drafts]); + + useEffect(() => { + let cancelled = false; + setLoading(true); + fetchProjectLocations() + .then((next) => { + if (cancelled) return; + setLocations(next); + setDrafts(externalLocations(next)); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [setCfg]); + + const builtIn = useMemo( + () => locations.find((location) => location.builtIn), + [locations], + ); + const effectiveDefaultLocationId = useMemo(() => { + const configured = cfg.defaultProjectLocationId ?? 'default'; + return locations.some((location) => location.id === configured) ? configured : 'default'; + }, [cfg.defaultProjectLocationId, locations]); + + function defaultControlLabel(locationId: string): string { + return effectiveDefaultLocationId === locationId + ? t('settings.projectLocationsDefaultBadge') + : t('settings.projectLocationsMakeDefault'); + } + + function handleDefaultLocationChange(locationId: string) { + setError(null); + setStatus(t('settings.projectLocationsDefaultSaved')); + setCfg((current) => ({ ...current, defaultProjectLocationId: locationId })); + } + + async function save(nextDrafts: DraftLocation[]) { + setSaving(true); + setError(null); + setStatus(null); + try { + const saved = await updateProjectLocations( + nextDrafts.filter((location) => location.path.trim()), + ); + if (!saved) { + setError(t('settings.projectLocationsSaveError')); + return null; + } + setLocations(saved); + const external = externalLocations(saved); + setDrafts(external); + setCfg((current) => { + const configuredDefault = current.defaultProjectLocationId ?? 'default'; + const nextDefault = saved.some((location) => location.id === configuredDefault) + ? configuredDefault + : 'default'; + return { + ...current, + projectLocations: toConfigLocations(saved), + defaultProjectLocationId: nextDefault, + }; + }); + setStatus(t('settings.projectLocationsSaved')); + void onProjectsRefresh?.(); + return external; + } finally { + setSaving(false); + } + } + + async function runScan() { + const result = await scanProjectLocations(); + if (!result) { + setError(t('settings.projectLocationsScanError')); + return null; + } + setStatus(t('settings.projectLocationsScanComplete', { + imported: result.imported.length, + existing: result.existing.length, + })); + void onProjectsRefresh?.(); + return result; + } + + async function handleAddFolder() { + setError(null); + setStatus(null); + const selected = await openProjectLocationFolderDialog(); + if (!selected) { + setStatus(t('settings.projectLocationsNoFolderSelected')); + return; + } + if (draftsRef.current.some((draft) => draft.path === selected)) { + setStatus(t('settings.projectLocationsDuplicate')); + return; + } + const previous = draftsRef.current; + const next = [...previous, { path: selected }]; + setDrafts(next); + const saved = await save(next); + if (!saved) setDrafts(previous); + else await runScan(); + } + + async function removeDraft(index: number) { + const previous = draftsRef.current; + const next = previous.filter((_, i) => i !== index); + setDrafts(next); + const saved = await save(next); + if (!saved) setDrafts(previous); + } + + return ( +
+
+
+

{t('settings.projectLocations')}

+

{t('settings.projectLocationsDescription')}

+
+
+ + {builtIn ? ( +
+
+ {t('newproj.locationDefault')} + {builtIn.path} +
+ +
+ ) : null} + +
+ {drafts.map((draft, index) => ( +
+
+ {locationLabel(draft.path)} + {draft.path} + {t('settings.projectLocationsWorkBaseMeta')} +
+ {draft.id ? ( + + ) : null} + +
+ ))} +
+ + + + {status ?

{status}

: null} + {error ?

{error}

: null} +
+ ); +} diff --git a/apps/web/src/components/SettingsDialog.tsx b/apps/web/src/components/SettingsDialog.tsx index 6fc58bde6..3045b433b 100644 --- a/apps/web/src/components/SettingsDialog.tsx +++ b/apps/web/src/components/SettingsDialog.tsx @@ -94,6 +94,7 @@ import { McpClientSection } from './McpClientSection'; import { SkillsSection } from './SkillsSection'; import { DesignSystemsSection } from './DesignSystemsSection'; import { PrivacySection } from './PrivacySection'; +import { ProjectLocationsSection } from './ProjectLocationsSection'; import { RoutinesSection } from './RoutinesSection'; import { ConnectorsBrowser } from './ConnectorsBrowser'; import { MemoryModelInline } from './MemoryModelInline'; @@ -135,6 +136,7 @@ export type SettingsSection = | 'pet' | 'skills' | 'designSystems' + | 'projectLocations' | 'memory' | 'privacy' // 'library' is consumed by the EntryShell library route — App opens it @@ -194,6 +196,7 @@ interface Props { daemonMediaProvidersFetchState?: 'idle' | 'ok' | 'error'; mediaProvidersNotice?: string | null; onReloadMediaProviders?: () => Promise; + onProjectsRefresh?: () => Promise | void; /** * Notified by Settings → Skills after a successful skill registry * mutation (create / edit / delete). App.tsx uses this to drop preview @@ -835,6 +838,7 @@ export function SettingsDialog({ daemonMediaProvidersFetchState = 'idle', mediaProvidersNotice, onReloadMediaProviders, + onProjectsRefresh, onSkillsChanged, onDesignSystemsChanged, providerModelsCache: sharedProviderModelsCache, @@ -2034,6 +2038,10 @@ export function SettingsDialog({ title: t('settings.designSystems'), subtitle: t('settings.designSystemsHint'), }, + projectLocations: { + title: t('settings.projectLocations'), + subtitle: t('settings.projectLocationsHint'), + }, memory: { title: t('settings.memory'), subtitle: t('settings.memoryHint') }, // 'library' is opened via EntryShell route — SettingsDialog doesn't // render it but SettingsSection must accept the token (see type def). @@ -2465,6 +2473,17 @@ export function SettingsDialog({ {t('settings.designSystemsHint')} +