@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
package/src/dev.js ADDED
@@ -0,0 +1,373 @@
1
+ // `lerret dev` — run the studio against a project folder.
2
+ //
3
+ // Boots a Node-side Vite dev server that serves the bundled studio plus the
4
+ // user's `.lerret/` folder, opens the studio in the browser (per `--open`),
5
+ // and runs until the user kills the process. Argument parsing uses node's
6
+ // built-in `util.parseArgs` — the architecture's explicit choice, no heavy
7
+ // CLI framework.
8
+ //
9
+ // ── Flags (PRD contract — names are fixed) ─────────────────────────────────
10
+ // --port <n> Dev-server port. Defaults to Vite's default.
11
+ // --folder <path> Override the project folder, bypassing walk-up auto-
12
+ // detection. Useful for "I'm not cd'd into
13
+ // the project, but I want to point at THIS folder".
14
+ // --open Open the studio in the browser on start (Vite's
15
+ // `server.open`). Flip off with `--no-open`. Default: on.
16
+ // --help, -h Print this command's usage and exit.
17
+ //
18
+ // ── How the studio is loaded ───────────────────────────────────────────────
19
+ // We point Vite at the **studio package's source directory** — i.e. the same
20
+ // dir its standalone `vite dev` runs against. Vite serves `index.html` +
21
+ // `src/main.jsx` from there. The Lerret-specific bits are added by
22
+ // `vite-plugin-lerret-project`: a virtual module exposing the scanned project
23
+ // model, a stable URL prefix aliased to the user's project root, and the
24
+ // chokidar-driven `lerret:change` HMR event.
25
+ //
26
+ // In dev mode the studio is loaded *from source*. The production path
27
+ // uses a pre-built static bundle shipped inside the `lerret` npm package;
28
+ // the plugin contract here is the same, only the bit of code that figures
29
+ // out where the studio's HTML + JS live differs.
30
+
31
+ import { parseArgs } from 'node:util';
32
+ import { dirname, resolve } from 'node:path';
33
+ import { fileURLToPath } from 'node:url';
34
+
35
+ import { realpathOrSelf, pathExists } from './fs/node-backend.js';
36
+ import { resolveProject } from './resolve-project.js';
37
+ import {
38
+ lerretProjectPlugin,
39
+ normalizeFolderArg,
40
+ PROJECT_ASSET_BASE_URL,
41
+ } from './vite-plugin-lerret-project.js';
42
+
43
+ /**
44
+ * The argv shape `parseArgs` produces for `lerret dev`.
45
+ *
46
+ * @typedef {object} DevFlags
47
+ * @property {number | undefined} port
48
+ * @property {string | undefined} folder
49
+ * @property {boolean} open
50
+ * @property {boolean} help
51
+ */
52
+
53
+ /**
54
+ * Print the `dev`-subcommand-specific usage banner.
55
+ *
56
+ * @returns {void}
57
+ */
58
+ function printUsage() {
59
+ const lines = [
60
+ 'lerret dev — run the studio against a project folder.',
61
+ '',
62
+ 'Usage: lerret dev [options]',
63
+ '',
64
+ 'Options:',
65
+ ' --port <n> Dev-server port (default: Vite default)',
66
+ ' --folder <path> Project folder (bypasses walk-up auto-detection)',
67
+ ' --open Open the studio in the browser on start (default)',
68
+ ' --no-open Do not open the browser on start',
69
+ ' -h, --help Show this help',
70
+ ];
71
+ process.stdout.write(lines.join('\n') + '\n');
72
+ }
73
+
74
+ /**
75
+ * Parse `lerret dev`'s argv. A separate function so tests can verify flag
76
+ * handling without spinning up a server.
77
+ *
78
+ * @param {string[]} argv
79
+ * @returns {{ flags: DevFlags, error: string | null }}
80
+ * `error` is set when parsing fails — the caller prints it and the usage
81
+ * banner. On success the caller acts on `flags`.
82
+ */
83
+ export function parseDevArgs(argv) {
84
+ // `--no-open` is the documented PRD spelling for "do not open the
85
+ // browser". Node's `parseArgs` does not turn `--no-<name>` into a boolean
86
+ // false automatically in `strict` mode — it rejects the unknown flag — so
87
+ // we strip the token here and remember the intent for the result. This
88
+ // keeps the strict-unknown-flag check working for every OTHER bogus flag.
89
+ let openIntent;
90
+ const filteredArgv = [];
91
+ for (const tok of argv) {
92
+ if (tok === '--no-open') {
93
+ openIntent = false;
94
+ } else {
95
+ filteredArgv.push(tok);
96
+ }
97
+ }
98
+
99
+ let parsed;
100
+ try {
101
+ parsed = parseArgs({
102
+ args: filteredArgv,
103
+ options: {
104
+ port: { type: 'string' },
105
+ folder: { type: 'string' },
106
+ open: { type: 'boolean' },
107
+ help: { type: 'boolean', short: 'h' },
108
+ },
109
+ // Reject unknown flags rather than silently ignoring them — surface a
110
+ // typo as a usage error.
111
+ strict: true,
112
+ // No positionals are expected; reject them too.
113
+ allowPositionals: false,
114
+ });
115
+ } catch (err) {
116
+ return { flags: /** @type {any} */ (null), error: err && err.message ? err.message : String(err) };
117
+ }
118
+
119
+ const values = parsed.values || {};
120
+
121
+ // Port: `parseArgs` returns a string; coerce here. An invalid port is a
122
+ // usage error.
123
+ let port;
124
+ if (typeof values.port === 'string') {
125
+ const n = Number(values.port);
126
+ if (!Number.isInteger(n) || n < 1 || n > 65535) {
127
+ return { flags: /** @type {any} */ (null), error: `--port: not a valid port number: ${values.port}` };
128
+ }
129
+ port = n;
130
+ }
131
+
132
+ // Folder: pass through as a string; resolution to an absolute path is the
133
+ // caller's job (so tests can verify the resolution step separately).
134
+ const folder = typeof values.folder === 'string' ? values.folder : undefined;
135
+
136
+ // Open: default true (matches "open the studio in the browser" in the
137
+ // PRD). Explicit `--open` overrides nothing (it sets true already);
138
+ // `--no-open` (stripped above) overrides to false.
139
+ let open;
140
+ if (openIntent === false) {
141
+ open = false;
142
+ } else if (values.open === true) {
143
+ open = true;
144
+ } else {
145
+ open = true; // default
146
+ }
147
+
148
+ return {
149
+ flags: {
150
+ port,
151
+ folder,
152
+ open,
153
+ help: !!values.help,
154
+ },
155
+ error: null,
156
+ };
157
+ }
158
+
159
+ /**
160
+ * Locate the studio root that the dev server will serve.
161
+ *
162
+ * Resolution order:
163
+ * 1. `<cli-package>/dist-studio/` — the pre-built static assets bundled
164
+ * into the published `lerret` package. Present after `pnpm --filter
165
+ * @lerret/cli build` (or after `npm install lerret`).
166
+ * 2. `<monorepo>/packages/studio/` source — the workspace fallback for
167
+ * fresh checkouts or contributors who haven't run the build yet. Vite
168
+ * will serve from source in this path, so HMR works but the bundle is
169
+ * NOT production-optimised. A warning is printed so the developer knows.
170
+ *
171
+ * In the published npm tarball only `dist-studio/` exists (the source is not
172
+ * shipped), so path 1 is the only option for end users. Path 2 is a dev
173
+ * convenience — it keeps `lerret dev` usable in the monorepo even without
174
+ * a preceding build step.
175
+ *
176
+ * @returns {string} An absolute path to the studio root (static or source).
177
+ */
178
+ export function resolveStudioRoot() {
179
+ // This file: packages/cli/src/dev.js → packages/cli/src/ → packages/cli/
180
+ const cliDir = resolve(dirname(fileURLToPath(import.meta.url)), '..');
181
+
182
+ // 1. Prefer the pre-built dist-studio/ (production / published case).
183
+ const distStudio = resolve(cliDir, 'dist-studio');
184
+ if (pathExists(resolve(distStudio, 'index.html'))) {
185
+ return distStudio;
186
+ }
187
+
188
+ // 2. Fall back to the studio source for in-monorepo dev without a build.
189
+ // The warning surfaces the situation so contributors know why startup
190
+ // is slower (Vite transforms source on every request).
191
+ const studioSource = resolve(cliDir, '..', 'studio');
192
+ process.stderr.write(
193
+ 'lerret: dist-studio/ not found — serving studio from source.\n' +
194
+ ' Run `pnpm --filter @lerret/cli build` for production performance.\n',
195
+ );
196
+ return studioSource;
197
+ }
198
+
199
+ /**
200
+ * Run `lerret dev`. Resolves the project, starts the Vite server, waits for
201
+ * Ctrl-C, and returns an exit code.
202
+ *
203
+ * @param {string[]} argv Argv slice after the `dev` subcommand.
204
+ * @returns {Promise<number>} Exit code. 0 on graceful shutdown, 1 on an
205
+ * unrecoverable error (Vite failed to start, port already in use, etc.).
206
+ */
207
+ export async function runDev(argv) {
208
+ const { flags, error } = parseDevArgs(argv);
209
+ if (error) {
210
+ process.stderr.write(`lerret dev: ${error}\n\n`);
211
+ printUsage();
212
+ return 1;
213
+ }
214
+
215
+ if (flags.help) {
216
+ printUsage();
217
+ return 0;
218
+ }
219
+
220
+ // 1. Resolve the project.
221
+ //
222
+ // The PRD's flow:
223
+ // - `--folder <path>` overrides the start dir for the walk-up.
224
+ // - Otherwise we start from `process.cwd()` and let `resolveProject`
225
+ // find the nearest ancestor that owns `.lerret/`.
226
+ //
227
+ // A NOT-FOUND result is NOT a crash (FR43). We still start
228
+ // the dev server, but the plugin exposes `project: null` and the studio
229
+ // mounts its no-folder placeholder. This makes `lerret dev` always
230
+ // reachable — even invoked from the wrong directory, the user sees the
231
+ // studio and is guided to open a folder.
232
+ const startDir = flags.folder
233
+ ? normalizeFolderArg(flags.folder)
234
+ : process.cwd();
235
+
236
+ const projectResolution = await resolveProject(startDir);
237
+
238
+ // Vite's `server.fs.allow` compares against symlink-resolved paths, so
239
+ // we *always* canonicalize the project root before handing it to the
240
+ // plugin. On macOS `/tmp` → `/private/tmp` is the classic gotcha; the
241
+ // helper is a no-op for already-canonical paths.
242
+ /** @type {string | null} */
243
+ const projectRoot = projectResolution.found
244
+ ? realpathOrSelf(projectResolution.projectRoot).replaceAll('\\', '/')
245
+ : null;
246
+ /** @type {string | null} */
247
+ const lerretDir = projectResolution.found
248
+ ? realpathOrSelf(projectResolution.lerretDir).replaceAll('\\', '/')
249
+ : null;
250
+
251
+ if (projectResolution.found) {
252
+ process.stdout.write(`lerret dev: project ${projectRoot}\n`);
253
+ } else {
254
+ process.stdout.write(
255
+ `lerret dev: no \`.lerret/\` project found from ${startDir} — starting in no-folder mode.\n`,
256
+ );
257
+ }
258
+
259
+ // 2. Find the studio source dir Vite will serve as its root.
260
+ const studioRoot = resolveStudioRoot();
261
+
262
+ // 3. Boot Vite programmatically.
263
+ //
264
+ // We import `vite` dynamically so the CLI's static-analysis (and the
265
+ // `lerret --help` path) doesn't pay the import cost up front.
266
+ //
267
+ // When serving from pre-built `dist-studio/` assets:
268
+ // - `@vitejs/plugin-react` is NOT needed — the JSX is already compiled.
269
+ // - Only the `lerretProjectPlugin` is needed (virtual module + HMR).
270
+ //
271
+ // When serving from studio source (fallback path):
272
+ // - `@vitejs/plugin-react` IS needed for the JSX transform + Fast Refresh.
273
+ //
274
+ // `searchForWorkspaceRoot` finds the workspace root (the pnpm-
275
+ // workspace's top-level), which is what Vite uses by default when
276
+ // `server.fs.allow` is undefined. We set it explicitly so we can append
277
+ // the user's project root without losing the workspace access the studio
278
+ // needs (its node_modules, its source dir, etc.).
279
+ const vite = await import('vite');
280
+ const { createServer, searchForWorkspaceRoot } = vite;
281
+
282
+ // Whether we are serving from the pre-built CLI bundle or from source.
283
+ const cliDir = resolve(dirname(fileURLToPath(import.meta.url)), '..');
284
+ const isPreBuilt = pathExists(resolve(cliDir, 'dist-studio', 'index.html')) &&
285
+ studioRoot === resolve(cliDir, 'dist-studio');
286
+
287
+ const plugins = [lerretProjectPlugin({ projectRoot, lerretDir })];
288
+ if (!isPreBuilt) {
289
+ // Serving from source — need the React plugin for JSX transform.
290
+ const reactPlugin = (await import('@vitejs/plugin-react')).default;
291
+ plugins.unshift(reactPlugin());
292
+ }
293
+
294
+ const workspaceRoot = searchForWorkspaceRoot(studioRoot);
295
+
296
+ const server = await createServer({
297
+ // Don't pick up the studio's own `vite.config.js` (it has a fixture
298
+ // alias the CLI doesn't want); we hand Vite a clean inline config.
299
+ configFile: false,
300
+ root: studioRoot,
301
+ plugins,
302
+ server: {
303
+ port: flags.port,
304
+ open: flags.open,
305
+ fs: {
306
+ // Studio root + monorepo/workspace root. The plugin appends the
307
+ // user's project root on top of this list. The CLI never writes
308
+ // to any of these — only reads (NFR13).
309
+ allow: [studioRoot, workspaceRoot],
310
+ },
311
+ // `host` left undefined so Vite uses its default (localhost). Users
312
+ // who need network access can re-run with --port and Vite's own
313
+ // --host knob in a future change; for `lerret dev` against a local
314
+ // user folder the default localhost behavior is right.
315
+ },
316
+ });
317
+
318
+ await server.listen();
319
+ server.printUrls();
320
+
321
+ // The asset base URL the studio expects — log so a curious user can see
322
+ // it (and to help debugging if a future change touches the contract).
323
+ if (projectRoot) {
324
+ process.stdout.write(`lerret dev: serving project at ${PROJECT_ASSET_BASE_URL}/\n`);
325
+ }
326
+
327
+ // 4. Hold the process open until SIGINT/SIGTERM, then close cleanly.
328
+ //
329
+ // Without this `await`, runDev would resolve and the CLI would exit
330
+ // before the server ever served a request. With it, the function
331
+ // resolves only when the user kills the process — perfect for a
332
+ // long-running dev command.
333
+ return await waitForShutdown(server);
334
+ }
335
+
336
+ /**
337
+ * Resolve when the dev server should shut down — on SIGINT (Ctrl-C) or
338
+ * SIGTERM. The handlers are removed after the first signal so a second one
339
+ * can hard-kill if the close hangs.
340
+ *
341
+ * @param {import('vite').ViteDevServer} server
342
+ * @returns {Promise<number>}
343
+ */
344
+ function waitForShutdown(server) {
345
+ return new Promise((resolve) => {
346
+ let shuttingDown = false;
347
+ const onSignal = async (signal) => {
348
+ if (shuttingDown) return;
349
+ shuttingDown = true;
350
+ process.stdout.write(`\nlerret dev: ${signal} received, shutting down…\n`);
351
+ try {
352
+ await server.close();
353
+ } catch {
354
+ // Best effort; we're exiting anyway.
355
+ }
356
+ // Detach the other handler so a second Ctrl-C exits immediately.
357
+ process.removeListener('SIGINT', onSigint);
358
+ process.removeListener('SIGTERM', onSigterm);
359
+ resolve(0);
360
+ };
361
+ const onSigint = () => onSignal('SIGINT');
362
+ const onSigterm = () => onSignal('SIGTERM');
363
+ process.once('SIGINT', onSigint);
364
+ process.once('SIGTERM', onSigterm);
365
+ });
366
+ }
367
+
368
+ /**
369
+ * Re-export of the dev-side asset base URL, so the few studio-side tests
370
+ * that need it can import it from the CLI without depending on the plugin
371
+ * file directly.
372
+ */
373
+ export { PROJECT_ASSET_BASE_URL };