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