@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.
- package/LICENSE +21 -0
- package/dist-studio/.bundle-stamp +34 -0
- package/dist-studio/assets/asset-runtime-MFjDKvQD.js +129 -0
- package/dist-studio/assets/cli-project-source-9dNA_gVa.js +1 -0
- package/dist-studio/assets/dev-harness-BH6a8T7l.js +18 -0
- package/dist-studio/assets/hosted-project-source-dVGq_8c6.js +135 -0
- package/dist-studio/assets/index-BNmJ8c2t.css +1 -0
- package/dist-studio/assets/index-EslqdOhg.js +10 -0
- package/dist-studio/assets/leaf-marker-command.png +0 -0
- package/dist-studio/assets/leaf-marker-comment-box.png +0 -0
- package/dist-studio/assets/leaf-marker-homescreen.png +0 -0
- package/dist-studio/assets/leafmarker-icon-dark-128.png +0 -0
- package/dist-studio/assets/leafmarker-logo-transparent.png +0 -0
- package/dist-studio/assets/leafmarker-logo.png +0 -0
- package/dist-studio/assets/lerret-logo.png +0 -0
- package/dist-studio/assets/lerret-wordmark.svg +3 -0
- package/dist-studio/assets/logo-angular.svg +1 -0
- package/dist-studio/assets/logo-claude.svg +7 -0
- package/dist-studio/assets/logo-codex.svg +1 -0
- package/dist-studio/assets/logo-cursor.svg +1 -0
- package/dist-studio/assets/logo-javascript.svg +1 -0
- package/dist-studio/assets/logo-react.svg +1 -0
- package/dist-studio/assets/logo-svelte.svg +1 -0
- package/dist-studio/assets/logo-vue.svg +1 -0
- package/dist-studio/assets/open-folder-D5OR7eLb.js +8 -0
- package/dist-studio/assets/project-studio-BjNaIuRb.js +795 -0
- package/dist-studio/assets/project-studio-CKuMOMsC.css +1 -0
- package/dist-studio/assets/superwhisper-logo.png +0 -0
- package/dist-studio/index.html +47 -0
- package/dist-studio/module-sw.js +275 -0
- package/package.json +51 -0
- package/src/dev.js +373 -0
- package/src/export.js +1386 -0
- package/src/fs/node-backend.js +631 -0
- package/src/lerret.js +143 -0
- package/src/resolve-project.js +178 -0
- package/src/vite-plugin-lerret-project.js +986 -0
- 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
|
+
}
|