@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,631 @@
1
+ // node-backend — the Node `fs` implementation of `FilesystemAccess`.
2
+ //
3
+ // This is one of the two filesystem backends behind the `core`
4
+ // `FilesystemAccess` contract; it powers CLI / self-host mode, where Lerret
5
+ // has full Node `fs` access. The studio's File System Access backend is the
6
+ // browser counterpart.
7
+ //
8
+ // IMPORTANT — this file is the ONLY place in the codebase permitted to import
9
+ // `node:fs`. The architecture's separation invariant (and an ESLint
10
+ // `no-restricted-imports` rule in `eslint.config.js`) bans the `fs` family
11
+ // everywhere else; every other subsystem reaches the filesystem exclusively
12
+ // through a `FilesystemAccess` value. `node:path` / `node:os` are allowed in
13
+ // Node packages generally — only the `fs` family is gated to this file.
14
+
15
+ import { spawn } from 'node:child_process';
16
+ import {
17
+ existsSync as fsExistsSync,
18
+ mkdtempSync,
19
+ promises as fsp,
20
+ realpathSync,
21
+ watch as watchNative,
22
+ } from 'node:fs';
23
+ import { tmpdir, platform } from 'node:os';
24
+ import {
25
+ basename,
26
+ dirname,
27
+ extname,
28
+ join as joinNative,
29
+ sep as nativeSep,
30
+ } from 'node:path';
31
+
32
+ import { assertFilesystemContract, serializeJson } from '@lerret/core';
33
+
34
+ // ---------------------------------------------------------------------------
35
+ // Path normalization
36
+ // ---------------------------------------------------------------------------
37
+ //
38
+ // The `FilesystemAccess` contract speaks POSIX-style paths (forward slashes)
39
+ // at its boundary. On macOS / Linux the native separator already is `/`, so
40
+ // these are near no-ops; on Windows they bridge `/` <-> `\`. Normalizing here,
41
+ // at the backend edge, means the loader and editors never see a backslash.
42
+
43
+ /**
44
+ * Convert a contract-level {@link LerretPath} (forward slashes) into a path
45
+ * the host OS understands.
46
+ *
47
+ * @param {string} lerretPath
48
+ * @returns {string} A path using the native separator.
49
+ */
50
+ function toNativePath(lerretPath) {
51
+ return nativeSep === '/' ? lerretPath : lerretPath.replaceAll('/', nativeSep);
52
+ }
53
+
54
+ /**
55
+ * Convert a native OS path into a contract-level {@link LerretPath} (forward
56
+ * slashes).
57
+ *
58
+ * @param {string} nativePath
59
+ * @returns {string} A forward-slash path.
60
+ */
61
+ function toLerretPath(nativePath) {
62
+ return nativeSep === '/' ? nativePath : nativePath.replaceAll(nativeSep, '/');
63
+ }
64
+
65
+ /**
66
+ * Join a directory path and a child name into a normalized {@link LerretPath}.
67
+ *
68
+ * @param {string} dirPath A contract-level (forward-slash) directory path.
69
+ * @param {string} name A single path segment.
70
+ * @returns {string} The joined, forward-slash path.
71
+ */
72
+ function joinLerretPath(dirPath, name) {
73
+ return toLerretPath(joinNative(toNativePath(dirPath), name));
74
+ }
75
+
76
+ // ---------------------------------------------------------------------------
77
+ // Capability flags
78
+ // ---------------------------------------------------------------------------
79
+
80
+ /**
81
+ * Capabilities of the Node backend. In a Node / self-host environment the
82
+ * process has full filesystem access: it can write, it can watch via
83
+ * `fs.watch`, and the host OS can reveal a path in a native file manager.
84
+ *
85
+ * @type {import('@lerret/core').FilesystemCapabilities}
86
+ */
87
+ const NODE_CAPABILITIES = Object.freeze({
88
+ canWrite: true,
89
+ canWatch: true,
90
+ canReveal: true,
91
+ });
92
+
93
+ // ---------------------------------------------------------------------------
94
+ // readDir
95
+ // ---------------------------------------------------------------------------
96
+
97
+ /**
98
+ * List the immediate children of a directory.
99
+ *
100
+ * @param {string} dirPath A contract-level (forward-slash) directory path.
101
+ * @returns {Promise<import('@lerret/core').DirEntry[]>}
102
+ * One {@link DirEntry} per child, files distinguished from subdirectories.
103
+ * Rejects if `dirPath` is missing or not a directory.
104
+ */
105
+ async function readDir(dirPath) {
106
+ // `withFileTypes` yields Dirent objects, so the file/dir distinction comes
107
+ // for free without an extra `stat` per entry.
108
+ const dirents = await fsp.readdir(toNativePath(dirPath), {
109
+ withFileTypes: true,
110
+ });
111
+
112
+ return dirents.map((dirent) => {
113
+ const isDirectory = dirent.isDirectory();
114
+ return {
115
+ name: dirent.name,
116
+ path: joinLerretPath(dirPath, dirent.name),
117
+ kind: isDirectory ? 'directory' : 'file',
118
+ isFile: !isDirectory,
119
+ isDirectory,
120
+ };
121
+ });
122
+ }
123
+
124
+ // ---------------------------------------------------------------------------
125
+ // readFile
126
+ // ---------------------------------------------------------------------------
127
+
128
+ /**
129
+ * Read a file's full contents.
130
+ *
131
+ * @param {string} filePath A contract-level (forward-slash) file path.
132
+ * @param {import('@lerret/core').ReadFileOptions} [options]
133
+ * `encoding: 'utf-8'` (default) decodes to a `string`; `encoding: 'binary'`
134
+ * returns raw bytes as a `Uint8Array`.
135
+ * @returns {Promise<string | Uint8Array>}
136
+ */
137
+ async function readFile(filePath, options = {}) {
138
+ const { encoding = 'utf-8' } = options;
139
+ const nativePath = toNativePath(filePath);
140
+
141
+ if (encoding === 'binary') {
142
+ // Read as a Node Buffer, then hand back a plain Uint8Array — the contract
143
+ // shape — so callers get an identical type from either backend. The
144
+ // returned view is a copy, fully owned by the caller.
145
+ const buffer = await fsp.readFile(nativePath);
146
+ return new Uint8Array(buffer);
147
+ }
148
+
149
+ return fsp.readFile(nativePath, 'utf-8');
150
+ }
151
+
152
+ // ---------------------------------------------------------------------------
153
+ // writeFile — safe (atomic) write
154
+ // ---------------------------------------------------------------------------
155
+
156
+ /**
157
+ * Write a file's full contents via a temp-file-then-atomic-rename, so a failed
158
+ * or interrupted write never corrupts or truncates the existing file (NFR9).
159
+ *
160
+ * The sequence:
161
+ * 1. write the new content to a fresh temp file in the OS temp directory,
162
+ * 2. `fsync` it so the bytes are durably on disk,
163
+ * 3. `rename` the temp file over the destination — an atomic operation, so a
164
+ * reader sees either the whole old file or the whole new file,
165
+ * 4. on any failure before the rename, delete the temp file and reject with
166
+ * the original target left untouched.
167
+ *
168
+ * Note the temp file is created in the system temp dir (not beside the target)
169
+ * so a partially-written file never appears inside the user's project — the
170
+ * watcher would otherwise observe it. The rename therefore crosses devices on
171
+ * some setups; `fsp.rename` handles the common case, and the fallback path
172
+ * copies-then-replaces while still never exposing a partial destination.
173
+ *
174
+ * @param {string} filePath A contract-level (forward-slash) file path.
175
+ * @param {string | Uint8Array} data
176
+ * The full new contents — a `string` for `encoding: 'utf-8'`, a
177
+ * `Uint8Array` for `encoding: 'binary'`.
178
+ * @param {import('@lerret/core').WriteFileOptions} [options]
179
+ * @returns {Promise<void>} Resolves once the new content is durably in place.
180
+ */
181
+ async function writeFile(filePath, data, options = {}) {
182
+ const { encoding = 'utf-8' } = options;
183
+ const nativeTarget = toNativePath(filePath);
184
+
185
+ // A unique temp directory per write — `mkdtemp` guarantees no collision even
186
+ // under concurrent writes, and one file inside it keeps cleanup trivial.
187
+ const tempDir = mkdtempSync(joinNative(tmpdir(), 'lerret-write-'));
188
+ const tempFile = joinNative(tempDir, basename(nativeTarget));
189
+
190
+ try {
191
+ // Write + fsync the temp copy so the new bytes are durable before we
192
+ // expose them via the rename.
193
+ const handle = await fsp.open(tempFile, 'w');
194
+ try {
195
+ if (encoding === 'binary') {
196
+ await handle.write(data);
197
+ } else {
198
+ await handle.write(data, 0, 'utf-8');
199
+ }
200
+ await handle.sync();
201
+ } finally {
202
+ await handle.close();
203
+ }
204
+
205
+ // Atomic publish. On the same filesystem this is a single atomic rename;
206
+ // if it crosses devices (EXDEV) fall back to copy-into-place, which Node's
207
+ // `copyFile` performs without ever leaving a truncated destination.
208
+ try {
209
+ await fsp.rename(tempFile, nativeTarget);
210
+ } catch (err) {
211
+ if (err && err.code === 'EXDEV') {
212
+ await fsp.copyFile(tempFile, nativeTarget);
213
+ await fsp.rm(tempFile, { force: true });
214
+ } else {
215
+ throw err;
216
+ }
217
+ }
218
+ } catch (err) {
219
+ // Any failure before the destination is replaced: clean up the temp
220
+ // artifacts and re-reject. The original target file is untouched.
221
+ await fsp.rm(tempDir, { recursive: true, force: true });
222
+ throw err;
223
+ }
224
+
225
+ // Success — remove the now-empty temp directory (the temp file was renamed
226
+ // out of it, or copied out and deleted).
227
+ await fsp.rm(tempDir, { recursive: true, force: true });
228
+ }
229
+
230
+ /**
231
+ * Convenience: serialize a value as canonical Lerret JSON (stable key order,
232
+ * two-space indent, trailing newline) and write it atomically.
233
+ *
234
+ * Equivalent to `writeFile(path, serializeJson(value))`, exposed so callers
235
+ * writing `config.json` / `<Name>.data.json` / the `.lerret/.state/` sidecar
236
+ * do not each re-import {@link serializeJson}.
237
+ *
238
+ * @param {string} filePath A contract-level (forward-slash) file path.
239
+ * @param {unknown} value The JSON-serializable value to write.
240
+ * @returns {Promise<void>}
241
+ */
242
+ async function writeJson(filePath, value) {
243
+ await writeFile(filePath, serializeJson(value), { encoding: 'utf-8' });
244
+ }
245
+
246
+ // ---------------------------------------------------------------------------
247
+ // watch
248
+ // ---------------------------------------------------------------------------
249
+ //
250
+ // Deliberately minimal. This is a thin `fs.watch` wrapper that surfaces raw
251
+ // rename/change events; the normalized `{ type: 'add'|'change'|'remove',
252
+ // path }` change-event layer the loader consumes lives in
253
+ // (`core/loader/watch.js`), built by diffing these raw events against the
254
+ // project model.
255
+
256
+ /**
257
+ * Watch a file or directory for changes.
258
+ *
259
+ * @param {string} targetPath A contract-level (forward-slash) path.
260
+ * @param {import('@lerret/core').WatchListener} listener
261
+ * Invoked with a {@link RawWatchEvent} on each change.
262
+ * @returns {import('@lerret/core').Watcher}
263
+ * A handle whose `close()` ends the watch. `close()` is idempotent.
264
+ */
265
+ function watch(targetPath, listener) {
266
+ const nativeTarget = toNativePath(targetPath);
267
+ const fsWatcher = watchNative(nativeTarget);
268
+
269
+ fsWatcher.on('change', (eventType, filename) => {
270
+ listener({
271
+ kind: eventType === 'rename' ? 'rename' : 'change',
272
+ // `filename` may be a Buffer or null depending on platform.
273
+ path: filename ? joinLerretPath(targetPath, filename.toString()) : null,
274
+ });
275
+ });
276
+
277
+ let closed = false;
278
+ return {
279
+ close() {
280
+ if (closed) return;
281
+ closed = true;
282
+ fsWatcher.close();
283
+ },
284
+ };
285
+ }
286
+
287
+ // ---------------------------------------------------------------------------
288
+ // Factory
289
+ // ---------------------------------------------------------------------------
290
+
291
+ /**
292
+ * Construct the Node `fs` filesystem backend — an object satisfying the
293
+ * `core` {@link FilesystemAccess} contract.
294
+ *
295
+ * The returned backend is stateless and may be shared across the whole CLI
296
+ * process. It is validated against the contract before being returned, so a
297
+ * future drift from the interface fails loudly at construction rather than
298
+ * silently at a call site.
299
+ *
300
+ * @returns {import('@lerret/core').FilesystemAccess & {
301
+ * writeJson: (filePath: string, value: unknown) => Promise<void>,
302
+ * }}
303
+ * The backend. Beyond the four contract methods and `capabilities` it also
304
+ * exposes `writeJson` as a typed convenience.
305
+ */
306
+ export function createNodeBackend() {
307
+ /** @type {import('@lerret/core').FilesystemAccess} */
308
+ const backend = {
309
+ readDir,
310
+ readFile,
311
+ writeFile,
312
+ watch,
313
+ capabilities: NODE_CAPABILITIES,
314
+ };
315
+
316
+ // Fail fast if this backend ever drifts from the contract.
317
+ assertFilesystemContract(backend, 'node-backend');
318
+
319
+ return Object.assign(backend, { writeJson });
320
+ }
321
+
322
+ export { NODE_CAPABILITIES };
323
+
324
+ // ---------------------------------------------------------------------------
325
+ // realpath helper — CLI-internal, NOT part of the FilesystemAccess contract
326
+ // ---------------------------------------------------------------------------
327
+ //
328
+ // `lerret dev` configures Vite's `server.fs.allow`, which Vite enforces by
329
+ // comparing against the *real* (symlink-resolved) path of each request. On
330
+ // macOS `/tmp` is a symlink to `/private/tmp`, so an `--folder /tmp/foo`
331
+ // argument must be resolved to `/private/tmp/foo` before being added to
332
+ // `fs.allow` — otherwise every request 404s with an "outside of Vite serving
333
+ // allow list" warning.
334
+ //
335
+ // This is the only place in the CLI permitted to call `realpathSync` (same
336
+ // `node:fs` ban as the rest of the file — this is the sanctioned escape).
337
+ // The helper is `Sync` because it is called once during CLI startup, before
338
+ // the dev server boots, on at most a handful of paths; making it async would
339
+ // only complicate the boot sequence without buying anything.
340
+
341
+ /**
342
+ * Resolve a path through symlinks if it exists on disk; if the path does
343
+ * not exist (a programmer-typo case the caller wants to surface separately),
344
+ * return the input unchanged so the downstream `fs.allow` / existence-check
345
+ * machinery sees the original string.
346
+ *
347
+ * @param {string} osPath A native OS path. The return value is also native.
348
+ * @returns {string} The real path, or `osPath` on `ENOENT`.
349
+ */
350
+ export function realpathOrSelf(osPath) {
351
+ try {
352
+ return realpathSync(osPath);
353
+ } catch (err) {
354
+ if (err && err.code === 'ENOENT') return osPath;
355
+ throw err;
356
+ }
357
+ }
358
+
359
+ /**
360
+ * Synchronously check whether a path exists on the filesystem. A thin wrapper
361
+ * around Node's `fs.existsSync` that can be imported by the rest of the CLI
362
+ * without violating the `no-restricted-imports` rule (only this file may touch
363
+ * the `fs` family directly).
364
+ *
365
+ * Intentionally synchronous: callers such as `resolveStudioRoot()` need to
366
+ * probe the CLI package's own `dist-studio/` at startup — the result is needed
367
+ * before any async boundary and is a self-inspection of the CLI package, not a
368
+ * user-data read. The `FilesystemAccess` abstraction is for user project files;
369
+ * this is a one-shot packaging check.
370
+ *
371
+ * @param {string} path An absolute OS path.
372
+ * @returns {boolean} `true` when the path exists (any type).
373
+ */
374
+ export function pathExists(path) {
375
+ return fsExistsSync(path);
376
+ }
377
+
378
+ /**
379
+ * Recursively create a directory (no-op if it already exists). Used by
380
+ * subsystems that need to materialize an output tree on disk — the bulk
381
+ * `lerret export` writer lands captured images under a user-
382
+ * specified `--out` directory and needs to mkdir intermediate folders for the
383
+ * structured layout. Kept in this file so the `fs` ban for the rest of the
384
+ * codebase is preserved (this is the sanctioned escape).
385
+ *
386
+ * @param {string} lerretPath A contract-level (forward-slash) directory path.
387
+ * @returns {Promise<void>} Resolves once the directory exists.
388
+ */
389
+ export async function ensureDir(lerretPath) {
390
+ const native = toNativePath(lerretPath);
391
+ await fsp.mkdir(native, { recursive: true });
392
+ }
393
+
394
+ /**
395
+ * Canonicalize the deepest existing prefix of a path, then re-attach the
396
+ * still-virtual trailing components. Used by `lerret export` to compare a
397
+ * user-supplied `--out` directory against the project's `.lerret/` path even
398
+ * when `--out` does not yet exist on disk.
399
+ *
400
+ * `realpathSync` flatly refuses to resolve a path that does not exist (it
401
+ * throws `ENOENT`). This walks up component by component until it finds an
402
+ * existing ancestor, canonicalizes that, and joins the leftover virtual
403
+ * leaf back on. The result is always returned in forward-slash form to match
404
+ * the rest of the CLI's `LerretPath` convention.
405
+ *
406
+ * @param {string} osPath An absolute native path.
407
+ * @returns {string} A forward-slash, canonicalized-prefix path.
408
+ */
409
+ export function realpathOfExistingPrefix(osPath) {
410
+ // Walk up until we find an ancestor that exists.
411
+ let head = osPath;
412
+ /** @type {string[]} */
413
+ const tail = [];
414
+ for (;;) {
415
+ try {
416
+ const real = realpathSync(head);
417
+ // Re-attach virtual leaves. `joinNative` collapses any redundant
418
+ // separators introduced by the loop above; forward-slash conversion
419
+ // happens once at the end.
420
+ const stitched = tail.length === 0 ? real : joinNative(real, ...tail);
421
+ return stitched.replaceAll('\\', '/');
422
+ } catch (err) {
423
+ if (!err || err.code !== 'ENOENT') throw err;
424
+ const parent = dirname(head);
425
+ if (parent === head) {
426
+ // Reached the filesystem root without finding anything that exists —
427
+ // return the original path normalized to forward slashes.
428
+ return osPath.replaceAll('\\', '/');
429
+ }
430
+ tail.unshift(basename(head));
431
+ head = parent;
432
+ }
433
+ }
434
+ }
435
+
436
+ // ---------------------------------------------------------------------------
437
+ // rename / duplicate / delete / reveal helpers
438
+ // ---------------------------------------------------------------------------
439
+ //
440
+ // These are CLI-internal lifecycle operations powering the per-entity kebab
441
+ // menus. They live here because this file is the only spot allowed to import
442
+ // `node:fs` (and `node:child_process` for reveal). The Vite plugin wraps each
443
+ // helper in a JSON endpoint and gates the input through `checkWritePath`.
444
+
445
+ /**
446
+ * Rename (or move) a file or directory atomically. Refuses to overwrite an
447
+ * existing destination — the caller must supply a path the disk does not
448
+ * already use. Both paths are contract-level `LerretPath` (forward slashes).
449
+ *
450
+ * @param {string} fromPath
451
+ * @param {string} toPath
452
+ * @returns {Promise<void>}
453
+ */
454
+ async function renameEntry(fromPath, toPath) {
455
+ const fromNative = toNativePath(fromPath);
456
+ const toNative = toNativePath(toPath);
457
+
458
+ // Refuse to clobber an existing destination — make collisions visible.
459
+ try {
460
+ await fsp.access(toNative);
461
+ throw new Error(`destination already exists: ${toPath}`);
462
+ } catch (err) {
463
+ if (err && err.code !== 'ENOENT') throw err;
464
+ }
465
+
466
+ await fsp.mkdir(dirname(toNative), { recursive: true });
467
+ await fsp.rename(fromNative, toNative);
468
+ }
469
+
470
+ /**
471
+ * Duplicate a file or directory into the SAME parent folder with a derived
472
+ * "(copy)"/`(copy N)` name. Returns the resulting path so the caller can
473
+ * surface it (and the watcher will fire an `add` event).
474
+ *
475
+ * The naming rule mirrors macOS Finder / VS Code:
476
+ * `Foo.jsx` → `Foo (copy).jsx`
477
+ * `Foo (copy).jsx` → `Foo (copy 2).jsx`
478
+ * `Foo (copy 2).jsx` → `Foo (copy 3).jsx`
479
+ *
480
+ * For folders the suffix sits at the end (no extension to consider).
481
+ *
482
+ * @param {string} sourcePath
483
+ * @returns {Promise<{ path: string }>} The duplicated entry's path.
484
+ */
485
+ async function duplicateEntry(sourcePath) {
486
+ const sourceNative = toNativePath(sourcePath);
487
+ const stat = await fsp.stat(sourceNative);
488
+ const parentNative = dirname(sourceNative);
489
+ const baseName = basename(sourceNative);
490
+
491
+ const ext = stat.isDirectory() ? '' : extname(baseName);
492
+ const stem = ext ? baseName.slice(0, -ext.length) : baseName;
493
+
494
+ // Strip an existing "(copy)" / "(copy N)" suffix so consecutive duplicates
495
+ // produce `(copy 2)`, `(copy 3)`, … instead of `(copy) (copy)`.
496
+ const copyRe = /\s*\(copy(?:\s+(\d+))?\)$/;
497
+ const match = stem.match(copyRe);
498
+ const rootStem = match ? stem.slice(0, -match[0].length) : stem;
499
+ const startN = match ? (match[1] ? parseInt(match[1], 10) + 1 : 2) : 1;
500
+
501
+ const targetFor = (n) => {
502
+ const suffix = n === 1 ? ' (copy)' : ` (copy ${n})`;
503
+ return joinNative(parentNative, `${rootStem}${suffix}${ext}`);
504
+ };
505
+
506
+ // Probe upward until we find a non-existing name. Cap the loop to keep a
507
+ // pathological caller from spinning forever.
508
+ let targetNative = '';
509
+ for (let n = startN; n < startN + 1000; n += 1) {
510
+ const candidate = targetFor(n);
511
+ try {
512
+ await fsp.access(candidate);
513
+ } catch (err) {
514
+ if (err && err.code === 'ENOENT') {
515
+ targetNative = candidate;
516
+ break;
517
+ }
518
+ throw err;
519
+ }
520
+ }
521
+ if (!targetNative) {
522
+ throw new Error(`unable to find a free duplicate name for: ${sourcePath}`);
523
+ }
524
+
525
+ if (stat.isDirectory()) {
526
+ // Node 16+: recursive copy. Force = false to refuse clobbering (we already
527
+ // probed above; this is belt-and-braces).
528
+ await fsp.cp(sourceNative, targetNative, { recursive: true, force: false, errorOnExist: true });
529
+ } else {
530
+ await fsp.copyFile(sourceNative, targetNative);
531
+ }
532
+
533
+ return { path: toLerretPath(targetNative) };
534
+ }
535
+
536
+ /**
537
+ * Delete a file or directory. Directories are removed recursively. Missing
538
+ * targets succeed silently (already-gone is the desired post-state).
539
+ *
540
+ * @param {string} targetPath
541
+ * @returns {Promise<void>}
542
+ */
543
+ async function deleteEntry(targetPath) {
544
+ const native = toNativePath(targetPath);
545
+ await fsp.rm(native, { recursive: true, force: true });
546
+ }
547
+
548
+ /**
549
+ * Shell out to reveal a path in the user's preferred editor (`code <path>`)
550
+ * or in their file manager (`open -R` on macOS, `explorer /select,` on
551
+ * Windows, `xdg-open` on Linux). Best-effort: a missing helper does not throw,
552
+ * the caller surfaces the message to the user.
553
+ *
554
+ * Security: the path is the caller's `LerretPath` already vetted through
555
+ * `checkWritePath`, so it must live under the project's `.lerret/` tree.
556
+ * The command itself is the fixed binary name — we never pass user-supplied
557
+ * data as a shell command, only as a single argument to `spawn`.
558
+ *
559
+ * @param {string} targetPath A contract-level (forward-slash) path.
560
+ * @param {'editor' | 'finder'} target Which surface to reveal in.
561
+ * @returns {Promise<{ ok: boolean, error?: string }>}
562
+ */
563
+ async function revealEntry(targetPath, target) {
564
+ const native = toNativePath(targetPath);
565
+ const onMac = platform() === 'darwin';
566
+ const onWindows = platform() === 'win32';
567
+
568
+ /**
569
+ * @param {string} bin
570
+ * @param {string[]} args
571
+ * @returns {Promise<{ ok: boolean, error?: string }>}
572
+ */
573
+ function run(bin, args) {
574
+ return new Promise((resolve) => {
575
+ try {
576
+ const child = spawn(bin, args, { stdio: 'ignore', detached: true });
577
+ child.on('error', (err) => {
578
+ resolve({ ok: false, error: err && err.message ? err.message : String(err) });
579
+ });
580
+ child.on('spawn', () => {
581
+ // Detach so the child outlives the CLI process if needed.
582
+ try { child.unref(); } catch { /* ignore */ }
583
+ resolve({ ok: true });
584
+ });
585
+ } catch (err) {
586
+ resolve({ ok: false, error: err && err.message ? err.message : String(err) });
587
+ }
588
+ });
589
+ }
590
+
591
+ if (target === 'editor') {
592
+ // `code` is the universal CLI launcher — most editors install it under that
593
+ // name (VS Code itself, Cursor, Code-OSS variants). If it isn't in PATH the
594
+ // spawn errors and we report a calm message.
595
+ return run('code', [native]);
596
+ }
597
+
598
+ if (target === 'finder') {
599
+ if (onMac) return run('open', ['-R', native]);
600
+ if (onWindows) return run('explorer.exe', [`/select,${native}`]);
601
+ // Linux/Other — `xdg-open` opens the file's parent folder; no native
602
+ // "select" equivalent across distros.
603
+ return run('xdg-open', [dirname(native)]);
604
+ }
605
+
606
+ return { ok: false, error: `unknown reveal target: ${target}` };
607
+ }
608
+
609
+ export { renameEntry, duplicateEntry, deleteEntry, revealEntry };
610
+
611
+ // ---------------------------------------------------------------------------
612
+ // Plain text file reader (CLI-internal, NOT part of FilesystemAccess)
613
+ // ---------------------------------------------------------------------------
614
+ //
615
+ // `loadOverrideFile` in `export.js` needs to read a user-supplied JSON (or .js)
616
+ // file by absolute path. The `FilesystemAccess` contract's `readFile` method
617
+ // works but binds a whole backend object. Exposing this thin helper keeps the
618
+ // ban on direct `node:fs` imports outside this file while giving the caller
619
+ // exactly the one function it needs.
620
+
621
+ /**
622
+ * Read an absolute file path and return its contents as a UTF-8 string.
623
+ * Throws with an `ENOENT`-style message when the file does not exist.
624
+ * Used exclusively by `loadOverrideFile` in `export.js`.
625
+ *
626
+ * @param {string} absPath Absolute path (any separator — normalized internally).
627
+ * @returns {Promise<string>}
628
+ */
629
+ export async function readTextFile(absPath) {
630
+ return fsp.readFile(toNativePath(absPath), 'utf-8');
631
+ }