@jxsuite/server 2.0.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/package.json +19 -16
- package/src/ai-api.ts +30 -13
- package/src/code-api.ts +2 -1
- package/src/collab.ts +35 -4
- package/src/data-api.ts +8 -1
- package/src/import-api.ts +5 -4
- package/src/jx-mounts.ts +11 -0
- package/src/net-guard.ts +188 -28
- package/src/oauth-loopback.ts +373 -0
- package/src/packages.ts +13 -2
- package/src/problem.ts +89 -0
- package/src/project-server.ts +84 -11
- package/src/refactor/apply.ts +3 -57
- package/src/refactor/find-refs.ts +0 -0
- package/src/refactor/index.ts +7 -0
- package/src/refactor/refs.ts +103 -63
- package/src/refactor/scan.ts +70 -0
- package/src/refactor/watcher.ts +4 -0
- package/src/resolve.ts +9 -10
- package/src/server.ts +36 -6
- package/src/site-output.ts +111 -0
- package/src/site-preview.ts +106 -0
- package/src/studio-api.ts +198 -89
- package/src/watch.ts +54 -3
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
?
|
|
1104
|
-
:
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
?
|
|
1169
|
-
:
|
|
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
|
|
1235
|
+
return problem("invalidRequest", "Invalid JSON");
|
|
1180
1236
|
}
|
|
1181
1237
|
const { from, to } = body;
|
|
1182
1238
|
if (!from || !to) {
|
|
1183
|
-
return
|
|
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
|
|
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
|
|
1303
|
+
return problem("invalidRequest", "Invalid JSON");
|
|
1223
1304
|
}
|
|
1224
1305
|
const { name } = body;
|
|
1225
1306
|
if (!name) {
|
|
1226
|
-
return
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
1391
|
+
return problem("invalidRequest", "Missing format or action");
|
|
1311
1392
|
}
|
|
1312
1393
|
if (action !== "parse" && action !== "serialize") {
|
|
1313
|
-
return
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
1667
|
+
return problem("invalidRequest", "Missing files");
|
|
1580
1668
|
}
|
|
1581
1669
|
for (const f of files) {
|
|
1582
1670
|
if (f.includes("..")) {
|
|
1583
|
-
return
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
1770
|
+
return problem("invalidRequest", "Missing path");
|
|
1662
1771
|
}
|
|
1663
1772
|
if (fp.includes("..")) {
|
|
1664
|
-
return
|
|
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
|
|
1783
|
+
return problem("invalidRequest", "Missing path");
|
|
1675
1784
|
}
|
|
1676
1785
|
if (fp.includes("..")) {
|
|
1677
|
-
return
|
|
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
|
|
1795
|
+
return problem("invalidRequest", "Missing files");
|
|
1687
1796
|
}
|
|
1688
1797
|
for (const f of files) {
|
|
1689
1798
|
if (f.includes("..")) {
|
|
1690
|
-
return
|
|
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
|
|
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
|
|
1826
|
+
return problem("internalError", errorMessage(error));
|
|
1718
1827
|
}
|
|
1719
1828
|
}
|
|
1720
1829
|
|