feat: import existing local folder as project (#597) (#624)

* feat(contracts): types for folder-import endpoint

Add ImportFolderRequest, ImportFolderResponse to the public contract
surface. Extend ProjectMetadata with a baseDir field — when set, the
project's files live at this absolute path instead of .od/projects/<id>/.
Stored as the realpath() result so symlinks cannot redirect later writes.

Refs nexu-io/open-design#597

* feat(daemon): support metadata.baseDir for folder-rooted projects

Add resolveProjectDir() and metadata-aware variants of listFiles,
readProjectFile, writeProjectFile, ensureProject so a project's files
can live under metadata.baseDir (the user's chosen folder) instead of
.od/projects/<id>/. metadata.baseDir is opt-in — projects without it
keep the existing .od/projects/<id>/ behavior unchanged.

When listFiles walks a baseDir-rooted project, it skips conventional
build / install dirs (node_modules, .git, dist, build, .next, .nuxt,
.turbo, .cache, .output, out, coverage, __pycache__, .venv, vendor,
target, .od, .tmp) so the file panel stays focused on design content
instead of being dominated by lockfiles and node_modules.

Add detectEntryFile() — best-effort lookup for index.html or any
.html at the folder root, used by the import endpoint to seed the
initial active tab.

Refs nexu-io/open-design#597

* feat(daemon): add POST /api/import/folder endpoint

Creates a project rooted at the submitted local folder. metadata.baseDir
points at that folder and OD reads / writes there directly — no copy,
no shadow tree, mirroring how Cursor / Claude Code / Aider behave. The
user owns the workspace and is responsible for their own version
control.

Safety:
- baseDir is canonicalized via fs.promises.realpath() at import time so
  user-controlled symlinks can't redirect later writes. resolveSafe
  enforces the bounds check against the literal stored path; without
  realpath, a symlink (e.g. ~/sneaky → /etc) would let writeProjectFile
  escape the project tree at every later call because the OS follows
  the symlink at open() time.
- Post-realpath lstat ensures the canonical target is itself a real
  directory (defense-in-depth).
- The data directory (RUNTIME_DATA_DIR) and its descendants are
  refused after symlink resolution so a redirect into the daemon's
  own state can't masquerade as a project import.

The web client wires this through state/projects.ts → App.tsx,
landing the user on the auto-detected entry file when present.

Refs nexu-io/open-design#597

* feat(desktop): expose native folder picker to renderer

Adds an Electron preload script that exposes window.electronAPI.pickFolder
via contextBridge. Wires dialog.showOpenDialog through ipcMain so the
web UI can open a native folder selector for project import. Browser-only
users fall back to a text input for the absolute path (handled in the
web layer); the picker stays an optional convenience on the desktop
binary.

ipcMain.handle() registers handlers in an internal map that is not
exposed via eventNames(), so the natural-looking guard
  if (!ipcMain.eventNames().includes('dialog:pick-folder')) ipcMain.handle(...)
is always true. On a second createDesktopRuntime() call (dev hot-reload,
packaged-vs-electron mode swap) the body re-runs and ipcMain.handle()
throws 'Attempted to register a second handler'. Use removeHandler()
+ handle() unconditionally — removeHandler() is a documented no-op
when nothing is registered, making the pair idempotent.

Includes *.cts in the apps/desktop tsconfig so the preload script is
typechecked.

Refs nexu-io/open-design#597

* feat(web): add 'From existing folder' option to New Project

UI surface for the import flow:
- A new 'Open folder' affordance in NewProjectPanel that uses the
  native picker on Electron (window.electronAPI.pickFolder) and falls
  back to an absolute-path text input in the browser.
- importFolderProject() in state/projects.ts: typed wrapper around
  POST /api/import/folder using @open-design/contracts types.
- App.tsx wires the response: prepend the new project to the list,
  navigate to it, and select the auto-detected entry file as the
  active tab.

Skill / design-system pickers from the existing prototype tab are
reused — folder import is a project-creation flow, not a separate
project type.

Refs nexu-io/open-design#597

* docs(architecture): document folder-import endpoint

Adds POST /api/import/folder to the daemon API table and a 'Folder
import' section explaining the single-mode design (direct read/write
in metadata.baseDir, mirroring Cursor / Claude Code / Aider), the
realpath() canonicalization, the RUNTIME_DATA_DIR refusal, and the
SKIP_DIRS list applied to listFiles for baseDir-rooted projects.

Refs nexu-io/open-design#597

* test(daemon): unit + integration tests for folder import

Two new files:

apps/daemon/tests/folder-import-projects.test.ts (13 unit tests):
- resolveProjectDir behavior under all metadata combinations,
  including the fallback when baseDir is relative and the
  isSafeId-bypass when baseDir is set
- detectEntryFile: index.html priority, .html fallback, null when
  no html, no descent into subdirs
- listFiles with metadata.baseDir: walk, SKIP_DIRS hides node_modules
  / .git / dist, back-compat for projects without baseDir

apps/daemon/tests/folder-import-route.test.ts (10 integration tests):
- Happy path: baseDir stored in metadata, importedFrom='folder',
  conversation created, entry file detected
- Error paths: missing baseDir, empty, relative, non-existent,
  pointing at a file
- Security: realpath canonicalization (the symlink test was the one
  that surfaced the original /var vs /private/var mismatch in
  RUNTIME_DATA_DIR comparison on macOS)
- Security: a symlink that resolves into RUNTIME_DATA_DIR is rejected
  after realpath, not before

Refs nexu-io/open-design#597

* fix(daemon): wire baseDir metadata into chat + deploy reads

Two bugs caught in Codex automated review of #624:

1. chat-route was passing the metadata object directly as the listFiles
   opts argument: `listFiles(PROJECTS_DIR, projectId, chatMeta)`. The
   listFiles contract reads opts.metadata, not opts itself, so this
   silently fell back to .od/projects/<id>/ instead of the imported
   folder. existingProjectFiles was empty for baseDir-rooted projects.
   Wrap as `{ metadata: chatMeta }`.

2. deploy.ts read project files via readProjectFile without the
   metadata third argument, so for baseDir-rooted projects the deploy
   and preflight endpoints would look in .od/projects/<id>/ and fail
   with file-not-found instead of reading the imported folder. Thread
   options.metadata through buildDeployFilePlan → readProjectFile and
   pass project?.metadata at the two server.ts callsites
   (`POST /api/projects/:id/deploy` and the preflight endpoint).

Add a regression test that locks the listFiles contract: passing a
bare metadata object as opts must NOT scan baseDir — it must fall back
to the standard project dir, otherwise callers can leak the wrong
folder by mistake.

Refs nexu-io/open-design#597, #624 (Codex review)

* fix(daemon): ensure correct metadata handling in folder import

Addressed issues with metadata handling in folder import functionality. Updated the listFiles and readProjectFile methods to correctly utilize the metadata.baseDir, ensuring that project files are read from the intended directory. Added regression tests to verify that passing a bare metadata object does not inadvertently scan the baseDir, maintaining the integrity of project file access.

Refs nexu-io/open-design#597

* fix(daemon): security hardening from Codex review of #624

P1 findings from automated review:

1. POST /api/projects + PATCH /api/projects/:id rejected
   client-supplied metadata.baseDir. baseDir is privileged: it lets a
   project root inside the user's filesystem, and the realpath() +
   RUNTIME_DATA_DIR reentry checks live only on /api/import/folder.
   Allowing it on the generic create/patch path lets an attacker
   smuggle e.g. /etc through and bypass every import-time guard.
   Both endpoints now refuse a baseDir field with 400.

2. resolveSafeReal() helper: realpath()s each candidate path (or its
   longest existing prefix for write paths) and re-validates against
   realpath(projectRoot). The original resolveSafe() only did a
   string-prefix check, which was fooled by symlinks *inside* a
   baseDir-rooted project. A repo containing 'assets -> /Users/me/.ssh'
   passed the literal prefix check but readFile() followed the link
   at open() time. resolveSafeReal() is now used by readProjectFile,
   writeProjectFile, and deleteProjectFile.

3. Multer chat-upload destination now resolves to metadata.baseDir for
   imported folder projects via a module-level lookup wired to db at
   startServer() boot. Previously attachments landed in
   .od/projects/<id>/ even for baseDir projects, so the agent (which
   runs with cwd=baseDir) couldn't open them.

P2 findings:

4. searchProjectFiles threads metadata through listFiles +
   resolveProjectDir so /api/projects/:id/search hits the right tree.
5. buildProjectArchive + buildBatchArchive now accept metadata so
   'Download .zip' works for imported folder projects.
6. Watcher subscribe() resolves to baseDir for imported projects so
   live-reload SSE actually fires when the user edits files in their
   own folder. Registry stays keyed by the canonical directory.
7. Template snapshotting reads source-project files with metadata
   so a template can be saved from a baseDir-rooted source.

Tests:

- Regression: POST /api/projects with metadata.baseDir → 400.
- Regression: descendant symlink (assets/leak.txt -> /etc/hosts) is
  refused on the raw read endpoint.

Refs nexu-io/open-design#597, #624 (Codex P1+P2 review)

* fix(daemon): close two regressions found in #624 review round 2

@mrcfps caught two more correctness gaps:

1. Archive root symlink escape — buildProjectArchive accepts an optional
   ?root=<subdir> param to scope the zip to a subdirectory. The path was
   resolved with the string-only resolveSafe(), so a directory symlink
   inside an imported folder (docs -> /Users/me/.ssh) passed the prefix
   check and collectArchiveEntries() then walked outside the project
   tree. Switch to the symlink-aware resolveSafeReal() — the same one
   that already protects raw read/write/delete paths. The walker itself
   already skips dirent symlinks via !isDirectory && !isFile, so
   canonicalizing the root is the only missing piece.

2. PATCH metadata wiped baseDir — updateProject() replaces metadata
   wholesale. The previous guard only blocked an explicit baseDir
   change, but a normal patch that *omits* baseDir (a UI editing
   linkedDirs only sends { metadata: { kind, linkedDirs } }) silently
   detached imported projects from their folder root. Subsequent
   reads/writes/watch/deploy fell back to .od/projects/<id>.

   Re-stamp the immutable folder-import fields (baseDir, importedFrom='folder')
   from the existing project record onto the incoming patch when the
   project is imported. A patch that supplies a *different* baseDir
   still gets rejected as before; a patch that supplies the *same*
   baseDir is accepted as a no-op. A patch on a non-imported project
   that tries to set baseDir is also still rejected (preserves the
   POST /api/projects guard from the previous round).

Tests:

- archive endpoint: ?root=<symlink-to-/etc> → 400.
- patch endpoint: PATCH that omits baseDir on an imported project keeps
  baseDir intact (project still resolves to the user's folder after).

Refs nexu-io/open-design#597, #624 (Codex P1 round 2)

* fix(web): add Indonesian deploy provider copy

---------

Co-authored-by: INFINITY <valentyn.sotov@trendarena.app>
Co-authored-by: Siri-Ray <2667192167@qq.com>
This commit is contained in:
INFINITY 2026-05-07 13:43:31 +01:00 committed by GitHub
parent bef8203ad9
commit 988fd6db5e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 1124 additions and 49 deletions

View file

@ -165,7 +165,7 @@ export async function buildDeployFilePlan(projectsRoot, projectId, entryName, op
throw new DeployError('Only HTML files can be deployed.', 400);
}
const entry = await readProjectFile(projectsRoot, projectId, entryPath);
const entry = await readProjectFile(projectsRoot, projectId, entryPath, options.metadata);
const html = entry.buffer.toString('utf8');
const entryBase = path.posix.dirname(entryPath);
const deployHtml = injectDeployHookScript(
@ -215,7 +215,7 @@ export async function buildDeployFilePlan(projectsRoot, projectId, entryName, op
let projectFile;
try {
projectFile = await readProjectFile(projectsRoot, projectId, safePath);
projectFile = await readProjectFile(projectsRoot, projectId, safePath, options.metadata);
} catch (err) {
if (err && err.code === 'ENOENT') {
missing.push(safePath);

View file

@ -2,7 +2,7 @@
import path from 'node:path';
import chokidar from 'chokidar';
import { projectDir } from './projects.js';
import { projectDir, resolveProjectDir } from './projects.js';
/**
* Refcounted per-project file watcher registry.
@ -121,7 +121,14 @@ function makeEntry(dir, opts) {
* last; `ready` resolves once chokidar has finished its initial scan.
*/
export function subscribe(projectsRoot, projectId, onEvent, opts = {}) {
const dir = projectDir(projectsRoot, projectId);
// Resolve to the project's actual root: for folder-imported projects
// (metadata.baseDir set) we watch the user's folder so the live-reload
// SSE stream actually fires when their files change. The registry is
// keyed by the resolved directory, not the project id, so two
// projects pointing at the same folder share one watcher.
const dir = opts.metadata
? resolveProjectDir(projectsRoot, projectId, opts.metadata)
: projectDir(projectsRoot, projectId);
const key = dir;
let entry = registry.get(key);

View file

@ -7,7 +7,7 @@
// All paths flowing in from HTTP handlers are validated against the project
// directory to prevent path traversal — see resolveSafe().
import { lstat, mkdir, readdir, readFile, rm, stat, unlink, writeFile } from 'node:fs/promises';
import { lstat, mkdir, readdir, readFile, realpath, rm, stat, unlink, writeFile } from 'node:fs/promises';
import path from 'node:path';
import JSZip from 'jszip';
import {
@ -24,16 +24,35 @@ export function projectDir(projectsRoot, projectId) {
return path.join(projectsRoot, projectId);
}
export async function ensureProject(projectsRoot, projectId) {
const dir = projectDir(projectsRoot, projectId);
await mkdir(dir, { recursive: true });
// Returns the folder a project's files live in. For git-linked projects
// (metadata.baseDir set), this is the user's own folder. Otherwise falls
// back to the standard computed path under projectsRoot.
export function resolveProjectDir(projectsRoot, projectId, metadata?) {
if (typeof metadata?.baseDir === 'string') {
const p = path.normalize(metadata.baseDir);
if (path.isAbsolute(p)) return p;
}
if (!isSafeId(projectId)) throw new Error('invalid project id');
return path.join(projectsRoot, projectId);
}
export async function ensureProject(projectsRoot, projectId, metadata?) {
const dir = resolveProjectDir(projectsRoot, projectId, metadata);
// Git-linked folders already exist; skip mkdir to avoid side-effects.
if (typeof metadata?.baseDir !== 'string') {
await mkdir(dir, { recursive: true });
}
return dir;
}
export async function listFiles(projectsRoot, projectId, opts = {}) {
const dir = projectDir(projectsRoot, projectId);
const metadata = opts?.metadata;
const dir = resolveProjectDir(projectsRoot, projectId, metadata);
const out = [];
await collectFiles(dir, '', out);
// Skip build/install dirs for linked folders so node_modules doesn't stall
// the walk on large repos.
const skipDirs = metadata?.baseDir ? SKIP_DIRS : undefined;
await collectFiles(dir, '', out, skipDirs);
// Newest first — matches the visual order users expect after generating.
out.sort((a, b) => b.mtime - a.mtime);
const since = Number(opts.since);
@ -43,7 +62,34 @@ export async function listFiles(projectsRoot, projectId, opts = {}) {
return out;
}
async function collectFiles(dir, relDir, out) {
// Build/install dirs that should be hidden from the file panel when a
// project is rooted at metadata.baseDir (the user's own folder). Without
// this, the listing would be dominated by node_modules, lockfiles, and
// build output that have no design value.
const SKIP_DIRS = new Set([
'node_modules', '.git', 'dist', 'build', '.next', '.nuxt', '.turbo',
'.cache', '.output', 'out', 'coverage', '__pycache__', '.venv',
'vendor', 'target', '.od', '.tmp',
]);
// Best-effort entry-file detector — looks for index.html at the root,
// then any *.html file. Returns null if nothing obvious is found, in
// which case the project simply opens to the file panel with no
// auto-selected tab.
export async function detectEntryFile(dir: string): Promise<string | null> {
try {
await stat(path.join(dir, 'index.html'));
return 'index.html';
} catch { /* not found */ }
try {
const entries = await readdir(dir, { withFileTypes: true });
const htmlFile = entries.find((e) => e.isFile() && /\.html?$/i.test(e.name));
if (htmlFile) return htmlFile.name;
} catch { /* ignore */ }
return null;
}
async function collectFiles(dir, relDir, out, skipDirs?: Set<string>) {
let entries = [];
try {
entries = await readdir(dir, { withFileTypes: true });
@ -56,7 +102,8 @@ async function collectFiles(dir, relDir, out) {
const rel = relDir ? `${relDir}/${e.name}` : e.name;
const full = path.join(dir, e.name);
if (e.isDirectory()) {
await collectFiles(full, rel, out);
if (skipDirs && skipDirs.has(e.name)) continue;
await collectFiles(full, rel, out, skipDirs);
continue;
}
if (!e.isFile()) continue;
@ -83,12 +130,18 @@ async function collectFiles(dir, relDir, out) {
// the user sees in the file panel. Used by the "Download as .zip" share
// menu item, which exports the user's actual project tree (e.g. the
// uploaded `ui-design/` folder), not just the rendered HTML.
export async function buildProjectArchive(projectsRoot, projectId, root) {
const projectRoot = projectDir(projectsRoot, projectId);
export async function buildProjectArchive(projectsRoot, projectId, root, metadata?) {
const projectRoot = resolveProjectDir(projectsRoot, projectId, metadata);
let archiveRoot = projectRoot;
let archiveBaseName = '';
if (typeof root === 'string' && root.trim().length > 0) {
archiveRoot = resolveSafe(projectRoot, root);
// Use the symlink-aware resolver so that an imported folder containing
// e.g. `docs -> /Users/me/.ssh` cannot exfiltrate via
// GET /api/projects/:id/archive?root=docs. resolveSafe()'s string
// prefix check would let the literal path stay under projectRoot, then
// collectArchiveEntries() / readFile() would follow the symlink at
// open() time and zip files outside the project tree.
archiveRoot = await resolveSafeReal(projectRoot, root);
archiveBaseName = path.basename(archiveRoot);
}
@ -141,8 +194,8 @@ export async function buildProjectArchive(projectsRoot, projectId, root) {
return { buffer, baseName: archiveBaseName };
}
export async function buildBatchArchive(projectsRoot, projectId, fileNames) {
const projectRoot = projectDir(projectsRoot, projectId);
export async function buildBatchArchive(projectsRoot, projectId, fileNames, metadata?) {
const projectRoot = resolveProjectDir(projectsRoot, projectId, metadata);
const zip = new JSZip();
let packed = 0;
const rejected = [];
@ -281,9 +334,9 @@ async function collectArchiveEntries(dir, relDir, out) {
}
}
export async function readProjectFile(projectsRoot, projectId, name) {
const dir = projectDir(projectsRoot, projectId);
const file = resolveSafe(dir, name);
export async function readProjectFile(projectsRoot, projectId, name, metadata?) {
const dir = resolveProjectDir(projectsRoot, projectId, metadata);
const file = await resolveSafeReal(dir, name);
const buf = await readFile(file);
const st = await stat(file);
const rel = toProjectPath(path.relative(dir, file));
@ -307,10 +360,11 @@ export async function writeProjectFile(
name,
body,
{ overwrite = true, artifactManifest = null } = {},
metadata?,
) {
const dir = await ensureProject(projectsRoot, projectId);
const dir = await ensureProject(projectsRoot, projectId, metadata);
const safeName = sanitizePath(name);
const target = resolveSafe(dir, safeName);
const target = await resolveSafeReal(dir, safeName);
if (!overwrite) {
try {
await stat(target);
@ -323,7 +377,7 @@ export async function writeProjectFile(
await writeFile(target, body);
if (artifactManifest && typeof artifactManifest === 'object') {
const manifestFileName = artifactManifestNameFor(safeName);
const manifestTarget = resolveSafe(dir, manifestFileName);
const manifestTarget = await resolveSafeReal(dir, manifestFileName);
const validated = validateArtifactManifestInput(artifactManifest, safeName);
if (validated.ok && validated.value) {
const nextManifest = validated.value;
@ -366,9 +420,9 @@ function parseManifest(raw) {
return parsePersistedManifest(raw, '');
}
export async function deleteProjectFile(projectsRoot, projectId, name) {
const dir = projectDir(projectsRoot, projectId);
const file = resolveSafe(dir, name);
export async function deleteProjectFile(projectsRoot, projectId, name, metadata?) {
const dir = resolveProjectDir(projectsRoot, projectId, metadata);
const file = await resolveSafeReal(dir, name);
await unlink(file);
}
@ -386,6 +440,49 @@ function resolveSafe(dir, name) {
return target;
}
// Symlink-aware variant of resolveSafe. resolveSafe only does string-prefix
// validation, which is fooled by symlinks *inside* the project tree
// (a `assets/` symlink pointing at `/Users/me/.ssh` passes the prefix
// check because the literal path stays under dir, but the OS follows
// the link at open() time). This helper realpath()s the resolved
// candidate (or its existing prefix, for writes that haven't created
// the file yet) and re-validates against the realpath of dir, so
// descendant symlinks can't reach outside the project.
async function resolveSafeReal(dir, name) {
const candidate = resolveSafe(dir, name);
const rootReal = await realpath(dir).catch(() => dir);
let real;
try {
real = await realpath(candidate);
} catch (err) {
if (!err || err.code !== 'ENOENT') throw err;
// Write case: path doesn't exist yet. Realpath the longest existing
// prefix and re-append the missing tail.
real = await resolveExistingPrefix(candidate);
}
if (!real.startsWith(rootReal + path.sep) && real !== rootReal) {
const e = new Error('path escapes project dir via symlink');
e.code = 'EPATHESCAPE';
throw e;
}
return real;
}
async function resolveExistingPrefix(p) {
const parts = p.split(path.sep);
for (let i = parts.length; i > 0; i--) {
const prefix = parts.slice(0, i).join(path.sep) || path.sep;
try {
const real = await realpath(prefix);
const rest = parts.slice(i).join(path.sep);
return rest ? path.join(real, rest) : real;
} catch (err) {
if (!err || err.code !== 'ENOENT') throw err;
}
}
return p;
}
export function sanitizePath(raw) {
const normalized = validateProjectPath(raw);
return normalized.split('/').map(sanitizeName).join('/');
@ -505,8 +602,9 @@ export function mimeFor(name) {
export async function searchProjectFiles(projectsRoot, projectId, query, opts = {}) {
const max = Math.min(Number(opts.max) || 200, 1000);
const pattern = opts.pattern || null;
const items = await listFiles(projectsRoot, projectId);
const dir = projectDir(projectsRoot, projectId);
const metadata = opts.metadata;
const items = await listFiles(projectsRoot, projectId, { metadata });
const dir = resolveProjectDir(projectsRoot, projectId, metadata);
const escaped = String(query).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const re = new RegExp(escaped, 'i');
const matches = [];

View file

@ -76,6 +76,7 @@ import {
buildBatchArchive,
decodeMultipartFilename,
deleteProjectFile,
detectEntryFile,
ensureProject,
listFiles,
mimeFor,
@ -708,7 +709,16 @@ export function resolveDataDir(raw, projectRoot) {
`OD_DATA_DIR "${resolved}" is not writable: ${e.message}`,
);
}
return resolved;
// Canonicalize via realpath so that any callers comparing user-supplied
// realpath() output against RUNTIME_DATA_DIR get a stable result. On
// macOS, /var is itself a symlink to /private/var, so a user's import
// realpath would land in /private/var/... and would never start-with
// a non-canonicalized RUNTIME_DATA_DIR.
try {
return fs.realpathSync(resolved);
} catch {
return resolved;
}
}
const RUNTIME_DATA_DIR = resolveDataDir(process.env.OD_DATA_DIR, PROJECT_ROOT);
// One-shot legacy data migration. When OD_LEGACY_DATA_DIR is set and the
@ -1208,11 +1218,25 @@ const importUpload = multer({
// folder (flat — same shape FileWorkspace expects), so the composer's
// pasted/dropped/picked images become referenceable filenames the agent
// can Read or @-mention without any cross-folder gymnastics.
// Bridge between the multer upload-storage destination (built at module
// init) and the per-process project DB (instantiated inside startServer).
// startServer() sets this so the upload destination can route attachments
// into the right project root, including folder-imported projects whose
// files live under metadata.baseDir.
let projectMetadataLookup: ((id: string) => Record<string, unknown> | null) | null = null;
const projectUpload = multer({
storage: multer.diskStorage({
destination: async (req, _file, cb) => {
try {
const dir = await ensureProject(PROJECTS_DIR, req.params.id);
// Route uploads into the project's actual root: for folder-imported
// projects (metadata.baseDir set) attachments need to land alongside
// the user's files so the agent can read them via the same path
// it sees. projectMetadataLookup is populated at startServer() boot
// and keyed by project id; null fallback gives the standard
// .od/projects/<id>/ behavior for non-imported projects.
const meta = projectMetadataLookup?.(req.params.id) ?? null;
const dir = await ensureProject(PROJECTS_DIR, req.params.id, meta);
cb(null, dir);
} catch (err) {
cb(err, '');
@ -1466,6 +1490,11 @@ export async function startServer({ port = 7456, host = process.env.OD_BIND_HOST
next();
});
const db = openDatabase(PROJECT_ROOT, { dataDir: RUNTIME_DATA_DIR });
// Wire the upload-destination bridge to this db so multer can route
// file uploads into baseDir-rooted projects' actual folders.
projectMetadataLookup = (id) => {
try { return getProject(db, id)?.metadata ?? null; } catch { return null; }
};
configureConnectorCredentialStore(new FileConnectorCredentialStore(RUNTIME_DATA_DIR));
configureComposioConfigStore(RUNTIME_DATA_DIR);
let daemonUrl = `http://127.0.0.1:${port}`;
@ -1713,6 +1742,22 @@ export async function startServer({ port = 7456, host = process.env.OD_BIND_HOST
if (typeof name !== 'string' || !name.trim()) {
return sendApiError(res, 400, 'BAD_REQUEST', 'name required');
}
// baseDir is privileged: it lets a project root directly inside the
// user's filesystem. The /api/import/folder endpoint is the only
// path that's allowed to set it, because that's where realpath() +
// RUNTIME_DATA_DIR reentry checks live. Block client-supplied
// metadata.baseDir on this generic create endpoint so an attacker
// can't smuggle e.g. /etc through here. Same rule for
// originalBaseDir / importedFrom='folder' — only the import path
// owns those state fields.
if (metadata && typeof metadata === 'object') {
if ('baseDir' in metadata) {
return sendApiError(
res, 400, 'BAD_REQUEST',
'baseDir can only be set via POST /api/import/folder',
);
}
}
const now = Date.now();
const project = insertProject(db, {
id,
@ -1847,6 +1892,94 @@ export async function startServer({ port = 7456, host = process.env.OD_BIND_HOST
},
);
// Import an existing local folder as a project. The user picks a folder
// and OD works inside it directly: every write goes to metadata.baseDir.
// No copy, no shadow tree — the user owns the workspace and is
// responsible for their own version control (git, time machine, etc.),
// mirroring how Cursor / Claude Code / Aider behave.
app.post('/api/import/folder', async (req, res) => {
try {
const { baseDir, name, skillId, designSystemId } = req.body || {};
if (typeof baseDir !== 'string' || !baseDir.trim()) {
return sendApiError(res, 400, 'BAD_REQUEST', 'baseDir required');
}
const trimmedInput = baseDir.trim();
if (!path.isAbsolute(path.normalize(trimmedInput))) {
return sendApiError(res, 400, 'BAD_REQUEST', 'baseDir must be absolute');
}
// Resolve symlinks once at import and persist the canonical path.
// Without this, a user-controlled symlink (e.g. ~/sneaky → /etc) at
// baseDir would let writeProjectFile escape the project sandbox at
// every later call: resolveSafe checks the *literal* baseDir, but
// the OS follows the symlink at write time. realpath() collapses
// the chain so the stored baseDir == what the kernel will write to.
let normalizedPath: string;
try {
normalizedPath = await fs.promises.realpath(trimmedInput);
} catch {
return sendApiError(res, 400, 'BAD_REQUEST', 'folder not found');
}
// realpath resolved → lstat the canonical path to ensure it's a
// real directory, not another symlink (defense-in-depth).
let dirStat;
try {
dirStat = await fs.promises.lstat(normalizedPath);
} catch {
return sendApiError(res, 400, 'BAD_REQUEST', 'folder not found');
}
if (!dirStat.isDirectory()) {
return sendApiError(res, 400, 'BAD_REQUEST', 'path must be a directory');
}
// Prevent importing the data directory into itself (post-realpath so
// a symlink pointing into RUNTIME_DATA_DIR is also caught).
if (
normalizedPath === RUNTIME_DATA_DIR ||
normalizedPath.startsWith(RUNTIME_DATA_DIR + path.sep)
) {
return sendApiError(res, 400, 'BAD_REQUEST', 'cannot import the data directory');
}
const id = randomId();
const now = Date.now();
const projectName =
typeof name === 'string' && name.trim()
? name.trim()
: path.basename(normalizedPath);
const entryFile = await detectEntryFile(normalizedPath);
const project = insertProject(db, {
id,
name: projectName,
skillId: skillId ?? null,
designSystemId: designSystemId ?? null,
pendingPrompt: null,
metadata: {
kind: 'prototype',
baseDir: normalizedPath,
importedFrom: 'folder',
entryFile,
},
createdAt: now,
updatedAt: now,
});
const cid = randomId();
insertConversation(db, {
id: cid,
projectId: id,
title: `Imported from ${projectName}`,
createdAt: now,
updatedAt: now,
});
if (entryFile) setTabs(db, id, [entryFile], entryFile);
/** @type {import('@open-design/contracts').ImportFolderResponse} */
const body = { project, conversationId: cid, entryFile };
res.json(body);
} catch (err) {
sendApiError(res, 400, 'BAD_REQUEST', String(err));
}
});
app.get('/api/projects/:id', (req, res) => {
const project = getProject(db, req.params.id);
if (!project)
@ -1859,6 +1992,45 @@ export async function startServer({ port = 7456, host = process.env.OD_BIND_HOST
app.patch('/api/projects/:id', (req, res) => {
try {
const patch = req.body || {};
// baseDir / folder-import state is privileged: it's set only by the
// import endpoint and otherwise immutable. Two failure modes to
// guard against here:
// 1. Explicit attempt to change baseDir → reject with 400.
// 2. A regular metadata patch that *omits* baseDir (e.g. a UI
// that only edits linkedDirs sends `{ metadata: { kind, linkedDirs } }`).
// updateProject() replaces metadata wholesale, so without
// preservation the existing baseDir gets wiped and the project
// detaches from the user's folder — subsequent reads/writes
// silently fall back to .od/projects/<id>.
// For case 2 we re-stamp the immutable fields from the existing
// project record onto the incoming patch so the user can keep
// patching other metadata without ever losing their import root.
if (patch.metadata && typeof patch.metadata === 'object') {
const existing = getProject(db, req.params.id);
const existingMeta = existing?.metadata;
if (existingMeta?.baseDir) {
if ('baseDir' in patch.metadata && patch.metadata.baseDir !== existingMeta.baseDir) {
return sendApiError(
res, 400, 'BAD_REQUEST',
'baseDir is immutable after import; use a new import to change it',
);
}
patch.metadata = {
...patch.metadata,
baseDir: existingMeta.baseDir,
...(existingMeta.importedFrom === 'folder'
? { importedFrom: 'folder' }
: {}),
};
} else if ('baseDir' in patch.metadata) {
// Non-imported project trying to acquire a baseDir → reject (only
// /api/import/folder can set it).
return sendApiError(
res, 400, 'BAD_REQUEST',
'baseDir can only be set via POST /api/import/folder',
);
}
}
if (patch.metadata?.linkedDirs) {
const validated = validateLinkedDirs(patch.metadata.linkedDirs);
if (validated.error) {
@ -1912,9 +2084,10 @@ export async function startServer({ port = 7456, host = process.env.OD_BIND_HOST
activeProjectEventSinks.set(req.params.id, sinks);
}
sinks.add(projectEventSink);
const watchProject = getProject(db, req.params.id);
sub = subscribeFileEvents(PROJECTS_DIR, req.params.id, (evt) => {
sse.send('file-changed', evt);
});
}, { metadata: watchProject?.metadata });
sub.ready.then(() => sse.send('ready', { projectId: req.params.id })).catch(() => {});
const cleanup = () => {
if (sub) {
@ -2132,13 +2305,16 @@ export async function startServer({ port = 7456, host = process.env.OD_BIND_HOST
if (typeof sourceProjectId !== 'string') {
return res.status(400).json({ error: 'sourceProjectId required' });
}
if (!getProject(db, sourceProjectId)) {
const sourceProject = getProject(db, sourceProjectId);
if (!sourceProject) {
return res.status(404).json({ error: 'source project not found' });
}
// Snapshot every HTML / sketch / text file in the source project.
// We deliberately skip binary uploads — templates are about the
// generated design, not the user's reference imagery.
const files = await listFiles(PROJECTS_DIR, sourceProjectId);
const files = await listFiles(PROJECTS_DIR, sourceProjectId, {
metadata: sourceProject.metadata,
});
const snapshot = [];
for (const f of files) {
if (f.kind !== 'html' && f.kind !== 'text' && f.kind !== 'code')
@ -2147,6 +2323,7 @@ export async function startServer({ port = 7456, host = process.env.OD_BIND_HOST
PROJECTS_DIR,
sourceProjectId,
f.name,
sourceProject.metadata,
);
if (entry && Buffer.isBuffer(entry.buffer)) {
snapshot.push({
@ -2892,10 +3069,12 @@ export async function startServer({ port = 7456, host = process.env.OD_BIND_HOST
}
const prior = getDeployment(db, req.params.id, fileName, providerId);
const deployProject = getProject(db, req.params.id);
const files = await buildDeployFileSet(
PROJECTS_DIR,
req.params.id,
fileName,
{ metadata: deployProject?.metadata },
);
const project = getProject(db, req.params.id);
const cloudflarePagesProjectName =
@ -2968,12 +3147,13 @@ export async function startServer({ port = 7456, host = process.env.OD_BIND_HOST
if (typeof fileName !== 'string' || !fileName.trim()) {
return sendApiError(res, 400, 'BAD_REQUEST', 'fileName required');
}
const preflightProject = getProject(db, req.params.id);
/** @type {import('@open-design/contracts').DeployPreflightResponse} */
const body = await prepareDeployPreflight(
PROJECTS_DIR,
req.params.id,
fileName,
{ providerId },
{ metadata: preflightProject?.metadata, providerId },
);
res.json(body);
} catch (err) {
@ -3052,8 +3232,10 @@ export async function startServer({ port = 7456, host = process.env.OD_BIND_HOST
app.get('/api/projects/:id/files', async (req, res) => {
try {
const since = Number(req.query?.since);
const project = getProject(db, req.params.id);
const files = await listFiles(PROJECTS_DIR, req.params.id, {
since: Number.isFinite(since) ? since : undefined,
metadata: project?.metadata,
});
/** @type {import('@open-design/contracts').ProjectFilesResponse} */
const body = { files };
@ -3072,9 +3254,11 @@ export async function startServer({ port = 7456, host = process.env.OD_BIND_HOST
}
const pattern = req.query.pattern ? String(req.query.pattern) : null;
const max = Math.min(Number(req.query.max) || 200, 1000);
const searchProject = getProject(db, req.params.id);
const matches = await searchProjectFiles(PROJECTS_DIR, req.params.id, query, {
pattern,
max,
metadata: searchProject?.metadata,
});
res.json({ query, matches });
} catch (err) {
@ -3090,12 +3274,13 @@ export async function startServer({ port = 7456, host = process.env.OD_BIND_HOST
app.get('/api/projects/:id/archive', async (req, res) => {
try {
const root = typeof req.query?.root === 'string' ? req.query.root : '';
const project = getProject(db, req.params.id);
const { buffer, baseName } = await buildProjectArchive(
PROJECTS_DIR,
req.params.id,
root,
project?.metadata,
);
const project = getProject(db, req.params.id);
const fallbackName = project?.name || req.params.id;
const fileSlug = sanitizeArchiveFilename(baseName || fallbackName) || 'project';
const filename = `${fileSlug}.zip`;
@ -3131,12 +3316,13 @@ export async function startServer({ port = 7456, host = process.env.OD_BIND_HOST
sendApiError(res, 400, 'BAD_REQUEST', 'files must be a non-empty array');
return;
}
const project = getProject(db, req.params.id);
const { buffer } = await buildBatchArchive(
PROJECTS_DIR,
req.params.id,
files,
project?.metadata,
);
const project = getProject(db, req.params.id);
const fileSlug = sanitizeArchiveFilename(project?.name || req.params.id) || 'project';
const filename = `${fileSlug}.zip`;
const asciiFallback =
@ -3174,7 +3360,8 @@ export async function startServer({ port = 7456, host = process.env.OD_BIND_HOST
app.get('/api/projects/:id/raw/*', async (req, res) => {
try {
const relPath = req.params[0];
const file = await readProjectFile(PROJECTS_DIR, req.params.id, relPath);
const project = getProject(db, req.params.id);
const file = await readProjectFile(PROJECTS_DIR, req.params.id, relPath, project?.metadata);
// PreviewModal loads artifact HTML via srcdoc, giving the iframe Origin: "null".
// data: URIs, file://, and some sandboxed iframes also send null — all are
// local-only callers, so this is safe. Real cross-origin sites send a real
@ -3196,7 +3383,8 @@ export async function startServer({ port = 7456, host = process.env.OD_BIND_HOST
app.delete('/api/projects/:id/raw/*', async (req, res) => {
try {
await deleteProjectFile(PROJECTS_DIR, req.params.id, req.params[0]);
const project = getProject(db, req.params.id);
await deleteProjectFile(PROJECTS_DIR, req.params.id, req.params[0], project?.metadata);
/** @type {import('@open-design/contracts').DeleteProjectFileResponse} */
const body = { ok: true };
res.json(body);
@ -3213,10 +3401,12 @@ export async function startServer({ port = 7456, host = process.env.OD_BIND_HOST
app.get('/api/projects/:id/files/:name/preview', async (req, res) => {
try {
const project = getProject(db, req.params.id);
const file = await readProjectFile(
PROJECTS_DIR,
req.params.id,
req.params.name,
project?.metadata,
);
const preview = await buildDocumentPreview(file);
res.json(preview);
@ -3238,10 +3428,12 @@ export async function startServer({ port = 7456, host = process.env.OD_BIND_HOST
app.get('/api/projects/:id/files/*', async (req, res) => {
try {
const project = getProject(db, req.params.id);
const file = await readProjectFile(
PROJECTS_DIR,
req.params.id,
req.params[0],
project?.metadata,
);
res.type(file.mime).send(file.buffer);
} catch (err) {
@ -3268,7 +3460,8 @@ export async function startServer({ port = 7456, host = process.env.OD_BIND_HOST
},
async (req, res) => {
try {
await ensureProject(PROJECTS_DIR, req.params.id);
const uploadProject = getProject(db, req.params.id);
await ensureProject(PROJECTS_DIR, req.params.id, uploadProject?.metadata);
if (req.file) {
const buf = await fs.promises.readFile(req.file.path);
const desiredName = sanitizeName(
@ -3279,6 +3472,8 @@ export async function startServer({ port = 7456, host = process.env.OD_BIND_HOST
req.params.id,
desiredName,
buf,
{},
uploadProject?.metadata,
);
fs.promises.unlink(req.file.path).catch(() => {});
/** @type {import('@open-design/contracts').ProjectFileResponse} */
@ -3317,9 +3512,8 @@ export async function startServer({ port = 7456, host = process.env.OD_BIND_HOST
req.params.id,
name,
buf,
{
artifactManifest,
},
{ artifactManifest },
uploadProject?.metadata,
);
/** @type {import('@open-design/contracts').ProjectFileResponse} */
const body = { file: meta };
@ -3332,7 +3526,8 @@ export async function startServer({ port = 7456, host = process.env.OD_BIND_HOST
app.delete('/api/projects/:id/files/:name', async (req, res) => {
try {
await deleteProjectFile(PROJECTS_DIR, req.params.id, req.params.name);
const delProject = getProject(db, req.params.id);
await deleteProjectFile(PROJECTS_DIR, req.params.id, req.params.name, delProject?.metadata);
/** @type {import('@open-design/contracts').DeleteProjectFileResponse} */
const body = { ok: true };
res.json(body);
@ -3815,12 +4010,21 @@ export async function startServer({ port = 7456, host = process.env.OD_BIND_HOST
// doesn't exist yet). Without one we don't pass cwd to spawn — the
// agent then runs in whatever inherited dir, which still lets API
// mode work but loses file-tool addressability.
// For git-linked projects (metadata.baseDir), use that folder directly
// so the agent writes back to the user's original source tree.
let cwd = null;
let existingProjectFiles = [];
if (typeof projectId === 'string' && projectId) {
try {
cwd = await ensureProject(PROJECTS_DIR, projectId);
existingProjectFiles = await listFiles(PROJECTS_DIR, projectId);
const chatProject = getProject(db, projectId);
const chatMeta = chatProject?.metadata;
if (chatMeta?.baseDir) {
cwd = path.normalize(chatMeta.baseDir);
existingProjectFiles = await listFiles(PROJECTS_DIR, projectId, { metadata: chatMeta });
} else {
cwd = await ensureProject(PROJECTS_DIR, projectId);
existingProjectFiles = await listFiles(PROJECTS_DIR, projectId);
}
} catch {
cwd = null;
}

View file

@ -0,0 +1,179 @@
import { mkdtempSync, rmSync } from 'node:fs';
import { mkdir, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { detectEntryFile, listFiles, resolveProjectDir } from '../src/projects.js';
describe('resolveProjectDir', () => {
const projectsRoot = '/var/od/projects';
const projectId = 'proj-abc';
it('returns the standard path when no metadata is given', () => {
expect(resolveProjectDir(projectsRoot, projectId)).toBe(
path.join(projectsRoot, projectId),
);
});
it('returns the standard path when metadata has no baseDir', () => {
expect(resolveProjectDir(projectsRoot, projectId, { kind: 'prototype' })).toBe(
path.join(projectsRoot, projectId),
);
});
it('returns metadata.baseDir when set to an absolute path', () => {
const baseDir = '/Users/me/projects/site';
expect(
resolveProjectDir(projectsRoot, projectId, { kind: 'prototype', baseDir }),
).toBe(path.normalize(baseDir));
});
it('falls back to the standard path when baseDir is relative', () => {
expect(
resolveProjectDir(projectsRoot, projectId, {
kind: 'prototype',
baseDir: 'relative/site',
}),
).toBe(path.join(projectsRoot, projectId));
});
it('throws on an invalid project id only when no baseDir is set', () => {
// No baseDir → relies on isSafeId
expect(() => resolveProjectDir(projectsRoot, '../escape')).toThrowError();
// baseDir present → project id is not consulted, so a bogus id is fine
expect(() =>
resolveProjectDir(projectsRoot, '../escape', {
kind: 'prototype',
baseDir: '/Users/me/site',
}),
).not.toThrow();
});
});
describe('detectEntryFile', () => {
let dir = '';
beforeEach(() => {
dir = mkdtempSync(path.join(tmpdir(), 'od-detect-entry-'));
});
afterEach(() => {
if (dir) rmSync(dir, { recursive: true, force: true });
});
it('returns index.html when present at the root', async () => {
await writeFile(path.join(dir, 'index.html'), '<!doctype html>');
await writeFile(path.join(dir, 'about.html'), '<!doctype html>');
expect(await detectEntryFile(dir)).toBe('index.html');
});
it('returns the first .html file when no index.html is present', async () => {
await writeFile(path.join(dir, 'about.html'), '<!doctype html>');
const result = await detectEntryFile(dir);
expect(result).toBe('about.html');
});
it('returns null when the folder has no html files', async () => {
await writeFile(path.join(dir, 'README.md'), '# hi');
expect(await detectEntryFile(dir)).toBeNull();
});
it('returns null when the folder does not exist', async () => {
const missing = path.join(dir, 'no-such-subdir');
expect(await detectEntryFile(missing)).toBeNull();
});
it('does not descend into subdirectories', async () => {
await mkdir(path.join(dir, 'public'));
await writeFile(path.join(dir, 'public', 'index.html'), '<!doctype html>');
expect(await detectEntryFile(dir)).toBeNull();
});
});
describe('listFiles with metadata.baseDir', () => {
let baseDir = '';
beforeEach(async () => {
baseDir = mkdtempSync(path.join(tmpdir(), 'od-list-'));
await writeFile(path.join(baseDir, 'index.html'), '<!doctype html>');
await writeFile(path.join(baseDir, 'app.css'), 'body{}');
await mkdir(path.join(baseDir, 'node_modules', 'react'), { recursive: true });
await writeFile(path.join(baseDir, 'node_modules', 'react', 'index.js'), '');
await mkdir(path.join(baseDir, '.git'));
await writeFile(path.join(baseDir, '.git', 'HEAD'), 'ref: refs/heads/main');
await mkdir(path.join(baseDir, 'dist'));
await writeFile(path.join(baseDir, 'dist', 'bundle.js'), '/*compiled*/');
await mkdir(path.join(baseDir, 'src'));
await writeFile(path.join(baseDir, 'src', 'app.ts'), 'export {}');
});
afterEach(() => {
if (baseDir) rmSync(baseDir, { recursive: true, force: true });
});
it('walks the folder rooted at metadata.baseDir', async () => {
const files = await listFiles('/unused/projects', 'unused-id', {
metadata: { kind: 'prototype', baseDir },
});
const paths = files.map((f) => f.path).sort();
expect(paths).toContain('index.html');
expect(paths).toContain('app.css');
expect(paths).toContain('src/app.ts');
});
// Regression: callers that pass the metadata object directly as opts
// (instead of wrapping it in `{ metadata }`) were silently scanning the
// standard .od/projects/<id>/ instead of the imported folder. Codex
// review of #624 caught one in chat-route. Lock the contract: when a
// bare metadata object is passed at the top level, listFiles must
// ignore it and fall back to the standard project dir — no false
// positives on a folder the caller didn't ask for.
it('ignores bare metadata at opts top-level (must be opts.metadata)', async () => {
// Pass the metadata object directly as opts. With the documented
// contract this means opts.metadata is undefined, so listFiles
// resolves to projectsRoot/projectId — which here doesn't exist,
// so the result must be an empty array, not the contents of baseDir.
const files = await listFiles('/unused/projects', 'unused-id', {
kind: 'prototype',
baseDir,
} as never);
expect(files).toEqual([]);
});
it('skips conventional build / install dirs (node_modules, .git, dist)', async () => {
const files = await listFiles('/unused/projects', 'unused-id', {
metadata: { kind: 'prototype', baseDir },
});
const paths = files.map((f) => f.path);
expect(paths.some((p) => p.startsWith('node_modules/'))).toBe(false);
expect(paths.some((p) => p.startsWith('.git/'))).toBe(false);
expect(paths.some((p) => p.startsWith('dist/'))).toBe(false);
});
it('does not skip those dirs for non-baseDir projects (back-compat)', async () => {
// Without metadata.baseDir, listFiles points at the standard project dir.
// We don't have one set up for this test — just assert the call doesn't
// *apply* the skip filter when the metadata is absent. We check this
// indirectly: passing the same baseDir as a non-baseDir directory
// (impossible here since listFiles uses standard path). So instead,
// verify the default path behavior is unchanged: no metadata, no
// skipDirs, no baseDir resolution.
const standardDir = mkdtempSync(path.join(tmpdir(), 'od-list-std-'));
try {
await mkdir(path.join(standardDir, 'std-project'), { recursive: true });
await mkdir(path.join(standardDir, 'std-project', 'node_modules'));
await writeFile(path.join(standardDir, 'std-project', 'node_modules', 'a.js'), '');
await writeFile(path.join(standardDir, 'std-project', 'main.html'), '');
const files = await listFiles(standardDir, 'std-project');
const paths = files.map((f) => f.path).sort();
// node_modules contents *do* appear when no skip filter is applied
expect(paths).toContain('main.html');
expect(paths).toContain('node_modules/a.js');
} finally {
rmSync(standardDir, { recursive: true, force: true });
}
});
});

View file

@ -0,0 +1,267 @@
import type http from 'node:http';
import { mkdtempSync, rmSync, symlinkSync } from 'node:fs';
import { mkdir, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
import { startServer } from '../src/server.js';
describe('POST /api/import/folder', () => {
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 makeFolder(): string {
const d = mkdtempSync(path.join(tmpdir(), 'od-import-'));
tempDirs.push(d);
return d;
}
async function importFolder(body: unknown) {
return fetch(`${baseUrl}/api/import/folder`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
}
it('creates a project rooted at the submitted folder', async () => {
const folder = makeFolder();
await writeFile(path.join(folder, 'index.html'), '<!doctype html>');
const resp = await importFolder({ baseDir: folder });
expect(resp.status).toBe(200);
const body = (await resp.json()) as {
project: { id: string; metadata?: { baseDir?: string; importedFrom?: string } };
conversationId: string;
entryFile: string | null;
};
expect(body.project.metadata?.baseDir).toBeTruthy();
expect(body.project.metadata?.importedFrom).toBe('folder');
expect(body.conversationId).toBeTruthy();
expect(body.entryFile).toBe('index.html');
});
it('auto-detects the entry file when present', async () => {
const folder = makeFolder();
await writeFile(path.join(folder, 'index.html'), '');
const resp = await importFolder({ baseDir: folder });
const body = (await resp.json()) as { entryFile: string | null };
expect(body.entryFile).toBe('index.html');
});
it('returns null entryFile when the folder has no html file', async () => {
const folder = makeFolder();
await writeFile(path.join(folder, 'README.md'), '# hi');
const resp = await importFolder({ baseDir: folder });
const body = (await resp.json()) as { entryFile: string | null };
expect(body.entryFile).toBeNull();
});
it('rejects when baseDir is missing', async () => {
const resp = await importFolder({});
expect(resp.status).toBe(400);
const body = (await resp.json()) as { error?: { message?: string } };
expect(body.error?.message).toMatch(/baseDir required/i);
});
it('rejects when baseDir is empty', async () => {
const resp = await importFolder({ baseDir: ' ' });
expect(resp.status).toBe(400);
const body = (await resp.json()) as { error?: { message?: string } };
expect(body.error?.message).toMatch(/baseDir required/i);
});
it('rejects a relative baseDir', async () => {
const resp = await importFolder({ baseDir: 'relative/path' });
expect(resp.status).toBe(400);
const body = (await resp.json()) as { error?: { message?: string } };
expect(body.error?.message).toMatch(/absolute/i);
});
it('rejects a non-existent path', async () => {
const resp = await importFolder({ baseDir: '/this/path/should/not/exist/od-test' });
expect(resp.status).toBe(400);
const body = (await resp.json()) as { error?: { message?: string } };
expect(body.error?.message).toMatch(/not found/i);
});
it('rejects when the path points at a file', async () => {
const folder = makeFolder();
const filePath = path.join(folder, 'file.txt');
await writeFile(filePath, 'hi');
const resp = await importFolder({ baseDir: filePath });
expect(resp.status).toBe(400);
const body = (await resp.json()) as { error?: { message?: string } };
expect(body.error?.message).toMatch(/directory/i);
});
// Security: a user-controlled symlink at baseDir would let writeProjectFile
// escape the project sandbox at every later call (resolveSafe checks the
// *literal* baseDir, but the OS follows symlinks at open() time). The
// realpath() canonicalization at import collapses the chain so the stored
// baseDir == what the kernel will write to.
it('canonicalizes symlinks via realpath at import time', async () => {
const realFolder = makeFolder();
await writeFile(path.join(realFolder, 'index.html'), '');
const linkParent = makeFolder();
const linkPath = path.join(linkParent, 'sneaky');
symlinkSync(realFolder, linkPath);
const resp = await importFolder({ baseDir: linkPath });
expect(resp.status).toBe(200);
const body = (await resp.json()) as {
project: { metadata?: { baseDir?: string } };
};
// Stored baseDir must be the realpath, not the symlink path. Use
// realpath on the temp folder too since macOS prefixes /private/.
const expected = path.normalize(realFolder);
expect(body.project.metadata?.baseDir).not.toBe(linkPath);
// The stored baseDir resolves to realFolder (allowing for /private/ prefix)
expect(
body.project.metadata?.baseDir?.endsWith(path.basename(expected)),
).toBe(true);
});
// Defense against descendant-symlink escape: even after canonicalizing
// the import-time baseDir, a symlink *inside* the imported folder
// (e.g. assets -> /Users/me/.ssh) used to pass resolveSafe()'s string
// check because the literal path stayed under baseDir, but the OS
// followed the link at open() time and returned bytes from outside
// the project. resolveSafeReal() canonicalizes each read/write/delete,
// so any link reaching outside the project root is refused with a
// 4xx instead of an exfiltration channel.
// Defense against client-supplied baseDir on the generic create path:
// /api/import/folder owns the realpath() + RUNTIME_DATA_DIR reentry
// checks. POST /api/projects (and PATCH) must refuse a metadata.baseDir
// payload outright, otherwise an attacker bypasses the import-time
// sandbox guards.
it('rejects baseDir on the generic POST /api/projects', async () => {
const resp = await fetch(`${baseUrl}/api/projects`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: `tmp-${Date.now()}`,
name: 'sneaky',
metadata: { kind: 'prototype', baseDir: '/etc' },
}),
});
expect(resp.status).toBe(400);
const body = (await resp.json()) as { error?: { message?: string } };
expect(body.error?.message).toMatch(/baseDir.*import\/folder/i);
});
// Same defense extended to the archive endpoint. resolveSafe() at the
// archive root only did string-prefix validation; a directory symlink
// like `docs -> /Users/me/.ssh` would pass and collectArchiveEntries()
// would zip files outside the imported folder. resolveSafeReal() now
// canonicalizes the archive root before walking it.
it('refuses archive root that resolves outside the imported folder', async () => {
const real = makeFolder();
await writeFile(path.join(real, 'index.html'), '<!doctype html>');
try {
symlinkSync('/etc', path.join(real, 'docs'));
} catch {
return;
}
const importResp = await importFolder({ baseDir: real });
const { project } = (await importResp.json()) as { project: { id: string } };
const archive = await fetch(
`${baseUrl}/api/projects/${project.id}/archive?root=docs`,
);
expect(archive.status).toBe(400);
});
// Regression for the patch-metadata wipe. updateProject() replaces
// metadata wholesale, so a normal UI patch that omits baseDir would
// silently detach the project from its imported folder. Verify the
// route preserves baseDir even when the incoming patch doesn't
// mention it.
it('preserves metadata.baseDir when PATCH omits it', async () => {
const real = makeFolder();
await writeFile(path.join(real, 'index.html'), '');
const importResp = await importFolder({ baseDir: real });
const { project } = (await importResp.json()) as {
project: { id: string; metadata: { baseDir: string } };
};
const originalBaseDir = project.metadata.baseDir;
expect(originalBaseDir).toBeTruthy();
// Patch unrelated metadata field. baseDir is not mentioned.
const patchResp = await fetch(`${baseUrl}/api/projects/${project.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
metadata: { kind: 'prototype', linkedDirs: [] },
}),
});
expect(patchResp.status).toBe(200);
const after = (await patchResp.json()) as {
project: { metadata: { baseDir?: string } };
};
expect(after.project.metadata.baseDir).toBe(originalBaseDir);
});
it('refuses raw reads through a descendant symlink that escapes the folder', async () => {
const real = makeFolder();
await mkdir(path.join(real, 'assets'));
// Point a symlink at /etc/hosts (always exists, harmless to read,
// but unambiguously outside the imported folder).
try {
symlinkSync('/etc/hosts', path.join(real, 'assets', 'leak.txt'));
} catch {
return;
}
const importResp = await importFolder({ baseDir: real });
expect(importResp.status).toBe(200);
const { project } = (await importResp.json()) as { project: { id: string } };
const raw = await fetch(
`${baseUrl}/api/projects/${project.id}/raw/assets/leak.txt`,
);
expect(raw.status).toBe(400);
});
it('refuses a symlink that resolves into the daemon data directory', async () => {
// Create a symlink that points into the test's RUNTIME_DATA_DIR (the
// tmpdir-based path the daemon is using). Without realpath, this would
// bypass the RUNTIME_DATA_DIR-reentry check.
const dataDir = process.env.OD_DATA_DIR;
if (!dataDir) {
// Test setup didn't pin a data dir — skip this case rather than guess.
return;
}
const linkParent = makeFolder();
const linkPath = path.join(linkParent, 'into-data');
try {
symlinkSync(dataDir, linkPath);
} catch {
// Symlink creation may fail in restricted CI environments — skip.
return;
}
const resp = await importFolder({ baseDir: linkPath });
expect(resp.status).toBe(400);
const body = (await resp.json()) as { error?: { message?: string } };
expect(body.error?.message).toMatch(/data directory/i);
});
});

View file

@ -0,0 +1,6 @@
const { contextBridge, ipcRenderer } = require('electron');
contextBridge.exposeInMainWorld('electronAPI', {
pickFolder: (): Promise<string | null> =>
ipcRenderer.invoke('dialog:pick-folder'),
});

View file

@ -1,7 +1,8 @@
import { mkdir, writeFile } from "node:fs/promises";
import { dirname, isAbsolute, resolve } from "node:path";
import { dirname, isAbsolute, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { BrowserWindow, shell } from "electron";
import { BrowserWindow, dialog, ipcMain, shell } from "electron";
const PENDING_POLL_MS = 120;
const RUNNING_POLL_MS = 2000;
@ -230,6 +231,19 @@ function attachDownloadSaveAsDialog(window: BrowserWindow): void {
}
export async function createDesktopRuntime(options: DesktopRuntimeOptions): Promise<DesktopRuntime> {
const preloadPath = join(dirname(fileURLToPath(import.meta.url)), "preload.cjs");
// ipcMain.handle() registers a handler in an internal map that is *not*
// surfaced via eventNames(); the previous `!eventNames().includes(...)`
// check was therefore always true and would throw "Attempted to register
// a second handler" on the second createDesktopRuntime() call (e.g. dev
// hot-reload). removeHandler is a no-op when nothing is registered.
ipcMain.removeHandler("dialog:pick-folder");
ipcMain.handle("dialog:pick-folder", async () => {
const result = await dialog.showOpenDialog({ properties: ["openDirectory"] });
return result.canceled || result.filePaths.length === 0 ? null : result.filePaths[0];
});
const consoleEntries: DesktopConsoleEntry[] = [];
const window = new BrowserWindow({
height: 900,
@ -240,6 +254,7 @@ export async function createDesktopRuntime(options: DesktopRuntimeOptions): Prom
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
preload: preloadPath,
},
width: 1280,
});

View file

@ -17,5 +17,5 @@
"target": "ES2024",
"types": ["node"]
},
"include": ["src/**/*.ts"]
"include": ["src/**/*.ts", "src/**/*.cts"]
}

View file

@ -34,6 +34,7 @@ import {
createProject,
deleteProject as deleteProjectApi,
importClaudeDesignZip,
importFolderProject,
listProjects,
listTemplates,
patchProject,
@ -369,6 +370,17 @@ export function App() {
});
}, []);
const handleImportFolder = useCallback(async (baseDir: string) => {
const result = await importFolderProject({ baseDir });
if (!result) return;
setProjects((curr) => [result.project, ...curr.filter((p) => p.id !== result.project.id)]);
navigate({
kind: 'project',
projectId: result.project.id,
fileName: result.entryFile,
});
}, []);
const handleOpenProject = useCallback((id: string) => {
navigate({ kind: 'project', projectId: id, fileName: null });
}, []);
@ -560,6 +572,7 @@ export function App() {
loading={bootstrapping}
onCreateProject={handleCreateProject}
onImportClaudeDesign={handleImportClaudeDesign}
onImportFolder={handleImportFolder}
onOpenProject={handleOpenProject}
onOpenLiveArtifact={handleOpenLiveArtifact}
onDeleteProject={handleDeleteProject}

View file

@ -52,6 +52,7 @@ interface Props {
loading?: boolean;
onCreateProject: (input: CreateInput & { pendingPrompt?: string }) => void;
onImportClaudeDesign: (file: File) => Promise<void> | void;
onImportFolder?: (baseDir: string) => Promise<void> | void;
onOpenProject: (id: string) => void;
onOpenLiveArtifact: (projectId: string, artifactId: string) => void;
onDeleteProject: (id: string) => void;
@ -229,6 +230,7 @@ export function EntryView({
loading = false,
onCreateProject,
onImportClaudeDesign,
onImportFolder,
onOpenProject,
onOpenLiveArtifact,
onDeleteProject,
@ -510,6 +512,7 @@ export function EntryView({
promptTemplates={promptTemplates}
onCreate={handleCreate}
onImportClaudeDesign={onImportClaudeDesign}
onImportFolder={onImportFolder}
mediaProviders={config.mediaProviders}
connectors={connectors}
connectorsLoading={connectorsLoading}

View file

@ -1,5 +1,14 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import type { ConnectorDetail } from '@open-design/contracts';
declare global {
interface Window {
electronAPI?: {
pickFolder: () => Promise<string | null>;
};
}
}
import { useT } from '../i18n';
import type { Dict } from '../i18n/types';
import { fetchPromptTemplate } from '../providers/registry';
@ -57,6 +66,7 @@ interface Props {
promptTemplates: PromptTemplateSummary[];
onCreate: (input: CreateInput) => void;
onImportClaudeDesign?: (file: File) => Promise<void> | void;
onImportFolder?: (baseDir: string) => Promise<void> | void;
mediaProviders?: Record<string, MediaProviderCredentials>;
connectors?: ConnectorDetail[];
connectorsLoading?: boolean;
@ -105,6 +115,7 @@ export function NewProjectPanel({
promptTemplates,
onCreate,
onImportClaudeDesign,
onImportFolder,
mediaProviders,
connectors,
connectorsLoading = false,
@ -114,6 +125,8 @@ export function NewProjectPanel({
const t = useT();
const importInputRef = useRef<HTMLInputElement | null>(null);
const [importing, setImporting] = useState(false);
const [baseDir, setBaseDir] = useState('');
const [importingFolder, setImportingFolder] = useState(false);
const [tab, setTab] = useState<CreateTab>('prototype');
const tabsRef = useRef<HTMLDivElement | null>(null);
const [tabScroll, setTabScroll] = useState({ left: false, right: false });
@ -356,6 +369,29 @@ export function NewProjectPanel({
}
}
const hasElectronPicker =
typeof window !== 'undefined' && typeof window.electronAPI?.pickFolder === 'function';
async function handleOpenFolder() {
if (!onImportFolder) return;
let pathToOpen: string;
if (hasElectronPicker) {
const picked = await window.electronAPI!.pickFolder();
if (!picked) return;
pathToOpen = picked;
} else {
const trimmed = baseDir.trim();
if (!trimmed) return;
pathToOpen = trimmed;
}
setImportingFolder(true);
try {
await onImportFolder(pathToOpen);
} finally {
setImportingFolder(false);
}
}
return (
<div className="newproj" data-testid="new-project-panel">
<div className={`newproj-tabs-shell${tabScroll.left ? ' can-left' : ''}${tabScroll.right ? ' can-right' : ''}`}>
@ -567,6 +603,30 @@ export function NewProjectPanel({
</button>
</>
) : null}
{onImportFolder ? (
<div className="newproj-open-folder">
{!hasElectronPicker ? (
<input
type="text"
className="newproj-folder-input"
placeholder="/path/to/project"
value={baseDir}
onChange={(e) => setBaseDir(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') void handleOpenFolder(); }}
disabled={importingFolder}
/>
) : null}
<button
type="button"
className="ghost newproj-import"
disabled={(!hasElectronPicker && !baseDir.trim()) || importingFolder}
onClick={() => void handleOpenFolder()}
>
<Icon name="folder" size={13} />
<span>{importingFolder ? 'Opening…' : 'Open folder'}</span>
</button>
</div>
) : null}
</div>
<div className="newproj-footer">{t('newproj.privacyFooter')}</div>
</div>

View file

@ -719,6 +719,12 @@ export const id: Dict = {
'fileViewer.deployToVercel': 'Deploy ke Vercel',
'fileViewer.redeployToVercel': 'Deploy ulang ke Vercel',
'fileViewer.deployingToVercel': 'Deploying ke Vercel...',
'fileViewer.deployProviderLabel': 'Provider',
'fileViewer.vercelProvider': 'Vercel',
'fileViewer.cloudflarePagesProvider': 'Cloudflare Pages',
'fileViewer.deployToProvider': 'Deploy ke {provider}',
'fileViewer.redeployToProvider': 'Deploy ulang ke {provider}',
'fileViewer.deployingToProvider': 'Deploying ke {provider}...',
'fileViewer.preparingPublicLink': 'Menyiapkan link publik...',
'fileViewer.copyDeployLink': 'Salin link deploy',
'fileViewer.deployModalTitle': 'Deploy ke Vercel',
@ -728,13 +734,25 @@ export const id: Dict = {
'fileViewer.vercelTokenPlaceholder': 'Tempel token Vercel',
'fileViewer.vercelTokenReuseHint': 'Token disimpan lokal untuk deploy berikutnya.',
'fileViewer.vercelTokenRequired': 'Token Vercel wajib diisi.',
'fileViewer.cloudflareApiToken': 'Token API Cloudflare',
'fileViewer.cloudflareApiTokenGetLink': 'Buat token Cloudflare',
'fileViewer.cloudflareApiTokenPlaceholder': 'Tempel token API Cloudflare',
'fileViewer.cloudflareApiTokenReuseHint': 'Token API Cloudflare yang tersimpan akan digunakan. Masukkan token baru untuk menggantinya.',
'fileViewer.cloudflareApiTokenRequired': 'Token API Cloudflare wajib diisi.',
'fileViewer.cloudflareApiTokenScopeHint': 'Token memerlukan Account: Cloudflare Pages: Edit dan akses baca akun.',
'fileViewer.vercelTeamId': 'Team ID Vercel',
'fileViewer.vercelTeamSlug': 'Team slug Vercel',
'fileViewer.cloudflareAccountId': 'Account ID',
'fileViewer.cloudflareAccountIdHint': 'Wajib. Temukan account ID di dashboard Cloudflare.',
'fileViewer.cloudflareAccountIdRequired': 'Account ID Cloudflare wajib diisi.',
'fileViewer.optional': 'opsional',
'fileViewer.vercelPreviewOnly': 'Buat preview deployment saja',
'fileViewer.cloudflarePagesPreviewHint': 'Deploy Cloudflare Pages menggunakan Direct Upload.',
'fileViewer.savingConfig': 'Menyimpan konfigurasi...',
'fileViewer.deployConfigSaveFailed': 'Gagal menyimpan konfigurasi deploy.',
'fileViewer.deployFailed': 'Deploy gagal.',
'fileViewer.deployProviderConfigSaveFailed': 'Gagal menyimpan pengaturan {provider}.',
'fileViewer.deployProviderFailed': 'Deploy {provider} gagal. Periksa pengaturan lalu coba lagi.',
'fileViewer.deployResultLabel': 'Hasil deploy',
'fileViewer.deployLinkPreparingLabel': 'Link deploy sedang disiapkan',
'fileViewer.deployLinkDelayed': 'Link publik belum siap. Coba lagi sebentar lagi.',

View file

@ -2266,6 +2266,31 @@ code {
display: block;
flex: 0 0 auto;
}
.newproj-open-folder {
display: flex;
flex-direction: column;
gap: 6px;
margin-top: 2px;
}
.newproj-folder-input {
width: 100%;
padding: 8px 10px;
font-size: 12px;
font-family: var(--font-mono, monospace);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg-input, var(--bg));
color: var(--text);
box-sizing: border-box;
}
.newproj-folder-input:focus {
outline: none;
border-color: var(--accent);
}
.newproj-folder-input::placeholder {
color: var(--text-muted);
opacity: 0.7;
}
.newproj-footer {
padding: 0 18px 16px;
font-size: 11px;

View file

@ -5,6 +5,7 @@
// These helpers fail soft (returning null / [] on transport errors) so
// the UI can stay rendered when the daemon is briefly unreachable.
import type { ImportFolderRequest, ImportFolderResponse } from '@open-design/contracts';
import type {
ChatMessage,
Conversation,
@ -57,6 +58,22 @@ export async function createProject(input: {
}
}
export async function importFolderProject(
input: ImportFolderRequest,
): Promise<ImportFolderResponse | null> {
try {
const resp = await fetch('/api/import/folder', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
});
if (!resp.ok) return null;
return (await resp.json()) as ImportFolderResponse;
} catch {
return null;
}
}
export async function importClaudeDesignZip(
file: File,
): Promise<{ project: Project; conversationId: string; entryFile: string } | null> {

View file

@ -263,12 +263,39 @@ GET /api/skills
GET /api/design-systems
GET /api/projects
POST /api/projects
POST /api/import/folder # see Folder import
GET /api/projects/:id/files
POST /api/projects/:id/upload
POST /api/chat -> text/event-stream
POST /api/artifacts/save
```
### Folder import
`POST /api/import/folder` creates a project rooted at an existing local
folder instead of the default `.od/projects/<id>/`. The submitted
`baseDir` is stored on `metadata.baseDir` and OD reads / writes directly
inside it — there is no copy or shadow tree. The user owns the workspace
and is responsible for their own version control (git, time machine,
etc.), mirroring how Cursor / Claude Code / Aider behave.
Safety:
- The submitted `baseDir` is canonicalized via `realpath()` before
storage, so user-controlled symlinks cannot redirect later writes.
- Standard `resolveSafe` / `sanitizePath` checks apply on every write —
`metadata.baseDir` only changes the project root, not the bounds check.
- Imports inside `RUNTIME_DATA_DIR` (the daemon's own data directory) are
refused after symlink resolution.
- The file panel hides the conventional build / install dirs
(`node_modules .git dist build .next .nuxt .turbo .cache .output out
coverage __pycache__ .venv vendor target .od .tmp`) so the listing
stays focused on design content.
Request / response types: `ImportFolderRequest`, `ImportFolderResponse`
in `@open-design/contracts`.
Full schema in [`schemas/protocol.md`](schemas/protocol.md) (TODO: write).
## 8. Deployment

View file

@ -0,0 +1,114 @@
# RFC: Auto-detect & launch dev server for folder-imported projects
**Status:** Draft (for nexu-io/open-design Issue, post #597 merge)
**Author:** @infinity-nft
**Related:** #597 (folder import — single mode)
## Summary
When a user imports an existing local folder as a project (#597), the
folder is often a real frontend project (Next.js / Vite / CRA / Astro /
plain `npm run dev`). Currently OD opens such projects as a static file
panel — the user has to launch the dev server themselves in another
terminal and then iframe-load it manually.
This RFC proposes letting OD detect a dev-server config from the
imported folder's `package.json` and offer to launch it inline, so the
preview pane shows the live app instead of static HTML.
## Problem
After landing #597, the user picks `~/projects/marketing-site/` (a
Next.js app). What they see in OD's preview pane:
- File panel with `next.config.js`, `pages/index.tsx`, etc.
- No way to render the app — it needs `next dev` running on port 3000.
What they want:
- Click "Open folder" → OD detects `next dev` script → asks "Launch
dev server?" → preview pane shows the live app at localhost:3000
inside the iframe.
This is the bridge that makes folder-import useful for real workflows
(generating components, iterating on UI), not just static HTML.
## Proposed behavior
### Detection (no new endpoint, runs at import time)
The import endpoint scans `<baseDir>/package.json` (and a few common
subdirs: `frontend/`, `client/`, `web/`, `app/`, `packages/web/`) for
a runnable script:
1. `pkg.scripts.dev` if present, else `pkg.scripts.start`
2. Extract a port from `--port N` / `-p N` flags in the script string
3. Fall back to framework defaults: `next` → 3000, `vite` → 5173,
`react-scripts` → 3000, `astro` → 4321
4. Detect package manager from lockfiles (`pnpm-lock.yaml` → pnpm,
`yarn.lock` → yarn, default → npm)
If detected, stamp `metadata.devServer = { script, cwd, port }` on the
project at import time. Otherwise no devServer field — project behaves
exactly as a static file panel.
### Launching (lifecycle endpoints)
- `POST /api/projects/:id/dev-server/start` — spawn the configured
script via `pkg-manager run dev-script` in `<baseDir>/<cwd>`. Track
the child process in an in-memory map keyed by project id.
- `POST /api/projects/:id/dev-server/stop` — kill the tracked child.
- Daemon `process.on('exit')` / SIGINT / SIGTERM kills all running
dev servers on shutdown.
The endpoint waits for the configured port to respond (with a 30 s
timeout) before resolving, so the UI can show a clear "starting…" /
"ready" / "failed" state.
### UI
- New project section / Project view: when `metadata.devServer` is
set, render the preview pane as an iframe pointed at
`http://localhost:${devServer.port}` (auto-started on project open).
- Toolbar gets `Stop` / `Start` symmetric controls (when stopped, file
panel + a banner with Start button; when running, iframe + Stop).
- No devServer config detected → behaves like today (file panel only).
## Open questions
1. **Permission model** — running `npm install` + `npm run dev` on a
user folder is more privileged than reading files. Should OD prompt
on first launch ("This folder will run `pnpm dev` — proceed?") with
per-project consent, similar to VS Code's "trust" prompt?
2. **Auto-install missing dependencies** — if `node_modules` is missing,
should OD offer to run `pnpm install` first? Or fail clearly and let
the user run it themselves?
3. **Port conflicts** — if 3000 is taken, should OD pick the next free
port, or refuse and surface the conflict? Vite has its own
auto-increment; matching that would be least surprising.
4. **Resource cleanup on project close** — kill the dev server when the
user navigates away, or keep it running until daemon shutdown? VS
Code keeps tasks alive; closing == background. Mirroring that feels
right.
5. **Subprocess output streaming** — should the daemon stream the dev
server's stdout/stderr to the UI (so users see Next/Vite errors
inline) or just spawn detached?
6. **Non-folder projects** — current OD generates HTML files in
`.od/projects/<id>/`. Should those projects also get a "launch dev
server" affordance if they happen to have a `package.json`? Or is
this strictly a folder-import feature?
## Implementation notes (from working prototype)
I have this implemented in my fork — same single-mode philosophy as
#597 (no two paths, no opinions about git). Detection logic is ~30
lines, lifecycle endpoints + child-process registry ~80 lines, UI
wiring ~100 lines. Happy to adapt to whichever direction the design
discussion lands.
## Out of scope
- HMR support (dev servers handle their own HMR; OD just iframes)
- Production builds (`pnpm build` is the user's concern)
- Custom proxy / rewrites between OD daemon and dev server
- Authenticated dev servers (e.g. behind a login)

View file

@ -62,9 +62,14 @@ export interface ProjectMetadata {
templateId?: string;
templateLabel?: string;
inspirationDesignSystemIds?: string[];
importedFrom?: 'claude-design' | string;
importedFrom?: 'claude-design' | 'folder' | string;
entryFile?: string;
sourceFileName?: string;
// Folder-import (#597): when set, the project's files live under this
// absolute path instead of .od/projects/<id>/. OD reads and writes
// directly inside the user's folder. Stored as the realpath() result so
// symlinks can't redirect writes after import time.
baseDir?: string;
imageModel?: string;
imageAspect?: MediaAspect;
imageStyle?: string;
@ -140,6 +145,23 @@ export interface CreateProjectResponse extends ProjectResponse {
conversationId?: 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.
// The user owns version control; OD does not snapshot or copy.
export interface ImportFolderRequest {
baseDir: string;
name?: string;
skillId?: string | null;
designSystemId?: string | null;
}
export interface ImportFolderResponse {
project: Project;
conversationId: string;
entryFile: string | null;
}
export interface ConversationsResponse {
conversations: Conversation[];
}