@jxsuite/server 4.5.0 → 6.0.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,7 @@
10
10
  import { basename, dirname, extname, isAbsolute, join, relative, resolve } from "node:path";
11
11
  import { componentMetaFrom } from "@jxsuite/schema/component-meta";
12
12
  import { errorMessage } from "@jxsuite/schema/parse";
13
- import { mkdir, readFile, readdir, rename, stat, unlink, writeFile } from "node:fs/promises";
13
+ import { mkdir, readFile, readdir, rename, rmdir, stat, unlink, writeFile } from "node:fs/promises";
14
14
  import { existsSync, readFileSync, statSync, unlinkSync } from "node:fs";
15
15
  import { homedir } from "node:os";
16
16
  import { LOCATION_ID_FILE } from "@jxsuite/protocol/routes";
@@ -40,8 +40,9 @@ import {
40
40
  import type { ExtensionRegistry } from "@jxsuite/schema/extension-registry";
41
41
  import type { FormatRegistry } from "@jxsuite/schema/format-registry";
42
42
  import type { ClassJsonDef } from "./types.ts";
43
- import type { DesignOptions } from "@jxsuite/create/generate";
44
- import type { ProjectConfig } from "@jxsuite/schema/types";
43
+ import type { DesignOptions, ProjectOptions } from "@jxsuite/create/generate";
44
+ import type { JsonObject, JsonPrimitive, JsonValue, ProjectConfig } from "@jxsuite/schema/types";
45
+ import { isFunction, isJsonObject, isNonNullObject, isString } from "@jxsuite/schema/guards";
45
46
  import { problem } from "./problem.ts";
46
47
 
47
48
  /** Normalise a path to forward slashes (Windows `path` module returns backslashes). */
@@ -55,13 +56,13 @@ const fwd = (p: string) => p.replaceAll("\\", "/");
55
56
  const expandTilde = (p: string) =>
56
57
  p.startsWith("~") ? join(process.env.HOME || process.env.USERPROFILE || "", p.slice(1)) : p;
57
58
 
59
+ /** The `package.json` members these routes read. */
58
60
  interface PackageJson {
59
61
  name?: string;
60
- workspaces?: unknown;
62
+ workspaces?: JsonValue;
61
63
  dependencies?: Record<string, string>;
62
64
  devDependencies?: Record<string, string>;
63
65
  customElements?: string;
64
- [key: string]: unknown;
65
66
  }
66
67
 
67
68
  /** A custom-element-manifest declaration (subset consumed here). */
@@ -69,17 +70,17 @@ interface CemDeclaration {
69
70
  customElement?: boolean;
70
71
  tagName?: string;
71
72
  description?: string;
72
- cssProperties?: unknown[];
73
- events?: unknown[];
74
- slots?: unknown[];
75
- members?: { kind?: string; privacy?: string; [key: string]: unknown }[];
73
+ cssProperties?: JsonValue[];
74
+ events?: JsonValue[];
75
+ slots?: JsonValue[];
76
+ /** Relayed verbatim once filtered on `kind` and `privacy`. */
77
+ members?: JsonObject[];
76
78
  attributes?: {
77
79
  name?: string;
78
- default?: unknown;
80
+ default?: JsonValue;
79
81
  description?: string;
80
82
  type?: { text?: string };
81
83
  }[];
82
- [key: string]: unknown;
83
84
  }
84
85
 
85
86
  interface CemModule {
@@ -91,15 +92,6 @@ interface Cem {
91
92
  modules?: CemModule[];
92
93
  }
93
94
 
94
- /** A parsed Jx component document (subset consumed by component discovery). */
95
- interface ComponentDoc {
96
- $id?: string;
97
- tagName?: string;
98
- $elements?: unknown[];
99
- state?: Record<string, unknown>;
100
- [key: string]: unknown;
101
- }
102
-
103
95
  // ─── Extension registry (per project root, invalidated on project.json change) ──
104
96
 
105
97
  const extensionRegistryCache = new Map<string, { mtime: number; registry: ExtensionRegistry }>();
@@ -117,6 +109,8 @@ async function getExtensionRegistry(projectRoot: string): Promise<ExtensionRegis
117
109
  let projectConfig: ProjectConfig | undefined;
118
110
  try {
119
111
  mtime = statSync(projectJsonPath).mtimeMs;
112
+ /* SAFETY: buildProjectExtensionRegistry reads only `projectConfig?.extensions` and validates
113
+ that list itself; every other ProjectConfig member is optional and unread on this path. */
120
114
  projectConfig = JSON.parse(readFileSync(projectJsonPath, "utf8")) as ProjectConfig;
121
115
  } catch {
122
116
  projectConfig = undefined;
@@ -206,19 +200,43 @@ export function isOwnedProjectDir(dir: string): boolean {
206
200
  return existsSync(resolve(dir, "project.json"));
207
201
  }
208
202
 
209
- const statusMap: Record<string, string> = {
210
- A: "A",
211
- C: "C",
212
- D: "D",
213
- M: "M",
214
- R: "R",
215
- T: "T",
216
- U: "U",
217
- };
203
+ /**
204
+ * Whether the project at `dir` is under version control of its own, rather than a stranger inside
205
+ * somebody else's checkout.
206
+ *
207
+ * `git` answers for the nearest enclosing work tree, which is not the same question. A project
208
+ * copied into a folder another repository IGNORES (a scratch copy under a monorepo's `.cache/`, a
209
+ * download into a dotfiles repo's ignored `tmp/`) is "inside a work tree" by git's account, yet
210
+ * none of its files can ever be committed there: Publish would list the enclosing repository's
211
+ * branches and commits, and opening the project would fetch and pull a repository the user never
212
+ * chose. Such a project is therefore not a repository, and offers to initialise one. A project that
213
+ * is a TRACKED subfolder of a larger repository (a site inside a monorepo) still is one.
214
+ *
215
+ * @param {string} dir - The project root
216
+ * @returns {Promise<boolean>}
217
+ */
218
+ export async function projectHasRepository(dir: string): Promise<boolean> {
219
+ const exitOf = async (args: string[]) => {
220
+ const proc = Bun.spawn(["git", ...args], { cwd: dir, stderr: "ignore", stdout: "ignore" });
221
+ return await proc.exited;
222
+ };
223
+ if ((await exitOf(["rev-parse", "--is-inside-work-tree"])) !== 0) {
224
+ return false;
225
+ }
226
+ // `check-ignore` exits 0 when the path IS ignored, 1 when it is not.
227
+ return (await exitOf(["check-ignore", "-q", "."])) !== 0;
228
+ }
218
229
 
219
230
  /**
220
231
  * Parse raw `git status --porcelain=v2 --branch` output into structured data.
221
232
  *
233
+ * The route runs it with `-z`, and that is the form that matters: without `-z` git C-quotes every
234
+ * path holding a byte outside printable ASCII (`72” Grapple.md` arrives as `"72\342\200\235
235
+ * Grapple.md"`), so Studio listed octal escapes inside stray quotes and asked git to stage a file
236
+ * that does not exist. With `-z` each record ends in NUL and every path is verbatim; a rename's
237
+ * original path is the record after it, which is skipped. Newline-separated output is still read,
238
+ * for callers and tests that hand it plain text.
239
+ *
222
240
  * @param {string} out
223
241
  * @returns {{
224
242
  * branch: string;
@@ -236,7 +254,10 @@ export function parseGitStatus(out: string) {
236
254
  /** @type {{ path: string; status: string; staged: boolean }[]} */
237
255
  const files = [];
238
256
 
239
- for (const line of out.split("\n")) {
257
+ const nulTerminated = out.includes("\0");
258
+ const records = out.split(nulTerminated ? "\0" : "\n");
259
+ for (let index = 0; index < records.length; index += 1) {
260
+ const line = records[index]!;
240
261
  if (!line) {
241
262
  continue;
242
263
  }
@@ -255,25 +276,30 @@ export function parseGitStatus(out: string) {
255
276
  const stagedCode = xy[0]!;
256
277
  const unstagedCode = xy[1]!;
257
278
  let filePath;
258
- if (line.startsWith("2 ")) {
279
+ if (line.startsWith("2 ") && nulTerminated) {
280
+ // `2 XY sub mH mI mW hH hI Xscore path`, then the original path as its own record.
281
+ filePath = parts.slice(9).join(" ");
282
+ index += 1;
283
+ } else if (line.startsWith("2 ")) {
259
284
  const tabIdx = line.indexOf("\t");
260
285
  const pathPart = line.slice(tabIdx + 1);
261
286
  filePath = pathPart.split("\t").pop() || "";
262
287
  } else {
263
288
  filePath = parts.slice(8).join(" ");
264
289
  }
290
+ // Porcelain v2 XY codes (A, C, D, M, R, T, U, ...) are reported verbatim; "." means unchanged.
265
291
  if (stagedCode !== ".") {
266
292
  files.push({
267
293
  path: filePath,
268
294
  staged: true,
269
- status: statusMap[stagedCode] || stagedCode,
295
+ status: stagedCode,
270
296
  });
271
297
  }
272
298
  if (unstagedCode !== ".") {
273
299
  files.push({
274
300
  path: filePath,
275
301
  staged: false,
276
- status: statusMap[unstagedCode] || unstagedCode,
302
+ status: unstagedCode,
277
303
  });
278
304
  }
279
305
  } else if (line.startsWith("? ")) {
@@ -284,6 +310,33 @@ export function parseGitStatus(out: string) {
284
310
  return { ahead, behind, branch, files };
285
311
  }
286
312
 
313
+ /** An imported plugin module, or its default export, read by export name. */
314
+ interface PluginModule {
315
+ readonly [exportName: string]: PluginExport;
316
+ }
317
+
318
+ /** A function a plugin module exports: the plugin class, whose static `schema` the studio renders. */
319
+ interface PluginClass extends PluginModule {
320
+ (...args: never[]): void;
321
+ }
322
+
323
+ /**
324
+ * What schema discovery can meet under an export name, spelled out by kind. Only a function is used
325
+ * (as the plugin class); an object is searched one level, as a default export; every other kind of
326
+ * value is a miss.
327
+ */
328
+ type PluginExport = PluginClass | PluginModule | JsonPrimitive | bigint | symbol | undefined;
329
+
330
+ /** Whether an export is a function, and so a candidate plugin class. */
331
+ function isPluginClass(value: PluginExport): value is PluginClass {
332
+ return isFunction(value);
333
+ }
334
+
335
+ /** The body of `POST /__studio/git/push`; an absent or unparsable body means a plain push. */
336
+ interface PushBody {
337
+ setUpstream?: boolean;
338
+ }
339
+
287
340
  /** Host hooks for the routes that reach outside the server root (project creation). */
288
341
  export interface StudioApiOptions {
289
342
  /** Extra roots a new project may be created under, mirroring `createDevServer.allowedRoots`. */
@@ -357,6 +410,27 @@ function byPathOrder<T extends { path: string }>(entries: T[]): T[] {
357
410
  return entries.toSorted((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
358
411
  }
359
412
 
413
+ /**
414
+ * A git command that exited non-zero, carrying both output streams (git splits failures across
415
+ * them).
416
+ */
417
+ class GitCommandError extends Error {
418
+ readonly stderr: string;
419
+ readonly stdout: string;
420
+
421
+ constructor(message: string, stderr: string, stdout: string) {
422
+ super(message);
423
+ this.name = "GitCommandError";
424
+ this.stderr = stderr;
425
+ this.stdout = stdout;
426
+ }
427
+ }
428
+
429
+ /** True when a failed filesystem call failed because the path does not exist (`ENOENT`). */
430
+ function isNotFound(cause: unknown): boolean {
431
+ return isNonNullObject(cause) && "code" in cause && cause.code === "ENOENT";
432
+ }
433
+
360
434
  export async function handleStudioApi(
361
435
  req: Request,
362
436
  url: URL,
@@ -375,6 +449,9 @@ export async function handleStudioApi(
375
449
  // Project metadata
376
450
  if (path === "/__studio/project" && req.method === "GET") {
377
451
  try {
452
+ /* SAFETY: only `name` and `workspaces` are read, each behind a `??` fallback; a file whose
453
+ JSON is not an object yields undefined members (or throws on null) and answers the same
454
+ fallback shape as the catch below. */
378
455
  const pkg = JSON.parse(await readFile(resolve(root, "package.json"), "utf8")) as PackageJson;
379
456
  return Response.json({
380
457
  name: pkg.name ?? basename(root),
@@ -419,9 +496,14 @@ export async function handleStudioApi(
419
496
  let isSiteProject = false;
420
497
  let projectConfig: ProjectConfig | null = null;
421
498
  try {
422
- const raw = JSON.parse(await readFile(resolve(absDir, "project.json"), "utf8")) as unknown;
423
- if (typeof raw === "object" && raw !== null && !Array.isArray(raw)) {
499
+ // SAFETY: JSON.parse without a reviver only ever produces JSON values.
500
+ const raw = JSON.parse(
501
+ await readFile(resolve(absDir, "project.json"), "utf8"),
502
+ ) as JsonValue;
503
+ if (isJsonObject(raw)) {
424
504
  isSiteProject = true;
505
+ /* SAFETY: `raw` is a JSON object and every ProjectConfig member is optional; the config
506
+ is only relayed to the client here, never interpreted. */
425
507
  projectConfig = raw as ProjectConfig;
426
508
  }
427
509
  } catch {}
@@ -462,6 +544,8 @@ export async function handleStudioApi(
462
544
  while (dir) {
463
545
  const candidate = resolve(dir, "project.json");
464
546
  if (existsSync(candidate)) {
547
+ /* SAFETY: the config is relayed to the client verbatim and not interpreted here, so its
548
+ member types are the client's to check; malformed JSON throws into the catch below. */
465
549
  const config = JSON.parse(readFileSync(candidate, "utf8")) as ProjectConfig;
466
550
  const relPath = fwd(dir);
467
551
  const fileRelPath = fwd(relative(dir, absFile));
@@ -595,9 +679,12 @@ export async function handleStudioApi(
595
679
  }
596
680
  const fp = resolve(root, match);
597
681
  try {
598
- const raw = JSON.parse(await readFile(fp, "utf8")) as unknown;
599
- if (typeof raw === "object" && raw !== null && !Array.isArray(raw)) {
682
+ // SAFETY: JSON.parse without a reviver only ever produces JSON values.
683
+ const raw = JSON.parse(await readFile(fp, "utf8")) as JsonValue;
684
+ if (isJsonObject(raw)) {
600
685
  const projectDir = fwd(dirname(fp));
686
+ /* SAFETY: `raw` is a JSON object and every ProjectConfig member is optional; the site
687
+ list only relays the config to the client. */
601
688
  sites.push({ config: raw as ProjectConfig, path: projectDir });
602
689
  }
603
690
  } catch {}
@@ -611,6 +698,12 @@ export async function handleStudioApi(
611
698
  // Create a new project
612
699
  if (path === "/__studio/create-project" && req.method === "POST") {
613
700
  try {
701
+ /* SAFETY: trust boundary. The only client is Studio's New Project modal, which sends these
702
+ members with these types. `name`, `directory` and `destination` are checked below before
703
+ any filesystem use, and `template` goes through isTemplateId. `description`, `url`,
704
+ `adapter`, `starter` and `design` are relayed to generateProject unchecked (a falsy
705
+ `adapter` is dropped so the static default applies), and it writes them into the scaffold
706
+ as given. */
614
707
  const body = (await req.json()) as {
615
708
  name?: string;
616
709
  description?: string;
@@ -661,16 +754,29 @@ export async function handleStudioApi(
661
754
  const destPath = resolve(destination.parent, directory);
662
755
 
663
756
  const { generateProject } = await import("@jxsuite/create/generate");
664
- await generateProject(destPath, {
665
- name,
666
- ...(adapter !== undefined ? { adapter } : {}),
667
- ...(description !== undefined ? { description } : {}),
668
- ...(siteUrl !== undefined ? { url: siteUrl } : {}),
669
- ...(starter !== undefined ? { starter } : {}),
670
- ...(template !== undefined && isTemplateId(template) ? { template } : {}),
671
- ...(design !== undefined ? { design } : {}),
672
- });
757
+ const projectOptions: ProjectOptions = { name };
758
+ if (adapter) {
759
+ projectOptions.adapter = adapter;
760
+ }
761
+ if (description !== undefined) {
762
+ projectOptions.description = description;
763
+ }
764
+ if (siteUrl !== undefined) {
765
+ projectOptions.url = siteUrl;
766
+ }
767
+ if (starter !== undefined) {
768
+ projectOptions.starter = starter;
769
+ }
770
+ if (template !== undefined && isTemplateId(template)) {
771
+ projectOptions.template = template;
772
+ }
773
+ if (design !== undefined) {
774
+ projectOptions.design = design;
775
+ }
776
+ await generateProject(destPath, projectOptions);
673
777
 
778
+ /* SAFETY: generateProject just wrote this project.json from its own template, so it is a
779
+ project config; it is relayed to the client, not interpreted here. */
674
780
  const config = JSON.parse(
675
781
  await readFile(resolve(destPath, "project.json"), "utf8"),
676
782
  ) as ProjectConfig;
@@ -706,16 +812,15 @@ export async function handleStudioApi(
706
812
  return problem("invalidRequest", errorMessage(error));
707
813
  }
708
814
 
709
- /** Report a path relative to the active project root (or server root as fallback). */
710
- const reportRelative = (fp: string) => {
711
- if (activeProjectRoot) {
712
- const rel = relative(activeProjectRoot, fp);
713
- if (!rel.startsWith("..")) {
714
- return fwd(rel);
715
- }
716
- }
717
- return fwd(relative(root, fp));
718
- };
815
+ /*
816
+ * Every path out is relative to the active project root (§4.1), a path BESIDE the project
817
+ * included: `../../docs/start`, not `docs/start`. A collection may live outside its project
818
+ * (`sites/jxsuite.com` declares `content.docs.source: "../../docs"`), and a server-root-relative
819
+ * reply for it reads as project-relative to the client, which resolves it against the server
820
+ * root again on the next call and names a directory nobody has. A recursive walk of that
821
+ * collection stopped at its first level, with a 500 for every subdirectory.
822
+ */
823
+ const reportRelative = (fp: string) => fwd(relative(activeProjectRoot ?? root, fp));
719
824
 
720
825
  try {
721
826
  if (pattern) {
@@ -756,7 +861,11 @@ export async function handleStudioApi(
756
861
  }
757
862
  return Response.json(byPathOrder(files));
758
863
  } catch (error) {
759
- return problem("internalError", errorMessage(error));
864
+ // A directory that does not exist is a 404, not a server fault: a client probing whether a
865
+ // Folder exists yet (Add new's collision check) asks exactly this.
866
+ return isNotFound(error)
867
+ ? problem("notFound", errorMessage(error))
868
+ : problem("internalError", errorMessage(error));
760
869
  }
761
870
  }
762
871
 
@@ -784,16 +893,20 @@ export async function handleStudioApi(
784
893
  }
785
894
  const fp = resolve(scanRoot, match);
786
895
  try {
787
- let content: ComponentDoc;
896
+ let content: JsonValue;
788
897
  if (match.endsWith(".json")) {
789
- content = JSON.parse(await readFile(fp, "utf8")) as ComponentDoc;
898
+ // SAFETY: JSON.parse without a reviver only ever produces JSON values.
899
+ content = JSON.parse(await readFile(fp, "utf8")) as JsonValue;
790
900
  } else {
791
901
  const entry = registry.byExtension(extname(match), "parse");
792
902
  if (!entry) {
793
903
  continue;
794
904
  }
795
905
  const source = await readFile(fp, "utf8");
796
- content = (await entry.call("parse", source)) as ComponentDoc;
906
+ const parsed = await entry.call("parse", source);
907
+ /* Only a document object can declare a component, and componentMetaFrom answers null
908
+ for anything else, so loaded entries reach it as null rather than as a document. */
909
+ content = isJsonObject(parsed) ? parsed : null;
797
910
  }
798
911
  /* The metadata rules live in @jxsuite/schema/component-meta because THREE backends need
799
912
  the same answer — this one, the desktop session, and now the cloud adapter, which can
@@ -811,6 +924,9 @@ export async function handleStudioApi(
811
924
  try {
812
925
  const projectPkgPath = resolve(scanRoot, "package.json");
813
926
  if (existsSync(projectPkgPath)) {
927
+ /* SAFETY: only the keys of the dependency maps are used, each as a package name that must
928
+ resolve to node_modules/<name>/package.json before anything else is read, so a name
929
+ that does not is skipped; a throw skips this whole scan. */
814
930
  const pkg = JSON.parse(await readFile(projectPkgPath, "utf8")) as PackageJson;
815
931
  const deps = { ...pkg.dependencies, ...pkg.devDependencies };
816
932
  for (const name of Object.keys(deps)) {
@@ -836,6 +952,8 @@ export async function handleStudioApi(
836
952
  if (!actualPath) {
837
953
  continue;
838
954
  }
955
+ /* SAFETY: `customElements` is the one member read; npm defines it as the manifest
956
+ path, and a non-string value makes resolve() throw into this package's catch. */
839
957
  const depPkg = JSON.parse(await readFile(actualPath, "utf8")) as PackageJson;
840
958
  if (!depPkg.customElements) {
841
959
  continue;
@@ -844,6 +962,9 @@ export async function handleStudioApi(
844
962
  if (!existsSync(cemPath)) {
845
963
  continue;
846
964
  }
965
+ /* SAFETY: the package names this file as its custom-elements manifest, and every
966
+ member read below has a fallback; a manifest of another shape either throws into
967
+ this package's catch or contributes no declarations. */
847
968
  const cem = JSON.parse(await readFile(cemPath, "utf8")) as Cem;
848
969
  for (const mod of cem.modules || []) {
849
970
  for (const decl of mod.declarations || []) {
@@ -912,12 +1033,9 @@ export async function handleStudioApi(
912
1033
  // First would mean every asset 404s for as long as the build takes.
913
1034
  const result = await buildSite(dir, { clean: false, verbose: false });
914
1035
  const preview = startSitePreview(dir);
915
- return Response.json({
916
- errors: result.errors,
917
- files: result.files,
918
- routes: result.routes,
919
- ...(preview ? { url: preview.origin } : {}),
920
- });
1036
+ const built = { errors: result.errors, files: result.files, routes: result.routes };
1037
+ // `url` follows the build fields, and only when a preview origin exists.
1038
+ return Response.json(preview ? { ...built, url: preview.origin } : built);
921
1039
  } catch (error) {
922
1040
  return problem("internalError", errorMessage(error));
923
1041
  }
@@ -943,6 +1061,8 @@ export async function handleStudioApi(
943
1061
  }
944
1062
  let body: { route?: string };
945
1063
  try {
1064
+ /* SAFETY: `route` is the only member read, and only when truthy; it is relayed verbatim in
1065
+ the preview tab's `navigate` event, so nothing here depends on its runtime type. */
946
1066
  body = (await req.json()) as { route?: string };
947
1067
  } catch {
948
1068
  return problem("invalidRequest", "Invalid JSON body");
@@ -976,19 +1096,21 @@ export async function handleStudioApi(
976
1096
  const dir = activeProjectRoot ?? root;
977
1097
  let body: { path?: string; contents?: string };
978
1098
  try {
1099
+ /* SAFETY: both members are re-checked with isString below before either reaches the
1100
+ overlay store. */
979
1101
  body = (await req.json()) as { path?: string; contents?: string };
980
1102
  } catch {
981
1103
  return problem("invalidRequest", "Invalid JSON body");
982
1104
  }
983
1105
  if (req.method === "POST") {
984
- if (typeof body.path !== "string" || typeof body.contents !== "string") {
1106
+ if (!isString(body.path) || !isString(body.contents)) {
985
1107
  return problem("invalidRequest", "Missing path or contents");
986
1108
  }
987
1109
  setLivePreviewOverlay(dir, body.path, body.contents);
988
1110
  return new Response(null, { status: 204 });
989
1111
  }
990
1112
  if (req.method === "DELETE") {
991
- clearLivePreviewOverlay(dir, typeof body.path === "string" ? body.path : undefined);
1113
+ clearLivePreviewOverlay(dir, isString(body.path) ? body.path : undefined);
992
1114
  return new Response(null, { status: 204 });
993
1115
  }
994
1116
  }
@@ -1004,6 +1126,8 @@ export async function handleStudioApi(
1004
1126
  if (!existsSync(pkgPath)) {
1005
1127
  return Response.json([]);
1006
1128
  }
1129
+ /* SAFETY: npm and bun write `dependencies` / `devDependencies` as name-to-range string maps,
1130
+ which is all this route reads; a package.json that is not JSON throws into the catch. */
1007
1131
  const pkg = JSON.parse(await readFile(pkgPath, "utf8")) as PackageJson;
1008
1132
  const deps = { ...pkg.dependencies, ...pkg.devDependencies };
1009
1133
  const packages: {
@@ -1025,6 +1149,8 @@ export async function handleStudioApi(
1025
1149
  continue;
1026
1150
  }
1027
1151
  try {
1152
+ /* SAFETY: `customElements` is the one member read, and it is relayed verbatim or tested
1153
+ for truthiness; malformed JSON skips the package in the catch. */
1028
1154
  const depPkg = JSON.parse(await readFile(actualPath, "utf8")) as PackageJson;
1029
1155
  packages.push({
1030
1156
  customElementsPath: depPkg.customElements || null,
@@ -1060,6 +1186,8 @@ export async function handleStudioApi(
1060
1186
  if (!actualPath) {
1061
1187
  return Response.json({ cem: null });
1062
1188
  }
1189
+ /* SAFETY: `customElements` is the one member read; npm defines it as the manifest path, and
1190
+ a non-string value makes resolve() throw into the catch below. */
1063
1191
  const depPkg = JSON.parse(await readFile(actualPath, "utf8")) as PackageJson;
1064
1192
  if (!depPkg.customElements) {
1065
1193
  return Response.json({ cem: null });
@@ -1068,7 +1196,9 @@ export async function handleStudioApi(
1068
1196
  if (!existsSync(cemPath)) {
1069
1197
  return Response.json({ cem: null });
1070
1198
  }
1071
- const cem = JSON.parse(await readFile(cemPath, "utf8")) as Cem;
1199
+ /* SAFETY: JSON.parse without a reviver only ever produces JSON values, and the manifest is
1200
+ relayed verbatim, so it needs decoding no further than that. */
1201
+ const cem = JSON.parse(await readFile(cemPath, "utf8")) as JsonValue;
1072
1202
  return Response.json({ cem });
1073
1203
  } catch (error) {
1074
1204
  return problem("internalError", errorMessage(error));
@@ -1078,9 +1208,12 @@ export async function handleStudioApi(
1078
1208
  // Add an npm package
1079
1209
  if (path === "/__studio/packages/add" && req.method === "POST") {
1080
1210
  try {
1211
+ /* SAFETY: `name` reaches argv, so it is re-checked with isString below; `dir` is only
1212
+ path-resolved and `dev` only tested for truthiness, and a wrongly typed `dir` throws in
1213
+ isAbsolute inside this try and answers 500. */
1081
1214
  const body = (await req.json()) as { name?: string; dir?: string; dev?: boolean };
1082
1215
  const { name } = body;
1083
- if (!name || typeof name !== "string") {
1216
+ if (!name || !isString(name)) {
1084
1217
  return problem("invalidRequest", "Missing name");
1085
1218
  }
1086
1219
  const dir = body.dir || activeProjectRoot;
@@ -1108,9 +1241,11 @@ export async function handleStudioApi(
1108
1241
  // Remove an npm package
1109
1242
  if (path === "/__studio/packages/remove" && req.method === "POST") {
1110
1243
  try {
1244
+ /* SAFETY: `name` reaches argv, so it is re-checked with isString below; a wrongly typed
1245
+ `dir` throws in isAbsolute inside this try and answers 500. */
1111
1246
  const body = (await req.json()) as { name?: string; dir?: string };
1112
1247
  const { name } = body;
1113
- if (!name || typeof name !== "string") {
1248
+ if (!name || !isString(name)) {
1114
1249
  return problem("invalidRequest", "Missing name");
1115
1250
  }
1116
1251
  const dir = body.dir || activeProjectRoot;
@@ -1134,6 +1269,8 @@ export async function handleStudioApi(
1134
1269
  // Install all dependencies (bun install)
1135
1270
  if (path === "/__studio/packages/install" && req.method === "POST") {
1136
1271
  try {
1272
+ /* SAFETY: `dir` is the only member read and is only path-resolved; a wrongly typed `dir`
1273
+ throws in isAbsolute inside this try and answers 500. */
1137
1274
  const body = (await req.json().catch(() => ({}))) as { dir?: string };
1138
1275
  const dir = body.dir || activeProjectRoot || root;
1139
1276
  const scanRoot = isAbsolute(dir) ? dir : resolve(root, dir);
@@ -1164,6 +1301,9 @@ export async function handleStudioApi(
1164
1301
  // Set version ranges for one or more packages, then reinstall
1165
1302
  if (path === "/__studio/packages/set-versions" && req.method === "POST") {
1166
1303
  try {
1304
+ /* SAFETY: `updates` is checked to be an array below and each entry is Studio's own
1305
+ `{ name, version, dev }` triple from the versions panel; a wrongly typed `dir` throws in
1306
+ isAbsolute inside this try and answers 500. */
1167
1307
  const body = (await req.json()) as {
1168
1308
  dir?: string;
1169
1309
  updates?: { name: string; version: string; dev?: boolean }[];
@@ -1200,9 +1340,18 @@ export async function handleStudioApi(
1200
1340
  { headers: { "Cache-Control": "public, max-age=5" } },
1201
1341
  );
1202
1342
  } catch (error) {
1203
- return (error as NodeJS.ErrnoException).code === "ENOENT"
1204
- ? problem("notFound", "Not found")
1205
- : problem("internalError", errorMessage(error));
1343
+ if (!isNotFound(error)) {
1344
+ return problem("internalError", errorMessage(error));
1345
+ }
1346
+ /* `optional=1`: the caller is asking whether the file is there, and "no" is an ordinary
1347
+ answer rather than a failure — the same reasoning that makes a missing directory's listing
1348
+ a 404 instead of a 500, one step further. A per-directory `.gitignore` probe is the case
1349
+ that needs it: a listing hides dotfiles, so the only way to learn a directory has none is
1350
+ to ask, and a 404 per directory lands in the browser console as a failed request. Never
1351
+ cached, so a file created a moment later is read rather than remembered as absent. */
1352
+ return url.searchParams.get("optional") === "1"
1353
+ ? Response.json({ content: null, path: fp }, { headers: { "Cache-Control": "no-store" } })
1354
+ : problem("notFound", "Not found");
1206
1355
  }
1207
1356
  }
1208
1357
 
@@ -1248,7 +1397,7 @@ export async function handleStudioApi(
1248
1397
  try {
1249
1398
  await mkdir(dirname(abs), { recursive: true });
1250
1399
  await writeFile(abs, await req.text(), "utf8");
1251
- return Response.json({ ok: true, path: fwd(relative(root, abs)) });
1400
+ return Response.json({ ok: true, path: fwd(relative(activeProjectRoot ?? root, abs)) });
1252
1401
  } catch (error) {
1253
1402
  return problem("internalError", errorMessage(error));
1254
1403
  }
@@ -1270,7 +1419,7 @@ export async function handleStudioApi(
1270
1419
  await mkdir(dirname(abs), { recursive: true });
1271
1420
  const buffer = await req.arrayBuffer();
1272
1421
  await Bun.write(abs, new Uint8Array(buffer));
1273
- return Response.json({ ok: true, path: fwd(relative(root, abs)) });
1422
+ return Response.json({ ok: true, path: fwd(relative(activeProjectRoot ?? root, abs)) });
1274
1423
  } catch (error) {
1275
1424
  return problem("internalError", errorMessage(error));
1276
1425
  }
@@ -1289,11 +1438,17 @@ export async function handleStudioApi(
1289
1438
  return new Response(errorMessage(error), { status: 400 });
1290
1439
  }
1291
1440
  try {
1292
- await unlink(abs);
1293
- return Response.json({ ok: true, path: fwd(relative(root, abs)) });
1441
+ /* A directory is removed only when it is EMPTY (`rmdir`), never recursively: a folder of
1442
+ files is a decision about every file in it, which the client makes one file at a time. */
1443
+ const entry = await stat(abs);
1444
+ await (entry.isDirectory() ? rmdir(abs) : unlink(abs));
1445
+ return Response.json({ ok: true, path: fwd(relative(activeProjectRoot ?? root, abs)) });
1294
1446
  } catch (error) {
1295
- return (error as NodeJS.ErrnoException).code === "ENOENT"
1296
- ? problem("notFound", "Not found")
1447
+ if (isNotFound(error)) {
1448
+ return problem("notFound", "Not found");
1449
+ }
1450
+ return isNonNullObject(error) && "code" in error && error.code === "ENOTEMPTY"
1451
+ ? problem("conflict", "The folder is not empty.")
1297
1452
  : problem("internalError", errorMessage(error));
1298
1453
  }
1299
1454
  }
@@ -1302,6 +1457,8 @@ export async function handleStudioApi(
1302
1457
  if (path === "/__studio/file/rename" && req.method === "POST") {
1303
1458
  let body: { from?: string; to?: string };
1304
1459
  try {
1460
+ /* SAFETY: both members are truthiness-checked below and then path-resolved, where a
1461
+ non-string throws before any filesystem call is made. */
1305
1462
  body = (await req.json()) as { from?: string; to?: string };
1306
1463
  } catch {
1307
1464
  return problem("invalidRequest", "Invalid JSON");
@@ -1386,6 +1543,8 @@ export async function handleStudioApi(
1386
1543
  if (path === "/__studio/locate" && req.method === "POST") {
1387
1544
  let body: { name?: string };
1388
1545
  try {
1546
+ /* SAFETY: `name` is truthiness-checked below and then interpolated into a glob pattern, so
1547
+ a non-string value is stringified the same way a template literal always would. */
1389
1548
  body = (await req.json()) as { name?: string };
1390
1549
  } catch {
1391
1550
  return problem("invalidRequest", "Invalid JSON");
@@ -1405,13 +1564,13 @@ export async function handleStudioApi(
1405
1564
  }
1406
1565
  matches.push(fwd(match));
1407
1566
  }
1408
- if (matches.length === 0) {
1567
+ const [first] = matches;
1568
+ if (first === undefined) {
1409
1569
  return Response.json({ path: null });
1410
1570
  }
1411
- return Response.json({
1412
- path: matches[0],
1413
- ...(matches.length > 1 ? { alternatives: matches } : {}),
1414
- });
1571
+ return Response.json(
1572
+ matches.length > 1 ? { path: first, alternatives: matches } : { path: first },
1573
+ );
1415
1574
  } catch (error) {
1416
1575
  return problem("internalError", errorMessage(error));
1417
1576
  }
@@ -1433,8 +1592,11 @@ export async function handleStudioApi(
1433
1592
  if (!cfToken) {
1434
1593
  return problem("unauthorized", "Missing X-CF-Token header");
1435
1594
  }
1436
- let payload: { path?: string; method?: string; body?: unknown };
1595
+ let payload: { path?: string; method?: string; body?: JsonValue };
1437
1596
  try {
1597
+ /* SAFETY: `path` is matched against CF_PROXY_ALLOWLIST before use, `method` goes to fetch,
1598
+ which converts and validates it per the Fetch standard, and `body` is re-serialized with
1599
+ JSON.stringify, so it only ever needs to be the JSON value it was parsed as. */
1438
1600
  payload = (await req.json()) as typeof payload;
1439
1601
  } catch {
1440
1602
  return problem("invalidRequest", "Invalid JSON body");
@@ -1444,14 +1606,16 @@ export async function handleStudioApi(
1444
1606
  return problem("forbidden", `Path not allowed: ${apiPath}`);
1445
1607
  }
1446
1608
  const method = payload.method ?? "GET";
1447
- const upstream = await fetch(`https://api.cloudflare.com/client/v4${apiPath}`, {
1448
- method,
1449
- headers: {
1450
- Authorization: `Bearer ${cfToken}`,
1451
- ...(payload.body === undefined ? {} : { "Content-Type": "application/json" }),
1452
- },
1453
- ...(payload.body === undefined ? {} : { body: JSON.stringify(payload.body) }),
1454
- });
1609
+ const authorization = `Bearer ${cfToken}`;
1610
+ const init: RequestInit =
1611
+ payload.body === undefined
1612
+ ? { method, headers: { Authorization: authorization } }
1613
+ : {
1614
+ method,
1615
+ headers: { Authorization: authorization, "Content-Type": "application/json" },
1616
+ body: JSON.stringify(payload.body),
1617
+ };
1618
+ const upstream = await fetch(`https://api.cloudflare.com/client/v4${apiPath}`, init);
1455
1619
  return new Response(upstream.body, {
1456
1620
  headers: { "Content-Type": upstream.headers.get("Content-Type") ?? "application/json" },
1457
1621
  status: upstream.status,
@@ -1462,12 +1626,15 @@ export async function handleStudioApi(
1462
1626
  // Dispatches parse/serialize through the project's format registry by import name.
1463
1627
  if (path === "/__studio/format" && req.method === "POST") {
1464
1628
  try {
1629
+ /* SAFETY: `format` and `action` are checked below (`action` against the two capability
1630
+ names), and `source`, `doc` and `options` are handed to the format's own parse/serialize,
1631
+ which owns their validation; any throw answers 500 from this try. */
1465
1632
  const body = (await req.json()) as {
1466
1633
  format?: string;
1467
1634
  action?: string;
1468
1635
  source?: string;
1469
- doc?: Record<string, unknown>;
1470
- options?: Record<string, unknown>;
1636
+ doc?: JsonObject;
1637
+ options?: JsonObject;
1471
1638
  dir?: string;
1472
1639
  };
1473
1640
  const { format, action, source, doc, options } = body;
@@ -1608,6 +1775,8 @@ export async function handleStudioApi(
1608
1775
  if (moduleAbsPath.endsWith(".class.json")) {
1609
1776
  try {
1610
1777
  const content = readFileSync(moduleAbsPath, "utf8");
1778
+ /* SAFETY: every ClassJsonDef member is optional and extractStudioSchema guards or relays
1779
+ each one it reads; a non-object file throws into the catch below or yields no members. */
1611
1780
  const classDef = JSON.parse(content) as ClassJsonDef;
1612
1781
  return Response.json({
1613
1782
  schema: extractStudioSchema(classDef, moduleAbsPath),
@@ -1626,6 +1795,8 @@ export async function handleStudioApi(
1626
1795
  if (existsSync(classJsonPath)) {
1627
1796
  try {
1628
1797
  const content = readFileSync(classJsonPath, "utf8");
1798
+ /* SAFETY: every ClassJsonDef member is optional and extractStudioSchema guards or relays
1799
+ each one it reads; a malformed file throws and falls through to the module import. */
1629
1800
  const classDef = JSON.parse(content) as ClassJsonDef;
1630
1801
  return Response.json({
1631
1802
  schema: extractStudioSchema(classDef, classJsonPath),
@@ -1637,18 +1808,20 @@ export async function handleStudioApi(
1637
1808
 
1638
1809
  // Fallback: import JS module (backwards compat for classes without .class.json)
1639
1810
  try {
1640
- const mod = (await import(moduleAbsPath)) as {
1641
- default?: Record<string, unknown>;
1642
- [key: string]: unknown;
1643
- };
1644
- const ExportedClass = mod[exportName] ?? mod.default?.[exportName];
1645
- if (typeof ExportedClass !== "function") {
1811
+ /* SAFETY: import() only ever resolves to a module namespace object, and PluginExport names
1812
+ every kind of value (primitive, function, object) a string-keyed member can hold. */
1813
+ const mod = (await import(moduleAbsPath)) as PluginModule;
1814
+ const fallback = mod.default;
1815
+ const ExportedClass =
1816
+ mod[exportName] ??
1817
+ (isNonNullObject(fallback) || isPluginClass(fallback) ? fallback[exportName] : undefined);
1818
+ if (!isPluginClass(ExportedClass)) {
1646
1819
  return Response.json({
1647
1820
  error: `Export "${exportName}" not found`,
1648
1821
  schema: null,
1649
1822
  });
1650
1823
  }
1651
- return Response.json({ schema: (ExportedClass as { schema?: unknown }).schema ?? null });
1824
+ return Response.json({ schema: ExportedClass.schema ?? null });
1652
1825
  } catch (error) {
1653
1826
  return Response.json({
1654
1827
  error: errorMessage(error),
@@ -1660,7 +1833,24 @@ export async function handleStudioApi(
1660
1833
  // ── Git endpoints ──────────────────────────────────────────────────────────
1661
1834
 
1662
1835
  if (path.startsWith("/__studio/git/")) {
1663
- const cwd = activeProjectRoot || root;
1836
+ /* THE REPOSITORY IS THE REQUEST'S, NOT THE SERVER'S. One dev server serves every Studio tab
1837
+ pointed at it, and `activeProjectRoot` is whichever project was activated last. Read from it
1838
+ alone, two sessions on two projects ran each other's git: Initialize Repository in one
1839
+ initialised the other. Every git call names its project with `?dir=`, as the file routes do,
1840
+ under the same access rule; the fallback is for a caller that names none (no project open). */
1841
+ const named = url.searchParams.get("dir");
1842
+ const cwd = named
1843
+ ? isAbsolute(named)
1844
+ ? named
1845
+ : resolve(root, named)
1846
+ : activeProjectRoot || root;
1847
+ if (named) {
1848
+ try {
1849
+ assertAccessible(cwd, root, activeProjectRoot);
1850
+ } catch (error) {
1851
+ return problem("invalidRequest", errorMessage(error));
1852
+ }
1853
+ }
1664
1854
  const gitCmd = path.slice("/__studio/git/".length);
1665
1855
 
1666
1856
  const runGit = async (args: string[]) => {
@@ -1680,24 +1870,23 @@ export async function handleStudioApi(
1680
1870
  * one failure this API publishes a shape for into a contentless message, and the 409 the
1681
1871
  * route table promises could never be produced.
1682
1872
  */
1683
- throw Object.assign(new Error(stderr || stdout || `git exited with ${exitCode}`), {
1873
+ throw new GitCommandError(
1874
+ stderr || stdout || `git exited with ${exitCode}`,
1684
1875
  stderr,
1685
1876
  stdout,
1686
- });
1877
+ );
1687
1878
  }
1688
1879
  return stdout;
1689
1880
  };
1690
1881
 
1691
1882
  try {
1883
+ /* Every command but the two that make a repository acts on THE PROJECT'S repository, so one
1884
+ that has none of its own is refused rather than handed to whatever checkout encloses it
1885
+ (see `projectHasRepository`). */
1886
+ const makesRepository = gitCmd === "init" || gitCmd === "clone";
1887
+ const hasRepository = makesRepository || (await projectHasRepository(cwd));
1692
1888
  if (gitCmd === "status" && req.method === "GET") {
1693
- // Check if we're in a git repo first
1694
- const checkProc = Bun.spawn(["git", "rev-parse", "--is-inside-work-tree"], {
1695
- cwd,
1696
- stderr: "pipe",
1697
- stdout: "pipe",
1698
- });
1699
- const checkExit = await checkProc.exited;
1700
- if (checkExit !== 0) {
1889
+ if (!hasRepository) {
1701
1890
  return Response.json({
1702
1891
  ahead: 0,
1703
1892
  behind: 0,
@@ -1708,8 +1897,12 @@ export async function handleStudioApi(
1708
1897
  });
1709
1898
  }
1710
1899
 
1900
+ /* `--untracked-files=all`: every untracked FILE, not its collapsed folder. By default git
1901
+ reports a wholly untracked `content/` as one entry, so a fresh repository listed a
1902
+ handful of folders that no group, comparison or checkbox could treat as a file, and the
1903
+ count jumped to the real one the moment staging expanded them (studio.md §22.8). */
1711
1904
  const [out, remotesOut] = await Promise.all([
1712
- runGit(["status", "--porcelain=v2", "--branch"]),
1905
+ runGit(["status", "--porcelain=v2", "--branch", "-z", "--untracked-files=all"]),
1713
1906
  runGit(["remote"]),
1714
1907
  ]);
1715
1908
  const status = parseGitStatus(out);
@@ -1721,12 +1914,22 @@ export async function handleStudioApi(
1721
1914
  return Response.json(fullStatus);
1722
1915
  }
1723
1916
 
1917
+ if (!hasRepository) {
1918
+ return problem(
1919
+ "conflict",
1920
+ "This project is not a git repository of its own. Initialize one to use source control.",
1921
+ );
1922
+ }
1923
+
1724
1924
  if (gitCmd === "init" && req.method === "POST") {
1725
1925
  await runGit(["init"]);
1726
1926
  return Response.json({ ok: true });
1727
1927
  }
1728
1928
 
1729
1929
  if (gitCmd === "add-remote" && req.method === "POST") {
1930
+ /* SAFETY: both members are truthiness-checked below and reach git as their own argv
1931
+ entries with no shell; Bun.spawn stringifies a non-string entry, so a wrong type can only
1932
+ become a literal remote name or URL, which git validates and refuses or records. */
1730
1933
  const { name, url: remoteUrl } = (await req.json()) as {
1731
1934
  name?: string;
1732
1935
  url?: string;
@@ -1757,6 +1960,16 @@ export async function handleStudioApi(
1757
1960
 
1758
1961
  if (gitCmd === "log" && req.method === "GET") {
1759
1962
  const limit = url.searchParams.get("limit") || "20";
1963
+ /* A repository with no commits yet has no history, which is an empty list, not a failure:
1964
+ `git log` exits 128 on an unborn HEAD, and that 500 was the first console error every
1965
+ freshly initialized project showed (studio.md §22.8). */
1966
+ const hasHead = await runGit(["rev-parse", "--verify", "--quiet", "HEAD"]).then(
1967
+ () => true,
1968
+ () => false,
1969
+ );
1970
+ if (!hasHead) {
1971
+ return Response.json([]);
1972
+ }
1760
1973
  const out = await runGit(["log", `--max-count=${limit}`, "--format=%H\t%s\t%an\t%aI"]);
1761
1974
  const entries = out
1762
1975
  .trim()
@@ -1770,6 +1983,9 @@ export async function handleStudioApi(
1770
1983
  }
1771
1984
 
1772
1985
  if (gitCmd === "stage" && req.method === "POST") {
1986
+ /* SAFETY: `files` is checked to be a non-empty array below; each entry is screened with
1987
+ `.includes("..")` (an entry without that method throws inside this try) and reaches git
1988
+ after `--`, where it can only be read as a pathspec. */
1773
1989
  const { files } = (await req.json()) as { files?: string[] };
1774
1990
  if (!Array.isArray(files) || files.length === 0) {
1775
1991
  return problem("invalidRequest", "Missing files");
@@ -1784,6 +2000,8 @@ export async function handleStudioApi(
1784
2000
  }
1785
2001
 
1786
2002
  if (gitCmd === "unstage" && req.method === "POST") {
2003
+ /* SAFETY: `files` is checked to be a non-empty array below, and its entries reach git
2004
+ after `--`, so they can only ever be read as pathspecs. */
1787
2005
  const { files } = (await req.json()) as { files?: string[] };
1788
2006
  if (!Array.isArray(files) || files.length === 0) {
1789
2007
  return problem("invalidRequest", "Missing files");
@@ -1793,8 +2011,9 @@ export async function handleStudioApi(
1793
2011
  }
1794
2012
 
1795
2013
  if (gitCmd === "commit" && req.method === "POST") {
2014
+ // SAFETY: `message` is re-checked with isString below before it reaches argv.
1796
2015
  const { message } = (await req.json()) as { message?: string };
1797
- if (!message || typeof message !== "string") {
2016
+ if (!message || !isString(message)) {
1798
2017
  return problem("invalidRequest", "Missing message");
1799
2018
  }
1800
2019
  const statusOut = await runGit(["status", "--porcelain"]);
@@ -1808,9 +2027,10 @@ export async function handleStudioApi(
1808
2027
  }
1809
2028
 
1810
2029
  if (gitCmd === "push" && req.method === "POST") {
1811
- let body: { setUpstream?: boolean } = {};
2030
+ let body: PushBody = {};
1812
2031
  try {
1813
- body = (await req.json()) as { setUpstream?: boolean };
2032
+ // SAFETY: `setUpstream` is the only member read, and only for truthiness.
2033
+ body = (await req.json()) as PushBody;
1814
2034
  } catch {}
1815
2035
  const { setUpstream } = body;
1816
2036
  if (setUpstream) {
@@ -1835,8 +2055,10 @@ export async function handleStudioApi(
1835
2055
  try {
1836
2056
  await runGit(["pull"]);
1837
2057
  } catch (error) {
1838
- const output = error as { stdout?: string; stderr?: string };
1839
- const conflicts = conflictingPaths(`${output.stdout ?? ""}\n${output.stderr ?? ""}`);
2058
+ const conflicts =
2059
+ error instanceof GitCommandError
2060
+ ? conflictingPaths(`${error.stdout}\n${error.stderr}`)
2061
+ : [];
1840
2062
  if (conflicts.length === 0) {
1841
2063
  throw error;
1842
2064
  }
@@ -1855,8 +2077,9 @@ export async function handleStudioApi(
1855
2077
  }
1856
2078
 
1857
2079
  if (gitCmd === "checkout" && req.method === "POST") {
2080
+ // SAFETY: `branch` is re-checked with isString below before it reaches argv.
1858
2081
  const { branch } = (await req.json()) as { branch?: string };
1859
- if (!branch || typeof branch !== "string") {
2082
+ if (!branch || !isString(branch)) {
1860
2083
  return problem("invalidRequest", "Missing branch");
1861
2084
  }
1862
2085
  await runGit(["checkout", branch]);
@@ -1864,8 +2087,9 @@ export async function handleStudioApi(
1864
2087
  }
1865
2088
 
1866
2089
  if (gitCmd === "create-branch" && req.method === "POST") {
2090
+ // SAFETY: `name` is re-checked with isString below before it reaches argv.
1867
2091
  const { name } = (await req.json()) as { name?: string };
1868
- if (!name || typeof name !== "string") {
2092
+ if (!name || !isString(name)) {
1869
2093
  return problem("invalidRequest", "Missing name");
1870
2094
  }
1871
2095
  await runGit(["checkout", "-b", name]);
@@ -1904,6 +2128,9 @@ export async function handleStudioApi(
1904
2128
  }
1905
2129
 
1906
2130
  if (gitCmd === "discard" && req.method === "POST") {
2131
+ /* SAFETY: `files` is checked to be a non-empty array below; each entry is screened with
2132
+ `.includes("..")` (an entry without that method throws inside this try) and reaches git
2133
+ after `--`, where it can only be read as a pathspec. */
1907
2134
  const { files } = (await req.json()) as { files?: string[] };
1908
2135
  if (!Array.isArray(files) || files.length === 0) {
1909
2136
  return problem("invalidRequest", "Missing files");
@@ -1918,8 +2145,9 @@ export async function handleStudioApi(
1918
2145
  }
1919
2146
 
1920
2147
  if (gitCmd === "clone" && req.method === "POST") {
2148
+ // SAFETY: `url` is re-checked with isString below before it reaches argv.
1921
2149
  const { url: repoUrl } = (await req.json()) as { url?: string };
1922
- if (!repoUrl || typeof repoUrl !== "string") {
2150
+ if (!repoUrl || !isString(repoUrl)) {
1923
2151
  return problem("invalidRequest", "Missing url");
1924
2152
  }
1925
2153
  const repoName = basename(repoUrl.replace(/\.git$/, ""));
@@ -1944,6 +2172,17 @@ export async function handleStudioApi(
1944
2172
  return null;
1945
2173
  }
1946
2174
 
2175
+ /** The flat form-rendering schema the studio's renderSchemaFields() consumes. */
2176
+ interface StudioSchema {
2177
+ description: string | undefined;
2178
+ properties: Record<string, JsonObject>;
2179
+ required: string[];
2180
+ returns?: JsonValue;
2181
+ format?: JsonValue;
2182
+ $studio?: JsonValue;
2183
+ capabilities?: Record<string, { identifier: string; timing: string[] }>;
2184
+ }
2185
+
1947
2186
  /**
1948
2187
  * Extract a studio-friendly schema from a .class.json definition. Transforms $defs.parameters and
1949
2188
  * $defs.fields into the flat { description, properties, required } shape that renderSchemaFields()
@@ -1951,19 +2190,18 @@ export async function handleStudioApi(
1951
2190
  *
1952
2191
  * @param {ClassJsonDef} classDef
1953
2192
  * @param {string} classJsonPath
1954
- * @returns {{
1955
- * description: string | undefined;
1956
- * properties: Record<string, Record<string, unknown>>;
1957
- * required: string[];
1958
- * }}
1959
2193
  */
1960
- function extractStudioSchema(classDef: ClassJsonDef, classJsonPath: string) {
2194
+ function extractStudioSchema(classDef: ClassJsonDef, classJsonPath: string): StudioSchema {
1961
2195
  // If extends.$ref points to a parent, recursively merge
1962
2196
  let parentSchema = null;
1963
- if (classDef.extends && typeof classDef.extends === "object" && classDef.extends.$ref) {
2197
+ // `?.$ref` is undefined for a non-object `extends`, so a primitive never reaches resolve().
2198
+ if (classDef.extends?.$ref) {
1964
2199
  try {
1965
2200
  const parentPath = resolve(dirname(classJsonPath), classDef.extends.$ref);
1966
2201
  const parentContent = readFileSync(parentPath, "utf8");
2202
+ /* SAFETY: every ClassJsonDef member is optional and each one read here is either guarded
2203
+ (isNonNullObject on `type`, truthiness elsewhere) or relayed verbatim; a non-object file
2204
+ throws into the catch below or yields no members. */
1967
2205
  const parentDef = JSON.parse(parentContent) as ClassJsonDef;
1968
2206
  parentSchema = extractStudioSchema(parentDef, parentPath);
1969
2207
  } catch {
@@ -1973,7 +2211,7 @@ function extractStudioSchema(classDef: ClassJsonDef, classJsonPath: string) {
1973
2211
 
1974
2212
  const params = classDef.$defs?.parameters ?? {};
1975
2213
  const fields = classDef.$defs?.fields ?? {};
1976
- const properties: Record<string, Record<string, unknown>> = {};
2214
+ const properties: Record<string, JsonObject> = {};
1977
2215
  const required: string[] = [];
1978
2216
 
1979
2217
  // Start with parent properties (child overrides)
@@ -1987,8 +2225,8 @@ function extractStudioSchema(classDef: ClassJsonDef, classJsonPath: string) {
1987
2225
  // Build properties from parameters (constructor config surface)
1988
2226
  for (const [key, param] of Object.entries(params)) {
1989
2227
  const id = param.identifier ?? key;
1990
- const prop: Record<string, unknown> = {};
1991
- if (param.type && typeof param.type === "object") {
2228
+ const prop: JsonObject = {};
2229
+ if (isNonNullObject(param.type)) {
1992
2230
  Object.assign(prop, param.type);
1993
2231
  }
1994
2232
  if (param.description) {
@@ -2012,8 +2250,8 @@ function extractStudioSchema(classDef: ClassJsonDef, classJsonPath: string) {
2012
2250
  continue;
2013
2251
  }
2014
2252
  const id = field.identifier ?? key;
2015
- const prop: Record<string, unknown> = {};
2016
- if (field.type && typeof field.type === "object") {
2253
+ const prop: JsonObject = {};
2254
+ if (isNonNullObject(field.type)) {
2017
2255
  Object.assign(prop, field.type);
2018
2256
  }
2019
2257
  if (field.description) {
@@ -2045,13 +2283,9 @@ function extractStudioSchema(classDef: ClassJsonDef, classJsonPath: string) {
2045
2283
 
2046
2284
  // Surface format-extension metadata: the format block, studio hints, and a
2047
2285
  // Capability summary ({ parse: { timing }, serialize: { timing }, ... }).
2048
- const def = classDef as Record<string, unknown>;
2049
2286
  const capabilityRoles = new Set(["parse", "serialize", "discover", "load"]);
2050
2287
  const capabilities: Record<string, { identifier: string; timing: string[] }> = {};
2051
- const methods = (classDef.$defs?.methods ?? {}) as Record<
2052
- string,
2053
- { role?: string; identifier?: string; timing?: string[] }
2054
- >;
2288
+ const methods = classDef.$defs?.methods ?? {};
2055
2289
  for (const [key, method] of Object.entries(methods)) {
2056
2290
  if (method.role && capabilityRoles.has(method.role)) {
2057
2291
  capabilities[method.role] = {
@@ -2061,13 +2295,23 @@ function extractStudioSchema(classDef: ClassJsonDef, classJsonPath: string) {
2061
2295
  }
2062
2296
  }
2063
2297
 
2064
- return {
2298
+ // Optional members are added in this order, which is the key order the studio receives.
2299
+ const schema: StudioSchema = {
2065
2300
  description: classDef.description ?? classDef.title,
2066
2301
  properties,
2067
2302
  required: [...requiredSet],
2068
- ...(resolveMethod?.returns ? { returns: resolveMethod.returns } : {}),
2069
- ...(def.format ? { format: def.format } : {}),
2070
- ...(def.$studio ? { $studio: def.$studio } : {}),
2071
- ...(Object.keys(capabilities).length > 0 ? { capabilities } : {}),
2072
2303
  };
2304
+ if (resolveMethod?.returns) {
2305
+ schema.returns = resolveMethod.returns;
2306
+ }
2307
+ if (classDef.format) {
2308
+ schema.format = classDef.format;
2309
+ }
2310
+ if (classDef.$studio) {
2311
+ schema.$studio = classDef.$studio;
2312
+ }
2313
+ if (Object.keys(capabilities).length > 0) {
2314
+ schema.capabilities = capabilities;
2315
+ }
2316
+ return schema;
2073
2317
  }