@jxsuite/server 1.3.0 → 2.1.0

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/src/studio-api.ts CHANGED
@@ -10,7 +10,9 @@
10
10
  import { basename, dirname, extname, isAbsolute, join, relative, resolve } from "node:path";
11
11
  import { errorMessage } from "@jxsuite/schema/parse";
12
12
  import { mkdir, readFile, readdir, rename, stat, unlink, writeFile } from "node:fs/promises";
13
- import { existsSync, readFileSync, statSync } from "node:fs";
13
+ import { existsSync, readFileSync, statSync, unlinkSync } from "node:fs";
14
+ import { homedir } from "node:os";
15
+ import { LOCATION_ID_FILE } from "@jxsuite/protocol/routes";
14
16
  import {
15
17
  buildExtensionsPayload,
16
18
  buildProjectExtensionRegistry,
@@ -18,7 +20,9 @@ import {
18
20
  import { readBundledProjectSchemas } from "@jxsuite/compiler/schema-command";
19
21
  import { handleDataApi } from "./data-api.ts";
20
22
  import { containedPath } from "./net-guard.ts";
23
+ import { startSitePreview } from "./site-preview.ts";
21
24
  import { applyRename } from "./refactor/apply.ts";
25
+ import { findReferences } from "./refactor/find-refs.ts";
22
26
  import {
23
27
  bunExecutable,
24
28
  dependenciesNeedInstall,
@@ -31,6 +35,7 @@ import type { FormatRegistry } from "@jxsuite/schema/format-registry";
31
35
  import type { ClassJsonDef } from "./types.ts";
32
36
  import type { DesignOptions } from "@jxsuite/create/generate";
33
37
  import type { ProjectConfig } from "@jxsuite/schema/types";
38
+ import { problem } from "./problem.ts";
34
39
 
35
40
  /** Normalise a path to forward slashes (Windows `path` module returns backslashes). */
36
41
  const fwd = (p: string) => p.replaceAll("\\", "/");
@@ -175,6 +180,57 @@ export function assertAccessible(filePath: string, root: string, activeProjectRo
175
180
  throw new Error("Path outside project root");
176
181
  }
177
182
 
183
+ /**
184
+ * Check that a NEW project may be scaffolded under `parent` (specs/server.md §4.2).
185
+ *
186
+ * Deliberately wider than {@link assertAccessible}: a new project is normally created _outside_ the
187
+ * server root — the whole point of the modal's Location field — so root containment cannot apply.
188
+ * What it still refuses is a destination the user did not name (relative paths) and anything
189
+ * outside the places a person keeps projects: the server root, any configured `allowedRoots`, and
190
+ * the account's home directory. That keeps a hostile page on the loopback origin from scaffolding
191
+ * into system paths while leaving every realistic location reachable.
192
+ *
193
+ * @param {string} parent
194
+ * @param {string} root
195
+ * @param {string[]} allowedRoots
196
+ */
197
+ export function assertCreatableParent(parent: string, root: string, allowedRoots: string[] = []) {
198
+ if (!parent || !isAbsolute(parent)) {
199
+ throw new Error("Destination folder must be an absolute path");
200
+ }
201
+ const bases = [root, ...allowedRoots, homedir()].filter(Boolean);
202
+ if (bases.some((base) => containedPath(parent, resolve(base)) !== null)) {
203
+ return;
204
+ }
205
+ throw new Error(
206
+ "Destination folder is outside the permitted roots (the server root, an allowed root, or your home directory)",
207
+ );
208
+ }
209
+
210
+ /**
211
+ * True when `dir` is a Jx project this account already owns: an absolute directory holding a
212
+ * `project.json`, somewhere under the user's home directory (specs/server.md §4.2).
213
+ *
214
+ * Existing projects live outside the server root as a matter of course — the dev server serving
215
+ * this monorepo is not where anyone keeps their sites — so root containment alone would leave
216
+ * `?project=/abs/path`, the Open Project picker, and the recent-projects list unable to activate
217
+ * anything but a project inside the checkout. This admits those without opening the filesystem API
218
+ * to arbitrary directories: a hostile page on the loopback origin still cannot bind the server to
219
+ * `/etc` or another account's files, only to a project the account already has on disk.
220
+ *
221
+ * @param {string} dir
222
+ */
223
+ export function isOwnedProjectDir(dir: string): boolean {
224
+ const home = homedir();
225
+ if (!home || !dir || !isAbsolute(dir)) {
226
+ return false;
227
+ }
228
+ if (containedPath(dir, resolve(home)) === null) {
229
+ return false;
230
+ }
231
+ return existsSync(resolve(dir, "project.json"));
232
+ }
233
+
178
234
  const statusMap: Record<string, string> = {
179
235
  A: "A",
180
236
  C: "C",
@@ -253,6 +309,18 @@ export function parseGitStatus(out: string) {
253
309
  return { ahead, behind, branch, files };
254
310
  }
255
311
 
312
+ /** Host hooks for the routes that reach outside the server root (project creation). */
313
+ export interface StudioApiOptions {
314
+ /** Extra roots a new project may be created under, mirroring `createDevServer.allowedRoots`. */
315
+ allowedRoots?: string[];
316
+ /**
317
+ * Called with the absolute root of a project this API just created, so the host can permit a
318
+ * subsequent `/__studio/activate` on it — a project created outside the server root is otherwise
319
+ * unreachable by the very request that made it.
320
+ */
321
+ onProjectCreated?: (projectRoot: string) => void;
322
+ }
323
+
256
324
  /**
257
325
  * Handle /__studio/* requests.
258
326
  *
@@ -260,13 +328,38 @@ export function parseGitStatus(out: string) {
260
328
  * @param {URL} url
261
329
  * @param {string} root
262
330
  * @param {string | null} [activeProjectRoot]
331
+ * @param {StudioApiOptions} [opts]
263
332
  * @returns {Promise<Response | null>}
264
333
  */
334
+ /**
335
+ * The paths git reported as conflicting, from a failed merge or pull's output.
336
+ *
337
+ * Parsed from the porcelain text because there is no machine-readable form of it: `git pull` writes
338
+ * `CONFLICT (content): Merge conflict in <path>` to stdout and exits non-zero, and the alternative
339
+ * — a second `git diff --name-only --diff-filter=U` — would be a separate command whose answer
340
+ * could already have changed. An empty result means the failure was not a conflict, and the caller
341
+ * rethrows rather than mislabelling it.
342
+ *
343
+ * @param {string} output - Combined git output from the failed command
344
+ * @returns {string[]} Conflicting paths, deduplicated, in the order git reported them
345
+ */
346
+ function conflictingPaths(output: string): string[] {
347
+ const paths = new Set<string>();
348
+ for (const line of output.split("\n")) {
349
+ const match = /^CONFLICT \([^)]*\): Merge conflict in (.+)$/.exec(line.trim());
350
+ if (match?.[1]) {
351
+ paths.add(match[1].trim());
352
+ }
353
+ }
354
+ return [...paths];
355
+ }
356
+
265
357
  export async function handleStudioApi(
266
358
  req: Request,
267
359
  url: URL,
268
360
  root: string,
269
361
  activeProjectRoot: string | null = null,
362
+ opts: StudioApiOptions = {},
270
363
  ) {
271
364
  const path = url.pathname;
272
365
 
@@ -297,7 +390,7 @@ export async function handleStudioApi(
297
390
  try {
298
391
  assertAccessible(absDir, root, activeProjectRoot);
299
392
  } catch (error) {
300
- return Response.json({ error: errorMessage(error) }, { status: 400 });
393
+ return problem("invalidRequest", errorMessage(error));
301
394
  }
302
395
  try {
303
396
  const projectRoot = fwd(absDir);
@@ -337,7 +430,7 @@ export async function handleStudioApi(
337
430
  projectRoot,
338
431
  });
339
432
  } catch (error) {
340
- return Response.json({ error: errorMessage(error) }, { status: 500 });
433
+ return problem("internalError", errorMessage(error));
341
434
  }
342
435
  }
343
436
 
@@ -345,7 +438,7 @@ export async function handleStudioApi(
345
438
  if (path === "/__studio/resolve-site" && req.method === "GET") {
346
439
  const filePath = url.searchParams.get("path");
347
440
  if (!filePath) {
348
- return Response.json({ error: "Missing path param" }, { status: 400 });
441
+ return problem("invalidRequest", "Missing path param");
349
442
  }
350
443
  try {
351
444
  // Walk up looking for project.json. Accept a directory (e.g. the project root itself, as
@@ -384,7 +477,7 @@ export async function handleStudioApi(
384
477
  }
385
478
  return Response.json({ sitePath: null });
386
479
  } catch (error) {
387
- return Response.json({ error: errorMessage(error) }, { status: 500 });
480
+ return problem("internalError", errorMessage(error));
388
481
  }
389
482
  }
390
483
 
@@ -393,7 +486,7 @@ export async function handleStudioApi(
393
486
  if (path === "/__studio/find-project" && req.method === "GET") {
394
487
  const name = url.searchParams.get("name");
395
488
  if (!name) {
396
- return Response.json({ error: "Missing name" }, { status: 400 });
489
+ return problem("invalidRequest", "Missing name");
397
490
  }
398
491
  try {
399
492
  const home = process.env.HOME || process.env.USERPROFILE || "";
@@ -412,7 +505,75 @@ export async function handleStudioApi(
412
505
  } catch {}
413
506
  return Response.json({ path: null });
414
507
  } catch (error) {
415
- return Response.json({ error: errorMessage(error) }, { status: 500 });
508
+ return problem("internalError", errorMessage(error));
509
+ }
510
+ }
511
+
512
+ /*
513
+ * Recover the absolute path of a directory the browser picked with `showDirectoryPicker()`.
514
+ *
515
+ * A `FileSystemDirectoryHandle` exposes only `.name`, never a filesystem path (§8.2), and unlike
516
+ * /__studio/find-project there is no `project.json` to search for — the whole point is that the
517
+ * folder is empty. So the client tags the folder with a hidden LOCATION_ID_FILE holding a random
518
+ * id and asks us which directory carries that id.
519
+ *
520
+ * Identity lives in the file's CONTENTS, not its name: matching the id is exact where matching a
521
+ * path shape is only probable — two folders can share a basename, and a file left behind by a
522
+ * crashed session can still be on disk. A candidate whose contents do not equal `id` is skipped,
523
+ * so a stale tag can never redirect a create. The winning file is deleted here, the moment it has
524
+ * served its purpose, rather than being left for the client to tidy up.
525
+ */
526
+ if (path === "/__studio/locate-directory" && req.method === "GET") {
527
+ const name = url.searchParams.get("name");
528
+ const id = url.searchParams.get("id");
529
+ // `name` is interpolated into a glob; the id is only ever compared, never used as a pattern.
530
+ if (!name || !id || !/^[a-f0-9]{32}$/.test(id) || !/^[^/\\]+$/.test(name)) {
531
+ return problem("invalidRequest", "Missing or invalid name/id");
532
+ }
533
+ try {
534
+ const home = process.env.HOME || process.env.USERPROFILE || "";
535
+ if (!home) {
536
+ return Response.json({ path: null });
537
+ }
538
+ /** The tagged directory when this candidate holds our id, else null. */
539
+ const claim = (dir: string): string | null => {
540
+ const file = resolve(dir, LOCATION_ID_FILE);
541
+ try {
542
+ if (readFileSync(file, "utf8").trim() !== id) {
543
+ return null;
544
+ }
545
+ } catch {
546
+ return null;
547
+ }
548
+ try {
549
+ unlinkSync(file);
550
+ } catch {
551
+ // Read-only or already gone; the client's own cleanup is the backstop.
552
+ }
553
+ return dir;
554
+ };
555
+
556
+ // The home directory itself is a legitimate pick, and no `**/<name>/` glob can match it.
557
+ const atHome = claim(home);
558
+ if (atHome) {
559
+ return Response.json({ path: atHome });
560
+ }
561
+ // `dot: true` — the tag is a dotfile, so a dot-blind scan would never see it.
562
+ const glob = new Bun.Glob(`**/${name}/${LOCATION_ID_FILE}`);
563
+ try {
564
+ for await (const match of glob.scan({ cwd: home, dot: true })) {
565
+ if (match.includes("node_modules") || match.includes(".Trash")) {
566
+ continue;
567
+ }
568
+ const claimed = claim(resolve(home, dirname(match)));
569
+ if (claimed) {
570
+ return Response.json({ path: claimed });
571
+ }
572
+ }
573
+ } catch {}
574
+ return Response.json({ path: null });
575
+ } catch (error) {
576
+ return problem("internalError", errorMessage(error));
416
577
  }
417
578
  }
418
579
 
@@ -440,7 +601,7 @@ export async function handleStudioApi(
440
601
  }
441
602
  return Response.json(sites);
442
603
  } catch (error) {
443
- return Response.json({ error: errorMessage(error) }, { status: 500 });
604
+ return problem("internalError", errorMessage(error));
444
605
  }
445
606
  }
446
607
 
@@ -453,6 +614,7 @@ export async function handleStudioApi(
453
614
  url?: string;
454
615
  adapter?: "static" | "cloudflare-pages" | "cloudflare-workers" | "node" | "bun";
455
616
  directory?: string;
617
+ destination?: { kind?: string; parent?: string };
456
618
  starter?: string;
457
619
  template?: string;
458
620
  design?: DesignOptions;
@@ -463,19 +625,37 @@ export async function handleStudioApi(
463
625
  url: siteUrl,
464
626
  adapter,
465
627
  directory,
628
+ destination,
466
629
  starter,
467
630
  template,
468
631
  design,
469
632
  } = body;
470
633
  if (!name || !directory) {
471
- return Response.json({ error: "name and directory are required" }, { status: 400 });
634
+ return problem("invalidRequest", "name and directory are required");
635
+ }
636
+ // The destination is the user's to choose (specs/server.md §4.2). Without one the project
637
+ // Would silently land under the server root — which, when the dev server is the jx checkout,
638
+ // Means scaffolding into the monorepo. Refuse instead of guessing.
639
+ if (destination?.kind !== "path" || !destination.parent) {
640
+ return problem("invalidRequest", "A destination folder is required.");
641
+ }
642
+ // `directory` names the project FOLDER, not a path: a separator or dot-segment would walk out
643
+ // Of the parent that was just vetted below, which is exactly what the vetting is for.
644
+ if (/[/\\]/.test(directory) || directory === "." || directory === "..") {
645
+ return problem("invalidRequest", "Directory must be a folder name, not a path");
472
646
  }
473
647
  const { isTemplateId } = await import("@jxsuite/create/templates");
474
648
  if (template !== undefined && !isTemplateId(template)) {
475
- return Response.json({ error: `Unknown template: "${template}"` }, { status: 400 });
649
+ return problem("invalidRequest", `Unknown template: "${template}"`);
476
650
  }
477
- const destPath = resolve(root, directory);
478
- assertAccessible(destPath, root, activeProjectRoot);
651
+ // A bad destination is client input, so answer 400 rather than letting the guard's throw fall
652
+ // Through to the catch-all 500 below.
653
+ try {
654
+ assertCreatableParent(destination.parent, root, opts.allowedRoots);
655
+ } catch (error) {
656
+ return problem("invalidRequest", errorMessage(error));
657
+ }
658
+ const destPath = resolve(destination.parent, directory);
479
659
 
480
660
  const { generateProject } = await import("@jxsuite/create/generate");
481
661
  await generateProject(destPath, {
@@ -491,10 +671,14 @@ export async function handleStudioApi(
491
671
  const config = JSON.parse(
492
672
  await readFile(resolve(destPath, "project.json"), "utf8"),
493
673
  ) as ProjectConfig;
494
- const projectRoot = fwd(relative(root, destPath));
495
- return Response.json({ config, root: projectRoot });
674
+ // Root-relative while the project happens to sit under the server root (the historical
675
+ // Shape every caller already handles); absolute otherwise, which is the same shape
676
+ // /__studio/find-project returns for an external project.
677
+ const inRoot = containedPath(destPath, root) !== null;
678
+ opts.onProjectCreated?.(destPath);
679
+ return Response.json({ config, root: inRoot ? fwd(relative(root, destPath)) : destPath });
496
680
  } catch (error) {
497
- return Response.json({ error: errorMessage(error) }, { status: 500 });
681
+ return problem("internalError", errorMessage(error));
498
682
  }
499
683
  }
500
684
 
@@ -504,7 +688,7 @@ export async function handleStudioApi(
504
688
  const { listStarters } = await import("@jxsuite/starters");
505
689
  return Response.json(listStarters());
506
690
  } catch (error) {
507
- return Response.json({ error: errorMessage(error) }, { status: 500 });
691
+ return problem("internalError", errorMessage(error));
508
692
  }
509
693
  }
510
694
 
@@ -516,7 +700,7 @@ export async function handleStudioApi(
516
700
  try {
517
701
  assertAccessible(absDir, root, activeProjectRoot);
518
702
  } catch (error) {
519
- return Response.json({ error: errorMessage(error) }, { status: 400 });
703
+ return problem("invalidRequest", errorMessage(error));
520
704
  }
521
705
 
522
706
  /** Report a path relative to the active project root (or server root as fallback). */
@@ -569,7 +753,7 @@ export async function handleStudioApi(
569
753
  }
570
754
  return Response.json(files);
571
755
  } catch (error) {
572
- return Response.json({ error: errorMessage(error) }, { status: 500 });
756
+ return problem("internalError", errorMessage(error));
573
757
  }
574
758
  }
575
759
 
@@ -580,7 +764,7 @@ export async function handleStudioApi(
580
764
  try {
581
765
  assertAccessible(scanRoot, root, activeProjectRoot);
582
766
  } catch (error) {
583
- return Response.json({ error: errorMessage(error) }, { status: 400 });
767
+ return problem("invalidRequest", errorMessage(error));
584
768
  }
585
769
  try {
586
770
  const registry = await getFormatRegistry(scanRoot);
@@ -721,7 +905,46 @@ export async function handleStudioApi(
721
905
 
722
906
  return Response.json(components);
723
907
  } catch (error) {
724
- return Response.json({ error: errorMessage(error) }, { status: 500 });
908
+ return problem("internalError", errorMessage(error));
909
+ }
910
+ }
911
+
912
+ // ─── Site build ──────────────────────────────────────────────────────────────
913
+
914
+ /**
915
+ * Build the site, so `View: Open in Browser` opens what the author is looking at.
916
+ *
917
+ * The compiler is imported dynamically for the same reason `dev.ts` does it: the server depends
918
+ * on the compiler for this one call, and a static import would pull the whole build pipeline into
919
+ * every server process that never builds anything.
920
+ *
921
+ * Build errors come back in the payload rather than as a 500. A partial build still produced
922
+ * pages, and the author is better served by opening the page they asked for with the failures
923
+ * named beside it than by a preview that refuses and says only "500".
924
+ *
925
+ * The reply carries the ORIGIN to open the result at, not just the counts, because the built site
926
+ * is served on its own port ({@link file://./site-preview.ts}) rather than on this one — the
927
+ * caller has no way to know that port and no business guessing it.
928
+ */
929
+ if (path === "/__studio/build" && req.method === "POST") {
930
+ const dir = activeProjectRoot ?? root;
931
+ if (!existsSync(resolve(dir, "project.json"))) {
932
+ return problem("invalidRequest", "Not a site project");
933
+ }
934
+ try {
935
+ const { buildSite } = await import("@jxsuite/compiler/site");
936
+ // `clean: false` — this runs on the way to opening a page, and wiping the output directory
937
+ // First would mean every asset 404s for as long as the build takes.
938
+ const result = await buildSite(dir, { clean: false, verbose: false });
939
+ const preview = startSitePreview(dir);
940
+ return Response.json({
941
+ errors: result.errors,
942
+ files: result.files,
943
+ routes: result.routes,
944
+ ...(preview ? { url: preview.origin } : {}),
945
+ });
946
+ } catch (error) {
947
+ return problem("internalError", errorMessage(error));
725
948
  }
726
949
  }
727
950
 
@@ -769,7 +992,7 @@ export async function handleStudioApi(
769
992
  }
770
993
  return Response.json(packages);
771
994
  } catch (error) {
772
- return Response.json({ error: errorMessage(error) }, { status: 500 });
995
+ return problem("internalError", errorMessage(error));
773
996
  }
774
997
  }
775
998
 
@@ -777,7 +1000,7 @@ export async function handleStudioApi(
777
1000
  if (path === "/__studio/cem" && req.method === "GET") {
778
1001
  const pkg = url.searchParams.get("pkg");
779
1002
  if (!pkg) {
780
- return new Response("Missing pkg", { status: 400 });
1003
+ return problem("invalidRequest", "Missing pkg");
781
1004
  }
782
1005
  const dir = url.searchParams.get("dir") || activeProjectRoot || root;
783
1006
  const scanRoot = isAbsolute(dir) ? dir : resolve(root, dir);
@@ -803,7 +1026,7 @@ export async function handleStudioApi(
803
1026
  const cem = JSON.parse(await readFile(cemPath, "utf8")) as Cem;
804
1027
  return Response.json({ cem });
805
1028
  } catch (error) {
806
- return Response.json({ error: errorMessage(error) }, { status: 500 });
1029
+ return problem("internalError", errorMessage(error));
807
1030
  }
808
1031
  }
809
1032
 
@@ -813,7 +1036,7 @@ export async function handleStudioApi(
813
1036
  const body = (await req.json()) as { name?: string; dir?: string; dev?: boolean };
814
1037
  const { name } = body;
815
1038
  if (!name || typeof name !== "string") {
816
- return Response.json({ error: "Missing name" }, { status: 400 });
1039
+ return problem("invalidRequest", "Missing name");
817
1040
  }
818
1041
  const dir = body.dir || activeProjectRoot;
819
1042
  const cwd = dir ? (isAbsolute(dir) ? dir : resolve(root, dir)) : root;
@@ -829,14 +1052,11 @@ export async function handleStudioApi(
829
1052
  const exitCode = await proc.exited;
830
1053
  if (exitCode !== 0) {
831
1054
  const stderr = await new Response(proc.stderr).text();
832
- return Response.json(
833
- { error: stderr || `bun add exited with ${exitCode}` },
834
- { status: 500 },
835
- );
1055
+ return problem("internalError", stderr || `bun add exited with ${exitCode}`);
836
1056
  }
837
1057
  return Response.json({ ok: true });
838
1058
  } catch (error) {
839
- return Response.json({ error: errorMessage(error) }, { status: 500 });
1059
+ return problem("internalError", errorMessage(error));
840
1060
  }
841
1061
  }
842
1062
 
@@ -846,7 +1066,7 @@ export async function handleStudioApi(
846
1066
  const body = (await req.json()) as { name?: string; dir?: string };
847
1067
  const { name } = body;
848
1068
  if (!name || typeof name !== "string") {
849
- return Response.json({ error: "Missing name" }, { status: 400 });
1069
+ return problem("invalidRequest", "Missing name");
850
1070
  }
851
1071
  const dir = body.dir || activeProjectRoot;
852
1072
  const cwd = dir ? (isAbsolute(dir) ? dir : resolve(root, dir)) : root;
@@ -858,14 +1078,11 @@ export async function handleStudioApi(
858
1078
  const exitCode = await proc.exited;
859
1079
  if (exitCode !== 0) {
860
1080
  const stderr = await new Response(proc.stderr).text();
861
- return Response.json(
862
- { error: stderr || `bun remove exited with ${exitCode}` },
863
- { status: 500 },
864
- );
1081
+ return problem("internalError", stderr || `bun remove exited with ${exitCode}`);
865
1082
  }
866
1083
  return Response.json({ ok: true });
867
1084
  } catch (error) {
868
- return Response.json({ error: errorMessage(error) }, { status: 500 });
1085
+ return problem("internalError", errorMessage(error));
869
1086
  }
870
1087
  }
871
1088
 
@@ -877,7 +1094,7 @@ export async function handleStudioApi(
877
1094
  const scanRoot = isAbsolute(dir) ? dir : resolve(root, dir);
878
1095
  return Response.json(await installDependencies(scanRoot));
879
1096
  } catch (error) {
880
- return Response.json({ error: errorMessage(error) }, { status: 500 });
1097
+ return problem("internalError", errorMessage(error));
881
1098
  }
882
1099
  }
883
1100
 
@@ -895,7 +1112,7 @@ export async function handleStudioApi(
895
1112
  const scanRoot = isAbsolute(dir) ? dir : resolve(root, dir);
896
1113
  return Response.json(await outdatedPackages(scanRoot));
897
1114
  } catch (error) {
898
- return Response.json({ error: errorMessage(error) }, { status: 500 });
1115
+ return problem("internalError", errorMessage(error));
899
1116
  }
900
1117
  }
901
1118
 
@@ -907,13 +1124,13 @@ export async function handleStudioApi(
907
1124
  updates?: { name: string; version: string; dev?: boolean }[];
908
1125
  };
909
1126
  if (!Array.isArray(body.updates)) {
910
- return Response.json({ error: "Missing updates" }, { status: 400 });
1127
+ return problem("invalidRequest", "Missing updates");
911
1128
  }
912
1129
  const dir = body.dir || activeProjectRoot || root;
913
1130
  const scanRoot = isAbsolute(dir) ? dir : resolve(root, dir);
914
1131
  return Response.json(await setPackageVersions(scanRoot, body.updates));
915
1132
  } catch (error) {
916
- return Response.json({ error: errorMessage(error) }, { status: 500 });
1133
+ return problem("internalError", errorMessage(error));
917
1134
  }
918
1135
  }
919
1136
 
@@ -921,7 +1138,7 @@ export async function handleStudioApi(
921
1138
  if (path === "/__studio/file" && req.method === "GET") {
922
1139
  const fp = url.searchParams.get("path");
923
1140
  if (!fp) {
924
- return new Response("Missing path", { status: 400 });
1141
+ return problem("invalidRequest", "Missing path");
925
1142
  }
926
1143
  const abs = expandTilde(fp);
927
1144
  try {
@@ -939,8 +1156,8 @@ export async function handleStudioApi(
939
1156
  );
940
1157
  } catch (error) {
941
1158
  return (error as NodeJS.ErrnoException).code === "ENOENT"
942
- ? new Response("Not found", { status: 404 })
943
- : Response.json({ error: errorMessage(error) }, { status: 500 });
1159
+ ? problem("notFound", "Not found")
1160
+ : problem("internalError", errorMessage(error));
944
1161
  }
945
1162
  }
946
1163
 
@@ -948,7 +1165,7 @@ export async function handleStudioApi(
948
1165
  if (path === "/__studio/file" && req.method === "PUT") {
949
1166
  const fp = url.searchParams.get("path");
950
1167
  if (!fp) {
951
- return new Response("Missing path", { status: 400 });
1168
+ return problem("invalidRequest", "Missing path");
952
1169
  }
953
1170
  const abs = resolve(root, fp);
954
1171
  try {
@@ -961,7 +1178,7 @@ export async function handleStudioApi(
961
1178
  await writeFile(abs, await req.text(), "utf8");
962
1179
  return Response.json({ ok: true, path: fwd(relative(root, abs)) });
963
1180
  } catch (error) {
964
- return Response.json({ error: errorMessage(error) }, { status: 500 });
1181
+ return problem("internalError", errorMessage(error));
965
1182
  }
966
1183
  }
967
1184
 
@@ -969,7 +1186,7 @@ export async function handleStudioApi(
969
1186
  if (path === "/__studio/file/upload" && req.method === "POST") {
970
1187
  const fp = url.searchParams.get("path");
971
1188
  if (!fp) {
972
- return new Response("Missing path", { status: 400 });
1189
+ return problem("invalidRequest", "Missing path");
973
1190
  }
974
1191
  const abs = resolve(root, fp);
975
1192
  try {
@@ -983,7 +1200,7 @@ export async function handleStudioApi(
983
1200
  await Bun.write(abs, new Uint8Array(buffer));
984
1201
  return Response.json({ ok: true, path: fwd(relative(root, abs)) });
985
1202
  } catch (error) {
986
- return Response.json({ error: errorMessage(error) }, { status: 500 });
1203
+ return problem("internalError", errorMessage(error));
987
1204
  }
988
1205
  }
989
1206
 
@@ -991,7 +1208,7 @@ export async function handleStudioApi(
991
1208
  if (path === "/__studio/file" && req.method === "DELETE") {
992
1209
  const fp = url.searchParams.get("path");
993
1210
  if (!fp) {
994
- return new Response("Missing path", { status: 400 });
1211
+ return problem("invalidRequest", "Missing path");
995
1212
  }
996
1213
  const abs = resolve(root, fp);
997
1214
  try {
@@ -1004,8 +1221,8 @@ export async function handleStudioApi(
1004
1221
  return Response.json({ ok: true, path: fwd(relative(root, abs)) });
1005
1222
  } catch (error) {
1006
1223
  return (error as NodeJS.ErrnoException).code === "ENOENT"
1007
- ? new Response("Not found", { status: 404 })
1008
- : Response.json({ error: errorMessage(error) }, { status: 500 });
1224
+ ? problem("notFound", "Not found")
1225
+ : problem("internalError", errorMessage(error));
1009
1226
  }
1010
1227
  }
1011
1228
 
@@ -1015,11 +1232,11 @@ export async function handleStudioApi(
1015
1232
  try {
1016
1233
  body = (await req.json()) as { from?: string; to?: string };
1017
1234
  } catch {
1018
- return new Response("Invalid JSON", { status: 400 });
1235
+ return problem("invalidRequest", "Invalid JSON");
1019
1236
  }
1020
1237
  const { from, to } = body;
1021
1238
  if (!from || !to) {
1022
- return new Response("Missing from or to", { status: 400 });
1239
+ return problem("invalidRequest", "Missing from or to");
1023
1240
  }
1024
1241
  const absFrom = resolve(root, from);
1025
1242
  const absTo = resolve(root, to);
@@ -1033,7 +1250,7 @@ export async function handleStudioApi(
1033
1250
  await mkdir(dirname(absTo), { recursive: true });
1034
1251
  await rename(absFrom, absTo);
1035
1252
  } catch (error) {
1036
- return Response.json({ error: errorMessage(error) }, { status: 500 });
1253
+ return problem("internalError", errorMessage(error));
1037
1254
  }
1038
1255
  // Refactor pass: rewrite path references project-wide and, for a component, auto-rename its tag.
1039
1256
  // The move already succeeded, so a refactor failure is reported but never fails the rename.
@@ -1052,17 +1269,42 @@ export async function handleStudioApi(
1052
1269
  }
1053
1270
  }
1054
1271
 
1272
+ // Where a file / component tag is used (the read side of the rename refactor's walker)
1273
+ if (path === "/__studio/references" && req.method === "GET") {
1274
+ const target = url.searchParams.get("path");
1275
+ const tag = url.searchParams.get("tag");
1276
+ if (!target && !tag) {
1277
+ return problem("invalidRequest", "Missing path or tag");
1278
+ }
1279
+ const scanRoot = activeProjectRoot ?? root;
1280
+ if (target) {
1281
+ try {
1282
+ assertAccessible(resolve(scanRoot, target), root, activeProjectRoot);
1283
+ } catch (error) {
1284
+ return new Response(errorMessage(error), { status: 400 });
1285
+ }
1286
+ }
1287
+ try {
1288
+ const registry = await getFormatRegistry(scanRoot);
1289
+ return Response.json(
1290
+ await findReferences({ path: target, registry, root: scanRoot, tagName: tag }),
1291
+ );
1292
+ } catch (error) {
1293
+ return problem("internalError", errorMessage(error));
1294
+ }
1295
+ }
1296
+
1055
1297
  // Locate a file by name within the project root
1056
1298
  if (path === "/__studio/locate" && req.method === "POST") {
1057
1299
  let body: { name?: string };
1058
1300
  try {
1059
1301
  body = (await req.json()) as { name?: string };
1060
1302
  } catch {
1061
- return new Response("Invalid JSON", { status: 400 });
1303
+ return problem("invalidRequest", "Invalid JSON");
1062
1304
  }
1063
1305
  const { name } = body;
1064
1306
  if (!name) {
1065
- return new Response("Missing name", { status: 400 });
1307
+ return problem("invalidRequest", "Missing name");
1066
1308
  }
1067
1309
 
1068
1310
  try {
@@ -1083,7 +1325,7 @@ export async function handleStudioApi(
1083
1325
  ...(matches.length > 1 ? { alternatives: matches } : {}),
1084
1326
  });
1085
1327
  } catch (error) {
1086
- return Response.json({ error: errorMessage(error) }, { status: 500 });
1328
+ return problem("internalError", errorMessage(error));
1087
1329
  }
1088
1330
  }
1089
1331
 
@@ -1101,17 +1343,17 @@ export async function handleStudioApi(
1101
1343
  ];
1102
1344
  const cfToken = req.headers.get("X-CF-Token");
1103
1345
  if (!cfToken) {
1104
- return Response.json({ error: "Missing X-CF-Token header" }, { status: 401 });
1346
+ return problem("unauthorized", "Missing X-CF-Token header");
1105
1347
  }
1106
1348
  let payload: { path?: string; method?: string; body?: unknown };
1107
1349
  try {
1108
1350
  payload = (await req.json()) as typeof payload;
1109
1351
  } catch {
1110
- return Response.json({ error: "Invalid JSON body" }, { status: 400 });
1352
+ return problem("invalidRequest", "Invalid JSON body");
1111
1353
  }
1112
1354
  const apiPath = payload.path ?? "";
1113
1355
  if (!CF_PROXY_ALLOWLIST.some((re) => re.test(apiPath))) {
1114
- return Response.json({ error: `Path not allowed: ${apiPath}` }, { status: 403 });
1356
+ return problem("forbidden", `Path not allowed: ${apiPath}`);
1115
1357
  }
1116
1358
  const method = payload.method ?? "GET";
1117
1359
  const upstream = await fetch(`https://api.cloudflare.com/client/v4${apiPath}`, {
@@ -1146,18 +1388,15 @@ export async function handleStudioApi(
1146
1388
  assertAccessible(projectRoot, root, activeProjectRoot);
1147
1389
 
1148
1390
  if (!format || !action) {
1149
- return Response.json({ error: "Missing format or action" }, { status: 400 });
1391
+ return problem("invalidRequest", "Missing format or action");
1150
1392
  }
1151
1393
  if (action !== "parse" && action !== "serialize") {
1152
- return Response.json({ error: `Unsupported action "${action}"` }, { status: 400 });
1394
+ return problem("invalidRequest", `Unsupported action "${action}"`);
1153
1395
  }
1154
1396
  const registry = await getFormatRegistry(projectRoot);
1155
1397
  const entry = registry.byName(format);
1156
1398
  if (!entry) {
1157
- return Response.json(
1158
- { error: `Format "${format}" is not an imported format class` },
1159
- { status: 404 },
1160
- );
1399
+ return problem("notFound", `Format "${format}" is not an imported format class`);
1161
1400
  }
1162
1401
  const result =
1163
1402
  action === "parse"
@@ -1165,7 +1404,7 @@ export async function handleStudioApi(
1165
1404
  : await entry.call("serialize", doc ?? {}, options);
1166
1405
  return Response.json({ result });
1167
1406
  } catch (error) {
1168
- return Response.json({ error: errorMessage(error) }, { status: 500 });
1407
+ return problem("internalError", errorMessage(error));
1169
1408
  }
1170
1409
  }
1171
1410
 
@@ -1193,7 +1432,7 @@ export async function handleStudioApi(
1193
1432
  })),
1194
1433
  });
1195
1434
  } catch (error) {
1196
- return Response.json({ error: errorMessage(error) }, { status: 400 });
1435
+ return problem("invalidRequest", errorMessage(error));
1197
1436
  }
1198
1437
  }
1199
1438
 
@@ -1206,13 +1445,13 @@ export async function handleStudioApi(
1206
1445
  try {
1207
1446
  assertAccessible(projectRoot, root, activeProjectRoot);
1208
1447
  } catch (error) {
1209
- return Response.json({ error: errorMessage(error) }, { status: 400 });
1448
+ return problem("invalidRequest", errorMessage(error));
1210
1449
  }
1211
1450
  try {
1212
1451
  const { document, project } = await readBundledProjectSchemas(projectRoot);
1213
1452
  return Response.json({ document, project });
1214
1453
  } catch (error) {
1215
- return Response.json({ error: errorMessage(error) }, { status: 500 });
1454
+ return problem("internalError", errorMessage(error));
1216
1455
  }
1217
1456
  }
1218
1457
 
@@ -1221,7 +1460,7 @@ export async function handleStudioApi(
1221
1460
  const prototype = url.searchParams.get("prototype");
1222
1461
  const base = url.searchParams.get("base");
1223
1462
  if (!src) {
1224
- return new Response("Missing src param", { status: 400 });
1463
+ return problem("invalidRequest", "Missing src param");
1225
1464
  }
1226
1465
 
1227
1466
  let moduleAbsPath;
@@ -1326,7 +1565,17 @@ export async function handleStudioApi(
1326
1565
  const stdout = await new Response(proc.stdout).text();
1327
1566
  const stderr = await new Response(proc.stderr).text();
1328
1567
  if (exitCode !== 0) {
1329
- throw new Error(stderr || `git exited with ${exitCode}`);
1568
+ /*
1569
+ * Both streams, because git splits its failures across them and stderr alone loses the
1570
+ * ones that matter most. A conflicting `git pull` writes every `CONFLICT (…)` line to
1571
+ * STDOUT and leaves stderr empty — so the old `stderr || "git exited with 1"` turned the
1572
+ * one failure this API publishes a shape for into a contentless message, and the 409 the
1573
+ * route table promises could never be produced.
1574
+ */
1575
+ throw Object.assign(new Error(stderr || stdout || `git exited with ${exitCode}`), {
1576
+ stderr,
1577
+ stdout,
1578
+ });
1330
1579
  }
1331
1580
  return stdout;
1332
1581
  };
@@ -1375,7 +1624,7 @@ export async function handleStudioApi(
1375
1624
  url?: string;
1376
1625
  };
1377
1626
  if (!name || !remoteUrl) {
1378
- return new Response("name and url required", { status: 400 });
1627
+ return problem("invalidRequest", "name and url required");
1379
1628
  }
1380
1629
  await runGit(["remote", "add", name, remoteUrl]);
1381
1630
  return Response.json({ ok: true });
@@ -1415,11 +1664,11 @@ export async function handleStudioApi(
1415
1664
  if (gitCmd === "stage" && req.method === "POST") {
1416
1665
  const { files } = (await req.json()) as { files?: string[] };
1417
1666
  if (!Array.isArray(files) || files.length === 0) {
1418
- return Response.json({ error: "Missing files" }, { status: 400 });
1667
+ return problem("invalidRequest", "Missing files");
1419
1668
  }
1420
1669
  for (const f of files) {
1421
1670
  if (f.includes("..")) {
1422
- return Response.json({ error: "Invalid path" }, { status: 400 });
1671
+ return problem("invalidRequest", "Invalid path");
1423
1672
  }
1424
1673
  }
1425
1674
  await runGit(["add", "--", ...files]);
@@ -1429,7 +1678,7 @@ export async function handleStudioApi(
1429
1678
  if (gitCmd === "unstage" && req.method === "POST") {
1430
1679
  const { files } = (await req.json()) as { files?: string[] };
1431
1680
  if (!Array.isArray(files) || files.length === 0) {
1432
- return Response.json({ error: "Missing files" }, { status: 400 });
1681
+ return problem("invalidRequest", "Missing files");
1433
1682
  }
1434
1683
  await runGit(["restore", "--staged", "--", ...files]);
1435
1684
  return Response.json({ ok: true });
@@ -1438,7 +1687,7 @@ export async function handleStudioApi(
1438
1687
  if (gitCmd === "commit" && req.method === "POST") {
1439
1688
  const { message } = (await req.json()) as { message?: string };
1440
1689
  if (!message || typeof message !== "string") {
1441
- return Response.json({ error: "Missing message" }, { status: 400 });
1690
+ return problem("invalidRequest", "Missing message");
1442
1691
  }
1443
1692
  const statusOut = await runGit(["status", "--porcelain"]);
1444
1693
  const hasStaged = statusOut
@@ -1467,7 +1716,28 @@ export async function handleStudioApi(
1467
1716
  }
1468
1717
 
1469
1718
  if (gitCmd === "pull" && req.method === "POST") {
1470
- await runGit(["pull"]);
1719
+ /*
1720
+ * The one failure the route table has always published — `409 {conflicts}` — and never
1721
+ * produced: a conflicting pull threw like any other git failure and reached the catch-all
1722
+ * as a 500, so Studio told the user the backend broke rather than that their branch and
1723
+ * the remote both touched the same files. The conflicting paths are the only thing that
1724
+ * makes the message actionable, so they ride along as the extension member the type
1725
+ * documents.
1726
+ */
1727
+ try {
1728
+ await runGit(["pull"]);
1729
+ } catch (error) {
1730
+ const output = error as { stdout?: string; stderr?: string };
1731
+ const conflicts = conflictingPaths(`${output.stdout ?? ""}\n${output.stderr ?? ""}`);
1732
+ if (conflicts.length === 0) {
1733
+ throw error;
1734
+ }
1735
+ return problem(
1736
+ "conflict",
1737
+ `Pull stopped: ${conflicts.length} file(s) changed on both sides.`,
1738
+ { conflicts },
1739
+ );
1740
+ }
1471
1741
  return Response.json({ ok: true });
1472
1742
  }
1473
1743
 
@@ -1479,7 +1749,7 @@ export async function handleStudioApi(
1479
1749
  if (gitCmd === "checkout" && req.method === "POST") {
1480
1750
  const { branch } = (await req.json()) as { branch?: string };
1481
1751
  if (!branch || typeof branch !== "string") {
1482
- return Response.json({ error: "Missing branch" }, { status: 400 });
1752
+ return problem("invalidRequest", "Missing branch");
1483
1753
  }
1484
1754
  await runGit(["checkout", branch]);
1485
1755
  return Response.json({ ok: true });
@@ -1488,7 +1758,7 @@ export async function handleStudioApi(
1488
1758
  if (gitCmd === "create-branch" && req.method === "POST") {
1489
1759
  const { name } = (await req.json()) as { name?: string };
1490
1760
  if (!name || typeof name !== "string") {
1491
- return Response.json({ error: "Missing name" }, { status: 400 });
1761
+ return problem("invalidRequest", "Missing name");
1492
1762
  }
1493
1763
  await runGit(["checkout", "-b", name]);
1494
1764
  return Response.json({ ok: true });
@@ -1497,10 +1767,10 @@ export async function handleStudioApi(
1497
1767
  if (gitCmd === "diff" && req.method === "GET") {
1498
1768
  const fp = url.searchParams.get("path");
1499
1769
  if (!fp) {
1500
- return Response.json({ error: "Missing path" }, { status: 400 });
1770
+ return problem("invalidRequest", "Missing path");
1501
1771
  }
1502
1772
  if (fp.includes("..")) {
1503
- return Response.json({ error: "Invalid path" }, { status: 400 });
1773
+ return problem("invalidRequest", "Invalid path");
1504
1774
  }
1505
1775
  const diff = await runGit(["diff", "--", fp]);
1506
1776
  return Response.json({ diff });
@@ -1510,10 +1780,10 @@ export async function handleStudioApi(
1510
1780
  const fp = url.searchParams.get("path");
1511
1781
  const ref = url.searchParams.get("ref") || "HEAD";
1512
1782
  if (!fp) {
1513
- return Response.json({ error: "Missing path" }, { status: 400 });
1783
+ return problem("invalidRequest", "Missing path");
1514
1784
  }
1515
1785
  if (fp.includes("..")) {
1516
- return Response.json({ error: "Invalid path" }, { status: 400 });
1786
+ return problem("invalidRequest", "Invalid path");
1517
1787
  }
1518
1788
  const content = await runGit(["show", `${ref}:${fp}`]);
1519
1789
  return Response.json({ content });
@@ -1522,11 +1792,11 @@ export async function handleStudioApi(
1522
1792
  if (gitCmd === "discard" && req.method === "POST") {
1523
1793
  const { files } = (await req.json()) as { files?: string[] };
1524
1794
  if (!Array.isArray(files) || files.length === 0) {
1525
- return Response.json({ error: "Missing files" }, { status: 400 });
1795
+ return problem("invalidRequest", "Missing files");
1526
1796
  }
1527
1797
  for (const f of files) {
1528
1798
  if (f.includes("..")) {
1529
- return Response.json({ error: "Invalid path" }, { status: 400 });
1799
+ return problem("invalidRequest", "Invalid path");
1530
1800
  }
1531
1801
  }
1532
1802
  await runGit(["checkout", "--", ...files]);
@@ -1536,7 +1806,7 @@ export async function handleStudioApi(
1536
1806
  if (gitCmd === "clone" && req.method === "POST") {
1537
1807
  const { url: repoUrl } = (await req.json()) as { url?: string };
1538
1808
  if (!repoUrl || typeof repoUrl !== "string") {
1539
- return Response.json({ error: "Missing url" }, { status: 400 });
1809
+ return problem("invalidRequest", "Missing url");
1540
1810
  }
1541
1811
  const repoName = basename(repoUrl.replace(/\.git$/, ""));
1542
1812
  const dest = resolve(cwd, repoName);
@@ -1553,7 +1823,7 @@ export async function handleStudioApi(
1553
1823
  return Response.json({ ok: true, root: dest });
1554
1824
  }
1555
1825
  } catch (error) {
1556
- return Response.json({ error: errorMessage(error) }, { status: 500 });
1826
+ return problem("internalError", errorMessage(error));
1557
1827
  }
1558
1828
  }
1559
1829