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
This commit is contained in:
BayesWang 2026-05-31 12:47:45 +08:00 committed by GitHub
parent 3395d2c855
commit af4a62b69a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
40 changed files with 2143 additions and 44 deletions

View file

@ -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<keyof AppConfigPrefs> = new Set([
@ -115,6 +124,8 @@ const ALLOWED_KEYS: ReadonlySet<keyof AppConfigPrefs> = 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<string>();
const seenPaths = new Set<string>();
for (const item of raw) {
if (!item || typeof item !== 'object' || Array.isArray(item)) continue;
const obj = item as Record<string, unknown>;
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<string, unknown>): AppConfigPrefs {

View file

@ -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<string> {
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<string> {
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<string> {
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<void> {
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<ProjectManifest | null> {
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<string, unknown>;
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<Array<{ dir: string; manifest: ProjectManifest }>> {
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;
}

View file

@ -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<string> {
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 }
: {}),

View file

@ -5733,6 +5733,7 @@ export async function startServer({
events: projectEventDeps,
ids: idDeps,
telemetry: { reportFinalizedMessage },
appConfig: appConfigDeps,
validation: validationDeps,
});
registerImportRoutes(app, {

View file

@ -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;

View file

@ -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<void>((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<Response> {
return fetch(`${baseUrl}/api/project-locations`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ locations }),
});
}
async function putAppConfig(config: Record<string, unknown>): Promise<Response> {
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 <extDir>/<projectId> (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 <extDir>/<projectId> 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_<base64url> 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<T>(run: () => Promise<T>): Promise<T> {
const previous = process.env.OD_SANDBOX_MODE;
process.env.OD_SANDBOX_MODE = '1';

View file

@ -1622,6 +1622,7 @@ function AppInner() {
daemonMediaProvidersFetchState={daemonMediaProvidersFetchState}
mediaProvidersNotice={mediaProvidersNotice}
onReloadMediaProviders={reloadMediaProvidersFromDaemon}
onProjectsRefresh={refreshProjects}
onSkillsChanged={handleSkillsChanged}
onDesignSystemsChanged={handleDesignSystemsChanged}
providerModelsCache={providerModelsCache}

View file

@ -307,6 +307,7 @@ interface Props {
| 'appearance'
| 'notifications'
| 'pet'
| 'projectLocations'
| 'library'
| 'about'
| 'memory'

View file

@ -130,7 +130,7 @@ interface Props {
onOpenDesignSystem?: (id: string) => void;
onDesignSystemsRefresh?: () => Promise<void> | void;
onPersistComposioKey: (composio: AppConfig['composio']) => Promise<void> | 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;
}

View file

@ -847,13 +847,15 @@ export function NewProjectPanel({
) : null}
</h3>
<input
className="newproj-name"
data-testid="new-project-name"
placeholder={t('newproj.namePlaceholder')}
value={name}
onChange={(e) => setName(e.target.value)}
/>
<div className="newproj-name-row">
<input
className="newproj-name"
data-testid="new-project-name"
placeholder={t('newproj.namePlaceholder')}
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
{showDesignSystemPicker ? (
<DesignSystemPicker

View file

@ -0,0 +1,239 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import type { Dispatch, SetStateAction } from 'react';
import type { ProjectLocation } from '@open-design/contracts';
import type { AppConfig } from '../types';
import {
fetchProjectLocations,
openProjectLocationFolderDialog,
scanProjectLocations,
updateProjectLocations,
} from '../state/project-locations';
import { useI18n } from '../i18n';
import { Icon } from './Icon';
interface Props {
cfg: AppConfig;
setCfg: Dispatch<SetStateAction<AppConfig>>;
onProjectsRefresh?: () => Promise<void> | 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<AppConfig['projectLocations']> {
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<ProjectLocation[]>([]);
const [drafts, setDrafts] = useState<DraftLocation[]>(cfg.projectLocations ?? []);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [status, setStatus] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const draftsRef = useRef<DraftLocation[]>(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 (
<section className="settings-section settings-section-card project-locations-section">
<div className="section-head">
<div>
<h3>{t('settings.projectLocations')}</h3>
<p className="hint">{t('settings.projectLocationsDescription')}</p>
</div>
</div>
{builtIn ? (
<div className={`project-location-card is-built-in${effectiveDefaultLocationId === builtIn.id ? ' is-default' : ''}`}>
<div>
<strong>{t('newproj.locationDefault')}</strong>
<code>{builtIn.path}</code>
</div>
<label className="project-location-default-control">
<input
type="radio"
name="project-location-default"
checked={effectiveDefaultLocationId === builtIn.id}
onChange={() => handleDefaultLocationChange(builtIn.id)}
/>
<span>{defaultControlLabel(builtIn.id)}</span>
</label>
</div>
) : null}
<div className="project-location-list">
{drafts.map((draft, index) => (
<div
className={`project-location-edit${draft.id && effectiveDefaultLocationId === draft.id ? ' is-default' : ''}`}
key={`${draft.id ?? 'new'}-${index}`}
>
<div className="project-location-edit-main">
<strong>{locationLabel(draft.path)}</strong>
<code>{draft.path}</code>
<small>{t('settings.projectLocationsWorkBaseMeta')}</small>
</div>
{draft.id ? (
<label className="project-location-default-control">
<input
type="radio"
name="project-location-default"
checked={effectiveDefaultLocationId === draft.id}
onChange={() => handleDefaultLocationChange(draft.id!)}
/>
<span>{defaultControlLabel(draft.id)}</span>
</label>
) : null}
<button type="button" className="icon-btn danger" onClick={() => removeDraft(index)} disabled={saving}>
{t('common.delete')}
</button>
</div>
))}
</div>
<button
type="button"
className="icon-btn project-location-add"
onClick={handleAddFolder}
disabled={loading || saving}
>
<Icon name="plus" size={12} />
{t('settings.projectLocationsAddFolder')}
</button>
{status ? <p className="settings-rescan-status">{status}</p> : null}
{error ? <p className="settings-rescan-status error">{error}</p> : null}
</section>
);
}

View file

@ -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<AppConfig['mediaProviders'] | null>;
onProjectsRefresh?: () => Promise<void> | 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({
<small>{t('settings.designSystemsHint')}</small>
</span>
</button>
<button
type="button"
className={`settings-nav-item${activeSection === 'projectLocations' ? ' active' : ''}`}
onClick={() => setActiveSection('projectLocations')}
>
<Icon name="folder" size={18} />
<span>
<strong>{t('settings.projectLocations')}</strong>
<small>{t('settings.projectLocationsHint')}</small>
</span>
</button>
<button
type="button"
className={`settings-nav-item${activeSection === 'privacy' ? ' active' : ''}`}
@ -3664,6 +3683,10 @@ export function SettingsDialog({
/>
) : null}
{activeSection === 'projectLocations' ? (
<ProjectLocationsSection cfg={cfg} setCfg={setCfg} onProjectsRefresh={onProjectsRefresh} />
) : null}
{activeSection === 'instructions' ? (
<section className="settings-section settings-section-card instructions-rules-section">
<div className="memory-field-block instructions-rules-card">

View file

@ -566,6 +566,9 @@ export const ar: Dict = {
'newproj.fileSingular': 'ملف',
'newproj.filePlural': 'ملفات',
'newproj.create': 'إنشاء',
'newproj.locationLabel': 'حفظ في',
'newproj.locationDefault': 'مشاريع Open Design',
'newproj.locationExternalBase': 'قاعدة خارجية',
'newproj.createFromTemplate': 'إنشاء من قالب',
'newproj.createDisabledTitle':
'احفظ مشروعاً كقالب أولاً (قائمة المشاركة داخل أي مشروع).',
@ -1552,6 +1555,20 @@ export const ar: Dict = {
'settings.designSystemsCategory': 'الفئة',
'settings.designSystemsAllCategories': 'كل الفئات',
'settings.designSystemsShowInHomeGallery': 'إظهار في معرض الصفحة الرئيسية',
'settings.projectLocations': 'مواقع المشاريع',
'settings.projectLocationsHint': 'جذور تخزين مساحات العمل',
'settings.projectLocationsDescription': 'أضف قواعد عمل يمكن أن تحتوي على عدة مجلدات مشاريع Open Design. تُحفظ المشاريع الجديدة كمجلد داخل القاعدة المحددة.',
'settings.projectLocationsSaveError': 'تعذّر حفظ مواقع المشاريع. تحقق من أن كل مسار مجلد يمكن الوصول إليه.',
'settings.projectLocationsSaved': 'تم حفظ مواقع المشاريع.',
'settings.projectLocationsScanError': 'تعذّر فحص مواقع المشاريع.',
'settings.projectLocationsScanComplete': 'اكتمل الفحص: تم استيراد {imported}، و{existing} مسجلة مسبقًا.',
'settings.projectLocationsNoFolderSelected': 'لم يتم اختيار مجلد.',
'settings.projectLocationsDuplicate': 'تمت إضافة قاعدة العمل هذه بالفعل.',
'settings.projectLocationsWorkBaseMeta': 'قاعدة عمل · يتم إنشاء المشاريع هنا كمجلدات فرعية',
'settings.projectLocationsAddFolder': 'إضافة مجلد…',
'settings.projectLocationsDefaultBadge': 'الموقع الافتراضي',
'settings.projectLocationsMakeDefault': 'تعيين كافتراضي',
'settings.projectLocationsDefaultSaved': 'تم تحديث موقع المشروع الافتراضي.',
'settings.librarySkills': 'المهارات',
'settings.libraryDesignSystems': 'أنظمة التصميم',
'settings.librarySearch': 'بحث...',

View file

@ -463,6 +463,9 @@ export const de: Dict = {
'newproj.fileSingular': 'Datei',
'newproj.filePlural': 'Dateien',
'newproj.create': 'Erstellen',
'newproj.locationLabel': 'Speichern unter',
'newproj.locationDefault': 'Open Design-Projekte',
'newproj.locationExternalBase': 'Externe Basis',
'newproj.createFromTemplate': 'Aus Template erstellen',
'newproj.createDisabledTitle':
'Speichern Sie zuerst ein Projekt als Template (Teilen-Menü in einem beliebigen Projekt).',
@ -1490,6 +1493,20 @@ export const de: Dict = {
'settings.designSystemsCategory': 'Kategorie',
'settings.designSystemsAllCategories': 'Alle Kategorien',
'settings.designSystemsShowInHomeGallery': 'In Home-Galerie anzeigen',
'settings.projectLocations': 'Projektorte',
'settings.projectLocationsHint': 'Workspace-Speicherorte',
'settings.projectLocationsDescription': 'Füge Arbeitsbasen hinzu, die mehrere Open Design-Projektordner enthalten können. Neue Projekte werden als Ordner in der ausgewählten Basis gespeichert.',
'settings.projectLocationsSaveError': 'Projektorte konnten nicht gespeichert werden. Prüfe, ob jeder Pfad ein zugänglicher Ordner ist.',
'settings.projectLocationsSaved': 'Projektorte gespeichert.',
'settings.projectLocationsScanError': 'Projektorte konnten nicht gescannt werden.',
'settings.projectLocationsScanComplete': 'Scan abgeschlossen: {imported} importiert, {existing} bereits registriert.',
'settings.projectLocationsNoFolderSelected': 'Kein Ordner ausgewählt.',
'settings.projectLocationsDuplicate': 'Diese Arbeitsbasis wurde bereits hinzugefügt.',
'settings.projectLocationsWorkBaseMeta': 'Arbeitsbasis · Projekte werden hier als Unterordner erstellt',
'settings.projectLocationsAddFolder': 'Ordner hinzufügen…',
'settings.projectLocationsDefaultBadge': 'Standardort',
'settings.projectLocationsMakeDefault': 'Als Standard festlegen',
'settings.projectLocationsDefaultSaved': 'Standard-Projektort aktualisiert.',
'settings.librarySkills': 'Fähigkeiten',
'settings.libraryDesignSystems': 'Designsysteme',
'settings.librarySearch': 'Suchen...',

View file

@ -1157,6 +1157,9 @@ export const en: Dict = {
'newproj.fileSingular': 'file',
'newproj.filePlural': 'files',
'newproj.create': 'Create',
'newproj.locationLabel': 'Save to',
'newproj.locationDefault': 'Open Design projects',
'newproj.locationExternalBase': 'External base',
'newproj.createLiveArtifact': 'Create live artifact',
'newproj.createFromTemplate': 'Create from template',
'newproj.createDisabledTitle':
@ -2349,6 +2352,20 @@ export const en: Dict = {
'settings.designSystemsCategory': 'Category',
'settings.designSystemsAllCategories': 'All categories',
'settings.designSystemsShowInHomeGallery': 'Show in home gallery',
'settings.projectLocations': 'Project locations',
'settings.projectLocationsHint': 'Workspace storage roots',
'settings.projectLocationsDescription': 'Add work bases that can contain multiple Open Design project folders. New projects are saved as one folder inside the selected base.',
'settings.projectLocationsSaveError': 'Could not save project locations. Check that each path is an accessible folder.',
'settings.projectLocationsSaved': 'Project locations saved.',
'settings.projectLocationsScanError': 'Could not scan project locations.',
'settings.projectLocationsScanComplete': 'Scan complete: {imported} imported, {existing} already registered.',
'settings.projectLocationsNoFolderSelected': 'No folder selected.',
'settings.projectLocationsDuplicate': 'That work base is already added.',
'settings.projectLocationsWorkBaseMeta': 'Work base · projects are created as subfolders here',
'settings.projectLocationsAddFolder': 'Add folder…',
'settings.projectLocationsDefaultBadge': 'Default location',
'settings.projectLocationsMakeDefault': 'Make default',
'settings.projectLocationsDefaultSaved': 'Default project location updated.',
'settings.librarySkills': 'Skills',
'settings.libraryDesignSystems': 'Design Systems',
'settings.librarySearch': 'Search...',

View file

@ -464,6 +464,9 @@ export const esES: Dict = {
'newproj.fileSingular': 'archivo',
'newproj.filePlural': 'archivos',
'newproj.create': 'Crear',
'newproj.locationLabel': 'Guardar en',
'newproj.locationDefault': 'Proyectos de Open Design',
'newproj.locationExternalBase': 'Base externa',
'newproj.createFromTemplate': 'Crear desde plantilla',
'newproj.createDisabledTitle':
'Guarda primero un proyecto como plantilla (menú Compartir dentro de cualquier proyecto).',
@ -1441,6 +1444,20 @@ export const esES: Dict = {
'settings.designSystemsCategory': 'Categoría',
'settings.designSystemsAllCategories': 'Todas las categorías',
'settings.designSystemsShowInHomeGallery': 'Mostrar en la galería de inicio',
'settings.projectLocations': 'Ubicaciones de proyectos',
'settings.projectLocationsHint': 'Raíces de almacenamiento del espacio de trabajo',
'settings.projectLocationsDescription': 'Añade bases de trabajo que pueden contener varias carpetas de proyectos de Open Design. Los proyectos nuevos se guardan como una carpeta dentro de la base seleccionada.',
'settings.projectLocationsSaveError': 'No se pudieron guardar las ubicaciones de proyectos. Comprueba que cada ruta sea una carpeta accesible.',
'settings.projectLocationsSaved': 'Ubicaciones de proyectos guardadas.',
'settings.projectLocationsScanError': 'No se pudieron escanear las ubicaciones de proyectos.',
'settings.projectLocationsScanComplete': 'Escaneo completado: {imported} importados, {existing} ya registrados.',
'settings.projectLocationsNoFolderSelected': 'No se seleccionó ninguna carpeta.',
'settings.projectLocationsDuplicate': 'Esa base de trabajo ya está añadida.',
'settings.projectLocationsWorkBaseMeta': 'Base de trabajo · los proyectos se crean aquí como subcarpetas',
'settings.projectLocationsAddFolder': 'Añadir carpeta…',
'settings.projectLocationsDefaultBadge': 'Ubicación predeterminada',
'settings.projectLocationsMakeDefault': 'Hacer predeterminada',
'settings.projectLocationsDefaultSaved': 'Ubicación de proyecto predeterminada actualizada.',
'settings.librarySkills': 'Habilidades',
'settings.libraryDesignSystems': 'Sistemas de diseño',
'settings.librarySearch': 'Buscar...',

View file

@ -578,6 +578,9 @@ export const fa: Dict = {
'newproj.fileSingular': 'فایل',
'newproj.filePlural': 'فایل',
'newproj.create': 'ایجاد',
'newproj.locationLabel': 'ذخیره در',
'newproj.locationDefault': 'پروژه‌های Open Design',
'newproj.locationExternalBase': 'پایهٔ خارجی',
'newproj.createLiveArtifact': 'ایجاد مصنوع زنده',
'newproj.createFromTemplate': 'ایجاد از قالب',
'newproj.createDisabledTitle':
@ -1595,6 +1598,20 @@ export const fa: Dict = {
'settings.designSystemsCategory': 'دسته‌بندی',
'settings.designSystemsAllCategories': 'همه دسته‌بندی‌ها',
'settings.designSystemsShowInHomeGallery': 'نمایش در گالری خانه',
'settings.projectLocations': 'مکان‌های پروژه',
'settings.projectLocationsHint': 'ریشه‌های ذخیره‌سازی فضای کاری',
'settings.projectLocationsDescription': 'پایه‌های کاری اضافه کنید که می‌توانند چند پوشهٔ پروژهٔ Open Design را در خود داشته باشند. پروژه‌های جدید به‌صورت یک پوشه داخل پایهٔ انتخاب‌شده ذخیره می‌شوند.',
'settings.projectLocationsSaveError': 'ذخیرهٔ مکان‌های پروژه ممکن نشد. بررسی کنید هر مسیر یک پوشهٔ قابل دسترسی باشد.',
'settings.projectLocationsSaved': 'مکان‌های پروژه ذخیره شد.',
'settings.projectLocationsScanError': 'اسکن مکان‌های پروژه ممکن نشد.',
'settings.projectLocationsScanComplete': 'اسکن کامل شد: {imported} وارد شد، {existing} از قبل ثبت شده بود.',
'settings.projectLocationsNoFolderSelected': 'پوشه‌ای انتخاب نشد.',
'settings.projectLocationsDuplicate': 'این پایهٔ کاری قبلاً اضافه شده است.',
'settings.projectLocationsWorkBaseMeta': 'پایهٔ کاری · پروژه‌ها اینجا به‌صورت زیرپوشه ساخته می‌شوند',
'settings.projectLocationsAddFolder': 'افزودن پوشه…',
'settings.projectLocationsDefaultBadge': 'مکان پیش‌فرض',
'settings.projectLocationsMakeDefault': 'تنظیم به‌عنوان پیش‌فرض',
'settings.projectLocationsDefaultSaved': 'مکان پیش‌فرض پروژه به‌روزرسانی شد.',
'settings.librarySkills': 'مهارت‌ها',
'settings.libraryDesignSystems': 'سیستم‌های طراحی',
'settings.librarySearch': 'جستجو...',

View file

@ -1101,6 +1101,9 @@ export const fr: Dict = {
'newproj.fileSingular': 'fichier',
'newproj.filePlural': 'fichiers',
'newproj.create': 'Créer',
'newproj.locationLabel': 'Enregistrer dans',
'newproj.locationDefault': 'Projets Open Design',
'newproj.locationExternalBase': 'Base externe',
'newproj.createLiveArtifact': 'Créer un artefact dynamique',
'newproj.createFromTemplate': 'Créer depuis le modèle',
'newproj.createDisabledTitle': 'Enregistrez d\'abord un projet comme modèle (menu Partager dans un projet).',
@ -2214,6 +2217,20 @@ export const fr: Dict = {
'settings.designSystemsCategory': 'Catégorie',
'settings.designSystemsAllCategories': 'Toutes les catégories',
'settings.designSystemsShowInHomeGallery': 'Afficher dans la galerie daccueil',
'settings.projectLocations': 'Emplacements de projets',
'settings.projectLocationsHint': 'Racines de stockage des espaces de travail',
'settings.projectLocationsDescription': 'Ajoutez des bases de travail pouvant contenir plusieurs dossiers de projets Open Design. Les nouveaux projets sont enregistrés comme un dossier dans la base sélectionnée.',
'settings.projectLocationsSaveError': 'Impossible denregistrer les emplacements de projets. Vérifiez que chaque chemin est un dossier accessible.',
'settings.projectLocationsSaved': 'Emplacements de projets enregistrés.',
'settings.projectLocationsScanError': 'Impossible danalyser les emplacements de projets.',
'settings.projectLocationsScanComplete': 'Analyse terminée : {imported} importé(s), {existing} déjà enregistré(s).',
'settings.projectLocationsNoFolderSelected': 'Aucun dossier sélectionné.',
'settings.projectLocationsDuplicate': 'Cette base de travail est déjà ajoutée.',
'settings.projectLocationsWorkBaseMeta': 'Base de travail · les projets sont créés ici comme sous-dossiers',
'settings.projectLocationsAddFolder': 'Ajouter un dossier…',
'settings.projectLocationsDefaultBadge': 'Emplacement par défaut',
'settings.projectLocationsMakeDefault': 'Définir par défaut',
'settings.projectLocationsDefaultSaved': 'Emplacement de projet par défaut mis à jour.',
'settings.librarySkills': 'Compétences',
'settings.libraryDesignSystems': 'Systèmes de design',
'settings.librarySearch': 'Rechercher...',

View file

@ -566,6 +566,9 @@ export const hu: Dict = {
'newproj.fileSingular': 'fájl',
'newproj.filePlural': 'fájl',
'newproj.create': 'Létrehozás',
'newproj.locationLabel': 'Mentés ide',
'newproj.locationDefault': 'Open Design projektek',
'newproj.locationExternalBase': 'Külső bázis',
'newproj.createFromTemplate': 'Létrehozás sablonból',
'newproj.createDisabledTitle':
'Először ments el egy projektet sablonként (bármely projekt Megosztás menüjéből).',
@ -1562,6 +1565,20 @@ export const hu: Dict = {
'settings.designSystemsCategory': 'Kategória',
'settings.designSystemsAllCategories': 'Minden kategória',
'settings.designSystemsShowInHomeGallery': 'Megjelenítés a kezdő galériában',
'settings.projectLocations': 'Projekt helyek',
'settings.projectLocationsHint': 'Munkaterület tárolási gyökerek',
'settings.projectLocationsDescription': 'Adj hozzá munkabázisokat, amelyek több Open Design projektmappát is tartalmazhatnak. Az új projektek mappaként jönnek létre a kiválasztott bázison belül.',
'settings.projectLocationsSaveError': 'Nem sikerült menteni a projekt helyeket. Ellenőrizd, hogy minden útvonal elérhető mappa-e.',
'settings.projectLocationsSaved': 'Projekt helyek mentve.',
'settings.projectLocationsScanError': 'Nem sikerült beolvasni a projekt helyeket.',
'settings.projectLocationsScanComplete': 'Beolvasás kész: {imported} importálva, {existing} már regisztrálva.',
'settings.projectLocationsNoFolderSelected': 'Nincs kiválasztott mappa.',
'settings.projectLocationsDuplicate': 'Ez a munkabázis már hozzá van adva.',
'settings.projectLocationsWorkBaseMeta': 'Munkabázis · a projektek itt almappaként jönnek létre',
'settings.projectLocationsAddFolder': 'Mappa hozzáadása…',
'settings.projectLocationsDefaultBadge': 'Alapértelmezett hely',
'settings.projectLocationsMakeDefault': 'Legyen alapértelmezett',
'settings.projectLocationsDefaultSaved': 'Az alapértelmezett projekt hely frissítve.',
'settings.librarySkills': 'Készségek',
'settings.libraryDesignSystems': 'Tervezőrendszerek',
'settings.librarySearch': 'Keresés...',

View file

@ -672,6 +672,9 @@ export const id: Dict = {
'newproj.fileSingular': 'berkas',
'newproj.filePlural': 'berkas',
'newproj.create': 'Buat',
'newproj.locationLabel': 'Simpan ke',
'newproj.locationDefault': 'Proyek Open Design',
'newproj.locationExternalBase': 'Basis eksternal',
'newproj.createLiveArtifact': 'Buat live artifact',
'newproj.createFromTemplate': 'Buat dari templat',
'newproj.createDisabledTitle': 'Simpan proyek sebagai templat dulu.',
@ -1700,6 +1703,20 @@ export const id: Dict = {
'settings.designSystemsCategory': 'Kategori',
'settings.designSystemsAllCategories': 'Semua kategori',
'settings.designSystemsShowInHomeGallery': 'Tampilkan di galeri beranda',
'settings.projectLocations': 'Lokasi proyek',
'settings.projectLocationsHint': 'Root penyimpanan workspace',
'settings.projectLocationsDescription': 'Tambahkan basis kerja yang dapat berisi beberapa folder proyek Open Design. Proyek baru disimpan sebagai folder di dalam basis yang dipilih.',
'settings.projectLocationsSaveError': 'Tidak dapat menyimpan lokasi proyek. Pastikan setiap path adalah folder yang dapat diakses.',
'settings.projectLocationsSaved': 'Lokasi proyek disimpan.',
'settings.projectLocationsScanError': 'Tidak dapat memindai lokasi proyek.',
'settings.projectLocationsScanComplete': 'Pemindaian selesai: {imported} diimpor, {existing} sudah terdaftar.',
'settings.projectLocationsNoFolderSelected': 'Tidak ada folder yang dipilih.',
'settings.projectLocationsDuplicate': 'Basis kerja itu sudah ditambahkan.',
'settings.projectLocationsWorkBaseMeta': 'Basis kerja · proyek dibuat di sini sebagai subfolder',
'settings.projectLocationsAddFolder': 'Tambah folder…',
'settings.projectLocationsDefaultBadge': 'Lokasi default',
'settings.projectLocationsMakeDefault': 'Jadikan default',
'settings.projectLocationsDefaultSaved': 'Lokasi proyek default diperbarui.',
'settings.librarySkills': 'Skill',
'settings.libraryDesignSystems': 'Sistem desain',
'settings.librarySearch': 'Cari...',

View file

@ -539,6 +539,9 @@ export const it: Dict = {
'newproj.fileSingular': 'file',
'newproj.filePlural': 'file',
'newproj.create': 'Crea',
'newproj.locationLabel': 'Salva in',
'newproj.locationDefault': 'Progetti Open Design',
'newproj.locationExternalBase': 'Base esterna',
'newproj.createFromTemplate': 'Crea dal modello',
'newproj.createDisabledTitle':
'Salva prima un progetto come modello (menu Condividi in un progetto).',
@ -1432,6 +1435,20 @@ export const it: Dict = {
'settings.designSystemsCategory': 'Categoria',
'settings.designSystemsAllCategories': 'Tutte le categorie',
'settings.designSystemsShowInHomeGallery': 'Mostra nella galleria iniziale',
'settings.projectLocations': 'Posizioni dei progetti',
'settings.projectLocationsHint': 'Radici di archiviazione workspace',
'settings.projectLocationsDescription': 'Aggiungi basi di lavoro che possono contenere più cartelle di progetti Open Design. I nuovi progetti vengono salvati come una cartella nella base selezionata.',
'settings.projectLocationsSaveError': 'Impossibile salvare le posizioni dei progetti. Verifica che ogni percorso sia una cartella accessibile.',
'settings.projectLocationsSaved': 'Posizioni dei progetti salvate.',
'settings.projectLocationsScanError': 'Impossibile scansionare le posizioni dei progetti.',
'settings.projectLocationsScanComplete': 'Scansione completata: {imported} importati, {existing} già registrati.',
'settings.projectLocationsNoFolderSelected': 'Nessuna cartella selezionata.',
'settings.projectLocationsDuplicate': 'Questa base di lavoro è già stata aggiunta.',
'settings.projectLocationsWorkBaseMeta': 'Base di lavoro · i progetti vengono creati qui come sottocartelle',
'settings.projectLocationsAddFolder': 'Aggiungi cartella…',
'settings.projectLocationsDefaultBadge': 'Posizione predefinita',
'settings.projectLocationsMakeDefault': 'Imposta come predefinita',
'settings.projectLocationsDefaultSaved': 'Posizione progetto predefinita aggiornata.',
'settings.librarySkills': 'Competenze',
'settings.libraryDesignSystems': 'Sistemi di design',
'settings.librarySearch': 'Cerca...',

View file

@ -463,6 +463,9 @@ export const ja: Dict = {
'newproj.fileSingular': 'ファイル',
'newproj.filePlural': 'ファイル',
'newproj.create': '作成',
'newproj.locationLabel': '保存先',
'newproj.locationDefault': 'Open Design プロジェクト',
'newproj.locationExternalBase': '外部ベース',
'newproj.createFromTemplate': 'テンプレートから作成',
'newproj.createDisabledTitle':
'最初にプロジェクトをテンプレートとして保存してください(プロジェクト内の共有メニュー)。',
@ -1489,6 +1492,20 @@ export const ja: Dict = {
'settings.designSystemsCategory': 'カテゴリー',
'settings.designSystemsAllCategories': 'すべてのカテゴリー',
'settings.designSystemsShowInHomeGallery': 'ホームギャラリーに表示',
'settings.projectLocations': 'プロジェクトの場所',
'settings.projectLocationsHint': 'ワークスペース保存ルート',
'settings.projectLocationsDescription': '複数の Open Design プロジェクトフォルダを含められる作業ベースを追加します。新しいプロジェクトは選択したベース内の 1 つのフォルダとして保存されます。',
'settings.projectLocationsSaveError': 'プロジェクトの場所を保存できませんでした。各パスがアクセス可能なフォルダであることを確認してください。',
'settings.projectLocationsSaved': 'プロジェクトの場所を保存しました。',
'settings.projectLocationsScanError': 'プロジェクトの場所をスキャンできませんでした。',
'settings.projectLocationsScanComplete': 'スキャン完了: {imported} 件をインポート、{existing} 件は登録済みです。',
'settings.projectLocationsNoFolderSelected': 'フォルダが選択されていません。',
'settings.projectLocationsDuplicate': 'その作業ベースはすでに追加されています。',
'settings.projectLocationsWorkBaseMeta': '作業ベース · プロジェクトはここにサブフォルダとして作成されます',
'settings.projectLocationsAddFolder': 'フォルダを追加…',
'settings.projectLocationsDefaultBadge': 'デフォルトの場所',
'settings.projectLocationsMakeDefault': 'デフォルトにする',
'settings.projectLocationsDefaultSaved': 'デフォルトのプロジェクト場所を更新しました。',
'settings.librarySkills': 'スキル',
'settings.libraryDesignSystems': 'デザインシステム',
'settings.librarySearch': '検索...',

View file

@ -566,6 +566,9 @@ export const ko: Dict = {
'newproj.fileSingular': '파일',
'newproj.filePlural': '파일들',
'newproj.create': '생성',
'newproj.locationLabel': '저장 위치',
'newproj.locationDefault': 'Open Design 프로젝트',
'newproj.locationExternalBase': '외부 베이스',
'newproj.createFromTemplate': '템플릿으로 생성',
'newproj.createDisabledTitle':
'먼저 프로젝트를 템플릿으로 저장하세요 (프로젝트 내 공유 메뉴 이용).',
@ -1602,6 +1605,20 @@ export const ko: Dict = {
'settings.designSystemsCategory': '카테고리',
'settings.designSystemsAllCategories': '모든 카테고리',
'settings.designSystemsShowInHomeGallery': '홈 갤러리에 표시',
'settings.projectLocations': '프로젝트 위치',
'settings.projectLocationsHint': '워크스페이스 저장 루트',
'settings.projectLocationsDescription': '여러 Open Design 프로젝트 폴더를 포함할 수 있는 작업 베이스를 추가합니다. 새 프로젝트는 선택한 베이스 안의 폴더로 저장됩니다.',
'settings.projectLocationsSaveError': '프로젝트 위치를 저장할 수 없습니다. 각 경로가 접근 가능한 폴더인지 확인하세요.',
'settings.projectLocationsSaved': '프로젝트 위치가 저장되었습니다.',
'settings.projectLocationsScanError': '프로젝트 위치를 스캔할 수 없습니다.',
'settings.projectLocationsScanComplete': '스캔 완료: {imported}개 가져옴, {existing}개는 이미 등록됨.',
'settings.projectLocationsNoFolderSelected': '선택한 폴더가 없습니다.',
'settings.projectLocationsDuplicate': '해당 작업 베이스는 이미 추가되었습니다.',
'settings.projectLocationsWorkBaseMeta': '작업 베이스 · 프로젝트는 여기에 하위 폴더로 생성됩니다',
'settings.projectLocationsAddFolder': '폴더 추가…',
'settings.projectLocationsDefaultBadge': '기본 위치',
'settings.projectLocationsMakeDefault': '기본값으로 설정',
'settings.projectLocationsDefaultSaved': '기본 프로젝트 위치가 업데이트되었습니다.',
'settings.librarySkills': '스킬',
'settings.libraryDesignSystems': '디자인 시스템',
'settings.librarySearch': '검색...',

View file

@ -566,6 +566,9 @@ export const pl: Dict = {
'newproj.fileSingular': 'plik',
'newproj.filePlural': 'pliki',
'newproj.create': 'Utwórz',
'newproj.locationLabel': 'Zapisz w',
'newproj.locationDefault': 'Projekty Open Design',
'newproj.locationExternalBase': 'Zewnętrzna baza',
'newproj.createFromTemplate': 'Utwórz z szablonu',
'newproj.createDisabledTitle':
'Najpierw zapisz projekt jako szablon (menu Udostępnij wewnątrz projektu).',
@ -1552,6 +1555,20 @@ export const pl: Dict = {
'settings.designSystemsCategory': 'Kategoria',
'settings.designSystemsAllCategories': 'Wszystkie kategorie',
'settings.designSystemsShowInHomeGallery': 'Pokaż w galerii głównej',
'settings.projectLocations': 'Lokalizacje projektów',
'settings.projectLocationsHint': 'Katalogi główne workspace',
'settings.projectLocationsDescription': 'Dodaj bazy robocze, które mogą zawierać wiele folderów projektów Open Design. Nowe projekty są zapisywane jako folder w wybranej bazie.',
'settings.projectLocationsSaveError': 'Nie udało się zapisać lokalizacji projektów. Sprawdź, czy każda ścieżka jest dostępnym folderem.',
'settings.projectLocationsSaved': 'Lokalizacje projektów zapisane.',
'settings.projectLocationsScanError': 'Nie udało się przeskanować lokalizacji projektów.',
'settings.projectLocationsScanComplete': 'Skanowanie zakończone: zaimportowano {imported}, już zarejestrowano {existing}.',
'settings.projectLocationsNoFolderSelected': 'Nie wybrano folderu.',
'settings.projectLocationsDuplicate': 'Ta baza robocza jest już dodana.',
'settings.projectLocationsWorkBaseMeta': 'Baza robocza · projekty są tworzone tutaj jako podfoldery',
'settings.projectLocationsAddFolder': 'Dodaj folder…',
'settings.projectLocationsDefaultBadge': 'Lokalizacja domyślna',
'settings.projectLocationsMakeDefault': 'Ustaw jako domyślną',
'settings.projectLocationsDefaultSaved': 'Domyślna lokalizacja projektu zaktualizowana.',
'settings.librarySkills': 'Umiejętności',
'settings.libraryDesignSystems': 'Systemy projektowe',
'settings.librarySearch': 'Szukaj...',

View file

@ -576,6 +576,9 @@ export const ptBR: Dict = {
'newproj.fileSingular': 'arquivo',
'newproj.filePlural': 'arquivos',
'newproj.create': 'Criar',
'newproj.locationLabel': 'Salvar em',
'newproj.locationDefault': 'Projetos Open Design',
'newproj.locationExternalBase': 'Base externa',
'newproj.createLiveArtifact': 'Criar artefato live',
'newproj.createFromTemplate': 'Criar a partir do template',
'newproj.createDisabledTitle':
@ -1593,6 +1596,20 @@ export const ptBR: Dict = {
'settings.designSystemsCategory': 'Categoria',
'settings.designSystemsAllCategories': 'Todas as categorias',
'settings.designSystemsShowInHomeGallery': 'Mostrar na galeria inicial',
'settings.projectLocations': 'Locais de projetos',
'settings.projectLocationsHint': 'Raízes de armazenamento do workspace',
'settings.projectLocationsDescription': 'Adicione bases de trabalho que podem conter várias pastas de projetos do Open Design. Novos projetos são salvos como uma pasta dentro da base selecionada.',
'settings.projectLocationsSaveError': 'Não foi possível salvar os locais de projetos. Verifique se cada caminho é uma pasta acessível.',
'settings.projectLocationsSaved': 'Locais de projetos salvos.',
'settings.projectLocationsScanError': 'Não foi possível escanear os locais de projetos.',
'settings.projectLocationsScanComplete': 'Escaneamento concluído: {imported} importados, {existing} já registrados.',
'settings.projectLocationsNoFolderSelected': 'Nenhuma pasta selecionada.',
'settings.projectLocationsDuplicate': 'Essa base de trabalho já foi adicionada.',
'settings.projectLocationsWorkBaseMeta': 'Base de trabalho · projetos são criados aqui como subpastas',
'settings.projectLocationsAddFolder': 'Adicionar pasta…',
'settings.projectLocationsDefaultBadge': 'Local padrão',
'settings.projectLocationsMakeDefault': 'Tornar padrão',
'settings.projectLocationsDefaultSaved': 'Local padrão do projeto atualizado.',
'settings.librarySkills': 'Habilidades',
'settings.libraryDesignSystems': 'Sistemas de design',
'settings.librarySearch': 'Pesquisar...',

View file

@ -576,6 +576,9 @@ export const ru: Dict = {
'newproj.fileSingular': 'файл',
'newproj.filePlural': 'файлов',
'newproj.create': 'Создать',
'newproj.locationLabel': 'Сохранить в',
'newproj.locationDefault': 'Проекты Open Design',
'newproj.locationExternalBase': 'Внешняя база',
'newproj.createLiveArtifact': 'Создать live-артефакт',
'newproj.createFromTemplate': 'Создать из шаблона',
'newproj.createDisabledTitle':
@ -1593,6 +1596,20 @@ export const ru: Dict = {
'settings.designSystemsCategory': 'Категория',
'settings.designSystemsAllCategories': 'Все категории',
'settings.designSystemsShowInHomeGallery': 'Показывать в домашней галерее',
'settings.projectLocations': 'Расположения проектов',
'settings.projectLocationsHint': 'Корни хранения рабочих пространств',
'settings.projectLocationsDescription': 'Добавьте рабочие базы, которые могут содержать несколько папок проектов Open Design. Новые проекты сохраняются как папка внутри выбранной базы.',
'settings.projectLocationsSaveError': 'Не удалось сохранить расположения проектов. Проверьте, что каждый путь является доступной папкой.',
'settings.projectLocationsSaved': 'Расположения проектов сохранены.',
'settings.projectLocationsScanError': 'Не удалось просканировать расположения проектов.',
'settings.projectLocationsScanComplete': 'Сканирование завершено: импортировано {imported}, уже зарегистрировано {existing}.',
'settings.projectLocationsNoFolderSelected': 'Папка не выбрана.',
'settings.projectLocationsDuplicate': 'Эта рабочая база уже добавлена.',
'settings.projectLocationsWorkBaseMeta': 'Рабочая база · проекты создаются здесь как подпапки',
'settings.projectLocationsAddFolder': 'Добавить папку…',
'settings.projectLocationsDefaultBadge': 'Расположение по умолчанию',
'settings.projectLocationsMakeDefault': 'Сделать по умолчанию',
'settings.projectLocationsDefaultSaved': 'Расположение проекта по умолчанию обновлено.',
'settings.librarySkills': 'Навыки',
'settings.libraryDesignSystems': 'Системы дизайна',
'settings.librarySearch': 'Поиск...',

View file

@ -535,6 +535,9 @@ export const th: Dict = {
'newproj.fileSingular': 'ไฟล์',
'newproj.filePlural': 'ไฟล์',
'newproj.create': 'สร้าง',
'newproj.locationLabel': 'บันทึกไปยัง',
'newproj.locationDefault': 'โปรเจกต์ Open Design',
'newproj.locationExternalBase': 'ฐานภายนอก',
'newproj.createLiveArtifact': 'สร้าง live artifact',
'newproj.createFromTemplate': 'สร้างจากเทมเพลต',
'newproj.createDisabledTitle': 'คุณต้องบันทึกโปรเจกต์เป็นเทมเพลตก่อน',
@ -1469,6 +1472,20 @@ export const th: Dict = {
'settings.notifySoundBuzz': 'เป็นจังหวะกระตุ้นอารมณ์สั่นเลย',
'settings.notifySoundTwoToneDown': 'โทนดังลดถอย 2 จังหวะ',
'settings.notifySoundThud': 'เสียงหนักเน้นโครมให้ระวัง',
'settings.projectLocations': 'ตำแหน่งโปรเจกต์',
'settings.projectLocationsHint': 'รากที่เก็บเวิร์กสเปซ',
'settings.projectLocationsDescription': 'เพิ่มฐานงานที่สามารถเก็บโฟลเดอร์โปรเจกต์ Open Design ได้หลายรายการ โปรเจกต์ใหม่จะถูกบันทึกเป็นหนึ่งโฟลเดอร์ภายในฐานที่เลือก',
'settings.projectLocationsSaveError': 'ไม่สามารถบันทึกตำแหน่งโปรเจกต์ได้ ตรวจสอบว่าแต่ละพาธเป็นโฟลเดอร์ที่เข้าถึงได้',
'settings.projectLocationsSaved': 'บันทึกตำแหน่งโปรเจกต์แล้ว',
'settings.projectLocationsScanError': 'ไม่สามารถสแกนตำแหน่งโปรเจกต์ได้',
'settings.projectLocationsScanComplete': 'สแกนเสร็จแล้ว: นำเข้า {imported} รายการ, ลงทะเบียนไว้แล้ว {existing} รายการ',
'settings.projectLocationsNoFolderSelected': 'ไม่ได้เลือกโฟลเดอร์',
'settings.projectLocationsDuplicate': 'เพิ่มฐานงานนี้ไว้แล้ว',
'settings.projectLocationsWorkBaseMeta': 'ฐานงาน · โปรเจกต์จะถูกสร้างเป็นโฟลเดอร์ย่อยที่นี่',
'settings.projectLocationsAddFolder': 'เพิ่มโฟลเดอร์…',
'settings.projectLocationsDefaultBadge': 'ตำแหน่งเริ่มต้น',
'settings.projectLocationsMakeDefault': 'ตั้งเป็นค่าเริ่มต้น',
'settings.projectLocationsDefaultSaved': 'อัปเดตตำแหน่งโปรเจกต์เริ่มต้นแล้ว',
'settings.librarySkills': 'พวก Skills',
'settings.libraryDesignSystems': 'ตัวของระบบแบบ Design Systems',
'settings.librarySearch': 'ต้องการหาสิ่งใด…',

View file

@ -556,6 +556,9 @@ export const tr: Dict = {
'newproj.fileSingular': 'dosya',
'newproj.filePlural': 'dosyalar',
'newproj.create': 'Oluştur',
'newproj.locationLabel': 'Şuraya kaydet',
'newproj.locationDefault': 'Open Design projeleri',
'newproj.locationExternalBase': 'Harici taban',
'newproj.createFromTemplate': 'Şablondan oluştur',
'newproj.createDisabledTitle':
'Önce bir projeyi şablon olarak kaydedin (herhangi bir projenin içinde Paylaş menüsünden).',
@ -1539,6 +1542,20 @@ export const tr: Dict = {
'settings.designSystemsCategory': 'Kategori',
'settings.designSystemsAllCategories': 'Tüm kategoriler',
'settings.designSystemsShowInHomeGallery': 'Ana galeride göster',
'settings.projectLocations': 'Proje konumları',
'settings.projectLocationsHint': 'Çalışma alanı depolama kökleri',
'settings.projectLocationsDescription': 'Birden fazla Open Design proje klasörü içerebilen çalışma tabanları ekleyin. Yeni projeler seçilen tabanın içinde bir klasör olarak kaydedilir.',
'settings.projectLocationsSaveError': 'Proje konumları kaydedilemedi. Her yolun erişilebilir bir klasör olduğunu kontrol edin.',
'settings.projectLocationsSaved': 'Proje konumları kaydedildi.',
'settings.projectLocationsScanError': 'Proje konumları taranamadı.',
'settings.projectLocationsScanComplete': 'Tarama tamamlandı: {imported} içe aktarıldı, {existing} zaten kayıtlı.',
'settings.projectLocationsNoFolderSelected': 'Klasör seçilmedi.',
'settings.projectLocationsDuplicate': 'Bu çalışma tabanı zaten eklendi.',
'settings.projectLocationsWorkBaseMeta': 'Çalışma tabanı · projeler burada alt klasörler olarak oluşturulur',
'settings.projectLocationsAddFolder': 'Klasör ekle…',
'settings.projectLocationsDefaultBadge': 'Varsayılan konum',
'settings.projectLocationsMakeDefault': 'Varsayılan yap',
'settings.projectLocationsDefaultSaved': 'Varsayılan proje konumu güncellendi.',
'settings.librarySkills': 'Beceriler',
'settings.libraryDesignSystems': 'Tasarım sistemleri',
'settings.librarySearch': 'Ara...',

View file

@ -578,6 +578,9 @@ export const uk: Dict = {
'newproj.fileSingular': 'файл',
'newproj.filePlural': 'файли',
'newproj.create': 'Створити',
'newproj.locationLabel': 'Зберегти в',
'newproj.locationDefault': 'Проєкти Open Design',
'newproj.locationExternalBase': 'Зовнішня база',
'newproj.createLiveArtifact': 'Створити live-артефакт',
'newproj.createFromTemplate': 'Створити з шаблону',
'newproj.createDisabledTitle':
@ -1594,6 +1597,20 @@ export const uk: Dict = {
'settings.designSystemsCategory': 'Категорія',
'settings.designSystemsAllCategories': 'Усі категорії',
'settings.designSystemsShowInHomeGallery': 'Показувати в домашній галереї',
'settings.projectLocations': 'Розташування проєктів',
'settings.projectLocationsHint': 'Корені зберігання робочих просторів',
'settings.projectLocationsDescription': 'Додайте робочі бази, які можуть містити кілька тек проєктів Open Design. Нові проєкти зберігаються як тека всередині вибраної бази.',
'settings.projectLocationsSaveError': 'Не вдалося зберегти розташування проєктів. Перевірте, що кожен шлях є доступною текою.',
'settings.projectLocationsSaved': 'Розташування проєктів збережено.',
'settings.projectLocationsScanError': 'Не вдалося просканувати розташування проєктів.',
'settings.projectLocationsScanComplete': 'Сканування завершено: імпортовано {imported}, уже зареєстровано {existing}.',
'settings.projectLocationsNoFolderSelected': 'Теку не вибрано.',
'settings.projectLocationsDuplicate': 'Цю робочу базу вже додано.',
'settings.projectLocationsWorkBaseMeta': 'Робоча база · проєкти створюються тут як підтеки',
'settings.projectLocationsAddFolder': 'Додати теку…',
'settings.projectLocationsDefaultBadge': 'Типове розташування',
'settings.projectLocationsMakeDefault': 'Зробити типовим',
'settings.projectLocationsDefaultSaved': 'Типове розташування проєкту оновлено.',
'settings.librarySkills': 'Навички',
'settings.libraryDesignSystems': 'Системи дизайну',
'settings.librarySearch': 'Пошук...',

View file

@ -1152,6 +1152,9 @@ export const zhCN: Dict = {
'newproj.fileSingular': '个文件',
'newproj.filePlural': '个文件',
'newproj.create': '创建',
'newproj.locationLabel': '保存到',
'newproj.locationDefault': 'Open Design 项目',
'newproj.locationExternalBase': '外部基目录',
'newproj.createLiveArtifact': '创建实时制品',
'newproj.createFromTemplate': '基于模板创建',
'newproj.createDisabledTitle': '请先在任意项目内通过「分享」菜单将其保存为模板。',
@ -2299,6 +2302,20 @@ export const zhCN: Dict = {
'settings.designSystemsCategory': '分类',
'settings.designSystemsAllCategories': '所有分类',
'settings.designSystemsShowInHomeGallery': '在首页 Gallery 中显示',
'settings.projectLocations': '项目位置',
'settings.projectLocationsHint': '工作区存储根目录',
'settings.projectLocationsDescription': '添加可包含多个 Open Design 项目文件夹的工作基目录。新项目会作为所选基目录内的一个文件夹保存。',
'settings.projectLocationsSaveError': '无法保存项目位置。请检查每个路径都是可访问的文件夹。',
'settings.projectLocationsSaved': '项目位置已保存。',
'settings.projectLocationsScanError': '无法扫描项目位置。',
'settings.projectLocationsScanComplete': '扫描完成:已导入 {imported} 个,已有 {existing} 个。',
'settings.projectLocationsNoFolderSelected': '未选择文件夹。',
'settings.projectLocationsDuplicate': '这个工作基目录已添加。',
'settings.projectLocationsWorkBaseMeta': '工作基目录 · 项目会作为子文件夹创建在这里',
'settings.projectLocationsAddFolder': '添加文件夹…',
'settings.projectLocationsDefaultBadge': '默认位置',
'settings.projectLocationsMakeDefault': '设为默认',
'settings.projectLocationsDefaultSaved': '默认项目位置已更新。',
'settings.librarySkills': '技能',
'settings.libraryDesignSystems': '设计系统',
'settings.librarySearch': '搜索...',

View file

@ -754,6 +754,9 @@ export const zhTW: Dict = {
'newproj.fileSingular': '個檔案',
'newproj.filePlural': '個檔案',
'newproj.create': '建立',
'newproj.locationLabel': '儲存到',
'newproj.locationDefault': 'Open Design 專案',
'newproj.locationExternalBase': '外部基目錄',
'newproj.createLiveArtifact': '建立即時成品',
'newproj.createFromTemplate': '基於範本建立',
'newproj.createDisabledTitle': '請先在任意專案內透過「分享」選單將其儲存為範本。',
@ -1851,6 +1854,20 @@ export const zhTW: Dict = {
'settings.designSystemsCategory': '分類',
'settings.designSystemsAllCategories': '所有分類',
'settings.designSystemsShowInHomeGallery': '在首頁 Gallery 中顯示',
'settings.projectLocations': '專案位置',
'settings.projectLocationsHint': '工作區儲存根目錄',
'settings.projectLocationsDescription': '新增可包含多個 Open Design 專案資料夾的工作基目錄。新專案會儲存為所選基目錄中的一個資料夾。',
'settings.projectLocationsSaveError': '無法儲存專案位置。請確認每個路徑都是可存取的資料夾。',
'settings.projectLocationsSaved': '專案位置已儲存。',
'settings.projectLocationsScanError': '無法掃描專案位置。',
'settings.projectLocationsScanComplete': '掃描完成:已匯入 {imported} 個,已有 {existing} 個。',
'settings.projectLocationsNoFolderSelected': '未選取資料夾。',
'settings.projectLocationsDuplicate': '這個工作基目錄已新增。',
'settings.projectLocationsWorkBaseMeta': '工作基目錄 · 專案會在這裡建立為子資料夾',
'settings.projectLocationsAddFolder': '新增資料夾…',
'settings.projectLocationsDefaultBadge': '預設位置',
'settings.projectLocationsMakeDefault': '設為預設',
'settings.projectLocationsDefaultSaved': '預設專案位置已更新。',
'settings.librarySkills': '技能',
'settings.libraryDesignSystems': '設計系統',
'settings.librarySearch': '搜尋...',

View file

@ -444,6 +444,20 @@ export interface Dict {
'settings.designSystemsCategory': string;
'settings.designSystemsAllCategories': string;
'settings.designSystemsShowInHomeGallery': string;
'settings.projectLocations': string;
'settings.projectLocationsHint': string;
'settings.projectLocationsDescription': string;
'settings.projectLocationsSaveError': string;
'settings.projectLocationsSaved': string;
'settings.projectLocationsScanError': string;
'settings.projectLocationsScanComplete': string;
'settings.projectLocationsNoFolderSelected': string;
'settings.projectLocationsDuplicate': string;
'settings.projectLocationsWorkBaseMeta': string;
'settings.projectLocationsAddFolder': string;
'settings.projectLocationsDefaultBadge': string;
'settings.projectLocationsMakeDefault': string;
'settings.projectLocationsDefaultSaved': string;
'settings.librarySkills': string;
'settings.libraryDesignSystems': string;
'settings.librarySearch': string;
@ -1431,6 +1445,9 @@ export interface Dict {
'newproj.fileSingular': string;
'newproj.filePlural': string;
'newproj.create': string;
'newproj.locationLabel': string;
'newproj.locationDefault': string;
'newproj.locationExternalBase': string;
'newproj.createLiveArtifact': string;
'newproj.createFromTemplate': string;
'newproj.createDisabledTitle': string;

View file

@ -82,6 +82,8 @@ export const DEFAULT_CONFIG: AppConfig = {
pet: DEFAULT_PET,
notifications: DEFAULT_NOTIFICATIONS,
orbit: DEFAULT_ORBIT,
projectLocations: [],
defaultProjectLocationId: 'default',
// Telemetry defaults to ON so fresh-install users emit onboarding /
// ui_click events from the first frame. The disclosure modal still
// appears after `onboardingCompleted` flips, and Settings → Privacy
@ -688,6 +690,12 @@ export function mergeDaemonConfig(
if (daemonConfig.customInstructions !== undefined) {
next.customInstructions = daemonConfig.customInstructions ?? undefined;
}
if (daemonConfig.projectLocations !== undefined) {
next.projectLocations = daemonConfig.projectLocations;
}
if (daemonConfig.defaultProjectLocationId !== undefined) {
next.defaultProjectLocationId = daemonConfig.defaultProjectLocationId ?? 'default';
}
return next;
}
@ -802,6 +810,8 @@ export async function syncConfigToDaemon(
telemetry: config.telemetry,
privacyDecisionAt: config.privacyDecisionAt,
customInstructions: config.customInstructions ?? null,
projectLocations: config.projectLocations ?? [],
defaultProjectLocationId: config.defaultProjectLocationId ?? 'default',
};
try {
const response = await fetch('/api/app-config', {

View file

@ -0,0 +1,55 @@
import type {
ProjectLocation,
ProjectLocationsResponse,
ScanProjectLocationsResponse,
UpdateProjectLocationsRequest,
} from '@open-design/contracts';
export async function fetchProjectLocations(): Promise<ProjectLocation[]> {
try {
const resp = await fetch('/api/project-locations');
if (!resp.ok) return [];
const json = (await resp.json()) as ProjectLocationsResponse;
return Array.isArray(json.locations) ? json.locations : [];
} catch {
return [];
}
}
export async function updateProjectLocations(
locations: UpdateProjectLocationsRequest['locations'],
): Promise<ProjectLocation[] | null> {
try {
const resp = await fetch('/api/project-locations', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ locations }),
});
if (!resp.ok) return null;
const json = (await resp.json()) as ProjectLocationsResponse;
return Array.isArray(json.locations) ? json.locations : [];
} catch {
return null;
}
}
export async function scanProjectLocations(): Promise<ScanProjectLocationsResponse | null> {
try {
const resp = await fetch('/api/project-locations/scan', { method: 'POST' });
if (!resp.ok) return null;
return (await resp.json()) as ScanProjectLocationsResponse;
} catch {
return null;
}
}
export async function openProjectLocationFolderDialog(): Promise<string | null> {
try {
const resp = await fetch('/api/dialog/open-folder', { method: 'POST' });
if (!resp.ok) return null;
const json = (await resp.json()) as { path?: string | null };
return typeof json.path === 'string' && json.path.trim() ? json.path : null;
} catch {
return null;
}
}

View file

@ -53,6 +53,7 @@ export async function getProject(id: string): Promise<Project | null> {
export async function createProject(input: {
name: string;
projectLocationId?: string;
skillId: string | null;
designSystemId: string | null;
pendingPrompt?: string;

View file

@ -1146,6 +1146,92 @@
}
.settings-section { display: flex; flex-direction: column; gap: 12px; }
.project-locations-section { gap: 14px; }
.project-location-card,
.project-location-edit {
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
gap: 8px;
align-items: center;
min-height: 58px;
padding: 8px 10px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg-panel);
transition: border-color 120ms cubic-bezier(0.23, 1, 0.32, 1), box-shadow 120ms cubic-bezier(0.23, 1, 0.32, 1), background 120ms cubic-bezier(0.23, 1, 0.32, 1);
}
.project-location-card:hover,
.project-location-edit:hover {
border-color: var(--border-strong);
}
.project-location-card.is-default,
.project-location-edit.is-default {
border-color: var(--accent);
box-shadow: 0 0 0 1px var(--accent);
}
.project-location-card code {
display: block;
margin-top: 3px;
color: var(--text-muted);
font-size: 11px;
word-break: break-all;
}
.project-location-default-control span {
border: 1px solid var(--border);
border-radius: var(--radius-pill);
padding: 4px 10px;
color: var(--text-muted);
font-size: 11px;
font-weight: 600;
background: var(--bg-subtle);
white-space: nowrap;
}
.project-location-default-control {
display: inline-flex;
align-items: center;
justify-self: end;
gap: 6px;
cursor: pointer;
}
.project-location-default-control input {
position: absolute;
opacity: 0;
pointer-events: none;
}
.project-location-default-control input:checked + span {
background: var(--accent-soft);
border-color: var(--accent);
color: var(--accent);
}
.project-location-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.project-location-edit {
grid-template-columns: minmax(0, 1fr) auto auto;
align-items: center;
}
.project-location-edit-main {
display: flex;
flex-direction: column;
gap: 3px;
min-width: 0;
}
.project-location-edit-main code {
color: var(--text-muted);
font-size: 11px;
word-break: break-all;
}
.project-location-edit-main small {
color: var(--text-muted);
font-size: 11px;
}
.project-location-add { align-self: flex-start; }
@media (max-width: 720px) {
.project-location-edit { grid-template-columns: 1fr; align-items: stretch; }
.project-location-default-control { justify-self: start; }
}
.settings-section-connectors { gap: 16px; }
/* Credentials sit above the catalog now; the divider lives under the field
so the eye reads "configure key → catalog unlocks below". */

View file

@ -50,6 +50,7 @@ import type {
ProviderModelsRequest,
ProviderModelsResponse,
Project,
ProjectLocationPrefs,
ProjectPlatform,
PreviewCommentMember,
PreviewAnnotationStyle,
@ -86,6 +87,7 @@ export type {
ChatCommentSelectionKind,
OrbitRunSummary,
OrbitStatusResponse,
ProjectLocation,
PreviewCommentMember,
PreviewAnnotationStyle,
PreviewCommentSelectionKind,
@ -369,6 +371,8 @@ export interface AppConfig {
// PrivacySection persist it through `syncConfigToDaemon`.
telemetry?: TelemetryConfig;
customInstructions?: string;
projectLocations?: ProjectLocationPrefs[];
defaultProjectLocationId?: string | null;
}
export interface TelemetryConfig {

View file

@ -1383,6 +1383,7 @@ export type TrackingSettingsArea =
| 'notifications'
| 'pets'
| 'design_systems'
| 'project_locations'
| 'privacy'
| 'about';
@ -2220,6 +2221,8 @@ export function settingsSectionToTracking(
return 'skills';
case 'designSystems':
return 'design_systems';
case 'projectLocations':
return 'project_locations';
case 'memory':
return 'memory';
case 'privacy':

View file

@ -19,6 +19,12 @@ export interface OrbitConfigPrefs {
templateSkillId?: string | null;
}
export interface ProjectLocationPrefs {
id: string;
name: string;
path: string;
}
export interface AppConfigPrefs {
onboardingCompleted?: boolean;
agentId?: string | null;
@ -40,6 +46,10 @@ export interface AppConfigPrefs {
privacyDecisionAt?: number | null;
orbit?: OrbitConfigPrefs;
customInstructions?: string | null;
/** External project library roots. The daemon adds its built-in .od/projects location at read time. */
projectLocations?: ProjectLocationPrefs[];
/** Project location id used for new projects when the create request does not choose one explicitly. */
defaultProjectLocationId?: string | null;
}
export interface AppConfigResponse {

View file

@ -115,6 +115,9 @@ export interface ProjectMetadata {
// directly inside the user's folder. Stored as the realpath() result so
// symlinks can't redirect writes after import time.
baseDir?: string;
// Project library location that owns baseDir when the project was created
// under a configured project location root.
projectLocationId?: string;
// PR #974: marker stamped by the daemon's HMAC-gated import handler
// when a folder import passed the desktop-main-process trust gate.
// Only set on folder-imported projects (`baseDir` set) and only when
@ -206,6 +209,8 @@ export interface Conversation {
export interface CreateProjectRequest {
name: string;
/** Optional project library location id. Omit or use `default` for .od/projects. */
projectLocationId?: string;
skillId?: string | null;
designSystemId?: string | null;
pendingPrompt?: string;
@ -251,6 +256,39 @@ export interface CreateProjectResponse extends ProjectResponse {
appliedPluginSnapshotId?: string;
}
export interface ProjectLocation {
id: string;
name: string;
path: string;
builtIn?: boolean;
}
export interface ProjectLocationsResponse {
locations: ProjectLocation[];
removedProjectIds?: string[];
}
export interface UpdateProjectLocationsRequest {
locations: Array<{ id?: string; name?: string; path: string }>;
}
export interface ProjectManifest {
schemaVersion: 1;
id: string;
name: string;
createdAt: number;
updatedAt: number;
skillId?: string | null;
designSystemId?: string | null;
}
export interface ScanProjectLocationsResponse {
scanned: number;
imported: Project[];
existing: string[];
skipped: Array<{ path: string; reason: string }>;
}
// POST /api/import/folder — create a project rooted at an existing local
// folder. The submitted baseDir is stored as the project's metadata.baseDir
// (after realpath canonicalization) and OD reads/writes directly inside it.