@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
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 };
|