@lerret/cli 0.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.
Files changed (38) hide show
  1. package/LICENSE +21 -0
  2. package/dist-studio/.bundle-stamp +34 -0
  3. package/dist-studio/assets/asset-runtime-MFjDKvQD.js +129 -0
  4. package/dist-studio/assets/cli-project-source-9dNA_gVa.js +1 -0
  5. package/dist-studio/assets/dev-harness-BH6a8T7l.js +18 -0
  6. package/dist-studio/assets/hosted-project-source-dVGq_8c6.js +135 -0
  7. package/dist-studio/assets/index-BNmJ8c2t.css +1 -0
  8. package/dist-studio/assets/index-EslqdOhg.js +10 -0
  9. package/dist-studio/assets/leaf-marker-command.png +0 -0
  10. package/dist-studio/assets/leaf-marker-comment-box.png +0 -0
  11. package/dist-studio/assets/leaf-marker-homescreen.png +0 -0
  12. package/dist-studio/assets/leafmarker-icon-dark-128.png +0 -0
  13. package/dist-studio/assets/leafmarker-logo-transparent.png +0 -0
  14. package/dist-studio/assets/leafmarker-logo.png +0 -0
  15. package/dist-studio/assets/lerret-logo.png +0 -0
  16. package/dist-studio/assets/lerret-wordmark.svg +3 -0
  17. package/dist-studio/assets/logo-angular.svg +1 -0
  18. package/dist-studio/assets/logo-claude.svg +7 -0
  19. package/dist-studio/assets/logo-codex.svg +1 -0
  20. package/dist-studio/assets/logo-cursor.svg +1 -0
  21. package/dist-studio/assets/logo-javascript.svg +1 -0
  22. package/dist-studio/assets/logo-react.svg +1 -0
  23. package/dist-studio/assets/logo-svelte.svg +1 -0
  24. package/dist-studio/assets/logo-vue.svg +1 -0
  25. package/dist-studio/assets/open-folder-D5OR7eLb.js +8 -0
  26. package/dist-studio/assets/project-studio-BjNaIuRb.js +795 -0
  27. package/dist-studio/assets/project-studio-CKuMOMsC.css +1 -0
  28. package/dist-studio/assets/superwhisper-logo.png +0 -0
  29. package/dist-studio/index.html +47 -0
  30. package/dist-studio/module-sw.js +275 -0
  31. package/package.json +51 -0
  32. package/src/dev.js +373 -0
  33. package/src/export.js +1386 -0
  34. package/src/fs/node-backend.js +631 -0
  35. package/src/lerret.js +143 -0
  36. package/src/resolve-project.js +178 -0
  37. package/src/vite-plugin-lerret-project.js +986 -0
  38. package/src/watcher.js +214 -0
@@ -0,0 +1,986 @@
1
+ // vite-plugin-lerret-project.js — the Vite plugin that exposes the user's
2
+ // `.lerret/` project to the studio in `lerret dev` mode.
3
+ //
4
+ // ── Why a Vite plugin ──────────────────────────────────────────────────────
5
+ // `lerret dev` boots a Node-side Vite dev server pointed at the studio source
6
+ // (`packages/studio/`). The studio is a normal Vite-served SPA. To swap from
7
+ // the studio's standalone fixture project to a real user folder we need four
8
+ // things, and a Vite plugin owns all of them in one place:
9
+ //
10
+ // 1. The scanned project model — handed to the studio as data, NOT scanned
11
+ // in the browser. We expose it through a **virtual module**, so the
12
+ // studio writes `import { project, assetBaseUrl } from 'virtual:lerret-
13
+ // project'` and Vite resolves it via this plugin.
14
+ // 2. The user's files — served through Vite so the asset-runtime's dynamic
15
+ // `import()` can fetch each `.jsx`/`.tsx`/`.md`. We add the project root
16
+ // to `server.fs.allow` and alias a stable URL prefix to it so the asset
17
+ // URLs the runtime composes resolve to real files.
18
+ // 3. The live-edit signal — when a file under `.lerret/` changes, the
19
+ // studio must know. We run the chokidar watcher on the user
20
+ // folder and forward each normalized `WatchEvent` over Vite's HMR
21
+ // custom-events channel (`server.hot.send('lerret:change', …)`).
22
+ // 4. A clean "no project" path — when the CLI couldn't resolve a project,
23
+ // the plugin still exposes the virtual module (so the studio's CLI-mode
24
+ // detection still succeeds) but with `project: null`; the studio renders
25
+ // its no-folder placeholder.
26
+ //
27
+ // ── Contract (the public face of this plugin) ─────────────────────────────
28
+ //
29
+ // Virtual module: 'virtual:lerret-project'
30
+ // export project — the ProjectNode (or null if no project resolved)
31
+ // export assetBaseUrl — '/@lerret-project' (or null with no project)
32
+ // export projectRoot — the project root path (or null)
33
+ // export lerretDir — the `.lerret/` directory path (or null)
34
+ // export mode — the string 'cli', so the studio can branch
35
+ //
36
+ // Asset URL base: '/@lerret-project'
37
+ // The plugin aliases this prefix to the project root, so an asset path
38
+ // like `<lerretDir>/ui-components/StatCard.jsx` resolves to
39
+ // `/@lerret-project/.lerret/ui-components/StatCard.jsx`. The asset
40
+ // runtime already does the rebasing (`assetModuleUrl`).
41
+ //
42
+ // HMR custom event: 'lerret:change'
43
+ // payload: { event: WatchEvent, project: ProjectNode | null }
44
+ // Sent on every file-system change under `.lerret/`. The studio's
45
+ // CLI-mode source bridges this into `runtime.notifyChange(event.path)`
46
+ // and, when `project` differs from the previously-mounted model, re-
47
+ // renders with the new project (handles add / remove / rename of files
48
+ // and folders — `applyWatchEvent` patches the model server-
49
+ // side so the client never has to re-scan).
50
+ //
51
+ // ── Boundaries kept ───────────────────────────────────────────────────────
52
+ // `core` stays pure: this plugin runs in Node and is the one that imports the
53
+ // scan/watch helpers and the Node `fs` backend. The studio sees only data
54
+ // (the project JSON) and the HMR event — it never imports `node:fs`.
55
+ //
56
+ // The plugin NEVER writes into the user's `.lerret/` (separation invariant
57
+ // NFR13). The chokidar watcher and the dev server only read.
58
+
59
+ import { resolve as resolvePath } from 'node:path';
60
+
61
+ import { scan, applyWatchEvent, makeWatchEvent, computeCascadedConfig } from '@lerret/core';
62
+
63
+ import {
64
+ createNodeBackend,
65
+ deleteEntry,
66
+ duplicateEntry,
67
+ renameEntry,
68
+ revealEntry,
69
+ } from './fs/node-backend.js';
70
+ import { startWatcher } from './watcher.js';
71
+
72
+ // ── Cascade-override helpers ─────────────────────────────────────────────────
73
+ //
74
+ // When `--config` is supplied to `lerret export`, its value is deep-merged
75
+ // into every entry of the cascade (the `cascadeEntries` the plugin exposes
76
+ // via the virtual module). We replicate the same deep-merge semantics that
77
+ // `computeCascadedConfig`'s internal `deepMerge` uses:
78
+ // • Both sides plain object → recurse.
79
+ // • Either side array, or mixed types → child wins wholesale.
80
+ // • Missing child keys → inherited from parent.
81
+ //
82
+ // This keeps the behaviour consistent with FR21 (config-override
83
+ // arrays replace wholesale, scalars and nested objects merge).
84
+
85
+ /**
86
+ * Deep-merge `child` onto `parent` (same rules as cascade.js's `deepMerge`).
87
+ * Neither argument is mutated. Returns a fresh plain object.
88
+ *
89
+ * @param {Record<string, unknown>} parent
90
+ * @param {Record<string, unknown>} child
91
+ * @returns {Record<string, unknown>}
92
+ */
93
+ function deepMergeConfig(parent, child) {
94
+ const result = Object.assign({}, parent);
95
+ for (const key of Object.keys(child)) {
96
+ const pv = result[key];
97
+ const cv = child[key];
98
+ if (
99
+ pv !== null && typeof pv === 'object' && !Array.isArray(pv) &&
100
+ cv !== null && typeof cv === 'object' && !Array.isArray(cv)
101
+ ) {
102
+ result[key] = deepMergeConfig(
103
+ /** @type {Record<string, unknown>} */ (pv),
104
+ /** @type {Record<string, unknown>} */ (cv),
105
+ );
106
+ } else {
107
+ result[key] = cv;
108
+ }
109
+ }
110
+ return result;
111
+ }
112
+
113
+ /**
114
+ * Apply a `configOverride` to every entry in a serialized cascade array.
115
+ * Returns a new array — does not mutate `cascadeEntries`.
116
+ *
117
+ * @param {Array<[string, object]>} cascadeEntries
118
+ * @param {Record<string, unknown>} configOverride
119
+ * @returns {Array<[string, object]>}
120
+ */
121
+ function applyConfigOverrideToCascade(cascadeEntries, configOverride) {
122
+ return cascadeEntries.map(([path, config]) => [
123
+ path,
124
+ deepMergeConfig(/** @type {Record<string, unknown>} */ (config), configOverride),
125
+ ]);
126
+ }
127
+
128
+ /**
129
+ * The studio writes `import { project, assetBaseUrl } from
130
+ * 'virtual:lerret-project'`. This plugin owns the resolution.
131
+ *
132
+ * @type {string}
133
+ */
134
+ export const VIRTUAL_MODULE_ID = 'virtual:lerret-project';
135
+
136
+ /**
137
+ * Vite recommends prefixing the resolved id of a virtual module with `\0` so
138
+ * other plugins (and tools that walk the module graph) know to leave it
139
+ * alone. See https://vitejs.dev/guide/api-plugin.html#virtual-modules-convention.
140
+ *
141
+ * @type {string}
142
+ */
143
+ const RESOLVED_VIRTUAL_MODULE_ID = '\0' + VIRTUAL_MODULE_ID;
144
+
145
+ /**
146
+ * The URL prefix the user's project root is served under. Stable so the
147
+ * studio's asset-runtime can compose URLs against it the same way the
148
+ * fixture dev-harness composes them against its alias.
149
+ *
150
+ * @type {string}
151
+ */
152
+ export const PROJECT_ASSET_BASE_URL = '/@lerret-project';
153
+
154
+ /**
155
+ * The HMR custom event name pushed on every file-system change. The studio's
156
+ * CLI-mode source listens for this event.
157
+ *
158
+ * @type {string}
159
+ */
160
+ export const HMR_CHANGE_EVENT = 'lerret:change';
161
+
162
+ /**
163
+ * The write-endpoint URL the CLI plugin exposes for the studio→CLI write path.
164
+ * Reused across the lifecycle and data-editor endpoints below.
165
+ *
166
+ * Contract:
167
+ * POST {WRITE_ENDPOINT}
168
+ * Body : { "path": "<LerretPath>", "content": "<utf-8 string>" }
169
+ * 200 : { "ok": true }
170
+ * 4xx/5xx: { "ok": false, "error": "<reason>" }
171
+ *
172
+ * Path safety: the endpoint REJECTS any path that does not start with the
173
+ * project's `.lerret/` tree. Writes outside `.lerret/` are an immediate 400.
174
+ *
175
+ * @type {string}
176
+ */
177
+ export const WRITE_ENDPOINT = '/__lerret/write';
178
+
179
+ /**
180
+ * Lifecycle endpoints for rename / duplicate / delete / reveal.
181
+ * Each accepts a POST with a small JSON body and returns the same calm
182
+ * `{ ok, error? }` shape the write endpoint uses. Every path passed in is run
183
+ * through {@link checkWritePath} before any filesystem call.
184
+ */
185
+ export const RENAME_ENDPOINT = '/__lerret/rename';
186
+ export const DUPLICATE_ENDPOINT = '/__lerret/duplicate';
187
+ export const DELETE_ENDPOINT = '/__lerret/delete';
188
+ export const REVEAL_ENDPOINT = '/__lerret/reveal';
189
+
190
+ /**
191
+ * Serialize a `Map<string, object>` cascade to a JSON-safe
192
+ * `Array<[string, object]>` that the studio can rehydrate into a `Map`.
193
+ *
194
+ * `Map` is not JSON-stringify-able across a virtual-module boundary, so we
195
+ * serialize it as an array of `[key, value]` pairs — identical to the form
196
+ * `Map.prototype.entries()` produces, and directly consumable by
197
+ * `new Map(entries)` on the studio side.
198
+ *
199
+ * @param {Map<string, object> | null} cascade
200
+ * @returns {Array<[string, object]>}
201
+ */
202
+ function serializeCascade(cascade) {
203
+ if (!cascade || cascade.size === 0) return [];
204
+ return Array.from(cascade.entries());
205
+ }
206
+
207
+ /**
208
+ * Build the JS source the virtual module returns. The project model is
209
+ * serialized to JSON and frozen into the module's exports — the studio gets
210
+ * the same plain-data tree it would get from a browser-side scan, just
211
+ * computed server-side.
212
+ *
213
+ * Using `JSON.stringify` is safe because the project model is pure plain
214
+ * data: only strings, numbers, booleans, arrays, and plain objects.
215
+ * The cascaded config is serialized as an `Array<[path, config]>` (a Map
216
+ * cannot be JSON-stringify'd directly — this form is rehydrated to a Map
217
+ * studio-side by `CascadedConfigProvider`).
218
+ *
219
+ * The `overrides` field carries the optional in-memory
220
+ * `dataOverride` and `configOverride` values from `--data` / `--config`. The
221
+ * studio runtime reads `overrides.data` to shadow the data tier (tier 1) of
222
+ * `resolveProps`, and reads `overrides.config` (already deep-merged into the
223
+ * cascade server-side) to ensure the studio's config-provider is consistent.
224
+ * Neither value is ever written to `.lerret/` (NFR13).
225
+ *
226
+ * @param {object} payload
227
+ * @param {object | null} payload.project The scanned project (or null).
228
+ * @param {string | null} payload.assetBaseUrl
229
+ * @param {string | null} payload.projectRoot
230
+ * @param {string | null} payload.lerretDir
231
+ * @param {Array<[string, object]>} payload.cascadeEntries
232
+ * Serialized cascade — `Array<[folderPath, effectiveConfig]>`.
233
+ * @param {{ data: object | null, config: object | null }} payload.overrides
234
+ * In-memory overrides from `--data` / `--config`. Both fields
235
+ * are `null` when the corresponding flag was not supplied.
236
+ * @returns {string} The module's source code.
237
+ */
238
+ function buildVirtualModuleSource({ project, assetBaseUrl, projectRoot, lerretDir, cascadeEntries, overrides }) {
239
+ return [
240
+ '// AUTO-GENERATED by `vite-plugin-lerret-project`. Do not edit.',
241
+ `export const project = ${JSON.stringify(project)};`,
242
+ `export const assetBaseUrl = ${JSON.stringify(assetBaseUrl)};`,
243
+ `export const projectRoot = ${JSON.stringify(projectRoot)};`,
244
+ `export const lerretDir = ${JSON.stringify(lerretDir)};`,
245
+ // cascadeEntries: Array<[LerretPath, ConfigObject]> — rehydrated to a Map
246
+ // in the studio's CascadedConfigProvider.
247
+ `export const cascadeEntries = ${JSON.stringify(cascadeEntries)};`,
248
+ // overrides: { data, config } — in-memory export-time overrides.
249
+ // `data` → the studio runtime merges this at tier 1 of resolveProps.
250
+ // `config` → already deep-merged into cascadeEntries above; exposed here
251
+ // so the studio can detect that an override is active if needed.
252
+ `export const overrides = ${JSON.stringify(overrides)};`,
253
+ `export const mode = 'cli';`,
254
+ `export default { project, assetBaseUrl, projectRoot, lerretDir, cascadeEntries, overrides, mode };`,
255
+ '',
256
+ ].join('\n');
257
+ }
258
+
259
+ /**
260
+ * Convert an OS path to the forward-slash form `core`'s loader/watcher use.
261
+ *
262
+ * @param {string} osPath
263
+ * @returns {string}
264
+ */
265
+ function toLerretPath(osPath) {
266
+ return osPath.replaceAll('\\', '/');
267
+ }
268
+
269
+ /**
270
+ * Decide whether a write target is safe — i.e. inside the project's `.lerret/`
271
+ * tree. This is the server-side gate the studio→CLI write path
272
+ * runs every request through.
273
+ *
274
+ * Rules (all must hold):
275
+ * 1. `lerretDir` is set (no writes without a resolved project).
276
+ * 2. The path is a non-empty string with no `\0` bytes.
277
+ * 3. The path does not contain a `..` segment (no traversal).
278
+ * 4. Normalized to forward slashes, the path starts with `lerretDir` + `/`
279
+ * or equals `lerretDir` itself (and no `.lerret` segment is reached
280
+ * via a non-`/`-bounded match).
281
+ *
282
+ * The rejection is intentionally calm — we return a short string the client
283
+ * surfaces to the user. No 5xx, no stack trace, no project-internals leak.
284
+ *
285
+ * @param {string} requestPath The client-supplied path (LerretPath form).
286
+ * @param {string | null} lerretDir The project's `.lerret/` path.
287
+ * @returns {{ ok: true, normalized: string } | { ok: false, error: string }}
288
+ */
289
+ export function checkWritePath(requestPath, lerretDir) {
290
+ if (!lerretDir) {
291
+ return { ok: false, error: 'no project is loaded — writes are not available' };
292
+ }
293
+ if (typeof requestPath !== 'string' || requestPath.length === 0) {
294
+ return { ok: false, error: 'path must be a non-empty string' };
295
+ }
296
+ if (requestPath.includes('\0')) {
297
+ return { ok: false, error: 'path contains an illegal NUL byte' };
298
+ }
299
+ const normalized = requestPath.replaceAll('\\', '/');
300
+ // Reject any `..` segment — never resolve, just refuse.
301
+ const segments = normalized.split('/');
302
+ for (const seg of segments) {
303
+ if (seg === '..') {
304
+ return { ok: false, error: 'path traversal (..) is not allowed' };
305
+ }
306
+ }
307
+ // Must live under `<lerretDir>/`. Equality (writing to the directory itself)
308
+ // is also a rejection — writes are to files, not the directory entry.
309
+ const root = lerretDir.replace(/\/+$/, '');
310
+ if (!normalized.startsWith(root + '/')) {
311
+ return { ok: false, error: 'path is outside the project .lerret/ tree' };
312
+ }
313
+ return { ok: true, normalized };
314
+ }
315
+
316
+ /**
317
+ * Create the `lerret dev` / `lerret export` Vite plugin.
318
+ *
319
+ * @param {object} opts
320
+ * @param {string | null} opts.projectRoot
321
+ * The user's project root — the folder that directly contains `.lerret/`,
322
+ * or `null` if `lerret dev` was invoked outside any project (no-folder
323
+ * fallback).
324
+ * @param {string | null} opts.lerretDir
325
+ * The user's `.lerret/` directory path, or `null` matching `projectRoot`.
326
+ * @param {Record<string, unknown> | undefined} [opts.dataOverride]
327
+ * Optional in-memory data override from `--data`. When supplied,
328
+ * the value is exposed via the virtual module's `overrides.data` export so
329
+ * the studio runtime can merge it at tier 1 of `resolveProps`. Never written
330
+ * to disk (NFR13).
331
+ * @param {Record<string, unknown> | undefined} [opts.configOverride]
332
+ * Optional in-memory config override from `--config`. When
333
+ * supplied, it is deep-merged (using `computeCascadedConfig`'s `deepMerge`
334
+ * semantics — child keys win, arrays replaced wholesale) into EVERY entry of
335
+ * the cascade before the virtual module is built. This makes the override
336
+ * visible to the studio's `CascadedConfigProvider` immediately at startup.
337
+ * Never written to disk (NFR13).
338
+ * @returns {import('vite').Plugin}
339
+ */
340
+ export function lerretProjectPlugin({ projectRoot, lerretDir, dataOverride, configOverride }) {
341
+ // The single source of truth for the current project model — the watcher
342
+ // keeps it patched, the virtual module emits a serialized snapshot, and
343
+ // the HMR event carries a fresh snapshot on each change so the client
344
+ // never has to recompute from scratch.
345
+ /** @type {object | null} */
346
+ let currentProject = null;
347
+
348
+ // The serialized cascade — kept in sync with `currentProject`. Recomputed
349
+ // whenever the project model is rebuilt (initial scan + every watcher event
350
+ // that changes a config.json or affects a page/group structure). The studio
351
+ // reads it from the virtual module on boot and re-receives it on every
352
+ // `lerret:change` HMR event as `payload.cascadeEntries`.
353
+ /** @type {Array<[string, object]>} */
354
+ let currentCascadeEntries = [];
355
+
356
+ /** @type {import('./watcher.js').WatcherHandle | null} */
357
+ let watcherHandle = null;
358
+
359
+ // The plugin works in two modes:
360
+ // - "project mode": a real user folder was resolved.
361
+ // - "no-project mode": the virtual module still exists but exports
362
+ // `project: null` so the studio's CLI-mode source can render its
363
+ // placeholder. No watcher in that case — there's nothing to watch.
364
+ const hasProject = !!(projectRoot && lerretDir);
365
+ const assetBaseUrl = hasProject ? PROJECT_ASSET_BASE_URL : null;
366
+
367
+ return {
368
+ name: 'lerret:project',
369
+
370
+ /**
371
+ * Extend the resolved Vite config so the user's project files are
372
+ * (a) served by the dev server even though they live outside the
373
+ * studio root, and (b) reachable at our stable URL prefix.
374
+ */
375
+ config(userConfig) {
376
+ if (!hasProject) {
377
+ // No project to serve — keep Vite's defaults untouched. The virtual
378
+ // module still resolves below.
379
+ return {};
380
+ }
381
+
382
+ // We MERGE — not replace — `server.fs.allow`: Vite resolves the
383
+ // existing list down to the workspace root (which contains the
384
+ // studio source `dev.js` boots), and we add the user's project root
385
+ // on top so a request for an asset under it is allowed. Mutating an
386
+ // existing array would override `dev.js`'s entries; returning a
387
+ // partial config (Vite merges arrays for `fs.allow`) keeps both.
388
+ const existingAllow = ((userConfig && userConfig.server && userConfig.server.fs && userConfig.server.fs.allow) || []);
389
+
390
+ return {
391
+ resolve: {
392
+ alias: {
393
+ // Alias the stable URL prefix to the user's `.lerret/`
394
+ // directory — the scan root the runtime composes paths from.
395
+ // The asset-runtime emits URLs of the shape
396
+ // `<assetBaseUrl>/<rel>`
397
+ // where `<rel>` is the asset's path *relative to the scan
398
+ // root* (`assetModuleUrl` already strips `project.path` from
399
+ // the asset path). So a runtime dynamic `import()` of
400
+ // `/@lerret-project/ui-components/StatCard.jsx`
401
+ // resolves through this alias to the same file on disk, and
402
+ // Vite transforms `.jsx`/`.tsx` on the fly (and `.md?raw`).
403
+ //
404
+ // (Mirrors the studio's standalone-dev fixture wiring, which
405
+ // aliases `/@fixture-lerret` → the fixture's `.lerret/` for
406
+ // exactly the same reason.)
407
+ [PROJECT_ASSET_BASE_URL]: lerretDir,
408
+ },
409
+ },
410
+ server: {
411
+ fs: {
412
+ // Append the user's project root to whatever `dev.js` already
413
+ // allowed (workspace root, studio root, etc.). We allow the
414
+ // PROJECT root, not just `.lerret/`, so a font/image whose
415
+ // relative-import path inside an asset escapes the scan root
416
+ // (e.g. `import logo from '../../assets/logo.png'`) is still
417
+ // serveable. The plugin never writes here.
418
+ allow: [...existingAllow, projectRoot],
419
+ },
420
+ },
421
+ };
422
+ },
423
+
424
+ /**
425
+ * Resolve `'virtual:lerret-project'` to a synthetic module id (per
426
+ * Vite's virtual-module convention).
427
+ */
428
+ resolveId(id) {
429
+ if (id === VIRTUAL_MODULE_ID) return RESOLVED_VIRTUAL_MODULE_ID;
430
+ return null;
431
+ },
432
+
433
+ /**
434
+ * Inject a tiny inline script into the served `index.html` so the
435
+ * studio's `main.jsx` can synchronously detect that it is running
436
+ * under `lerret dev`. Without this signal the studio would have to
437
+ * try a dynamic import of `virtual:lerret-project` — which the
438
+ * browser refuses with a CORS error (the bare specifier isn't a URL).
439
+ *
440
+ * The flag is the contract the studio reads; it is also written into
441
+ * the standalone-studio build path (where its absence keeps the
442
+ * fixture path as the fallback).
443
+ */
444
+ transformIndexHtml() {
445
+ return [
446
+ {
447
+ tag: 'script',
448
+ attrs: { type: 'application/javascript' },
449
+ children:
450
+ 'window.__LERRET_CLI_MODE__ = true;',
451
+ injectTo: 'head-prepend',
452
+ },
453
+ ];
454
+ },
455
+
456
+ /**
457
+ * Emit the virtual module's source — the project model as a frozen
458
+ * JSON snapshot plus the stable asset base URL. Live updates come in
459
+ * through the `lerret:change` HMR event below; this is just the
460
+ * starting state the studio mounts with.
461
+ */
462
+ load(id) {
463
+ if (id !== RESOLVED_VIRTUAL_MODULE_ID) return null;
464
+ // Apply the in-memory config override (if any) on top of
465
+ // the cascade. The override is deep-merged into every cascade entry so
466
+ // the studio's CascadedConfigProvider sees it from the first render
467
+ // without any HMR round-trip. The watcher still delivers live updates
468
+ // for the real .lerret/ config files; the override just adds on top.
469
+ const effectiveCascadeEntries = configOverride
470
+ ? applyConfigOverrideToCascade(currentCascadeEntries, configOverride)
471
+ : currentCascadeEntries;
472
+ return buildVirtualModuleSource({
473
+ project: currentProject,
474
+ assetBaseUrl,
475
+ projectRoot,
476
+ lerretDir,
477
+ cascadeEntries: effectiveCascadeEntries,
478
+ // Expose the override objects to the studio so it can apply the data
479
+ // override at tier 1 of resolveProps. null sentinel for
480
+ // absent overrides so the studio can check truthiness simply.
481
+ overrides: {
482
+ data: dataOverride !== undefined ? dataOverride : null,
483
+ config: configOverride !== undefined ? configOverride : null,
484
+ },
485
+ });
486
+ },
487
+
488
+ /**
489
+ * Once the dev server is configured, do the initial project scan and
490
+ * stand up the chokidar watcher that keeps the model in sync. We also
491
+ * register a `closeBundle`-style hook (`buildEnd`) so the watcher is
492
+ * torn down when Vite shuts down.
493
+ */
494
+ async configureServer(server) {
495
+ // Register the studio→CLI write endpoint as a Vite
496
+ // middleware. Lives BEFORE the no-project early-return so a stray
497
+ // POST in no-project mode still gets a calm JSON 400 instead of
498
+ // falling through to Vite's HTML 404 page.
499
+ //
500
+ // The data-editor flows reuse this same endpoint — please do not
501
+ // shape it around the data-editor's specific payload.
502
+ server.middlewares.use(WRITE_ENDPOINT, createWriteMiddleware({ lerretDir }));
503
+
504
+ // Lifecycle endpoints for the per-entity kebab menus.
505
+ // Each is the same calm POST-JSON shape as the write endpoint, gated
506
+ // through `checkWritePath` server-side so a malicious or buggy caller
507
+ // cannot escape the `.lerret/` tree.
508
+ server.middlewares.use(RENAME_ENDPOINT, createRenameMiddleware({ lerretDir }));
509
+ server.middlewares.use(DUPLICATE_ENDPOINT, createDuplicateMiddleware({ lerretDir }));
510
+ server.middlewares.use(DELETE_ENDPOINT, createDeleteMiddleware({ lerretDir }));
511
+ server.middlewares.use(REVEAL_ENDPOINT, createRevealMiddleware({ lerretDir }));
512
+
513
+ if (!hasProject) {
514
+ // No watcher needed in no-project mode; the virtual module already
515
+ // exports `project: null`.
516
+ return;
517
+ }
518
+
519
+ // Initial scan — feeds the first virtual-module load. The studio
520
+ // boots already knowing the project; the watcher only ever pushes
521
+ // *incremental* updates from here on.
522
+ const backend = createNodeBackend();
523
+ try {
524
+ currentProject = await scan(backend, toLerretPath(lerretDir));
525
+ // Compute the initial cascade immediately after scanning. This is the
526
+ // server-side computation that avoids any filesystem access in the
527
+ // browser. A failed cascade falls back to empty (safe default).
528
+ try {
529
+ const cascadeMap = await computeCascadedConfig(currentProject, backend);
530
+ currentCascadeEntries = serializeCascade(cascadeMap);
531
+ } catch (cascadeErr) {
532
+ console.error('[lerret] initial cascade computation failed:', cascadeErr && cascadeErr.message ? cascadeErr.message : cascadeErr);
533
+ currentCascadeEntries = [];
534
+ }
535
+ } catch (err) {
536
+ // A failed initial scan is rare (the loader is forgiving) but
537
+ // possible — e.g. the `.lerret/` directory was deleted between CLI
538
+ // start-up and plugin init. Surface a clear log, keep the server
539
+ // running with `project: null` so the studio at least mounts.
540
+ console.error('[lerret] initial project scan failed:', err && err.message ? err.message : err);
541
+ currentProject = null;
542
+ currentCascadeEntries = [];
543
+ }
544
+
545
+ // Start the watcher on the user's `.lerret/`. Each chokidar change is
546
+ // already normalized by `startWatcher` to a `WatchEvent`; we patch
547
+ // the model with `applyWatchEvent` and broadcast a `lerret:change`
548
+ // payload that carries both the event and the new full model.
549
+ watcherHandle = startWatcher({
550
+ root: toLerretPath(lerretDir),
551
+ onEvent: async (event) => {
552
+ // Patch the in-memory model — pure, idempotent (`applyWatchEvent`
553
+ // owns the FR2-7 mapping rules). On a no-op event (e.g. a
554
+ // `_assets/` image change) the model is returned unchanged; we
555
+ // still ship the event downstream so the runtime can bump its
556
+ // cache-bust for that file path.
557
+ try {
558
+ currentProject = applyWatchEvent(currentProject, event);
559
+ } catch (err) {
560
+ // applyWatchEvent should not throw on a validated event, but a
561
+ // bug here must not take down the live-edit loop. Log and keep
562
+ // the previous model.
563
+ console.error('[lerret] applyWatchEvent threw:', err && err.message ? err.message : err);
564
+ }
565
+
566
+ // Recompute the cascade whenever the model changes. This covers
567
+ // both config.json edits (which `applyWatchEvent` marks as a
568
+ // change event for the config path) and structural add/remove/
569
+ // rename events (which may alter which folders have cascade entries).
570
+ // A cascade failure is non-fatal — keep the prior entries.
571
+ if (currentProject) {
572
+ try {
573
+ const cascadeMap = await computeCascadedConfig(currentProject, backend);
574
+ currentCascadeEntries = serializeCascade(cascadeMap);
575
+ } catch (cascadeErr) {
576
+ console.error('[lerret] cascade recompute failed:', cascadeErr && cascadeErr.message ? cascadeErr.message : cascadeErr);
577
+ // Keep previous cascade entries — better to show stale bg than crash.
578
+ }
579
+ }
580
+
581
+ // Push to the studio. `server.hot.send` is Vite 8's HMR custom-
582
+ // events channel — the studio listens on `import.meta.hot.on(
583
+ // 'lerret:change', …)` and bridges into the runtime.
584
+ try {
585
+ server.hot.send(HMR_CHANGE_EVENT, {
586
+ event,
587
+ project: currentProject,
588
+ // The recomputed cascade so the studio's CascadedConfigProvider
589
+ // can update immediately when a config.json changes (FR18 live
590
+ // update — the section bg responds without a full reload).
591
+ cascadeEntries: currentCascadeEntries,
592
+ });
593
+ } catch (err) {
594
+ // The HMR channel can be torn down mid-shutdown. Ignore.
595
+ if (!String(err && err.message).includes('closed')) {
596
+ console.error('[lerret] hot.send failed:', err && err.message ? err.message : err);
597
+ }
598
+ }
599
+ },
600
+ onError: (err) => {
601
+ // Watcher errors are non-fatal — log and keep running.
602
+ console.error('[lerret watcher]', err && err.message ? err.message : err);
603
+ },
604
+ });
605
+
606
+ // Wait for the watcher's initial scan so the dev server is genuinely
607
+ // live when we hand control back to Vite. `ready` resolves once
608
+ // chokidar's silent first walk completes.
609
+ try {
610
+ await watcherHandle.ready;
611
+ } catch (err) {
612
+ // A pre-ready chokidar failure — the watcher won't deliver events
613
+ // but the server can still serve the initial project. Log so the
614
+ // user sees why live-edit isn't firing.
615
+ console.error('[lerret] watcher failed to start:', err && err.message ? err.message : err);
616
+ }
617
+ },
618
+
619
+ /**
620
+ * Close the chokidar watcher when the dev server shuts down. Without
621
+ * this the CLI process never exits on Ctrl-C — chokidar holds open
622
+ * `fs.watch` handles.
623
+ */
624
+ async closeBundle() {
625
+ if (watcherHandle) {
626
+ await watcherHandle.close().catch(() => {});
627
+ watcherHandle = null;
628
+ }
629
+ },
630
+ };
631
+ }
632
+
633
+ /**
634
+ * Resolve a `--folder` argument (or null) to absolute, normalized paths the
635
+ * plugin and `resolveProject` consume. Pure path arithmetic — no fs access.
636
+ *
637
+ * Exposed so `dev.js` and tests share one normalization helper.
638
+ *
639
+ * @param {string} folder
640
+ * @param {string} [cwd=process.cwd()]
641
+ * @returns {string} An absolute path with forward slashes.
642
+ */
643
+ export function normalizeFolderArg(folder, cwd = process.cwd()) {
644
+ return toLerretPath(resolvePath(cwd, folder));
645
+ }
646
+
647
+ /**
648
+ * Helper to push the synthetic `lerret:change` event payload, used in tests
649
+ * that want to verify the studio-side bridge without spinning up chokidar.
650
+ *
651
+ * @param {string} type
652
+ * @param {string} path
653
+ * @returns {{ type: string, path: string }}
654
+ */
655
+ export function buildChangeEvent(type, path) {
656
+ return makeWatchEvent(type, path);
657
+ }
658
+
659
+ // ── Studio→CLI write endpoint middleware ──────────────────────────────────────
660
+
661
+ /**
662
+ * Max size for a single write payload, in bytes. Keeps a runaway request from
663
+ * exhausting memory or filling the project tree. A data file, config edit, or
664
+ * asset rename never approaches this — 5 MB is conservative for the editors
665
+ * that legitimately call this endpoint.
666
+ *
667
+ * @type {number}
668
+ */
669
+ const MAX_WRITE_BYTES = 5 * 1024 * 1024;
670
+
671
+ /**
672
+ * Read the body of a Connect request as a UTF-8 string, bounded by
673
+ * `MAX_WRITE_BYTES`. Rejects with a string error on overflow / unreadable input.
674
+ *
675
+ * @param {import('node:http').IncomingMessage} req
676
+ * @returns {Promise<string>}
677
+ */
678
+ function readRequestBody(req) {
679
+ return new Promise((resolve, reject) => {
680
+ /** @type {Buffer[]} */
681
+ const chunks = [];
682
+ let total = 0;
683
+ req.on('data', (chunk) => {
684
+ total += chunk.length;
685
+ if (total > MAX_WRITE_BYTES) {
686
+ reject(new Error(`payload exceeds ${MAX_WRITE_BYTES} bytes`));
687
+ req.destroy();
688
+ return;
689
+ }
690
+ chunks.push(chunk);
691
+ });
692
+ req.on('end', () => {
693
+ try {
694
+ resolve(Buffer.concat(chunks).toString('utf-8'));
695
+ } catch (err) {
696
+ reject(err);
697
+ }
698
+ });
699
+ req.on('error', reject);
700
+ });
701
+ }
702
+
703
+ /**
704
+ * Write a JSON response and end the request. Always exits with a `{ ok, error? }`
705
+ * shape so the studio's write-client doesn't have to sniff for surprises.
706
+ *
707
+ * @param {import('node:http').ServerResponse} res
708
+ * @param {number} status
709
+ * @param {{ ok: boolean, error?: string }} body
710
+ */
711
+ function sendJson(res, status, body) {
712
+ res.statusCode = status;
713
+ res.setHeader('Content-Type', 'application/json');
714
+ res.end(JSON.stringify(body));
715
+ }
716
+
717
+ /**
718
+ * Build the Connect-style middleware that serves the studio→CLI write
719
+ * endpoint. Exposed (not just inlined) so tests can drive it directly with a
720
+ * mocked req/res pair — no need to boot Vite.
721
+ *
722
+ * The middleware:
723
+ * - accepts POST only (other methods → 405)
724
+ * - parses the JSON body into `{ path, content }`
725
+ * - runs the path through {@link checkWritePath} (rejects traversal, paths
726
+ * outside `.lerret/`, missing project)
727
+ * - writes via the Node backend's safe-write (atomic temp+rename, NFR9)
728
+ * - returns `{ ok: true }` on success, `{ ok: false, error }` otherwise
729
+ *
730
+ * Failure modes return a calm JSON body even on 4xx/5xx — the studio never
731
+ * sees an HTML error page, so a write failure is always actionable text.
732
+ *
733
+ * @param {object} opts
734
+ * @param {string | null} opts.lerretDir
735
+ * The user's `.lerret/` path, or null in no-project mode.
736
+ * @returns {(req: import('node:http').IncomingMessage, res: import('node:http').ServerResponse, next: () => void) => void}
737
+ */
738
+ export function createWriteMiddleware({ lerretDir }) {
739
+ // One Node backend per middleware instance — the backend is stateless so
740
+ // this is fine to share across requests.
741
+ const backend = createNodeBackend();
742
+
743
+ return function writeMiddleware(req, res /* , next */) {
744
+ if (req.method !== 'POST') {
745
+ sendJson(res, 405, { ok: false, error: 'method not allowed (use POST)' });
746
+ return;
747
+ }
748
+
749
+ readRequestBody(req)
750
+ .then(async (raw) => {
751
+ let parsed;
752
+ try {
753
+ parsed = JSON.parse(raw);
754
+ } catch (err) {
755
+ sendJson(res, 400, {
756
+ ok: false,
757
+ error: `invalid JSON body: ${err instanceof Error ? err.message : String(err)}`,
758
+ });
759
+ return;
760
+ }
761
+
762
+ if (!parsed || typeof parsed !== 'object') {
763
+ sendJson(res, 400, { ok: false, error: 'body must be a JSON object' });
764
+ return;
765
+ }
766
+
767
+ const { path: requestPath, content } = parsed;
768
+
769
+ if (typeof content !== 'string') {
770
+ sendJson(res, 400, { ok: false, error: 'content must be a string' });
771
+ return;
772
+ }
773
+
774
+ const check = checkWritePath(requestPath, lerretDir);
775
+ if (!check.ok) {
776
+ sendJson(res, 400, { ok: false, error: check.error });
777
+ return;
778
+ }
779
+
780
+ try {
781
+ await backend.writeFile(check.normalized, content, { encoding: 'utf-8' });
782
+ sendJson(res, 200, { ok: true });
783
+ } catch (err) {
784
+ // Surface the message, not the stack — the studio displays this
785
+ // string to the user (calm, actionable; no raw stack).
786
+ const message = err instanceof Error ? err.message : String(err);
787
+ console.error('[lerret] write failed:', message);
788
+ sendJson(res, 500, { ok: false, error: `write failed: ${message}` });
789
+ }
790
+ })
791
+ .catch((err) => {
792
+ const message = err instanceof Error ? err.message : String(err);
793
+ sendJson(res, 400, { ok: false, error: message });
794
+ });
795
+ };
796
+ }
797
+
798
+ // ── Lifecycle endpoint middlewares ────────────────────────────────────────────
799
+ //
800
+ // All four (rename / duplicate / delete / reveal) share the same accept-POST-
801
+ // parse-JSON-then-gate skeleton. The shared `withJsonBody` helper keeps each
802
+ // middleware down to its actual semantics.
803
+
804
+ /**
805
+ * Shared wrapper: accepts POST only, parses the JSON body, runs the supplied
806
+ * `handler` with the parsed body. On any framing error returns a calm
807
+ * `{ ok: false, error }` JSON response. The handler is responsible for the
808
+ * domain-specific path-safety check and disk call.
809
+ *
810
+ * @param {(req: import('node:http').IncomingMessage, res: import('node:http').ServerResponse, body: Record<string, unknown>) => Promise<void> | void} handler
811
+ * @returns {(req: import('node:http').IncomingMessage, res: import('node:http').ServerResponse, next: () => void) => void}
812
+ */
813
+ function withJsonBody(handler) {
814
+ return function middleware(req, res /* , next */) {
815
+ if (req.method !== 'POST') {
816
+ sendJson(res, 405, { ok: false, error: 'method not allowed (use POST)' });
817
+ return;
818
+ }
819
+ readRequestBody(req)
820
+ .then(async (raw) => {
821
+ let parsed;
822
+ try {
823
+ parsed = JSON.parse(raw);
824
+ } catch (err) {
825
+ sendJson(res, 400, {
826
+ ok: false,
827
+ error: `invalid JSON body: ${err instanceof Error ? err.message : String(err)}`,
828
+ });
829
+ return;
830
+ }
831
+ if (!parsed || typeof parsed !== 'object') {
832
+ sendJson(res, 400, { ok: false, error: 'body must be a JSON object' });
833
+ return;
834
+ }
835
+ await handler(req, res, parsed);
836
+ })
837
+ .catch((err) => {
838
+ const message = err instanceof Error ? err.message : String(err);
839
+ sendJson(res, 400, { ok: false, error: message });
840
+ });
841
+ };
842
+ }
843
+
844
+ /**
845
+ * `POST /__lerret/rename` — body `{ from: LerretPath, to: LerretPath }`.
846
+ *
847
+ * Both paths are gated through {@link checkWritePath} so neither escapes the
848
+ * project's `.lerret/` tree. The source must exist; the destination must NOT
849
+ * exist (so a typo never clobbers an unrelated file). The chokidar watcher
850
+ * fans the resulting rename out as an `add` + `remove` pair.
851
+ *
852
+ * @param {object} opts
853
+ * @param {string | null} opts.lerretDir
854
+ * @returns {(req: import('node:http').IncomingMessage, res: import('node:http').ServerResponse, next: () => void) => void}
855
+ */
856
+ export function createRenameMiddleware({ lerretDir }) {
857
+ return withJsonBody(async (_req, res, body) => {
858
+ const { from, to } = body;
859
+ if (typeof from !== 'string' || typeof to !== 'string') {
860
+ sendJson(res, 400, { ok: false, error: 'from and to must be strings' });
861
+ return;
862
+ }
863
+ const fromCheck = checkWritePath(from, lerretDir);
864
+ if (!fromCheck.ok) {
865
+ sendJson(res, 400, { ok: false, error: `from: ${fromCheck.error}` });
866
+ return;
867
+ }
868
+ const toCheck = checkWritePath(to, lerretDir);
869
+ if (!toCheck.ok) {
870
+ sendJson(res, 400, { ok: false, error: `to: ${toCheck.error}` });
871
+ return;
872
+ }
873
+ try {
874
+ await renameEntry(fromCheck.normalized, toCheck.normalized);
875
+ sendJson(res, 200, { ok: true });
876
+ } catch (err) {
877
+ const message = err instanceof Error ? err.message : String(err);
878
+ console.error('[lerret] rename failed:', message);
879
+ sendJson(res, 500, { ok: false, error: `rename failed: ${message}` });
880
+ }
881
+ });
882
+ }
883
+
884
+ /**
885
+ * `POST /__lerret/duplicate` — body `{ path: LerretPath }`.
886
+ *
887
+ * Produces a sibling copy of the file or folder at `path`, naming it with a
888
+ * `(copy)` / `(copy N)` suffix until a free name is found. The response
889
+ * carries the new path so the caller can highlight it.
890
+ *
891
+ * @param {object} opts
892
+ * @param {string | null} opts.lerretDir
893
+ * @returns {(req: import('node:http').IncomingMessage, res: import('node:http').ServerResponse, next: () => void) => void}
894
+ */
895
+ export function createDuplicateMiddleware({ lerretDir }) {
896
+ return withJsonBody(async (_req, res, body) => {
897
+ const { path: requestPath } = body;
898
+ if (typeof requestPath !== 'string') {
899
+ sendJson(res, 400, { ok: false, error: 'path must be a string' });
900
+ return;
901
+ }
902
+ const check = checkWritePath(requestPath, lerretDir);
903
+ if (!check.ok) {
904
+ sendJson(res, 400, { ok: false, error: check.error });
905
+ return;
906
+ }
907
+ try {
908
+ const result = await duplicateEntry(check.normalized);
909
+ sendJson(res, 200, { ok: true, path: result.path });
910
+ } catch (err) {
911
+ const message = err instanceof Error ? err.message : String(err);
912
+ console.error('[lerret] duplicate failed:', message);
913
+ sendJson(res, 500, { ok: false, error: `duplicate failed: ${message}` });
914
+ }
915
+ });
916
+ }
917
+
918
+ /**
919
+ * `POST /__lerret/delete` — body `{ path: LerretPath }`.
920
+ *
921
+ * Removes the file or folder. Folders are deleted recursively. The watcher
922
+ * fires a `remove` event so the canvas reflects the change automatically.
923
+ *
924
+ * @param {object} opts
925
+ * @param {string | null} opts.lerretDir
926
+ * @returns {(req: import('node:http').IncomingMessage, res: import('node:http').ServerResponse, next: () => void) => void}
927
+ */
928
+ export function createDeleteMiddleware({ lerretDir }) {
929
+ return withJsonBody(async (_req, res, body) => {
930
+ const { path: requestPath } = body;
931
+ if (typeof requestPath !== 'string') {
932
+ sendJson(res, 400, { ok: false, error: 'path must be a string' });
933
+ return;
934
+ }
935
+ const check = checkWritePath(requestPath, lerretDir);
936
+ if (!check.ok) {
937
+ sendJson(res, 400, { ok: false, error: check.error });
938
+ return;
939
+ }
940
+ try {
941
+ await deleteEntry(check.normalized);
942
+ sendJson(res, 200, { ok: true });
943
+ } catch (err) {
944
+ const message = err instanceof Error ? err.message : String(err);
945
+ console.error('[lerret] delete failed:', message);
946
+ sendJson(res, 500, { ok: false, error: `delete failed: ${message}` });
947
+ }
948
+ });
949
+ }
950
+
951
+ /**
952
+ * `POST /__lerret/reveal` — body `{ path: LerretPath, target: 'editor'|'finder' }`.
953
+ *
954
+ * Shells out to the OS to reveal the path in the user's editor (`code <path>`)
955
+ * or file manager (`open -R` on macOS, `explorer.exe /select,` on Windows,
956
+ * `xdg-open` on Linux). Missing binaries report a calm string the studio can
957
+ * show; the endpoint NEVER throws.
958
+ *
959
+ * @param {object} opts
960
+ * @param {string | null} opts.lerretDir
961
+ * @returns {(req: import('node:http').IncomingMessage, res: import('node:http').ServerResponse, next: () => void) => void}
962
+ */
963
+ export function createRevealMiddleware({ lerretDir }) {
964
+ return withJsonBody(async (_req, res, body) => {
965
+ const { path: requestPath, target } = body;
966
+ if (typeof requestPath !== 'string') {
967
+ sendJson(res, 400, { ok: false, error: 'path must be a string' });
968
+ return;
969
+ }
970
+ if (target !== 'editor' && target !== 'finder') {
971
+ sendJson(res, 400, { ok: false, error: 'target must be "editor" or "finder"' });
972
+ return;
973
+ }
974
+ const check = checkWritePath(requestPath, lerretDir);
975
+ if (!check.ok) {
976
+ sendJson(res, 400, { ok: false, error: check.error });
977
+ return;
978
+ }
979
+ const result = await revealEntry(check.normalized, target);
980
+ if (result.ok) {
981
+ sendJson(res, 200, { ok: true });
982
+ } else {
983
+ sendJson(res, 500, { ok: false, error: result.error || 'reveal failed' });
984
+ }
985
+ });
986
+ }