@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/package.json +18 -18
- package/src/ai-api.ts +8 -4
- package/src/build.ts +6 -6
- package/src/code-api.ts +29 -16
- package/src/collab.ts +9 -5
- package/src/data-api.ts +190 -137
- package/src/dev-vars.ts +15 -5
- package/src/dev.ts +3 -0
- package/src/extension-catalog.ts +62 -32
- package/src/import-api.ts +96 -54
- package/src/jx-mounts.ts +27 -27
- package/src/live-preview.ts +16 -10
- package/src/oauth-loopback.ts +23 -18
- package/src/packages.ts +28 -38
- package/src/problem.ts +3 -6
- package/src/project-server.ts +39 -9
- package/src/refactor/apply.ts +3 -2
- package/src/refactor/find-refs.ts +0 -0
- package/src/refactor/fs-events.ts +14 -15
- package/src/refactor/paths.ts +19 -10
- package/src/refactor/refs.ts +60 -63
- package/src/refactor/scan.ts +13 -19
- package/src/refactor/watcher.ts +5 -5
- package/src/resolve.ts +269 -82
- package/src/server.ts +52 -28
- package/src/site-output.ts +11 -5
- package/src/sse.ts +16 -2
- package/src/studio-api.ts +389 -145
- package/src/types.ts +17 -7
- package/src/watch.ts +8 -6
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?:
|
|
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?:
|
|
73
|
-
events?:
|
|
74
|
-
slots?:
|
|
75
|
-
|
|
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?:
|
|
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
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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
|
-
|
|
423
|
-
|
|
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
|
-
|
|
599
|
-
|
|
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
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
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
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
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
|
-
|
|
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:
|
|
896
|
+
let content: JsonValue;
|
|
788
897
|
if (match.endsWith(".json")) {
|
|
789
|
-
|
|
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
|
-
|
|
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
|
-
|
|
916
|
-
|
|
917
|
-
|
|
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 (
|
|
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,
|
|
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
|
-
|
|
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 ||
|
|
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 ||
|
|
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
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
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
|
-
|
|
1293
|
-
|
|
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
|
-
|
|
1296
|
-
|
|
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
|
-
|
|
1567
|
+
const [first] = matches;
|
|
1568
|
+
if (first === undefined) {
|
|
1409
1569
|
return Response.json({ path: null });
|
|
1410
1570
|
}
|
|
1411
|
-
return Response.json(
|
|
1412
|
-
path: matches
|
|
1413
|
-
|
|
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?:
|
|
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
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
Authorization:
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
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?:
|
|
1470
|
-
options?:
|
|
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
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
const ExportedClass =
|
|
1645
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 ||
|
|
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:
|
|
2030
|
+
let body: PushBody = {};
|
|
1812
2031
|
try {
|
|
1813
|
-
|
|
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
|
|
1839
|
-
|
|
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 ||
|
|
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 ||
|
|
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 ||
|
|
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
|
-
|
|
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,
|
|
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:
|
|
1991
|
-
if (param.type
|
|
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:
|
|
2016
|
-
if (field.type
|
|
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 =
|
|
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
|
-
|
|
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
|
}
|