@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/lerret.js ADDED
@@ -0,0 +1,143 @@
1
+ #!/usr/bin/env node
2
+ // `lerret` CLI entry point.
3
+ //
4
+ // The `lerret` binary's command surface starts here. Argument parsing uses
5
+ // node's built-in `util.parseArgs` — the architecture's explicit choice, no
6
+ // heavy CLI framework. Recognized subcommands:
7
+ //
8
+ // lerret dev [--port <n>] [--folder <path>] [--open | --no-open]
9
+ // lerret export [path] [--format png|jpg] [--out <dir>] [--flat]
10
+ //
11
+ // Adding a new subcommand is the act of importing one more module and adding
12
+ // an entry to the `SUBCOMMANDS` table below — the usage banner is derived
13
+ // from the same table so the two never drift apart.
14
+ //
15
+ // Exit codes:
16
+ // 0 — success, usage requested (`--help`), or graceful shutdown.
17
+ // 1 — unknown subcommand, malformed flags, or a runtime error.
18
+ //
19
+ // Process model: each subcommand owns its own lifecycle. `dev` starts a
20
+ // long-running Vite server and only resolves on SIGINT/SIGTERM; `export`
21
+ // drives a headless Chromium through Playwright once and exits when the
22
+ // capture run finishes; `--help` and unknown-command paths exit synchronously.
23
+
24
+ import { realpathSync } from 'node:fs';
25
+ import { parseArgs } from 'node:util';
26
+ import { pathToFileURL } from 'node:url';
27
+
28
+ import { runDev } from './dev.js';
29
+ import { runExport } from './export.js';
30
+
31
+ /**
32
+ * The set of recognized subcommands and their entry points. Centralized so the
33
+ * usage banner and the dispatch loop never drift apart.
34
+ *
35
+ * @type {Record<string, { describe: string, run: (argv: string[]) => Promise<number> | number }>}
36
+ */
37
+ const SUBCOMMANDS = {
38
+ dev: {
39
+ describe: 'Run the studio against a `.lerret/` project folder (Vite dev server)',
40
+ run: runDev,
41
+ },
42
+ export: {
43
+ describe: 'Headlessly render a project (or page/group) to image files',
44
+ run: runExport,
45
+ },
46
+ };
47
+
48
+ /**
49
+ * Print the top-level usage banner to stdout. Intentionally short — each
50
+ * subcommand prints its own `--help` flag detail.
51
+ *
52
+ * @returns {void}
53
+ */
54
+ function printUsage() {
55
+ const lines = [
56
+ 'lerret — the design-canvas CLI',
57
+ '',
58
+ 'Usage: lerret <command> [options]',
59
+ '',
60
+ 'Commands:',
61
+ ...Object.entries(SUBCOMMANDS).map(
62
+ ([name, { describe }]) => ` ${name.padEnd(8)} ${describe}`,
63
+ ),
64
+ '',
65
+ 'Run `lerret <command> --help` for command-specific options.',
66
+ ];
67
+ process.stdout.write(lines.join('\n') + '\n');
68
+ }
69
+
70
+ /**
71
+ * The CLI's top-level entry. Parses only the very first positional (the
72
+ * subcommand) and hands the remaining argv to that subcommand. The subcommand
73
+ * does its own flag parsing — keeping each command's flag surface owned by its
74
+ * own module, which is what `parseArgs` is designed for.
75
+ *
76
+ * @param {string[]} [argv=process.argv.slice(2)]
77
+ * The argv slice to parse — defaults to the real process argv, overridable
78
+ * for tests.
79
+ * @returns {Promise<number>} The exit code.
80
+ */
81
+ export async function main(argv = process.argv.slice(2)) {
82
+ // A bare invocation or an explicit help flag prints usage and exits 0.
83
+ if (argv.length === 0 || argv[0] === '--help' || argv[0] === '-h') {
84
+ printUsage();
85
+ return 0;
86
+ }
87
+
88
+ const [command, ...rest] = argv;
89
+ const handler = SUBCOMMANDS[command];
90
+
91
+ if (!handler) {
92
+ // An unknown subcommand is a usage error — print the banner so the user
93
+ // can see what is valid, then exit non-zero.
94
+ process.stderr.write(`lerret: unknown command "${command}"\n\n`);
95
+ printUsage();
96
+ return 1;
97
+ }
98
+
99
+ try {
100
+ const code = await handler.run(rest);
101
+ return typeof code === 'number' ? code : 0;
102
+ } catch (err) {
103
+ // A genuine runtime failure inside a subcommand — surface a short error and
104
+ // exit non-zero. The subcommand owns its own error UX otherwise.
105
+ process.stderr.write(`lerret ${command}: ${err && err.message ? err.message : String(err)}\n`);
106
+ return 1;
107
+ }
108
+ }
109
+
110
+ /**
111
+ * `parseArgs` itself is exported for tests so they can verify the basic
112
+ * dispatch shape without spinning up `dev`.
113
+ *
114
+ * @type {typeof parseArgs}
115
+ */
116
+ export { parseArgs };
117
+
118
+ // Only run main() when this file is the process entry. The exported `main`
119
+ // stays callable from tests without firing the real CLI.
120
+ //
121
+ // `process.argv[1]` is the path the user invoked. When installed via a zero-
122
+ // install runner (npx, pnpm dlx, bunx) the path may be a symlink inside the
123
+ // runner's cache, while `import.meta.url` is the physical (real) path. We
124
+ // dereference with `realpathSync` before comparing so all four runners work.
125
+ const invokedDirectly =
126
+ typeof process !== 'undefined' &&
127
+ Array.isArray(process.argv) &&
128
+ typeof process.argv[1] === 'string' &&
129
+ process.argv[1].length > 0 &&
130
+ (() => {
131
+ try {
132
+ return (
133
+ import.meta.url ===
134
+ pathToFileURL(realpathSync(process.argv[1])).href
135
+ );
136
+ } catch {
137
+ return false;
138
+ }
139
+ })();
140
+
141
+ if (invokedDirectly) {
142
+ main().then((code) => process.exit(code));
143
+ }
@@ -0,0 +1,178 @@
1
+ // resolve-project — project detection and `.lerret/` folder designation.
2
+ //
3
+ // A folder is a valid Lerret project if and only if it directly contains a
4
+ // `.lerret/` subdirectory (FR1) — the same "marker folder" idea as `git`'s
5
+ // `.git/`. Detection walks UP from a starting directory toward the filesystem
6
+ // root, returning the first ancestor that directly contains a `.lerret/`
7
+ // directory (FR43). If no ancestor has one, it reports "no project found" so
8
+ // the caller can fall back to a folder-picker / empty-state (that UI lives in
9
+ // the studio and is out of scope here — this module only branches the decision).
10
+ //
11
+ // IMPORTANT: detection reaches the filesystem ONLY through the `core`
12
+ // `FilesystemAccess` contract — never `node:fs` directly. The Node backend
13
+ // (`createNodeBackend()`) is the CLI-mode implementation. `node:path` is used
14
+ // purely for path arithmetic (walking up, normalizing), which the separation
15
+ // invariant explicitly permits.
16
+
17
+ import { dirname, resolve } from 'node:path';
18
+
19
+ import { createNodeBackend } from './fs/node-backend.js';
20
+
21
+ /**
22
+ * The reserved marker directory whose presence designates a Lerret project.
23
+ * @type {string}
24
+ */
25
+ const LERRET_DIR_NAME = '.lerret';
26
+
27
+ /**
28
+ * Successful project-detection result.
29
+ *
30
+ * @typedef {object} ProjectFound
31
+ * @property {true} found
32
+ * Discriminant — `true` for a resolved project.
33
+ * @property {string} projectRoot
34
+ * Absolute, normalized path of the folder that directly contains
35
+ * `.lerret/`. This is the project root.
36
+ * @property {string} lerretDir
37
+ * Absolute, normalized path of the project's `.lerret/` directory — the
38
+ * loader's scan root.
39
+ */
40
+
41
+ /**
42
+ * Unsuccessful project-detection result: no `.lerret/` directory was found in
43
+ * the start directory or any of its ancestors up to the filesystem root.
44
+ *
45
+ * @typedef {object} ProjectNotFound
46
+ * @property {false} found
47
+ * Discriminant — `false` when no project was located.
48
+ * @property {string} startDir
49
+ * Absolute, normalized path the walk started from, for diagnostics and for
50
+ * the caller's empty-state / folder-picker fallback.
51
+ */
52
+
53
+ /**
54
+ * The result of {@link resolveProject}: either a found project or a clear
55
+ * not-found outcome. Callers branch on the `found` discriminant.
56
+ *
57
+ * @typedef {ProjectFound | ProjectNotFound} ProjectResolution
58
+ */
59
+
60
+ /**
61
+ * Determine whether a directory directly contains a `.lerret/` subdirectory.
62
+ *
63
+ * Goes through the {@link import('@lerret/core').FilesystemAccess} contract.
64
+ * A `readDir` rejection (e.g. the directory is unreadable, or was removed
65
+ * mid-walk) is treated as "no `.lerret/` here" rather than an error: the walk
66
+ * should keep climbing toward the root instead of aborting on one bad
67
+ * ancestor.
68
+ *
69
+ * @param {import('@lerret/core').FilesystemAccess} fs
70
+ * The filesystem backend to read through.
71
+ * @param {string} dirPath
72
+ * A forward-slash directory path to inspect.
73
+ * @returns {Promise<boolean>}
74
+ * `true` iff `dirPath` directly contains a `.lerret/` directory.
75
+ */
76
+ async function hasLerretDir(fs, dirPath) {
77
+ let entries;
78
+ try {
79
+ entries = await fs.readDir(dirPath);
80
+ } catch {
81
+ // Unreadable / vanished directory — not a project root we can use. Let
82
+ // the caller keep walking up rather than failing the whole detection.
83
+ return false;
84
+ }
85
+
86
+ return entries.some(
87
+ (entry) => entry.isDirectory && entry.name === LERRET_DIR_NAME,
88
+ );
89
+ }
90
+
91
+ /**
92
+ * Detect the Lerret project containing a starting directory.
93
+ *
94
+ * Walks up from `startDir` toward the filesystem root. The first ancestor that
95
+ * directly contains a `.lerret/` directory is the project root; its `.lerret/`
96
+ * directory is the loader's scan root (FR43). If no ancestor qualifies, the
97
+ * walk stops cleanly at the filesystem root and a not-found result is returned
98
+ * — never an error, never an infinite loop.
99
+ *
100
+ * Path handling: `startDir` is resolved to an absolute path first (so a
101
+ * relative or `.`-style argument works), then normalized to forward slashes so
102
+ * every path in the result matches the `FilesystemAccess` contract convention.
103
+ *
104
+ * @param {string} [startDir=process.cwd()]
105
+ * The directory to begin detection from — typically the CLI's working
106
+ * directory. Resolved to absolute if relative.
107
+ * @param {import('@lerret/core').FilesystemAccess} [fs]
108
+ * The filesystem backend to detect through. Defaults to a fresh Node backend
109
+ * (`createNodeBackend()`), the CLI-mode implementation. Injectable so tests
110
+ * and alternate hosts can supply their own backend.
111
+ * @returns {Promise<ProjectResolution>}
112
+ * A {@link ProjectFound} carrying absolute `projectRoot` / `lerretDir`
113
+ * paths, or a {@link ProjectNotFound} carrying the absolute `startDir`.
114
+ */
115
+ export async function resolveProject(
116
+ startDir = process.cwd(),
117
+ fs = createNodeBackend(),
118
+ ) {
119
+ // Resolve to an absolute path, then speak the contract's forward-slash
120
+ // convention so every path we pass to `readDir` — and every path we return
121
+ // — is normalized identically.
122
+ const absoluteStart = toLerretPath(resolve(startDir));
123
+
124
+ let current = absoluteStart;
125
+
126
+ // Walk up one ancestor per iteration. The loop terminates because
127
+ // `dirname()` strictly shortens the path until it reaches the filesystem
128
+ // root, where `dirname(root) === root` — the explicit stop condition below.
129
+ for (;;) {
130
+ if (await hasLerretDir(fs, current)) {
131
+ return {
132
+ found: true,
133
+ projectRoot: current,
134
+ lerretDir: joinLerretPath(current, LERRET_DIR_NAME),
135
+ };
136
+ }
137
+
138
+ const parent = toLerretPath(dirname(current));
139
+ if (parent === current) {
140
+ // Reached the filesystem root — `dirname` no longer shortens the path.
141
+ // Stop cleanly: no `.lerret/` anywhere on the path to the root.
142
+ break;
143
+ }
144
+ current = parent;
145
+ }
146
+
147
+ return { found: false, startDir: absoluteStart };
148
+ }
149
+
150
+ /**
151
+ * Normalize an OS path to the forward-slash form the `FilesystemAccess`
152
+ * contract uses. A near no-op on POSIX hosts; on Windows it bridges `\` to
153
+ * `/`. A lone drive-root such as `C:\` keeps its trailing separator so it
154
+ * stays a valid directory path.
155
+ *
156
+ * @param {string} osPath An absolute path using native separators.
157
+ * @returns {string} The same path with forward slashes.
158
+ */
159
+ function toLerretPath(osPath) {
160
+ return osPath.replaceAll('\\', '/');
161
+ }
162
+
163
+ /**
164
+ * Join a forward-slash directory path and a single child segment, without
165
+ * reaching for `node:path` join semantics (which would re-introduce native
166
+ * separators). The directory path is already absolute and normalized.
167
+ *
168
+ * @param {string} dirPath A forward-slash directory path.
169
+ * @param {string} name A single path segment.
170
+ * @returns {string} The joined forward-slash path.
171
+ */
172
+ function joinLerretPath(dirPath, name) {
173
+ // A filesystem root like `/` or `C:/` already ends in a separator; appending
174
+ // another would produce `//`. Otherwise insert exactly one `/`.
175
+ return dirPath.endsWith('/') ? `${dirPath}${name}` : `${dirPath}/${name}`;
176
+ }
177
+
178
+ export { LERRET_DIR_NAME };