@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,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
|
+
}
|