@jxsuite/server 4.5.1 → 6.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jxsuite/server",
3
- "version": "4.5.1",
3
+ "version": "6.0.1",
4
4
  "description": "Jx development server with live reload, server-side proxy, and studio integration",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -41,25 +41,25 @@
41
41
  "upgrade": "bun update --latest && bun install"
42
42
  },
43
43
  "dependencies": {
44
- "@jxsuite/ai": "^0.39.3",
45
- "@jxsuite/catalog": "^0.1.0",
46
- "@jxsuite/collab": "^0.11.1",
47
- "@jxsuite/compiler": "^5.1.3",
48
- "@jxsuite/create": "^1.3.14",
49
- "@jxsuite/import": "^0.40.10",
50
- "@jxsuite/protocol": "^2.6.1",
51
- "@jxsuite/runtime": "^4.1.0",
52
- "@jxsuite/schema": "^3.0.0",
53
- "@jxsuite/site": "^2.0.4",
54
- "@jxsuite/starters": "^2.0.1",
44
+ "@jxsuite/ai": "^0.39.5",
45
+ "@jxsuite/catalog": "^0.1.2",
46
+ "@jxsuite/collab": "^0.11.2",
47
+ "@jxsuite/compiler": "^6.0.2",
48
+ "@jxsuite/create": "^1.3.17",
49
+ "@jxsuite/import": "^0.40.13",
50
+ "@jxsuite/protocol": "^4.0.0",
51
+ "@jxsuite/runtime": "^5.1.0",
52
+ "@jxsuite/schema": "^4.0.0",
53
+ "@jxsuite/site": "^2.1.0",
54
+ "@jxsuite/starters": "^3.0.1",
55
55
  "chokidar": "^5.0.0",
56
- "kysely": "^0.29.6",
56
+ "kysely": "^0.29.7",
57
57
  "oxfmt": "^0.73.0",
58
58
  "zod": "^4.6.7"
59
59
  },
60
60
  "devDependencies": {
61
- "@jxsuite/auth": "^0.6.2",
62
- "@jxsuite/connector": "^0.6.2",
63
- "@jxsuite/parser": "^2.0.1"
61
+ "@jxsuite/auth": "^0.7.0",
62
+ "@jxsuite/connector": "^0.7.0",
63
+ "@jxsuite/parser": "^4.0.1"
64
64
  }
65
65
  }
package/src/import-api.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  * Handles POST /__studio/import-site: runs @jxsuite/import's importSite pipeline in the backend
5
5
  * (headless Chrome + filesystem) and streams progress back as NDJSON lines over the POST response —
6
6
  * the same streaming-fetch transport the AI chat proxy uses, so every platform (dev server,
7
- * Electrobun services server, Chromium loopback server) can mount it unchanged.
7
+ * Electron hub's loopback servers, Chromium loopback server) can mount it unchanged.
8
8
  *
9
9
  * Stream protocol (one JSON object per line):
10
10
  *
package/src/packages.ts CHANGED
@@ -88,8 +88,8 @@ export function dependenciesNeedInstall(root: string): boolean {
88
88
 
89
89
  /**
90
90
  * Path to the bun executable. Uses the bun running the current process (`process.execPath`) so a
91
- * packaged desktop app spawns the bun bundled with its Electrobun distribution rather than relying
92
- * on a system-wide `bun` in `$PATH` (which end users may not have). Falls back to a PATH lookup if
91
+ * packaged desktop app spawns the bun bundled with its Electron distribution rather than relying on
92
+ * a system-wide `bun` in `$PATH` (which end users may not have). Falls back to a PATH lookup if
93
93
  * `execPath` is somehow unset.
94
94
  */
95
95
  export function bunExecutable(): string {
@@ -1,7 +1,6 @@
1
1
  /**
2
2
  * Shared project server factory. Generalizes the chromium variant's ad-hoc in-process Bun.serve
3
- * into one createProjectServer() used by the chromium desktop launcher (and, in Phase 7,
4
- * electrobun).
3
+ * into one createProjectServer() used by the chromium desktop launcher and the Electron hub.
5
4
  *
6
5
  * It serves three surfaces from a single loopback-bound Bun.serve:
7
6
  *
@@ -77,9 +76,10 @@ export interface ProjectServerSession {
77
76
 
78
77
  export interface CreateProjectServerOptions {
79
78
  /**
80
- * Resolve the session for a given window id (the `win` query param / stashed socket data).
81
- * chromium passes `() => defaultSession`; electrobun (Phase 7) swaps it for a per-window lookup.
82
- * A missing/unknown window returns `null`, which the routes treat as a 404 fail-closed.
79
+ * Resolve the session for a given window id (the `win` query param / stashed socket data). the
80
+ * single-window launcher passes `() => defaultSession`; the Electron hub swaps it for a
81
+ * per-window lookup. A missing/unknown window returns `null`, which the routes treat as a 404
82
+ * fail-closed.
83
83
  */
84
84
  resolveSession: (winId: string | null) => ProjectServerSession | null;
85
85
  /** Absolute dir holding the studio shell + canvas.html + dist/. */
Binary file
@@ -257,9 +257,12 @@ export interface RemapCtx {
257
257
  * Recompute a single path reference for a rename, preserving its original style. Returns the new
258
258
  * string, or null when nothing changes.
259
259
  *
260
- * `rootRelativeBare` makes a bare value (no "./" or "../") resolve against the project root rather
261
- * than the document directory — used for `$layout`, which the compiler resolves against projectRoot
262
- * (layout-resolver.ts), and for site-relative conventions.
260
+ * `rootRelativeBare` makes the value resolve against the project root rather than the document
261
+ * directory, in every spelling: `./layouts/base.json` in `pages/blog/[slug].json` names
262
+ * `layouts/base.json`, because the compiler resolves `$layout` with `resolve(projectRoot, ref)`
263
+ * (layout-resolver.ts). Treating a `./` value as document-relative there missed every page's own
264
+ * `$layout`, so a renamed layout left them pointing at the old name. Used for `$layout` and for
265
+ * `build.copy` keys, which are project paths too.
263
266
  */
264
267
  export function rewriteRef(
265
268
  cls: { core: string; suffix: string; rooted: boolean },
@@ -270,10 +273,8 @@ export function rewriteRef(
270
273
  if (rooted) {
271
274
  return rewriteSiteUrl(core, suffix, ctx);
272
275
  }
273
- const startsRel = core.startsWith("./") || core.startsWith("../");
274
- const useRoot = rootRelativeBare && !startsRel;
275
- const baseOld = useRoot ? ctx.root : ctx.docOldDir;
276
- const baseNew = useRoot ? ctx.root : ctx.docNewDir;
276
+ const baseOld = rootRelativeBare ? ctx.root : ctx.docOldDir;
277
+ const baseNew = rootRelativeBare ? ctx.root : ctx.docNewDir;
277
278
 
278
279
  const targetOld = joinPosix(baseOld, core);
279
280
  const targetNew = remapTarget(targetOld, ctx.oldAbs, ctx.newAbs);
package/src/resolve.ts CHANGED
@@ -4,7 +4,7 @@ import { siteBasePath } from "@jxsuite/schema/asset-paths";
4
4
  import { dirname, isAbsolute, relative, resolve } from "node:path";
5
5
  import { errorMessage, parseClassDef } from "@jxsuite/schema/parse";
6
6
  import { isFunction, isNonNullObject, isString } from "@jxsuite/schema/guards";
7
- import { existsSync, readFileSync, statSync } from "node:fs";
7
+ import { readFileSync, statSync } from "node:fs";
8
8
  import { createRequire } from "node:module";
9
9
  import { projectDevEnv } from "./dev-vars.ts";
10
10
  import { containedPath } from "./net-guard.ts";
@@ -14,6 +14,7 @@ import { loadAssetMounts } from "@jxsuite/compiler/asset-mounts";
14
14
  import type { AssetMount } from "@jxsuite/schema/asset-paths";
15
15
  import type { DynamicMethod } from "@jxsuite/runtime/types";
16
16
  import type { ProjectSections } from "@jxsuite/compiler/project-sections";
17
+ import type { ExtensionRegistry } from "@jxsuite/schema/extension-registry";
17
18
  import type {
18
19
  JsonObject,
19
20
  JsonPrimitive,
@@ -64,11 +65,18 @@ interface ModuleNamespace extends ExportedObject {
64
65
  */
65
66
  type ProjectContext = ProjectSections & { config: ProjectConfig; root: string };
66
67
 
67
- /** A cached project context, its extension asset mounts, and the `project.json` mtime it is for. */
68
+ /**
69
+ * One `project.json` generation: its config, extension registry and asset mounts, loaded together
70
+ * because they are cheap, and the extension-loaded sections, loaded only when a class asks for
71
+ * them.
72
+ */
68
73
  interface ProjectEntry {
69
74
  mtime: number;
70
- context: ProjectContext;
75
+ config: ProjectConfig;
76
+ registry: ExtensionRegistry;
71
77
  mounts: AssetMount[];
78
+ /** The full `_project` context, started by the first {@link loadProjectContext} and shared. */
79
+ context?: Promise<ProjectContext> | undefined;
72
80
  }
73
81
 
74
82
  /** A value the resolve proxy hands a class constructor under one config key. */
@@ -173,8 +181,19 @@ function baseDirectory($base: string, root: string, projectRoot: string): string
173
181
  return resolve(root, `.${urlDir}`);
174
182
  }
175
183
 
176
- /** Per-project context cache, invalidated when project.json changes on disk. */
177
- const projectContextCache = new Map<string, ProjectEntry>();
184
+ /**
185
+ * Per-project cache, invalidated when project.json changes on disk. It holds the PROMISE, keyed by
186
+ * the mtime it was started for, so concurrent requests on a cold cache share one load.
187
+ *
188
+ * Both halves of that are load-bearing. Every project file the canvas fetches asks for the asset
189
+ * mounts, and the canvas fetches a document's `$elements` in parallel: before the promise was
190
+ * cached, each of them started its own load, so a page with 25 components ran 25 at once. And the
191
+ * sections are not part of it: a content section loads every entry of every collection, which for a
192
+ * 910-entry knowledge base is ten seconds of parsing, so serving a JSON file next to it waited ten
193
+ * seconds for data it never reads. Both together put a component fetch past the canvas's register
194
+ * timeout on every cold open.
195
+ */
196
+ const projectEntryCache = new Map<string, { mtime: number; entry: Promise<ProjectEntry | null> }>();
178
197
 
179
198
  /**
180
199
  * Lazy-load project context (project.json + extension-loaded sections) for class instantiation.
@@ -186,32 +205,82 @@ const projectContextCache = new Map<string, ProjectEntry>();
186
205
  */
187
206
  async function loadProjectContext(projectRoot: string): Promise<ProjectContext | null> {
188
207
  const entry = await loadProjectEntry(projectRoot);
189
- return entry?.context ?? null;
208
+ if (!entry) {
209
+ return null;
210
+ }
211
+ entry.context ??= loadProjectSections(projectRoot, entry.config, entry.registry, {
212
+ lazy: true,
213
+ }).then((sections) => ({ config: entry.config, root: projectRoot, ...sections }));
214
+ try {
215
+ return await entry.context;
216
+ } catch {
217
+ // A failed load is not remembered: the next class to ask tries again.
218
+ entry.context = undefined;
219
+ return null;
220
+ }
221
+ }
222
+
223
+ /**
224
+ * Whether a class reads `_project` through its sections' on-demand methods, by declaring `static
225
+ * projectOnDemand = true` (specs/extensions.md §8). The sections here are loaded lazily, so a class
226
+ * that reads one synchronously (a `content.get(type)`) would find nothing it had not loaded; such a
227
+ * class is handed every section whole instead, exactly what it was always given.
228
+ */
229
+ function readsProjectOnDemand(ExportedClass: ExportedFunction | ExportedConstructor): boolean {
230
+ return "projectOnDemand" in ExportedClass && ExportedClass.projectOnDemand === true;
190
231
  }
191
232
 
192
- /** Load (or reuse) the cached project context plus its extension asset mounts. */
233
+ /** A section an owner loaded on demand: `complete()` makes it whole (extensions.md §8). */
234
+ interface OnDemandSection {
235
+ complete: () => Promise<void>;
236
+ }
237
+
238
+ /** Whether a project-context value is a section loaded on demand, by its `complete` method. */
239
+ function isOnDemandSection(
240
+ value: ProjectContext[string],
241
+ ): value is ProjectContext[string] & OnDemandSection {
242
+ return isNonNullObject(value) && "complete" in value && isFunction(value.complete);
243
+ }
244
+
245
+ /** Make every section of a project context whole, for a class that reads them synchronously. */
246
+ async function completeSections(context: ProjectContext): Promise<void> {
247
+ for (const value of Object.values(context)) {
248
+ if (isOnDemandSection(value)) {
249
+ await value.complete();
250
+ }
251
+ }
252
+ }
253
+
254
+ /** Load (or reuse) the cached project config, extension registry and asset mounts. */
193
255
  async function loadProjectEntry(projectRoot: string): Promise<ProjectEntry | null> {
194
256
  const projectJsonPath = resolve(projectRoot, "project.json");
195
- if (!existsSync(projectJsonPath)) {
257
+ const mtime = statSync(projectJsonPath, { throwIfNoEntry: false })?.mtimeMs;
258
+ if (mtime === undefined) {
196
259
  return null;
197
260
  }
261
+ const cached = projectEntryCache.get(projectRoot);
262
+ if (cached && cached.mtime === mtime) {
263
+ return cached.entry;
264
+ }
265
+ const entry = readProjectEntry(projectRoot, projectJsonPath, mtime);
266
+ projectEntryCache.set(projectRoot, { entry, mtime });
267
+ return entry;
268
+ }
269
+
270
+ /** Read one `project.json` generation. Never rejects: any failure answers "no project context". */
271
+ async function readProjectEntry(
272
+ projectRoot: string,
273
+ projectJsonPath: string,
274
+ mtime: number,
275
+ ): Promise<ProjectEntry | null> {
198
276
  try {
199
- const { mtimeMs } = statSync(projectJsonPath);
200
- const cached = projectContextCache.get(projectRoot);
201
- if (cached && cached.mtime === mtimeMs) {
202
- return cached;
203
- }
204
277
  /* SAFETY: trust boundary. project.json is the project's own config file, which the editor
205
278
  and `jx validate` hold to its per-project schema. Any failure reading it below is caught
206
279
  here and answers "no project context". */
207
280
  const config = JSON.parse(readFileSync(projectJsonPath, "utf8")) as ProjectConfig;
208
281
  const registry = await buildProjectExtensionRegistry(projectRoot, config);
209
- const sections = await loadProjectSections(projectRoot, config, registry);
210
282
  const { mounts } = await loadAssetMounts(registry, config, projectRoot);
211
- const context: ProjectContext = { config, root: projectRoot, ...sections };
212
- const entry: ProjectEntry = { context, mounts, mtime: mtimeMs };
213
- projectContextCache.set(projectRoot, entry);
214
- return entry;
283
+ return { config, mounts, mtime, registry };
215
284
  } catch {
216
285
  return null;
217
286
  }
@@ -229,7 +298,7 @@ async function loadProjectEntry(projectRoot: string): Promise<ProjectEntry | nul
229
298
  */
230
299
  export async function projectSiteBase(projectRoot: string): Promise<string> {
231
300
  const entry = await loadProjectEntry(projectRoot);
232
- return siteBasePath(entry?.context.config.url);
301
+ return siteBasePath(entry?.config.url);
233
302
  }
234
303
 
235
304
  /**
@@ -349,6 +418,9 @@ export async function handleResolve(
349
418
  status: 500,
350
419
  });
351
420
  }
421
+ if (config._project && !readsProjectOnDemand(ExportedClass)) {
422
+ await completeSections(config._project);
423
+ }
352
424
  /* SAFETY: trust boundary. A class file's `$implementation` names the module exporting the
353
425
  class it describes, whose constructor takes one config object (spec.md §12.3, §12.4).
354
426
  It is a function, checked above; one that is not constructible throws here, inside the
@@ -357,7 +429,10 @@ export async function handleResolve(
357
429
  return Response.json(await instanceResult(new ClassCtor(config)));
358
430
  }
359
431
 
360
- // Self-contained: construct class from schema
432
+ // Self-contained: construct class from schema. Its JSON bodies cannot await a section.
433
+ if (config._project) {
434
+ await completeSections(config._project);
435
+ }
361
436
  const DynClass = classFromSchema(classDef);
362
437
  return Response.json(await instanceResult(new DynClass(config)));
363
438
  } catch (error) {
package/src/server.ts CHANGED
@@ -554,7 +554,11 @@ export async function createDevServer(options: {
554
554
 
555
555
  // Resolve npm-style bare specifiers via node_modules.
556
556
  // Bundle on-demand so internal bare specifiers (e.g. lit/...) resolve.
557
- const resolved = resolveNpmPath(absRoot, path);
557
+ /* The open project's own packages first: it pins the versions its pages are built with,
558
+ and a project outside the served root has no other way to reach them. */
559
+ const resolved =
560
+ (activeProjectRoot ? resolveNpmPath(activeProjectRoot, path) : null) ??
561
+ resolveNpmPath(absRoot, path);
558
562
  if (resolved) {
559
563
  const cacheKey = resolved;
560
564
  if (!bundleCache.has(cacheKey)) {
package/src/studio-api.ts CHANGED
@@ -10,7 +10,7 @@
10
10
  import { basename, dirname, extname, isAbsolute, join, relative, resolve } from "node:path";
11
11
  import { componentMetaFrom } from "@jxsuite/schema/component-meta";
12
12
  import { errorMessage } from "@jxsuite/schema/parse";
13
- import { mkdir, readFile, readdir, rename, stat, unlink, writeFile } from "node:fs/promises";
13
+ import { mkdir, readFile, readdir, rename, rmdir, stat, unlink, writeFile } from "node:fs/promises";
14
14
  import { existsSync, readFileSync, statSync, unlinkSync } from "node:fs";
15
15
  import { homedir } from "node:os";
16
16
  import { LOCATION_ID_FILE } from "@jxsuite/protocol/routes";
@@ -200,9 +200,43 @@ export function isOwnedProjectDir(dir: string): boolean {
200
200
  return existsSync(resolve(dir, "project.json"));
201
201
  }
202
202
 
203
+ /**
204
+ * Whether the project at `dir` is under version control of its own, rather than a stranger inside
205
+ * somebody else's checkout.
206
+ *
207
+ * `git` answers for the nearest enclosing work tree, which is not the same question. A project
208
+ * copied into a folder another repository IGNORES (a scratch copy under a monorepo's `.cache/`, a
209
+ * download into a dotfiles repo's ignored `tmp/`) is "inside a work tree" by git's account, yet
210
+ * none of its files can ever be committed there: Publish would list the enclosing repository's
211
+ * branches and commits, and opening the project would fetch and pull a repository the user never
212
+ * chose. Such a project is therefore not a repository, and offers to initialise one. A project that
213
+ * is a TRACKED subfolder of a larger repository (a site inside a monorepo) still is one.
214
+ *
215
+ * @param {string} dir - The project root
216
+ * @returns {Promise<boolean>}
217
+ */
218
+ export async function projectHasRepository(dir: string): Promise<boolean> {
219
+ const exitOf = async (args: string[]) => {
220
+ const proc = Bun.spawn(["git", ...args], { cwd: dir, stderr: "ignore", stdout: "ignore" });
221
+ return await proc.exited;
222
+ };
223
+ if ((await exitOf(["rev-parse", "--is-inside-work-tree"])) !== 0) {
224
+ return false;
225
+ }
226
+ // `check-ignore` exits 0 when the path IS ignored, 1 when it is not.
227
+ return (await exitOf(["check-ignore", "-q", "."])) !== 0;
228
+ }
229
+
203
230
  /**
204
231
  * Parse raw `git status --porcelain=v2 --branch` output into structured data.
205
232
  *
233
+ * The route runs it with `-z`, and that is the form that matters: without `-z` git C-quotes every
234
+ * path holding a byte outside printable ASCII (`72” Grapple.md` arrives as `"72\342\200\235
235
+ * Grapple.md"`), so Studio listed octal escapes inside stray quotes and asked git to stage a file
236
+ * that does not exist. With `-z` each record ends in NUL and every path is verbatim; a rename's
237
+ * original path is the record after it, which is skipped. Newline-separated output is still read,
238
+ * for callers and tests that hand it plain text.
239
+ *
206
240
  * @param {string} out
207
241
  * @returns {{
208
242
  * branch: string;
@@ -220,7 +254,10 @@ export function parseGitStatus(out: string) {
220
254
  /** @type {{ path: string; status: string; staged: boolean }[]} */
221
255
  const files = [];
222
256
 
223
- for (const line of out.split("\n")) {
257
+ const nulTerminated = out.includes("\0");
258
+ const records = out.split(nulTerminated ? "\0" : "\n");
259
+ for (let index = 0; index < records.length; index += 1) {
260
+ const line = records[index]!;
224
261
  if (!line) {
225
262
  continue;
226
263
  }
@@ -239,7 +276,11 @@ export function parseGitStatus(out: string) {
239
276
  const stagedCode = xy[0]!;
240
277
  const unstagedCode = xy[1]!;
241
278
  let filePath;
242
- if (line.startsWith("2 ")) {
279
+ if (line.startsWith("2 ") && nulTerminated) {
280
+ // `2 XY sub mH mI mW hH hI Xscore path`, then the original path as its own record.
281
+ filePath = parts.slice(9).join(" ");
282
+ index += 1;
283
+ } else if (line.startsWith("2 ")) {
243
284
  const tabIdx = line.indexOf("\t");
244
285
  const pathPart = line.slice(tabIdx + 1);
245
286
  filePath = pathPart.split("\t").pop() || "";
@@ -771,16 +812,15 @@ export async function handleStudioApi(
771
812
  return problem("invalidRequest", errorMessage(error));
772
813
  }
773
814
 
774
- /** Report a path relative to the active project root (or server root as fallback). */
775
- const reportRelative = (fp: string) => {
776
- if (activeProjectRoot) {
777
- const rel = relative(activeProjectRoot, fp);
778
- if (!rel.startsWith("..")) {
779
- return fwd(rel);
780
- }
781
- }
782
- return fwd(relative(root, fp));
783
- };
815
+ /*
816
+ * Every path out is relative to the active project root (§4.1), a path BESIDE the project
817
+ * included: `../../docs/start`, not `docs/start`. A collection may live outside its project
818
+ * (`sites/jxsuite.com` declares `content.docs.source: "../../docs"`), and a server-root-relative
819
+ * reply for it reads as project-relative to the client, which resolves it against the server
820
+ * root again on the next call and names a directory nobody has. A recursive walk of that
821
+ * collection stopped at its first level, with a 500 for every subdirectory.
822
+ */
823
+ const reportRelative = (fp: string) => fwd(relative(activeProjectRoot ?? root, fp));
784
824
 
785
825
  try {
786
826
  if (pattern) {
@@ -821,7 +861,11 @@ export async function handleStudioApi(
821
861
  }
822
862
  return Response.json(byPathOrder(files));
823
863
  } catch (error) {
824
- return problem("internalError", errorMessage(error));
864
+ // A directory that does not exist is a 404, not a server fault: a client probing whether a
865
+ // Folder exists yet (Add new's collision check) asks exactly this.
866
+ return isNotFound(error)
867
+ ? problem("notFound", errorMessage(error))
868
+ : problem("internalError", errorMessage(error));
825
869
  }
826
870
  }
827
871
 
@@ -1296,9 +1340,18 @@ export async function handleStudioApi(
1296
1340
  { headers: { "Cache-Control": "public, max-age=5" } },
1297
1341
  );
1298
1342
  } catch (error) {
1299
- return isNotFound(error)
1300
- ? problem("notFound", "Not found")
1301
- : problem("internalError", errorMessage(error));
1343
+ if (!isNotFound(error)) {
1344
+ return problem("internalError", errorMessage(error));
1345
+ }
1346
+ /* `optional=1`: the caller is asking whether the file is there, and "no" is an ordinary
1347
+ answer rather than a failure — the same reasoning that makes a missing directory's listing
1348
+ a 404 instead of a 500, one step further. A per-directory `.gitignore` probe is the case
1349
+ that needs it: a listing hides dotfiles, so the only way to learn a directory has none is
1350
+ to ask, and a 404 per directory lands in the browser console as a failed request. Never
1351
+ cached, so a file created a moment later is read rather than remembered as absent. */
1352
+ return url.searchParams.get("optional") === "1"
1353
+ ? Response.json({ content: null, path: fp }, { headers: { "Cache-Control": "no-store" } })
1354
+ : problem("notFound", "Not found");
1302
1355
  }
1303
1356
  }
1304
1357
 
@@ -1344,7 +1397,7 @@ export async function handleStudioApi(
1344
1397
  try {
1345
1398
  await mkdir(dirname(abs), { recursive: true });
1346
1399
  await writeFile(abs, await req.text(), "utf8");
1347
- return Response.json({ ok: true, path: fwd(relative(root, abs)) });
1400
+ return Response.json({ ok: true, path: fwd(relative(activeProjectRoot ?? root, abs)) });
1348
1401
  } catch (error) {
1349
1402
  return problem("internalError", errorMessage(error));
1350
1403
  }
@@ -1366,7 +1419,7 @@ export async function handleStudioApi(
1366
1419
  await mkdir(dirname(abs), { recursive: true });
1367
1420
  const buffer = await req.arrayBuffer();
1368
1421
  await Bun.write(abs, new Uint8Array(buffer));
1369
- return Response.json({ ok: true, path: fwd(relative(root, abs)) });
1422
+ return Response.json({ ok: true, path: fwd(relative(activeProjectRoot ?? root, abs)) });
1370
1423
  } catch (error) {
1371
1424
  return problem("internalError", errorMessage(error));
1372
1425
  }
@@ -1385,11 +1438,17 @@ export async function handleStudioApi(
1385
1438
  return new Response(errorMessage(error), { status: 400 });
1386
1439
  }
1387
1440
  try {
1388
- await unlink(abs);
1389
- return Response.json({ ok: true, path: fwd(relative(root, abs)) });
1441
+ /* A directory is removed only when it is EMPTY (`rmdir`), never recursively: a folder of
1442
+ files is a decision about every file in it, which the client makes one file at a time. */
1443
+ const entry = await stat(abs);
1444
+ await (entry.isDirectory() ? rmdir(abs) : unlink(abs));
1445
+ return Response.json({ ok: true, path: fwd(relative(activeProjectRoot ?? root, abs)) });
1390
1446
  } catch (error) {
1391
- return isNotFound(error)
1392
- ? problem("notFound", "Not found")
1447
+ if (isNotFound(error)) {
1448
+ return problem("notFound", "Not found");
1449
+ }
1450
+ return isNonNullObject(error) && "code" in error && error.code === "ENOTEMPTY"
1451
+ ? problem("conflict", "The folder is not empty.")
1393
1452
  : problem("internalError", errorMessage(error));
1394
1453
  }
1395
1454
  }
@@ -1774,7 +1833,24 @@ export async function handleStudioApi(
1774
1833
  // ── Git endpoints ──────────────────────────────────────────────────────────
1775
1834
 
1776
1835
  if (path.startsWith("/__studio/git/")) {
1777
- const cwd = activeProjectRoot || root;
1836
+ /* THE REPOSITORY IS THE REQUEST'S, NOT THE SERVER'S. One dev server serves every Studio tab
1837
+ pointed at it, and `activeProjectRoot` is whichever project was activated last. Read from it
1838
+ alone, two sessions on two projects ran each other's git: Initialize Repository in one
1839
+ initialised the other. Every git call names its project with `?dir=`, as the file routes do,
1840
+ under the same access rule; the fallback is for a caller that names none (no project open). */
1841
+ const named = url.searchParams.get("dir");
1842
+ const cwd = named
1843
+ ? isAbsolute(named)
1844
+ ? named
1845
+ : resolve(root, named)
1846
+ : activeProjectRoot || root;
1847
+ if (named) {
1848
+ try {
1849
+ assertAccessible(cwd, root, activeProjectRoot);
1850
+ } catch (error) {
1851
+ return problem("invalidRequest", errorMessage(error));
1852
+ }
1853
+ }
1778
1854
  const gitCmd = path.slice("/__studio/git/".length);
1779
1855
 
1780
1856
  const runGit = async (args: string[]) => {
@@ -1804,15 +1880,13 @@ export async function handleStudioApi(
1804
1880
  };
1805
1881
 
1806
1882
  try {
1883
+ /* Every command but the two that make a repository acts on THE PROJECT'S repository, so one
1884
+ that has none of its own is refused rather than handed to whatever checkout encloses it
1885
+ (see `projectHasRepository`). */
1886
+ const makesRepository = gitCmd === "init" || gitCmd === "clone";
1887
+ const hasRepository = makesRepository || (await projectHasRepository(cwd));
1807
1888
  if (gitCmd === "status" && req.method === "GET") {
1808
- // Check if we're in a git repo first
1809
- const checkProc = Bun.spawn(["git", "rev-parse", "--is-inside-work-tree"], {
1810
- cwd,
1811
- stderr: "pipe",
1812
- stdout: "pipe",
1813
- });
1814
- const checkExit = await checkProc.exited;
1815
- if (checkExit !== 0) {
1889
+ if (!hasRepository) {
1816
1890
  return Response.json({
1817
1891
  ahead: 0,
1818
1892
  behind: 0,
@@ -1823,8 +1897,12 @@ export async function handleStudioApi(
1823
1897
  });
1824
1898
  }
1825
1899
 
1900
+ /* `--untracked-files=all`: every untracked FILE, not its collapsed folder. By default git
1901
+ reports a wholly untracked `content/` as one entry, so a fresh repository listed a
1902
+ handful of folders that no group, comparison or checkbox could treat as a file, and the
1903
+ count jumped to the real one the moment staging expanded them (studio.md §22.8). */
1826
1904
  const [out, remotesOut] = await Promise.all([
1827
- runGit(["status", "--porcelain=v2", "--branch"]),
1905
+ runGit(["status", "--porcelain=v2", "--branch", "-z", "--untracked-files=all"]),
1828
1906
  runGit(["remote"]),
1829
1907
  ]);
1830
1908
  const status = parseGitStatus(out);
@@ -1836,6 +1914,13 @@ export async function handleStudioApi(
1836
1914
  return Response.json(fullStatus);
1837
1915
  }
1838
1916
 
1917
+ if (!hasRepository) {
1918
+ return problem(
1919
+ "conflict",
1920
+ "This project is not a git repository of its own. Initialize one to use source control.",
1921
+ );
1922
+ }
1923
+
1839
1924
  if (gitCmd === "init" && req.method === "POST") {
1840
1925
  await runGit(["init"]);
1841
1926
  return Response.json({ ok: true });
@@ -1875,6 +1960,16 @@ export async function handleStudioApi(
1875
1960
 
1876
1961
  if (gitCmd === "log" && req.method === "GET") {
1877
1962
  const limit = url.searchParams.get("limit") || "20";
1963
+ /* A repository with no commits yet has no history, which is an empty list, not a failure:
1964
+ `git log` exits 128 on an unborn HEAD, and that 500 was the first console error every
1965
+ freshly initialized project showed (studio.md §22.8). */
1966
+ const hasHead = await runGit(["rev-parse", "--verify", "--quiet", "HEAD"]).then(
1967
+ () => true,
1968
+ () => false,
1969
+ );
1970
+ if (!hasHead) {
1971
+ return Response.json([]);
1972
+ }
1878
1973
  const out = await runGit(["log", `--max-count=${limit}`, "--format=%H\t%s\t%an\t%aI"]);
1879
1974
  const entries = out
1880
1975
  .trim()