@jxsuite/server 2.0.0 → 2.2.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/src/studio-api.ts CHANGED
@@ -20,7 +20,9 @@ import {
20
20
  import { readBundledProjectSchemas } from "@jxsuite/compiler/schema-command";
21
21
  import { handleDataApi } from "./data-api.ts";
22
22
  import { containedPath } from "./net-guard.ts";
23
+ import { startSitePreview } from "./site-preview.ts";
23
24
  import { applyRename } from "./refactor/apply.ts";
25
+ import { findReferences } from "./refactor/find-refs.ts";
24
26
  import {
25
27
  bunExecutable,
26
28
  dependenciesNeedInstall,
@@ -33,6 +35,7 @@ import type { FormatRegistry } from "@jxsuite/schema/format-registry";
33
35
  import type { ClassJsonDef } from "./types.ts";
34
36
  import type { DesignOptions } from "@jxsuite/create/generate";
35
37
  import type { ProjectConfig } from "@jxsuite/schema/types";
38
+ import { problem } from "./problem.ts";
36
39
 
37
40
  /** Normalise a path to forward slashes (Windows `path` module returns backslashes). */
38
41
  const fwd = (p: string) => p.replaceAll("\\", "/");
@@ -328,6 +331,29 @@ export interface StudioApiOptions {
328
331
  * @param {StudioApiOptions} [opts]
329
332
  * @returns {Promise<Response | null>}
330
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
+
331
357
  export async function handleStudioApi(
332
358
  req: Request,
333
359
  url: URL,
@@ -364,7 +390,7 @@ export async function handleStudioApi(
364
390
  try {
365
391
  assertAccessible(absDir, root, activeProjectRoot);
366
392
  } catch (error) {
367
- return Response.json({ error: errorMessage(error) }, { status: 400 });
393
+ return problem("invalidRequest", errorMessage(error));
368
394
  }
369
395
  try {
370
396
  const projectRoot = fwd(absDir);
@@ -404,7 +430,7 @@ export async function handleStudioApi(
404
430
  projectRoot,
405
431
  });
406
432
  } catch (error) {
407
- return Response.json({ error: errorMessage(error) }, { status: 500 });
433
+ return problem("internalError", errorMessage(error));
408
434
  }
409
435
  }
410
436
 
@@ -412,7 +438,7 @@ export async function handleStudioApi(
412
438
  if (path === "/__studio/resolve-site" && req.method === "GET") {
413
439
  const filePath = url.searchParams.get("path");
414
440
  if (!filePath) {
415
- return Response.json({ error: "Missing path param" }, { status: 400 });
441
+ return problem("invalidRequest", "Missing path param");
416
442
  }
417
443
  try {
418
444
  // Walk up looking for project.json. Accept a directory (e.g. the project root itself, as
@@ -451,7 +477,7 @@ export async function handleStudioApi(
451
477
  }
452
478
  return Response.json({ sitePath: null });
453
479
  } catch (error) {
454
- return Response.json({ error: errorMessage(error) }, { status: 500 });
480
+ return problem("internalError", errorMessage(error));
455
481
  }
456
482
  }
457
483
 
@@ -460,7 +486,7 @@ export async function handleStudioApi(
460
486
  if (path === "/__studio/find-project" && req.method === "GET") {
461
487
  const name = url.searchParams.get("name");
462
488
  if (!name) {
463
- return Response.json({ error: "Missing name" }, { status: 400 });
489
+ return problem("invalidRequest", "Missing name");
464
490
  }
465
491
  try {
466
492
  const home = process.env.HOME || process.env.USERPROFILE || "";
@@ -479,7 +505,7 @@ export async function handleStudioApi(
479
505
  } catch {}
480
506
  return Response.json({ path: null });
481
507
  } catch (error) {
482
- return Response.json({ error: errorMessage(error) }, { status: 500 });
508
+ return problem("internalError", errorMessage(error));
483
509
  }
484
510
  }
485
511
 
@@ -502,7 +528,7 @@ export async function handleStudioApi(
502
528
  const id = url.searchParams.get("id");
503
529
  // `name` is interpolated into a glob; the id is only ever compared, never used as a pattern.
504
530
  if (!name || !id || !/^[a-f0-9]{32}$/.test(id) || !/^[^/\\]+$/.test(name)) {
505
- return Response.json({ error: "Missing or invalid name/id" }, { status: 400 });
531
+ return problem("invalidRequest", "Missing or invalid name/id");
506
532
  }
507
533
  try {
508
534
  const home = process.env.HOME || process.env.USERPROFILE || "";
@@ -547,7 +573,7 @@ export async function handleStudioApi(
547
573
  } catch {}
548
574
  return Response.json({ path: null });
549
575
  } catch (error) {
550
- return Response.json({ error: errorMessage(error) }, { status: 500 });
576
+ return problem("internalError", errorMessage(error));
551
577
  }
552
578
  }
553
579
 
@@ -575,7 +601,7 @@ export async function handleStudioApi(
575
601
  }
576
602
  return Response.json(sites);
577
603
  } catch (error) {
578
- return Response.json({ error: errorMessage(error) }, { status: 500 });
604
+ return problem("internalError", errorMessage(error));
579
605
  }
580
606
  }
581
607
 
@@ -605,32 +631,29 @@ export async function handleStudioApi(
605
631
  design,
606
632
  } = body;
607
633
  if (!name || !directory) {
608
- return Response.json({ error: "name and directory are required" }, { status: 400 });
634
+ return problem("invalidRequest", "name and directory are required");
609
635
  }
610
636
  // The destination is the user's to choose (specs/server.md §4.2). Without one the project
611
637
  // Would silently land under the server root — which, when the dev server is the jx checkout,
612
638
  // Means scaffolding into the monorepo. Refuse instead of guessing.
613
639
  if (destination?.kind !== "path" || !destination.parent) {
614
- return Response.json({ error: "A destination folder is required." }, { status: 400 });
640
+ return problem("invalidRequest", "A destination folder is required.");
615
641
  }
616
642
  // `directory` names the project FOLDER, not a path: a separator or dot-segment would walk out
617
643
  // Of the parent that was just vetted below, which is exactly what the vetting is for.
618
644
  if (/[/\\]/.test(directory) || directory === "." || directory === "..") {
619
- return Response.json(
620
- { error: "Directory must be a folder name, not a path" },
621
- { status: 400 },
622
- );
645
+ return problem("invalidRequest", "Directory must be a folder name, not a path");
623
646
  }
624
647
  const { isTemplateId } = await import("@jxsuite/create/templates");
625
648
  if (template !== undefined && !isTemplateId(template)) {
626
- return Response.json({ error: `Unknown template: "${template}"` }, { status: 400 });
649
+ return problem("invalidRequest", `Unknown template: "${template}"`);
627
650
  }
628
651
  // A bad destination is client input, so answer 400 rather than letting the guard's throw fall
629
652
  // Through to the catch-all 500 below.
630
653
  try {
631
654
  assertCreatableParent(destination.parent, root, opts.allowedRoots);
632
655
  } catch (error) {
633
- return Response.json({ error: errorMessage(error) }, { status: 400 });
656
+ return problem("invalidRequest", errorMessage(error));
634
657
  }
635
658
  const destPath = resolve(destination.parent, directory);
636
659
 
@@ -655,7 +678,7 @@ export async function handleStudioApi(
655
678
  opts.onProjectCreated?.(destPath);
656
679
  return Response.json({ config, root: inRoot ? fwd(relative(root, destPath)) : destPath });
657
680
  } catch (error) {
658
- return Response.json({ error: errorMessage(error) }, { status: 500 });
681
+ return problem("internalError", errorMessage(error));
659
682
  }
660
683
  }
661
684
 
@@ -665,7 +688,7 @@ export async function handleStudioApi(
665
688
  const { listStarters } = await import("@jxsuite/starters");
666
689
  return Response.json(listStarters());
667
690
  } catch (error) {
668
- return Response.json({ error: errorMessage(error) }, { status: 500 });
691
+ return problem("internalError", errorMessage(error));
669
692
  }
670
693
  }
671
694
 
@@ -677,7 +700,7 @@ export async function handleStudioApi(
677
700
  try {
678
701
  assertAccessible(absDir, root, activeProjectRoot);
679
702
  } catch (error) {
680
- return Response.json({ error: errorMessage(error) }, { status: 400 });
703
+ return problem("invalidRequest", errorMessage(error));
681
704
  }
682
705
 
683
706
  /** Report a path relative to the active project root (or server root as fallback). */
@@ -730,7 +753,7 @@ export async function handleStudioApi(
730
753
  }
731
754
  return Response.json(files);
732
755
  } catch (error) {
733
- return Response.json({ error: errorMessage(error) }, { status: 500 });
756
+ return problem("internalError", errorMessage(error));
734
757
  }
735
758
  }
736
759
 
@@ -741,7 +764,7 @@ export async function handleStudioApi(
741
764
  try {
742
765
  assertAccessible(scanRoot, root, activeProjectRoot);
743
766
  } catch (error) {
744
- return Response.json({ error: errorMessage(error) }, { status: 400 });
767
+ return problem("invalidRequest", errorMessage(error));
745
768
  }
746
769
  try {
747
770
  const registry = await getFormatRegistry(scanRoot);
@@ -882,7 +905,46 @@ export async function handleStudioApi(
882
905
 
883
906
  return Response.json(components);
884
907
  } catch (error) {
885
- 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));
886
948
  }
887
949
  }
888
950
 
@@ -930,7 +992,7 @@ export async function handleStudioApi(
930
992
  }
931
993
  return Response.json(packages);
932
994
  } catch (error) {
933
- return Response.json({ error: errorMessage(error) }, { status: 500 });
995
+ return problem("internalError", errorMessage(error));
934
996
  }
935
997
  }
936
998
 
@@ -938,7 +1000,7 @@ export async function handleStudioApi(
938
1000
  if (path === "/__studio/cem" && req.method === "GET") {
939
1001
  const pkg = url.searchParams.get("pkg");
940
1002
  if (!pkg) {
941
- return new Response("Missing pkg", { status: 400 });
1003
+ return problem("invalidRequest", "Missing pkg");
942
1004
  }
943
1005
  const dir = url.searchParams.get("dir") || activeProjectRoot || root;
944
1006
  const scanRoot = isAbsolute(dir) ? dir : resolve(root, dir);
@@ -964,7 +1026,7 @@ export async function handleStudioApi(
964
1026
  const cem = JSON.parse(await readFile(cemPath, "utf8")) as Cem;
965
1027
  return Response.json({ cem });
966
1028
  } catch (error) {
967
- return Response.json({ error: errorMessage(error) }, { status: 500 });
1029
+ return problem("internalError", errorMessage(error));
968
1030
  }
969
1031
  }
970
1032
 
@@ -974,7 +1036,7 @@ export async function handleStudioApi(
974
1036
  const body = (await req.json()) as { name?: string; dir?: string; dev?: boolean };
975
1037
  const { name } = body;
976
1038
  if (!name || typeof name !== "string") {
977
- return Response.json({ error: "Missing name" }, { status: 400 });
1039
+ return problem("invalidRequest", "Missing name");
978
1040
  }
979
1041
  const dir = body.dir || activeProjectRoot;
980
1042
  const cwd = dir ? (isAbsolute(dir) ? dir : resolve(root, dir)) : root;
@@ -990,14 +1052,11 @@ export async function handleStudioApi(
990
1052
  const exitCode = await proc.exited;
991
1053
  if (exitCode !== 0) {
992
1054
  const stderr = await new Response(proc.stderr).text();
993
- return Response.json(
994
- { error: stderr || `bun add exited with ${exitCode}` },
995
- { status: 500 },
996
- );
1055
+ return problem("internalError", stderr || `bun add exited with ${exitCode}`);
997
1056
  }
998
1057
  return Response.json({ ok: true });
999
1058
  } catch (error) {
1000
- return Response.json({ error: errorMessage(error) }, { status: 500 });
1059
+ return problem("internalError", errorMessage(error));
1001
1060
  }
1002
1061
  }
1003
1062
 
@@ -1007,7 +1066,7 @@ export async function handleStudioApi(
1007
1066
  const body = (await req.json()) as { name?: string; dir?: string };
1008
1067
  const { name } = body;
1009
1068
  if (!name || typeof name !== "string") {
1010
- return Response.json({ error: "Missing name" }, { status: 400 });
1069
+ return problem("invalidRequest", "Missing name");
1011
1070
  }
1012
1071
  const dir = body.dir || activeProjectRoot;
1013
1072
  const cwd = dir ? (isAbsolute(dir) ? dir : resolve(root, dir)) : root;
@@ -1019,14 +1078,11 @@ export async function handleStudioApi(
1019
1078
  const exitCode = await proc.exited;
1020
1079
  if (exitCode !== 0) {
1021
1080
  const stderr = await new Response(proc.stderr).text();
1022
- return Response.json(
1023
- { error: stderr || `bun remove exited with ${exitCode}` },
1024
- { status: 500 },
1025
- );
1081
+ return problem("internalError", stderr || `bun remove exited with ${exitCode}`);
1026
1082
  }
1027
1083
  return Response.json({ ok: true });
1028
1084
  } catch (error) {
1029
- return Response.json({ error: errorMessage(error) }, { status: 500 });
1085
+ return problem("internalError", errorMessage(error));
1030
1086
  }
1031
1087
  }
1032
1088
 
@@ -1038,7 +1094,7 @@ export async function handleStudioApi(
1038
1094
  const scanRoot = isAbsolute(dir) ? dir : resolve(root, dir);
1039
1095
  return Response.json(await installDependencies(scanRoot));
1040
1096
  } catch (error) {
1041
- return Response.json({ error: errorMessage(error) }, { status: 500 });
1097
+ return problem("internalError", errorMessage(error));
1042
1098
  }
1043
1099
  }
1044
1100
 
@@ -1056,7 +1112,7 @@ export async function handleStudioApi(
1056
1112
  const scanRoot = isAbsolute(dir) ? dir : resolve(root, dir);
1057
1113
  return Response.json(await outdatedPackages(scanRoot));
1058
1114
  } catch (error) {
1059
- return Response.json({ error: errorMessage(error) }, { status: 500 });
1115
+ return problem("internalError", errorMessage(error));
1060
1116
  }
1061
1117
  }
1062
1118
 
@@ -1068,13 +1124,13 @@ export async function handleStudioApi(
1068
1124
  updates?: { name: string; version: string; dev?: boolean }[];
1069
1125
  };
1070
1126
  if (!Array.isArray(body.updates)) {
1071
- return Response.json({ error: "Missing updates" }, { status: 400 });
1127
+ return problem("invalidRequest", "Missing updates");
1072
1128
  }
1073
1129
  const dir = body.dir || activeProjectRoot || root;
1074
1130
  const scanRoot = isAbsolute(dir) ? dir : resolve(root, dir);
1075
1131
  return Response.json(await setPackageVersions(scanRoot, body.updates));
1076
1132
  } catch (error) {
1077
- return Response.json({ error: errorMessage(error) }, { status: 500 });
1133
+ return problem("internalError", errorMessage(error));
1078
1134
  }
1079
1135
  }
1080
1136
 
@@ -1082,7 +1138,7 @@ export async function handleStudioApi(
1082
1138
  if (path === "/__studio/file" && req.method === "GET") {
1083
1139
  const fp = url.searchParams.get("path");
1084
1140
  if (!fp) {
1085
- return new Response("Missing path", { status: 400 });
1141
+ return problem("invalidRequest", "Missing path");
1086
1142
  }
1087
1143
  const abs = expandTilde(fp);
1088
1144
  try {
@@ -1100,8 +1156,8 @@ export async function handleStudioApi(
1100
1156
  );
1101
1157
  } catch (error) {
1102
1158
  return (error as NodeJS.ErrnoException).code === "ENOENT"
1103
- ? new Response("Not found", { status: 404 })
1104
- : Response.json({ error: errorMessage(error) }, { status: 500 });
1159
+ ? problem("notFound", "Not found")
1160
+ : problem("internalError", errorMessage(error));
1105
1161
  }
1106
1162
  }
1107
1163
 
@@ -1109,7 +1165,7 @@ export async function handleStudioApi(
1109
1165
  if (path === "/__studio/file" && req.method === "PUT") {
1110
1166
  const fp = url.searchParams.get("path");
1111
1167
  if (!fp) {
1112
- return new Response("Missing path", { status: 400 });
1168
+ return problem("invalidRequest", "Missing path");
1113
1169
  }
1114
1170
  const abs = resolve(root, fp);
1115
1171
  try {
@@ -1122,7 +1178,7 @@ export async function handleStudioApi(
1122
1178
  await writeFile(abs, await req.text(), "utf8");
1123
1179
  return Response.json({ ok: true, path: fwd(relative(root, abs)) });
1124
1180
  } catch (error) {
1125
- return Response.json({ error: errorMessage(error) }, { status: 500 });
1181
+ return problem("internalError", errorMessage(error));
1126
1182
  }
1127
1183
  }
1128
1184
 
@@ -1130,7 +1186,7 @@ export async function handleStudioApi(
1130
1186
  if (path === "/__studio/file/upload" && req.method === "POST") {
1131
1187
  const fp = url.searchParams.get("path");
1132
1188
  if (!fp) {
1133
- return new Response("Missing path", { status: 400 });
1189
+ return problem("invalidRequest", "Missing path");
1134
1190
  }
1135
1191
  const abs = resolve(root, fp);
1136
1192
  try {
@@ -1144,7 +1200,7 @@ export async function handleStudioApi(
1144
1200
  await Bun.write(abs, new Uint8Array(buffer));
1145
1201
  return Response.json({ ok: true, path: fwd(relative(root, abs)) });
1146
1202
  } catch (error) {
1147
- return Response.json({ error: errorMessage(error) }, { status: 500 });
1203
+ return problem("internalError", errorMessage(error));
1148
1204
  }
1149
1205
  }
1150
1206
 
@@ -1152,7 +1208,7 @@ export async function handleStudioApi(
1152
1208
  if (path === "/__studio/file" && req.method === "DELETE") {
1153
1209
  const fp = url.searchParams.get("path");
1154
1210
  if (!fp) {
1155
- return new Response("Missing path", { status: 400 });
1211
+ return problem("invalidRequest", "Missing path");
1156
1212
  }
1157
1213
  const abs = resolve(root, fp);
1158
1214
  try {
@@ -1165,8 +1221,8 @@ export async function handleStudioApi(
1165
1221
  return Response.json({ ok: true, path: fwd(relative(root, abs)) });
1166
1222
  } catch (error) {
1167
1223
  return (error as NodeJS.ErrnoException).code === "ENOENT"
1168
- ? new Response("Not found", { status: 404 })
1169
- : Response.json({ error: errorMessage(error) }, { status: 500 });
1224
+ ? problem("notFound", "Not found")
1225
+ : problem("internalError", errorMessage(error));
1170
1226
  }
1171
1227
  }
1172
1228
 
@@ -1176,11 +1232,11 @@ export async function handleStudioApi(
1176
1232
  try {
1177
1233
  body = (await req.json()) as { from?: string; to?: string };
1178
1234
  } catch {
1179
- return new Response("Invalid JSON", { status: 400 });
1235
+ return problem("invalidRequest", "Invalid JSON");
1180
1236
  }
1181
1237
  const { from, to } = body;
1182
1238
  if (!from || !to) {
1183
- return new Response("Missing from or to", { status: 400 });
1239
+ return problem("invalidRequest", "Missing from or to");
1184
1240
  }
1185
1241
  const absFrom = resolve(root, from);
1186
1242
  const absTo = resolve(root, to);
@@ -1194,7 +1250,7 @@ export async function handleStudioApi(
1194
1250
  await mkdir(dirname(absTo), { recursive: true });
1195
1251
  await rename(absFrom, absTo);
1196
1252
  } catch (error) {
1197
- return Response.json({ error: errorMessage(error) }, { status: 500 });
1253
+ return problem("internalError", errorMessage(error));
1198
1254
  }
1199
1255
  // Refactor pass: rewrite path references project-wide and, for a component, auto-rename its tag.
1200
1256
  // The move already succeeded, so a refactor failure is reported but never fails the rename.
@@ -1213,17 +1269,42 @@ export async function handleStudioApi(
1213
1269
  }
1214
1270
  }
1215
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
+
1216
1297
  // Locate a file by name within the project root
1217
1298
  if (path === "/__studio/locate" && req.method === "POST") {
1218
1299
  let body: { name?: string };
1219
1300
  try {
1220
1301
  body = (await req.json()) as { name?: string };
1221
1302
  } catch {
1222
- return new Response("Invalid JSON", { status: 400 });
1303
+ return problem("invalidRequest", "Invalid JSON");
1223
1304
  }
1224
1305
  const { name } = body;
1225
1306
  if (!name) {
1226
- return new Response("Missing name", { status: 400 });
1307
+ return problem("invalidRequest", "Missing name");
1227
1308
  }
1228
1309
 
1229
1310
  try {
@@ -1244,7 +1325,7 @@ export async function handleStudioApi(
1244
1325
  ...(matches.length > 1 ? { alternatives: matches } : {}),
1245
1326
  });
1246
1327
  } catch (error) {
1247
- return Response.json({ error: errorMessage(error) }, { status: 500 });
1328
+ return problem("internalError", errorMessage(error));
1248
1329
  }
1249
1330
  }
1250
1331
 
@@ -1262,17 +1343,17 @@ export async function handleStudioApi(
1262
1343
  ];
1263
1344
  const cfToken = req.headers.get("X-CF-Token");
1264
1345
  if (!cfToken) {
1265
- return Response.json({ error: "Missing X-CF-Token header" }, { status: 401 });
1346
+ return problem("unauthorized", "Missing X-CF-Token header");
1266
1347
  }
1267
1348
  let payload: { path?: string; method?: string; body?: unknown };
1268
1349
  try {
1269
1350
  payload = (await req.json()) as typeof payload;
1270
1351
  } catch {
1271
- return Response.json({ error: "Invalid JSON body" }, { status: 400 });
1352
+ return problem("invalidRequest", "Invalid JSON body");
1272
1353
  }
1273
1354
  const apiPath = payload.path ?? "";
1274
1355
  if (!CF_PROXY_ALLOWLIST.some((re) => re.test(apiPath))) {
1275
- return Response.json({ error: `Path not allowed: ${apiPath}` }, { status: 403 });
1356
+ return problem("forbidden", `Path not allowed: ${apiPath}`);
1276
1357
  }
1277
1358
  const method = payload.method ?? "GET";
1278
1359
  const upstream = await fetch(`https://api.cloudflare.com/client/v4${apiPath}`, {
@@ -1307,18 +1388,15 @@ export async function handleStudioApi(
1307
1388
  assertAccessible(projectRoot, root, activeProjectRoot);
1308
1389
 
1309
1390
  if (!format || !action) {
1310
- return Response.json({ error: "Missing format or action" }, { status: 400 });
1391
+ return problem("invalidRequest", "Missing format or action");
1311
1392
  }
1312
1393
  if (action !== "parse" && action !== "serialize") {
1313
- return Response.json({ error: `Unsupported action "${action}"` }, { status: 400 });
1394
+ return problem("invalidRequest", `Unsupported action "${action}"`);
1314
1395
  }
1315
1396
  const registry = await getFormatRegistry(projectRoot);
1316
1397
  const entry = registry.byName(format);
1317
1398
  if (!entry) {
1318
- return Response.json(
1319
- { error: `Format "${format}" is not an imported format class` },
1320
- { status: 404 },
1321
- );
1399
+ return problem("notFound", `Format "${format}" is not an imported format class`);
1322
1400
  }
1323
1401
  const result =
1324
1402
  action === "parse"
@@ -1326,7 +1404,7 @@ export async function handleStudioApi(
1326
1404
  : await entry.call("serialize", doc ?? {}, options);
1327
1405
  return Response.json({ result });
1328
1406
  } catch (error) {
1329
- return Response.json({ error: errorMessage(error) }, { status: 500 });
1407
+ return problem("internalError", errorMessage(error));
1330
1408
  }
1331
1409
  }
1332
1410
 
@@ -1354,7 +1432,7 @@ export async function handleStudioApi(
1354
1432
  })),
1355
1433
  });
1356
1434
  } catch (error) {
1357
- return Response.json({ error: errorMessage(error) }, { status: 400 });
1435
+ return problem("invalidRequest", errorMessage(error));
1358
1436
  }
1359
1437
  }
1360
1438
 
@@ -1367,13 +1445,13 @@ export async function handleStudioApi(
1367
1445
  try {
1368
1446
  assertAccessible(projectRoot, root, activeProjectRoot);
1369
1447
  } catch (error) {
1370
- return Response.json({ error: errorMessage(error) }, { status: 400 });
1448
+ return problem("invalidRequest", errorMessage(error));
1371
1449
  }
1372
1450
  try {
1373
1451
  const { document, project } = await readBundledProjectSchemas(projectRoot);
1374
1452
  return Response.json({ document, project });
1375
1453
  } catch (error) {
1376
- return Response.json({ error: errorMessage(error) }, { status: 500 });
1454
+ return problem("internalError", errorMessage(error));
1377
1455
  }
1378
1456
  }
1379
1457
 
@@ -1382,7 +1460,7 @@ export async function handleStudioApi(
1382
1460
  const prototype = url.searchParams.get("prototype");
1383
1461
  const base = url.searchParams.get("base");
1384
1462
  if (!src) {
1385
- return new Response("Missing src param", { status: 400 });
1463
+ return problem("invalidRequest", "Missing src param");
1386
1464
  }
1387
1465
 
1388
1466
  let moduleAbsPath;
@@ -1487,7 +1565,17 @@ export async function handleStudioApi(
1487
1565
  const stdout = await new Response(proc.stdout).text();
1488
1566
  const stderr = await new Response(proc.stderr).text();
1489
1567
  if (exitCode !== 0) {
1490
- 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
+ });
1491
1579
  }
1492
1580
  return stdout;
1493
1581
  };
@@ -1536,7 +1624,7 @@ export async function handleStudioApi(
1536
1624
  url?: string;
1537
1625
  };
1538
1626
  if (!name || !remoteUrl) {
1539
- return new Response("name and url required", { status: 400 });
1627
+ return problem("invalidRequest", "name and url required");
1540
1628
  }
1541
1629
  await runGit(["remote", "add", name, remoteUrl]);
1542
1630
  return Response.json({ ok: true });
@@ -1576,11 +1664,11 @@ export async function handleStudioApi(
1576
1664
  if (gitCmd === "stage" && req.method === "POST") {
1577
1665
  const { files } = (await req.json()) as { files?: string[] };
1578
1666
  if (!Array.isArray(files) || files.length === 0) {
1579
- return Response.json({ error: "Missing files" }, { status: 400 });
1667
+ return problem("invalidRequest", "Missing files");
1580
1668
  }
1581
1669
  for (const f of files) {
1582
1670
  if (f.includes("..")) {
1583
- return Response.json({ error: "Invalid path" }, { status: 400 });
1671
+ return problem("invalidRequest", "Invalid path");
1584
1672
  }
1585
1673
  }
1586
1674
  await runGit(["add", "--", ...files]);
@@ -1590,7 +1678,7 @@ export async function handleStudioApi(
1590
1678
  if (gitCmd === "unstage" && req.method === "POST") {
1591
1679
  const { files } = (await req.json()) as { files?: string[] };
1592
1680
  if (!Array.isArray(files) || files.length === 0) {
1593
- return Response.json({ error: "Missing files" }, { status: 400 });
1681
+ return problem("invalidRequest", "Missing files");
1594
1682
  }
1595
1683
  await runGit(["restore", "--staged", "--", ...files]);
1596
1684
  return Response.json({ ok: true });
@@ -1599,7 +1687,7 @@ export async function handleStudioApi(
1599
1687
  if (gitCmd === "commit" && req.method === "POST") {
1600
1688
  const { message } = (await req.json()) as { message?: string };
1601
1689
  if (!message || typeof message !== "string") {
1602
- return Response.json({ error: "Missing message" }, { status: 400 });
1690
+ return problem("invalidRequest", "Missing message");
1603
1691
  }
1604
1692
  const statusOut = await runGit(["status", "--porcelain"]);
1605
1693
  const hasStaged = statusOut
@@ -1628,7 +1716,28 @@ export async function handleStudioApi(
1628
1716
  }
1629
1717
 
1630
1718
  if (gitCmd === "pull" && req.method === "POST") {
1631
- 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
+ }
1632
1741
  return Response.json({ ok: true });
1633
1742
  }
1634
1743
 
@@ -1640,7 +1749,7 @@ export async function handleStudioApi(
1640
1749
  if (gitCmd === "checkout" && req.method === "POST") {
1641
1750
  const { branch } = (await req.json()) as { branch?: string };
1642
1751
  if (!branch || typeof branch !== "string") {
1643
- return Response.json({ error: "Missing branch" }, { status: 400 });
1752
+ return problem("invalidRequest", "Missing branch");
1644
1753
  }
1645
1754
  await runGit(["checkout", branch]);
1646
1755
  return Response.json({ ok: true });
@@ -1649,7 +1758,7 @@ export async function handleStudioApi(
1649
1758
  if (gitCmd === "create-branch" && req.method === "POST") {
1650
1759
  const { name } = (await req.json()) as { name?: string };
1651
1760
  if (!name || typeof name !== "string") {
1652
- return Response.json({ error: "Missing name" }, { status: 400 });
1761
+ return problem("invalidRequest", "Missing name");
1653
1762
  }
1654
1763
  await runGit(["checkout", "-b", name]);
1655
1764
  return Response.json({ ok: true });
@@ -1658,10 +1767,10 @@ export async function handleStudioApi(
1658
1767
  if (gitCmd === "diff" && req.method === "GET") {
1659
1768
  const fp = url.searchParams.get("path");
1660
1769
  if (!fp) {
1661
- return Response.json({ error: "Missing path" }, { status: 400 });
1770
+ return problem("invalidRequest", "Missing path");
1662
1771
  }
1663
1772
  if (fp.includes("..")) {
1664
- return Response.json({ error: "Invalid path" }, { status: 400 });
1773
+ return problem("invalidRequest", "Invalid path");
1665
1774
  }
1666
1775
  const diff = await runGit(["diff", "--", fp]);
1667
1776
  return Response.json({ diff });
@@ -1671,10 +1780,10 @@ export async function handleStudioApi(
1671
1780
  const fp = url.searchParams.get("path");
1672
1781
  const ref = url.searchParams.get("ref") || "HEAD";
1673
1782
  if (!fp) {
1674
- return Response.json({ error: "Missing path" }, { status: 400 });
1783
+ return problem("invalidRequest", "Missing path");
1675
1784
  }
1676
1785
  if (fp.includes("..")) {
1677
- return Response.json({ error: "Invalid path" }, { status: 400 });
1786
+ return problem("invalidRequest", "Invalid path");
1678
1787
  }
1679
1788
  const content = await runGit(["show", `${ref}:${fp}`]);
1680
1789
  return Response.json({ content });
@@ -1683,11 +1792,11 @@ export async function handleStudioApi(
1683
1792
  if (gitCmd === "discard" && req.method === "POST") {
1684
1793
  const { files } = (await req.json()) as { files?: string[] };
1685
1794
  if (!Array.isArray(files) || files.length === 0) {
1686
- return Response.json({ error: "Missing files" }, { status: 400 });
1795
+ return problem("invalidRequest", "Missing files");
1687
1796
  }
1688
1797
  for (const f of files) {
1689
1798
  if (f.includes("..")) {
1690
- return Response.json({ error: "Invalid path" }, { status: 400 });
1799
+ return problem("invalidRequest", "Invalid path");
1691
1800
  }
1692
1801
  }
1693
1802
  await runGit(["checkout", "--", ...files]);
@@ -1697,7 +1806,7 @@ export async function handleStudioApi(
1697
1806
  if (gitCmd === "clone" && req.method === "POST") {
1698
1807
  const { url: repoUrl } = (await req.json()) as { url?: string };
1699
1808
  if (!repoUrl || typeof repoUrl !== "string") {
1700
- return Response.json({ error: "Missing url" }, { status: 400 });
1809
+ return problem("invalidRequest", "Missing url");
1701
1810
  }
1702
1811
  const repoName = basename(repoUrl.replace(/\.git$/, ""));
1703
1812
  const dest = resolve(cwd, repoName);
@@ -1714,7 +1823,7 @@ export async function handleStudioApi(
1714
1823
  return Response.json({ ok: true, root: dest });
1715
1824
  }
1716
1825
  } catch (error) {
1717
- return Response.json({ error: errorMessage(error) }, { status: 500 });
1826
+ return problem("internalError", errorMessage(error));
1718
1827
  }
1719
1828
  }
1720
1829