@jxsuite/server 4.5.1 → 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 +16 -16
- package/src/import-api.ts +1 -1
- package/src/packages.ts +2 -2
- package/src/project-server.ts +5 -5
- package/src/refactor/find-refs.ts +0 -0
- package/src/refactor/paths.ts +8 -7
- package/src/resolve.ts +95 -20
- package/src/server.ts +5 -1
- package/src/studio-api.ts +128 -33
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jxsuite/server",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "6.0.0",
|
|
4
4
|
"description": "Jx development server with live reload, server-side proxy, and studio integration",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -41,25 +41,25 @@
|
|
|
41
41
|
"upgrade": "bun update --latest && bun install"
|
|
42
42
|
},
|
|
43
43
|
"dependencies": {
|
|
44
|
-
"@jxsuite/ai": "^0.39.
|
|
45
|
-
"@jxsuite/catalog": "^0.1.
|
|
46
|
-
"@jxsuite/collab": "^0.11.
|
|
47
|
-
"@jxsuite/compiler": "^
|
|
48
|
-
"@jxsuite/create": "^1.3.
|
|
49
|
-
"@jxsuite/import": "^0.40.
|
|
50
|
-
"@jxsuite/protocol": "^
|
|
51
|
-
"@jxsuite/runtime": "^
|
|
52
|
-
"@jxsuite/schema": "^
|
|
53
|
-
"@jxsuite/site": "^2.0.
|
|
54
|
-
"@jxsuite/starters": "^
|
|
44
|
+
"@jxsuite/ai": "^0.39.5",
|
|
45
|
+
"@jxsuite/catalog": "^0.1.2",
|
|
46
|
+
"@jxsuite/collab": "^0.11.2",
|
|
47
|
+
"@jxsuite/compiler": "^6.0.1",
|
|
48
|
+
"@jxsuite/create": "^1.3.16",
|
|
49
|
+
"@jxsuite/import": "^0.40.12",
|
|
50
|
+
"@jxsuite/protocol": "^4.0.0",
|
|
51
|
+
"@jxsuite/runtime": "^5.0.0",
|
|
52
|
+
"@jxsuite/schema": "^4.0.0",
|
|
53
|
+
"@jxsuite/site": "^2.0.5",
|
|
54
|
+
"@jxsuite/starters": "^3.0.0",
|
|
55
55
|
"chokidar": "^5.0.0",
|
|
56
|
-
"kysely": "^0.29.
|
|
56
|
+
"kysely": "^0.29.7",
|
|
57
57
|
"oxfmt": "^0.73.0",
|
|
58
58
|
"zod": "^4.6.7"
|
|
59
59
|
},
|
|
60
60
|
"devDependencies": {
|
|
61
|
-
"@jxsuite/auth": "^0.6.
|
|
62
|
-
"@jxsuite/connector": "^0.6.
|
|
63
|
-
"@jxsuite/parser": "^
|
|
61
|
+
"@jxsuite/auth": "^0.6.3",
|
|
62
|
+
"@jxsuite/connector": "^0.6.3",
|
|
63
|
+
"@jxsuite/parser": "^4.0.0"
|
|
64
64
|
}
|
|
65
65
|
}
|
package/src/import-api.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Handles POST /__studio/import-site: runs @jxsuite/import's importSite pipeline in the backend
|
|
5
5
|
* (headless Chrome + filesystem) and streams progress back as NDJSON lines over the POST response —
|
|
6
6
|
* the same streaming-fetch transport the AI chat proxy uses, so every platform (dev server,
|
|
7
|
-
*
|
|
7
|
+
* Electron hub's loopback servers, Chromium loopback server) can mount it unchanged.
|
|
8
8
|
*
|
|
9
9
|
* Stream protocol (one JSON object per line):
|
|
10
10
|
*
|
package/src/packages.ts
CHANGED
|
@@ -88,8 +88,8 @@ export function dependenciesNeedInstall(root: string): boolean {
|
|
|
88
88
|
|
|
89
89
|
/**
|
|
90
90
|
* Path to the bun executable. Uses the bun running the current process (`process.execPath`) so a
|
|
91
|
-
* packaged desktop app spawns the bun bundled with its
|
|
92
|
-
*
|
|
91
|
+
* packaged desktop app spawns the bun bundled with its Electron distribution rather than relying on
|
|
92
|
+
* a system-wide `bun` in `$PATH` (which end users may not have). Falls back to a PATH lookup if
|
|
93
93
|
* `execPath` is somehow unset.
|
|
94
94
|
*/
|
|
95
95
|
export function bunExecutable(): string {
|
package/src/project-server.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Shared project server factory. Generalizes the chromium variant's ad-hoc in-process Bun.serve
|
|
3
|
-
* into one createProjectServer() used by the chromium desktop launcher
|
|
4
|
-
* electrobun).
|
|
3
|
+
* into one createProjectServer() used by the chromium desktop launcher and the Electron hub.
|
|
5
4
|
*
|
|
6
5
|
* It serves three surfaces from a single loopback-bound Bun.serve:
|
|
7
6
|
*
|
|
@@ -77,9 +76,10 @@ export interface ProjectServerSession {
|
|
|
77
76
|
|
|
78
77
|
export interface CreateProjectServerOptions {
|
|
79
78
|
/**
|
|
80
|
-
* Resolve the session for a given window id (the `win` query param / stashed socket data).
|
|
81
|
-
*
|
|
82
|
-
* A missing/unknown window returns `null`, which the routes treat as a 404
|
|
79
|
+
* Resolve the session for a given window id (the `win` query param / stashed socket data). the
|
|
80
|
+
* single-window launcher passes `() => defaultSession`; the Electron hub swaps it for a
|
|
81
|
+
* per-window lookup. A missing/unknown window returns `null`, which the routes treat as a 404
|
|
82
|
+
* fail-closed.
|
|
83
83
|
*/
|
|
84
84
|
resolveSession: (winId: string | null) => ProjectServerSession | null;
|
|
85
85
|
/** Absolute dir holding the studio shell + canvas.html + dist/. */
|
|
Binary file
|
package/src/refactor/paths.ts
CHANGED
|
@@ -257,9 +257,12 @@ export interface RemapCtx {
|
|
|
257
257
|
* Recompute a single path reference for a rename, preserving its original style. Returns the new
|
|
258
258
|
* string, or null when nothing changes.
|
|
259
259
|
*
|
|
260
|
-
* `rootRelativeBare` makes
|
|
261
|
-
*
|
|
262
|
-
*
|
|
260
|
+
* `rootRelativeBare` makes the value resolve against the project root rather than the document
|
|
261
|
+
* directory, in every spelling: `./layouts/base.json` in `pages/blog/[slug].json` names
|
|
262
|
+
* `layouts/base.json`, because the compiler resolves `$layout` with `resolve(projectRoot, ref)`
|
|
263
|
+
* (layout-resolver.ts). Treating a `./` value as document-relative there missed every page's own
|
|
264
|
+
* `$layout`, so a renamed layout left them pointing at the old name. Used for `$layout` and for
|
|
265
|
+
* `build.copy` keys, which are project paths too.
|
|
263
266
|
*/
|
|
264
267
|
export function rewriteRef(
|
|
265
268
|
cls: { core: string; suffix: string; rooted: boolean },
|
|
@@ -270,10 +273,8 @@ export function rewriteRef(
|
|
|
270
273
|
if (rooted) {
|
|
271
274
|
return rewriteSiteUrl(core, suffix, ctx);
|
|
272
275
|
}
|
|
273
|
-
const
|
|
274
|
-
const
|
|
275
|
-
const baseOld = useRoot ? ctx.root : ctx.docOldDir;
|
|
276
|
-
const baseNew = useRoot ? ctx.root : ctx.docNewDir;
|
|
276
|
+
const baseOld = rootRelativeBare ? ctx.root : ctx.docOldDir;
|
|
277
|
+
const baseNew = rootRelativeBare ? ctx.root : ctx.docNewDir;
|
|
277
278
|
|
|
278
279
|
const targetOld = joinPosix(baseOld, core);
|
|
279
280
|
const targetNew = remapTarget(targetOld, ctx.oldAbs, ctx.newAbs);
|
package/src/resolve.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { siteBasePath } from "@jxsuite/schema/asset-paths";
|
|
|
4
4
|
import { dirname, isAbsolute, relative, resolve } from "node:path";
|
|
5
5
|
import { errorMessage, parseClassDef } from "@jxsuite/schema/parse";
|
|
6
6
|
import { isFunction, isNonNullObject, isString } from "@jxsuite/schema/guards";
|
|
7
|
-
import {
|
|
7
|
+
import { readFileSync, statSync } from "node:fs";
|
|
8
8
|
import { createRequire } from "node:module";
|
|
9
9
|
import { projectDevEnv } from "./dev-vars.ts";
|
|
10
10
|
import { containedPath } from "./net-guard.ts";
|
|
@@ -14,6 +14,7 @@ import { loadAssetMounts } from "@jxsuite/compiler/asset-mounts";
|
|
|
14
14
|
import type { AssetMount } from "@jxsuite/schema/asset-paths";
|
|
15
15
|
import type { DynamicMethod } from "@jxsuite/runtime/types";
|
|
16
16
|
import type { ProjectSections } from "@jxsuite/compiler/project-sections";
|
|
17
|
+
import type { ExtensionRegistry } from "@jxsuite/schema/extension-registry";
|
|
17
18
|
import type {
|
|
18
19
|
JsonObject,
|
|
19
20
|
JsonPrimitive,
|
|
@@ -64,11 +65,18 @@ interface ModuleNamespace extends ExportedObject {
|
|
|
64
65
|
*/
|
|
65
66
|
type ProjectContext = ProjectSections & { config: ProjectConfig; root: string };
|
|
66
67
|
|
|
67
|
-
/**
|
|
68
|
+
/**
|
|
69
|
+
* One `project.json` generation: its config, extension registry and asset mounts, loaded together
|
|
70
|
+
* because they are cheap, and the extension-loaded sections, loaded only when a class asks for
|
|
71
|
+
* them.
|
|
72
|
+
*/
|
|
68
73
|
interface ProjectEntry {
|
|
69
74
|
mtime: number;
|
|
70
|
-
|
|
75
|
+
config: ProjectConfig;
|
|
76
|
+
registry: ExtensionRegistry;
|
|
71
77
|
mounts: AssetMount[];
|
|
78
|
+
/** The full `_project` context, started by the first {@link loadProjectContext} and shared. */
|
|
79
|
+
context?: Promise<ProjectContext> | undefined;
|
|
72
80
|
}
|
|
73
81
|
|
|
74
82
|
/** A value the resolve proxy hands a class constructor under one config key. */
|
|
@@ -173,8 +181,19 @@ function baseDirectory($base: string, root: string, projectRoot: string): string
|
|
|
173
181
|
return resolve(root, `.${urlDir}`);
|
|
174
182
|
}
|
|
175
183
|
|
|
176
|
-
/**
|
|
177
|
-
|
|
184
|
+
/**
|
|
185
|
+
* Per-project cache, invalidated when project.json changes on disk. It holds the PROMISE, keyed by
|
|
186
|
+
* the mtime it was started for, so concurrent requests on a cold cache share one load.
|
|
187
|
+
*
|
|
188
|
+
* Both halves of that are load-bearing. Every project file the canvas fetches asks for the asset
|
|
189
|
+
* mounts, and the canvas fetches a document's `$elements` in parallel: before the promise was
|
|
190
|
+
* cached, each of them started its own load, so a page with 25 components ran 25 at once. And the
|
|
191
|
+
* sections are not part of it: a content section loads every entry of every collection, which for a
|
|
192
|
+
* 910-entry knowledge base is ten seconds of parsing, so serving a JSON file next to it waited ten
|
|
193
|
+
* seconds for data it never reads. Both together put a component fetch past the canvas's register
|
|
194
|
+
* timeout on every cold open.
|
|
195
|
+
*/
|
|
196
|
+
const projectEntryCache = new Map<string, { mtime: number; entry: Promise<ProjectEntry | null> }>();
|
|
178
197
|
|
|
179
198
|
/**
|
|
180
199
|
* Lazy-load project context (project.json + extension-loaded sections) for class instantiation.
|
|
@@ -186,32 +205,82 @@ const projectContextCache = new Map<string, ProjectEntry>();
|
|
|
186
205
|
*/
|
|
187
206
|
async function loadProjectContext(projectRoot: string): Promise<ProjectContext | null> {
|
|
188
207
|
const entry = await loadProjectEntry(projectRoot);
|
|
189
|
-
|
|
208
|
+
if (!entry) {
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
entry.context ??= loadProjectSections(projectRoot, entry.config, entry.registry, {
|
|
212
|
+
lazy: true,
|
|
213
|
+
}).then((sections) => ({ config: entry.config, root: projectRoot, ...sections }));
|
|
214
|
+
try {
|
|
215
|
+
return await entry.context;
|
|
216
|
+
} catch {
|
|
217
|
+
// A failed load is not remembered: the next class to ask tries again.
|
|
218
|
+
entry.context = undefined;
|
|
219
|
+
return null;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Whether a class reads `_project` through its sections' on-demand methods, by declaring `static
|
|
225
|
+
* projectOnDemand = true` (specs/extensions.md §8). The sections here are loaded lazily, so a class
|
|
226
|
+
* that reads one synchronously (a `content.get(type)`) would find nothing it had not loaded; such a
|
|
227
|
+
* class is handed every section whole instead, exactly what it was always given.
|
|
228
|
+
*/
|
|
229
|
+
function readsProjectOnDemand(ExportedClass: ExportedFunction | ExportedConstructor): boolean {
|
|
230
|
+
return "projectOnDemand" in ExportedClass && ExportedClass.projectOnDemand === true;
|
|
190
231
|
}
|
|
191
232
|
|
|
192
|
-
/**
|
|
233
|
+
/** A section an owner loaded on demand: `complete()` makes it whole (extensions.md §8). */
|
|
234
|
+
interface OnDemandSection {
|
|
235
|
+
complete: () => Promise<void>;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Whether a project-context value is a section loaded on demand, by its `complete` method. */
|
|
239
|
+
function isOnDemandSection(
|
|
240
|
+
value: ProjectContext[string],
|
|
241
|
+
): value is ProjectContext[string] & OnDemandSection {
|
|
242
|
+
return isNonNullObject(value) && "complete" in value && isFunction(value.complete);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** Make every section of a project context whole, for a class that reads them synchronously. */
|
|
246
|
+
async function completeSections(context: ProjectContext): Promise<void> {
|
|
247
|
+
for (const value of Object.values(context)) {
|
|
248
|
+
if (isOnDemandSection(value)) {
|
|
249
|
+
await value.complete();
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Load (or reuse) the cached project config, extension registry and asset mounts. */
|
|
193
255
|
async function loadProjectEntry(projectRoot: string): Promise<ProjectEntry | null> {
|
|
194
256
|
const projectJsonPath = resolve(projectRoot, "project.json");
|
|
195
|
-
|
|
257
|
+
const mtime = statSync(projectJsonPath, { throwIfNoEntry: false })?.mtimeMs;
|
|
258
|
+
if (mtime === undefined) {
|
|
196
259
|
return null;
|
|
197
260
|
}
|
|
261
|
+
const cached = projectEntryCache.get(projectRoot);
|
|
262
|
+
if (cached && cached.mtime === mtime) {
|
|
263
|
+
return cached.entry;
|
|
264
|
+
}
|
|
265
|
+
const entry = readProjectEntry(projectRoot, projectJsonPath, mtime);
|
|
266
|
+
projectEntryCache.set(projectRoot, { entry, mtime });
|
|
267
|
+
return entry;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** Read one `project.json` generation. Never rejects: any failure answers "no project context". */
|
|
271
|
+
async function readProjectEntry(
|
|
272
|
+
projectRoot: string,
|
|
273
|
+
projectJsonPath: string,
|
|
274
|
+
mtime: number,
|
|
275
|
+
): Promise<ProjectEntry | null> {
|
|
198
276
|
try {
|
|
199
|
-
const { mtimeMs } = statSync(projectJsonPath);
|
|
200
|
-
const cached = projectContextCache.get(projectRoot);
|
|
201
|
-
if (cached && cached.mtime === mtimeMs) {
|
|
202
|
-
return cached;
|
|
203
|
-
}
|
|
204
277
|
/* SAFETY: trust boundary. project.json is the project's own config file, which the editor
|
|
205
278
|
and `jx validate` hold to its per-project schema. Any failure reading it below is caught
|
|
206
279
|
here and answers "no project context". */
|
|
207
280
|
const config = JSON.parse(readFileSync(projectJsonPath, "utf8")) as ProjectConfig;
|
|
208
281
|
const registry = await buildProjectExtensionRegistry(projectRoot, config);
|
|
209
|
-
const sections = await loadProjectSections(projectRoot, config, registry);
|
|
210
282
|
const { mounts } = await loadAssetMounts(registry, config, projectRoot);
|
|
211
|
-
|
|
212
|
-
const entry: ProjectEntry = { context, mounts, mtime: mtimeMs };
|
|
213
|
-
projectContextCache.set(projectRoot, entry);
|
|
214
|
-
return entry;
|
|
283
|
+
return { config, mounts, mtime, registry };
|
|
215
284
|
} catch {
|
|
216
285
|
return null;
|
|
217
286
|
}
|
|
@@ -229,7 +298,7 @@ async function loadProjectEntry(projectRoot: string): Promise<ProjectEntry | nul
|
|
|
229
298
|
*/
|
|
230
299
|
export async function projectSiteBase(projectRoot: string): Promise<string> {
|
|
231
300
|
const entry = await loadProjectEntry(projectRoot);
|
|
232
|
-
return siteBasePath(entry?.
|
|
301
|
+
return siteBasePath(entry?.config.url);
|
|
233
302
|
}
|
|
234
303
|
|
|
235
304
|
/**
|
|
@@ -349,6 +418,9 @@ export async function handleResolve(
|
|
|
349
418
|
status: 500,
|
|
350
419
|
});
|
|
351
420
|
}
|
|
421
|
+
if (config._project && !readsProjectOnDemand(ExportedClass)) {
|
|
422
|
+
await completeSections(config._project);
|
|
423
|
+
}
|
|
352
424
|
/* SAFETY: trust boundary. A class file's `$implementation` names the module exporting the
|
|
353
425
|
class it describes, whose constructor takes one config object (spec.md §12.3, §12.4).
|
|
354
426
|
It is a function, checked above; one that is not constructible throws here, inside the
|
|
@@ -357,7 +429,10 @@ export async function handleResolve(
|
|
|
357
429
|
return Response.json(await instanceResult(new ClassCtor(config)));
|
|
358
430
|
}
|
|
359
431
|
|
|
360
|
-
// Self-contained: construct class from schema
|
|
432
|
+
// Self-contained: construct class from schema. Its JSON bodies cannot await a section.
|
|
433
|
+
if (config._project) {
|
|
434
|
+
await completeSections(config._project);
|
|
435
|
+
}
|
|
361
436
|
const DynClass = classFromSchema(classDef);
|
|
362
437
|
return Response.json(await instanceResult(new DynClass(config)));
|
|
363
438
|
} catch (error) {
|
package/src/server.ts
CHANGED
|
@@ -554,7 +554,11 @@ export async function createDevServer(options: {
|
|
|
554
554
|
|
|
555
555
|
// Resolve npm-style bare specifiers via node_modules.
|
|
556
556
|
// Bundle on-demand so internal bare specifiers (e.g. lit/...) resolve.
|
|
557
|
-
|
|
557
|
+
/* The open project's own packages first: it pins the versions its pages are built with,
|
|
558
|
+
and a project outside the served root has no other way to reach them. */
|
|
559
|
+
const resolved =
|
|
560
|
+
(activeProjectRoot ? resolveNpmPath(activeProjectRoot, path) : null) ??
|
|
561
|
+
resolveNpmPath(absRoot, path);
|
|
558
562
|
if (resolved) {
|
|
559
563
|
const cacheKey = resolved;
|
|
560
564
|
if (!bundleCache.has(cacheKey)) {
|
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";
|
|
@@ -200,9 +200,43 @@ export function isOwnedProjectDir(dir: string): boolean {
|
|
|
200
200
|
return existsSync(resolve(dir, "project.json"));
|
|
201
201
|
}
|
|
202
202
|
|
|
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
|
+
}
|
|
229
|
+
|
|
203
230
|
/**
|
|
204
231
|
* Parse raw `git status --porcelain=v2 --branch` output into structured data.
|
|
205
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
|
+
*
|
|
206
240
|
* @param {string} out
|
|
207
241
|
* @returns {{
|
|
208
242
|
* branch: string;
|
|
@@ -220,7 +254,10 @@ export function parseGitStatus(out: string) {
|
|
|
220
254
|
/** @type {{ path: string; status: string; staged: boolean }[]} */
|
|
221
255
|
const files = [];
|
|
222
256
|
|
|
223
|
-
|
|
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]!;
|
|
224
261
|
if (!line) {
|
|
225
262
|
continue;
|
|
226
263
|
}
|
|
@@ -239,7 +276,11 @@ export function parseGitStatus(out: string) {
|
|
|
239
276
|
const stagedCode = xy[0]!;
|
|
240
277
|
const unstagedCode = xy[1]!;
|
|
241
278
|
let filePath;
|
|
242
|
-
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 ")) {
|
|
243
284
|
const tabIdx = line.indexOf("\t");
|
|
244
285
|
const pathPart = line.slice(tabIdx + 1);
|
|
245
286
|
filePath = pathPart.split("\t").pop() || "";
|
|
@@ -771,16 +812,15 @@ export async function handleStudioApi(
|
|
|
771
812
|
return problem("invalidRequest", errorMessage(error));
|
|
772
813
|
}
|
|
773
814
|
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
};
|
|
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));
|
|
784
824
|
|
|
785
825
|
try {
|
|
786
826
|
if (pattern) {
|
|
@@ -821,7 +861,11 @@ export async function handleStudioApi(
|
|
|
821
861
|
}
|
|
822
862
|
return Response.json(byPathOrder(files));
|
|
823
863
|
} catch (error) {
|
|
824
|
-
|
|
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));
|
|
825
869
|
}
|
|
826
870
|
}
|
|
827
871
|
|
|
@@ -1296,9 +1340,18 @@ export async function handleStudioApi(
|
|
|
1296
1340
|
{ headers: { "Cache-Control": "public, max-age=5" } },
|
|
1297
1341
|
);
|
|
1298
1342
|
} catch (error) {
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
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");
|
|
1302
1355
|
}
|
|
1303
1356
|
}
|
|
1304
1357
|
|
|
@@ -1344,7 +1397,7 @@ export async function handleStudioApi(
|
|
|
1344
1397
|
try {
|
|
1345
1398
|
await mkdir(dirname(abs), { recursive: true });
|
|
1346
1399
|
await writeFile(abs, await req.text(), "utf8");
|
|
1347
|
-
return Response.json({ ok: true, path: fwd(relative(root, abs)) });
|
|
1400
|
+
return Response.json({ ok: true, path: fwd(relative(activeProjectRoot ?? root, abs)) });
|
|
1348
1401
|
} catch (error) {
|
|
1349
1402
|
return problem("internalError", errorMessage(error));
|
|
1350
1403
|
}
|
|
@@ -1366,7 +1419,7 @@ export async function handleStudioApi(
|
|
|
1366
1419
|
await mkdir(dirname(abs), { recursive: true });
|
|
1367
1420
|
const buffer = await req.arrayBuffer();
|
|
1368
1421
|
await Bun.write(abs, new Uint8Array(buffer));
|
|
1369
|
-
return Response.json({ ok: true, path: fwd(relative(root, abs)) });
|
|
1422
|
+
return Response.json({ ok: true, path: fwd(relative(activeProjectRoot ?? root, abs)) });
|
|
1370
1423
|
} catch (error) {
|
|
1371
1424
|
return problem("internalError", errorMessage(error));
|
|
1372
1425
|
}
|
|
@@ -1385,11 +1438,17 @@ export async function handleStudioApi(
|
|
|
1385
1438
|
return new Response(errorMessage(error), { status: 400 });
|
|
1386
1439
|
}
|
|
1387
1440
|
try {
|
|
1388
|
-
|
|
1389
|
-
|
|
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)) });
|
|
1390
1446
|
} catch (error) {
|
|
1391
|
-
|
|
1392
|
-
|
|
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.")
|
|
1393
1452
|
: problem("internalError", errorMessage(error));
|
|
1394
1453
|
}
|
|
1395
1454
|
}
|
|
@@ -1774,7 +1833,24 @@ export async function handleStudioApi(
|
|
|
1774
1833
|
// ── Git endpoints ──────────────────────────────────────────────────────────
|
|
1775
1834
|
|
|
1776
1835
|
if (path.startsWith("/__studio/git/")) {
|
|
1777
|
-
|
|
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
|
+
}
|
|
1778
1854
|
const gitCmd = path.slice("/__studio/git/".length);
|
|
1779
1855
|
|
|
1780
1856
|
const runGit = async (args: string[]) => {
|
|
@@ -1804,15 +1880,13 @@ export async function handleStudioApi(
|
|
|
1804
1880
|
};
|
|
1805
1881
|
|
|
1806
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));
|
|
1807
1888
|
if (gitCmd === "status" && req.method === "GET") {
|
|
1808
|
-
|
|
1809
|
-
const checkProc = Bun.spawn(["git", "rev-parse", "--is-inside-work-tree"], {
|
|
1810
|
-
cwd,
|
|
1811
|
-
stderr: "pipe",
|
|
1812
|
-
stdout: "pipe",
|
|
1813
|
-
});
|
|
1814
|
-
const checkExit = await checkProc.exited;
|
|
1815
|
-
if (checkExit !== 0) {
|
|
1889
|
+
if (!hasRepository) {
|
|
1816
1890
|
return Response.json({
|
|
1817
1891
|
ahead: 0,
|
|
1818
1892
|
behind: 0,
|
|
@@ -1823,8 +1897,12 @@ export async function handleStudioApi(
|
|
|
1823
1897
|
});
|
|
1824
1898
|
}
|
|
1825
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). */
|
|
1826
1904
|
const [out, remotesOut] = await Promise.all([
|
|
1827
|
-
runGit(["status", "--porcelain=v2", "--branch"]),
|
|
1905
|
+
runGit(["status", "--porcelain=v2", "--branch", "-z", "--untracked-files=all"]),
|
|
1828
1906
|
runGit(["remote"]),
|
|
1829
1907
|
]);
|
|
1830
1908
|
const status = parseGitStatus(out);
|
|
@@ -1836,6 +1914,13 @@ export async function handleStudioApi(
|
|
|
1836
1914
|
return Response.json(fullStatus);
|
|
1837
1915
|
}
|
|
1838
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
|
+
|
|
1839
1924
|
if (gitCmd === "init" && req.method === "POST") {
|
|
1840
1925
|
await runGit(["init"]);
|
|
1841
1926
|
return Response.json({ ok: true });
|
|
@@ -1875,6 +1960,16 @@ export async function handleStudioApi(
|
|
|
1875
1960
|
|
|
1876
1961
|
if (gitCmd === "log" && req.method === "GET") {
|
|
1877
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
|
+
}
|
|
1878
1973
|
const out = await runGit(["log", `--max-count=${limit}`, "--format=%H\t%s\t%an\t%aI"]);
|
|
1879
1974
|
const entries = out
|
|
1880
1975
|
.trim()
|