@rozie/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.
@@ -0,0 +1,407 @@
1
+ // `rozie watch` subcommand — chokidar-driven incremental recompile.
2
+ //
3
+ // Mirrors `rozie build`'s flag surface (--target, --out, --source-map,
4
+ // --no-types) but is long-running. Intended for a component-library
5
+ // author iterating on a .rozie file while a live framework dev server
6
+ // in another terminal consumes the emitted .tsx/.vue/.svelte/etc.
7
+ //
8
+ // Key behaviours (modelled on `tsc --watch` / `vite build --watch`):
9
+ // • Always requires --out (no sense streaming to stdout from a daemon).
10
+ // • Initial build runs the full input set once, so output exists when
11
+ // the watcher arms; subsequent compiles are per-changed-file only.
12
+ // • Debounced via chokidar's awaitWriteFinish (text editors fire
13
+ // multiple events per save; 100 ms stability avoids spurious
14
+ // re-compiles).
15
+ // • Per-change log lines are timestamped + colourised; errors render
16
+ // full diagnostic frames but DON'T tear the watcher down (tsc
17
+ // --watch's behaviour — keep watching past compile errors).
18
+ // • Graceful exit on SIGINT/SIGTERM: closes the watcher, prints a
19
+ // "stopped" line, resolves runWatch() with exit code 0.
20
+ //
21
+ // The compile + write phase intentionally duplicates a small slice of
22
+ // build.ts (~30 lines) instead of refactoring out a shared writer.
23
+ // Drift risk is low (both call the same `compile()` core; the only
24
+ // per-target sidecar logic is React's .d.ts / .css / .global.css,
25
+ // which is stable). If the per-target sidecar matrix grows, the next
26
+ // editor of either file should hoist the shared helper.
27
+ import chokidar from 'chokidar';
28
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
29
+ import { dirname as pathDirname, resolve as pathResolve } from 'node:path';
30
+ import pc from 'picocolors';
31
+ import { compile } from '../../../core/src/compile.js';
32
+ import { renderDiagnostic } from '../../../core/src/diagnostics/frame.js';
33
+ // Phase 22 Plan 22-05 — CLI sidecar fallback (REQ-5): refresh the
34
+ // `.d.rozie.ts` per changed file via the SAME renderer the unplugin + build
35
+ // command use, so the watch-mode sidecar bytes don't drift.
36
+ import { renderSidecar } from '../../../unplugin/src/emitSidecar.js';
37
+ import { expandInputs } from '../utils/expandInputs.js';
38
+ import { computeOutputPath, TARGET_EXTENSIONS } from '../utils/outputPath.js';
39
+ import type { Target } from '../utils/parseTargets.js';
40
+ import { prettyFormat } from '../utils/prettyFormat.js';
41
+
42
+ export interface WatchOptions {
43
+ target?: Target | Target[];
44
+ out?: string;
45
+ /** D-91 default false. */
46
+ sourceMap?: boolean;
47
+ /** D-90 default true. */
48
+ types?: boolean;
49
+ /** Project root for source-rel-path computation; defaults to process.cwd(). */
50
+ root?: string;
51
+ /**
52
+ * Off by default per PROJECT.md "Out of Scope" carve-out. Pipes the
53
+ * per-change emit through prettier before write. Failures degrade
54
+ * gracefully — raw output still lands on disk + a stderr warning fires.
55
+ */
56
+ pretty?: boolean;
57
+ /**
58
+ * Phase 23 — Angular-only opt-out for the auto `ControlValueAccessor` emit.
59
+ * Default ON; `false` maps to `compile({ angular: { cva: false } })`.
60
+ * Mirrors the `rozie build` flag. No-op for non-Angular targets.
61
+ */
62
+ cva?: boolean;
63
+ /**
64
+ * Phase 26 (D-11) — the GLOBAL safe-interpolation opt-out. Default ON;
65
+ * `false` maps to `compile({ safeInterpolation: false })`. Mirrors the
66
+ * `rozie build` flag. No-op for the Vue target.
67
+ */
68
+ safeInterpolation?: boolean;
69
+ }
70
+
71
+ export interface RunWatchContext {
72
+ /** When 'throw', invalid-arg exits become thrown errors (vitest-friendly). */
73
+ exit?: 'process' | 'throw';
74
+ stderrWrite?: (chunk: string) => void;
75
+ stdoutWrite?: (chunk: string) => void;
76
+ /**
77
+ * Test injection — when this AbortSignal fires, the watcher closes and
78
+ * runWatch() resolves. Lets vitest drive the lifecycle without sending
79
+ * real OS signals to the test process.
80
+ */
81
+ signal?: AbortSignal;
82
+ }
83
+
84
+ const VALID_TARGETS = new Set<Target>(['vue', 'react', 'svelte', 'angular', 'solid', 'lit']);
85
+
86
+ /**
87
+ * Compact HH:MM:SS timestamp for log lines. en-US 24h matches what
88
+ * `tsc --watch` does and reads cleanly in PR screenshots.
89
+ */
90
+ function ts(): string {
91
+ return new Date().toLocaleTimeString('en-US', { hour12: false });
92
+ }
93
+
94
+ /**
95
+ * `rozie watch <inputs...>` entry point. Performs one initial build of
96
+ * the matched input set, then watches for changes and recompiles per
97
+ * file. Resolves on graceful shutdown (SIGINT/SIGTERM or ctx.signal).
98
+ */
99
+ export async function runWatch(
100
+ inputArgs: string[],
101
+ opts: WatchOptions = {},
102
+ ctx: RunWatchContext = {},
103
+ ): Promise<void> {
104
+ const stderrWrite = ctx.stderrWrite ?? ((s) => void process.stderr.write(s));
105
+ const stdoutWrite = ctx.stdoutWrite ?? ((s) => void process.stdout.write(s));
106
+
107
+ const exit = (code: number, msg = ''): never => {
108
+ if (msg) stderrWrite(msg);
109
+ if (ctx.exit === 'throw') {
110
+ throw new Error(`rozie watch exited with code ${code}: ${msg.trim()}`);
111
+ }
112
+ process.exit(code);
113
+ };
114
+
115
+ // ----- Normalize + validate the target list --------------------------
116
+ const targets: Target[] = Array.isArray(opts.target)
117
+ ? opts.target
118
+ : opts.target !== undefined
119
+ ? [opts.target as Target]
120
+ : ['vue'];
121
+
122
+ for (const t of targets) {
123
+ if (!VALID_TARGETS.has(t)) {
124
+ return exit(
125
+ 2,
126
+ pc.red(
127
+ `[ROZ850] rozie watch: unknown target '${t}' (expected vue|react|svelte|angular|solid|lit)\n`,
128
+ ),
129
+ );
130
+ }
131
+ }
132
+
133
+ // ----- --out is REQUIRED for watch -----------------------------------
134
+ // A long-running watcher cannot stream to stdout; nothing useful would
135
+ // consume the unending sequence of per-change compile outputs.
136
+ if (opts.out === undefined) {
137
+ return exit(
138
+ 2,
139
+ pc.red(
140
+ `[ROZ856] rozie watch: --out <dir> is required (cannot stream to stdout from a long-running watcher)\n`,
141
+ ),
142
+ );
143
+ }
144
+
145
+ const outDir = pathResolve(opts.out);
146
+ const rootDir = opts.root ?? process.cwd();
147
+ const wantTypes = opts.types !== false;
148
+ const wantSourceMap = opts.sourceMap === true;
149
+ const wantPretty = opts.pretty === true;
150
+ const cvaOff = opts.cva === false; // Phase 23 — Angular CVA opt-out
151
+ const safeInterpOff = opts.safeInterpolation === false; // Phase 26 — opt-out
152
+
153
+ // ----- Initial expansion + build -------------------------------------
154
+ let inputs: string[];
155
+ try {
156
+ inputs = await expandInputs(inputArgs);
157
+ } catch (err) {
158
+ return exit(1, pc.red(`${(err as Error).message}\n`));
159
+ }
160
+
161
+ if (inputs.length === 0) {
162
+ return exit(
163
+ 1,
164
+ pc.red(`[ROZ851] rozie watch: no .rozie files matched the given inputs\n`),
165
+ );
166
+ }
167
+
168
+ for (const input of inputs) {
169
+ await compileOne(input, targets, outDir, rootDir, wantTypes, wantSourceMap, wantPretty, cvaOff, safeInterpOff, stderrWrite, stdoutWrite);
170
+ }
171
+
172
+ stdoutWrite(
173
+ pc.cyan(
174
+ `[${ts()}] watching ${inputs.length} file(s) for ${targets.join('+')} (Ctrl-C to stop)\n`,
175
+ ),
176
+ );
177
+
178
+ // ----- Watcher setup -------------------------------------------------
179
+ // Pass the raw input args through to chokidar — it handles files, dirs,
180
+ // and globs natively (same way fast-glob did in expandInputs). For dir
181
+ // args, chokidar recurses by default, so newly-added .rozie files are
182
+ // picked up too. `ignoreInitial: true` is important: the initial build
183
+ // loop above already emitted everything, so we don't want chokidar's
184
+ // own initial scan to re-fire 'add' events for them.
185
+ //
186
+ // awaitWriteFinish coalesces editor-save bursts (Vim/Helix/VSCode all
187
+ // emit multiple write events per save). 100 ms stability is the
188
+ // standard tsc/vite tuning.
189
+ const watcher = chokidar.watch(inputArgs, {
190
+ awaitWriteFinish: { stabilityThreshold: 100, pollInterval: 30 },
191
+ ignoreInitial: true,
192
+ ignored: [
193
+ '**/node_modules/**',
194
+ '**/dist/**',
195
+ '**/.git/**',
196
+ '**/.turbo/**',
197
+ '**/.planning/**',
198
+ ],
199
+ });
200
+
201
+ const onUpsert = async (changedPath: string): Promise<void> => {
202
+ if (!changedPath.endsWith('.rozie')) return;
203
+ const abs = pathResolve(changedPath);
204
+ await compileOne(abs, targets, outDir, rootDir, wantTypes, wantSourceMap, wantPretty, cvaOff, safeInterpOff, stderrWrite, stdoutWrite);
205
+ };
206
+
207
+ watcher.on('add', onUpsert);
208
+ watcher.on('change', onUpsert);
209
+ watcher.on('unlink', (removedPath) => {
210
+ if (removedPath.endsWith('.rozie')) {
211
+ stdoutWrite(
212
+ pc.yellow(`[${ts()}] removed ${displayPath(removedPath, rootDir)} (output files left intact)\n`),
213
+ );
214
+ }
215
+ });
216
+ watcher.on('error', (err) => {
217
+ // Watcher errors (e.g., EMFILE on Linux when too many files open) get
218
+ // surfaced but don't tear the watcher down — chokidar internally
219
+ // recovers from most. We log + keep going.
220
+ stderrWrite(pc.red(`[${ts()}] watcher error: ${(err as Error).message}\n`));
221
+ });
222
+
223
+ // ----- Wait for shutdown signal --------------------------------------
224
+ return new Promise<void>((resolve) => {
225
+ let resolved = false;
226
+ const cleanup = async (): Promise<void> => {
227
+ if (resolved) return;
228
+ resolved = true;
229
+ stdoutWrite(pc.cyan(`\n[${ts()}] stopped.\n`));
230
+ try {
231
+ await watcher.close();
232
+ } catch {
233
+ // close() can reject if chokidar already torn down; swallow.
234
+ }
235
+ resolve();
236
+ };
237
+ if (ctx.signal) {
238
+ ctx.signal.addEventListener('abort', () => {
239
+ void cleanup();
240
+ });
241
+ }
242
+ process.once('SIGINT', () => void cleanup());
243
+ process.once('SIGTERM', () => void cleanup());
244
+ });
245
+ }
246
+
247
+ /**
248
+ * Compile one source file to all configured targets and emit each
249
+ * artefact to disk. Mirrors the per-tuple write logic in build.ts
250
+ * (`runBuildMatrix` lines 222-249). Errors render diagnostics but do
251
+ * not throw — watch mode keeps running past compile failures.
252
+ */
253
+ async function compileOne(
254
+ inputAbs: string,
255
+ targets: Target[],
256
+ outDir: string,
257
+ rootDir: string,
258
+ wantTypes: boolean,
259
+ wantSourceMap: boolean,
260
+ wantPretty: boolean,
261
+ cvaOff: boolean,
262
+ safeInterpOff: boolean,
263
+ stderrWrite: (s: string) => void,
264
+ stdoutWrite: (s: string) => void,
265
+ ): Promise<void> {
266
+ // Local helper — matches the one in build.ts's runBuildMatrix loop. Kept
267
+ // inline since the two write paths still diverge slightly (watch logs
268
+ // a per-file summary line; build aggregates failure counts).
269
+ const maybePretty = async (text: string, name: string): Promise<string> => {
270
+ if (!wantPretty) return text;
271
+ const r = await prettyFormat(text, name);
272
+ if (!r.ok) {
273
+ stderrWrite(
274
+ pc.yellow(`[warning] --pretty failed for ${name}: ${r.error}; emitting unformatted\n`),
275
+ );
276
+ }
277
+ return r.formatted;
278
+ };
279
+ const startedAt = Date.now();
280
+ let source: string;
281
+ try {
282
+ source = readFileSync(inputAbs, 'utf8');
283
+ } catch (err) {
284
+ stderrWrite(
285
+ pc.red(`[${ts()}] cannot read ${displayPath(inputAbs, rootDir)}: ${(err as Error).message}\n`),
286
+ );
287
+ return;
288
+ }
289
+
290
+ // Parallelize across targets — at 6 simultaneous --target invocations
291
+ // with --pretty on, sequential await would dominate the per-change
292
+ // latency. Each target's compile + write is independent (single source
293
+ // file, target-private output paths). Use Promise.all to fan them out;
294
+ // collect (target, errorCount) results back into ordered logging.
295
+ const perTarget = await Promise.all(
296
+ targets.map(async (target): Promise<{ target: Target; errors: number; emitted: boolean }> => {
297
+ const result = compile(source, {
298
+ target,
299
+ filename: inputAbs,
300
+ types: wantTypes,
301
+ sourceMap: wantSourceMap,
302
+ // Phase 23 — attach the `angular` namespace only on opt-out, so the
303
+ // default-ON path stays byte-identical to unplugin/babel-plugin.
304
+ ...(cvaOff ? { angular: { cva: false } } : {}),
305
+ // Phase 26 (D-11) — attach `safeInterpolation` only on opt-out, so the
306
+ // default-ON path stays byte-identical (dist-parity).
307
+ ...(safeInterpOff ? { safeInterpolation: false } : {}),
308
+ });
309
+
310
+ const errors = result.diagnostics.filter((d) => d.severity === 'error');
311
+ const warnings = result.diagnostics.filter((d) => d.severity === 'warning');
312
+
313
+ if (errors.length > 0) {
314
+ stderrWrite(errors.map((d) => `${renderDiagnostic(d, source)}\n`).join(''));
315
+ return { target, errors: errors.length, emitted: false };
316
+ }
317
+ if (warnings.length > 0) {
318
+ stderrWrite(warnings.map((d) => `${renderDiagnostic(d, source)}\n`).join(''));
319
+ }
320
+
321
+ const outPath = computeOutputPath(inputAbs, target, outDir, rootDir);
322
+ mkdirSync(pathDirname(outPath), { recursive: true });
323
+
324
+ // Phase 22 Plan 22-05 — `.d.rozie.ts` sidecar refresh per changed file
325
+ // (REQ-5). Same `renderSidecar` dispatch as build.ts / the unplugin, so
326
+ // a watch-driven re-emit produces byte-identical sidecars. Written RAW
327
+ // (never prettied) to preserve the do-not-edit hash header.
328
+ const sidecarText = wantTypes ? renderSidecar(source, target, inputAbs) : null;
329
+ const sidecarPath =
330
+ sidecarText !== null
331
+ ? outPath.slice(0, outPath.length - TARGET_EXTENSIONS[target].length) + '.d.rozie.ts'
332
+ : null;
333
+
334
+ // Pre-compute sidecar paths so we can fire all per-tuple prettier
335
+ // calls in parallel (mirrors the build.ts shape).
336
+ const dtsPath =
337
+ wantTypes && target === 'react' && result.types
338
+ ? outPath.replace(/\.tsx$/, '.d.ts')
339
+ : null;
340
+ // Phase 25 de-CSS-Modules: React emits a side-effect `import './X.css'`
341
+ // (was `import styles from './X.module.css'`), so the scoped-CSS sibling
342
+ // is written to a PLAIN `.css` path. (Matches build.ts + babel-plugin +
343
+ // unplugin `.rozie.css` routing.)
344
+ const modPath =
345
+ target === 'react' && result.css !== undefined && result.css.length > 0
346
+ ? outPath.replace(/\.tsx$/, '.css')
347
+ : null;
348
+ const globPath =
349
+ target === 'react' && result.globalCss !== undefined && result.globalCss.length > 0
350
+ ? outPath.replace(/\.tsx$/, '.global.css')
351
+ : null;
352
+
353
+ const [mainText, dtsText, modText, globText] = await Promise.all([
354
+ maybePretty(result.code, outPath),
355
+ dtsPath ? maybePretty(result.types ?? '', dtsPath) : Promise.resolve(null),
356
+ modPath ? maybePretty(result.css ?? '', modPath) : Promise.resolve(null),
357
+ globPath ? maybePretty(result.globalCss ?? '', globPath) : Promise.resolve(null),
358
+ ]);
359
+
360
+ writeFileSync(outPath, mainText, 'utf8');
361
+ // Phase 22 Plan 22-05: `.d.rozie.ts` sidecar — RAW write (never prettied)
362
+ // so the hash header bytes match the unplugin/build output.
363
+ if (sidecarPath !== null && sidecarText !== null) {
364
+ writeFileSync(sidecarPath, sidecarText, 'utf8');
365
+ }
366
+ if (dtsPath !== null && dtsText !== null) writeFileSync(dtsPath, dtsText, 'utf8');
367
+ if (modPath !== null && modText !== null) writeFileSync(modPath, modText, 'utf8');
368
+ if (globPath !== null && globText !== null) writeFileSync(globPath, globText, 'utf8');
369
+ if (wantSourceMap && result.map) {
370
+ writeFileSync(`${outPath}.map`, result.map.toString(), 'utf8');
371
+ }
372
+ return { target, errors: 0, emitted: true };
373
+ }),
374
+ );
375
+
376
+ // Aggregate ordered by the original target list so the log line is
377
+ // stable across runs (Promise.all preserves array index order in its
378
+ // result, so perTarget[i].target === targets[i]).
379
+ const emitted: Target[] = perTarget.filter((r) => r.emitted).map((r) => r.target);
380
+ const totalErrors = perTarget.reduce((acc, r) => acc + r.errors, 0);
381
+
382
+ const ms = Date.now() - startedAt;
383
+ const path = displayPath(inputAbs, rootDir);
384
+ if (totalErrors > 0 && emitted.length === 0) {
385
+ stdoutWrite(pc.red(`[${ts()}] failed ${path} (${totalErrors} error${totalErrors === 1 ? '' : 's'})\n`));
386
+ } else if (totalErrors > 0) {
387
+ stdoutWrite(
388
+ pc.yellow(
389
+ `[${ts()}] partial ${path} → ${emitted.join(', ')} (${totalErrors} error${totalErrors === 1 ? '' : 's'} in other targets, ${ms}ms)\n`,
390
+ ),
391
+ );
392
+ } else {
393
+ stdoutWrite(
394
+ pc.green(`[${ts()}] compiled ${pc.bold(path)} → ${emitted.join(', ')} (${ms}ms)\n`),
395
+ );
396
+ }
397
+ }
398
+
399
+ /**
400
+ * Strip the rootDir prefix from a path for compact log output. Absolute
401
+ * paths outside rootDir are passed through unmodified — matches what
402
+ * users see in `tsc --watch` output.
403
+ */
404
+ function displayPath(abs: string, rootDir: string): string {
405
+ if (abs.startsWith(rootDir + '/')) return abs.slice(rootDir.length + 1);
406
+ return abs;
407
+ }
package/src/index.ts ADDED
@@ -0,0 +1,187 @@
1
+ // @rozie/cli — `rozie` command surface.
2
+ //
3
+ // `rozie build <inputs...>` runs each .rozie file through @rozie/core's
4
+ // compile() public API (DIST-01 / D-80) — the same single source of truth
5
+ // shared with @rozie/unplugin and @rozie/babel-plugin. Comma-separated
6
+ // `--target` (D-87), variadic file/dir/glob inputs (D-88), and the
7
+ // dist/{target}/{rel}/Foo.{ext} output layout (D-89) ship as of Phase 6
8
+ // Plan 03.
9
+ //
10
+ // runCli is exported separately from the bin shebang so tests can drive the
11
+ // CLI in-process without spawning a child node.
12
+ import { Command } from 'commander';
13
+ import {
14
+ runBuild,
15
+ runBuildMany,
16
+ runBuildMatrix,
17
+ type BuildOptions,
18
+ type BuildOptionsExt,
19
+ } from './commands/build.js';
20
+ import { runWatch, type WatchOptions } from './commands/watch.js';
21
+ import { parseTargets, type Target } from './utils/parseTargets.js';
22
+
23
+ export { runBuild, runBuildMany, runBuildMatrix, runWatch };
24
+ export type { BuildOptions, BuildOptionsExt, WatchOptions };
25
+
26
+ /**
27
+ * Internal — the parsed shape of `rozie build`'s opts after commander applies
28
+ * the `parseTargets` parser. `target` is always `Target[]` post-parse (D-87);
29
+ * `types` defaults true per Commander's `--no-*` semantics (D-90); `sourceMap`
30
+ * defaults undefined/false (D-91).
31
+ */
32
+ interface BuildCliOpts {
33
+ target: Target[];
34
+ out?: string;
35
+ sourceMap?: boolean;
36
+ types?: boolean;
37
+ pretty?: boolean;
38
+ /**
39
+ * Phase 23 — Angular-only. Commander's `--no-cva` inverted boolean: present
40
+ * on argv → `opts.cva === false`; absent → `opts.cva === true` (default ON).
41
+ * Maps to `compile({ angular: { cva: false } })` when false.
42
+ */
43
+ cva?: boolean;
44
+ /**
45
+ * Phase 26 (D-11) — the GLOBAL safe-interpolation opt-out. Commander's
46
+ * `--no-safe-interpolation` inverted boolean: present on argv →
47
+ * `opts.safeInterpolation === false`; absent → `true` (default ON). Maps to
48
+ * `compile({ safeInterpolation: false })` when false (cross-target — applies
49
+ * to the five non-Vue targets).
50
+ */
51
+ safeInterpolation?: boolean;
52
+ }
53
+
54
+ /**
55
+ * Programmatic entry — constructs the commander program and parses argv.
56
+ * `argv` follows Node's `process.argv` shape (argv[0]=node, argv[1]=script).
57
+ */
58
+ export async function runCli(argv: readonly string[]): Promise<void> {
59
+ const program = new Command();
60
+ program
61
+ .name('rozie')
62
+ .description('Rozie cross-framework component compiler CLI')
63
+ .version('0.0.0')
64
+ // Surface help on `rozie` with no args rather than the silent default.
65
+ .showHelpAfterError();
66
+
67
+ program
68
+ .command('build <inputs...>')
69
+ .description('Compile one or more .rozie files to one or more target frameworks')
70
+ // D-87: comma-separated targets via parseTargets — validates each token.
71
+ // Default ['vue'] preserves backward-compat with pre-Phase-6 invocations
72
+ // that omitted --target entirely.
73
+ .option(
74
+ '-t, --target <names>',
75
+ 'target framework(s); comma-separated (vue|react|svelte|angular|solid|lit)',
76
+ parseTargets,
77
+ ['vue'] as Target[],
78
+ )
79
+ .option(
80
+ '-o, --out <path>',
81
+ 'output directory (required for multiple inputs or multiple targets)',
82
+ )
83
+ // D-91: source maps default OFF.
84
+ .option(
85
+ '--source-map',
86
+ 'emit .map sidecar files (default off per D-91)',
87
+ )
88
+ // D-90: .d.ts emission default ON; --no-types opts out. Commander auto-
89
+ // creates the inverted boolean: `--no-types` on argv → opts.types === false.
90
+ .option(
91
+ '--no-types',
92
+ 'skip .d.ts emission (React-only — no-op for inline-typed Vue/Svelte/Angular)',
93
+ )
94
+ // PROJECT.md "Out of Scope" carve-out: --pretty IS in scope for the
95
+ // CLI specifically; just not v1's default. Off by default so the
96
+ // dist-parity byte-equal gate (which routes through runBuildMatrix
97
+ // without --pretty) stays inert.
98
+ .option(
99
+ '--pretty',
100
+ 'format emitted artefacts with prettier before write (off by default)',
101
+ )
102
+ // Phase 23 — Angular-only opt-out for the auto ControlValueAccessor emit.
103
+ // Default ON; --no-cva → opts.cva === false → angular: { cva: false }.
104
+ // Commander auto-creates the inverted boolean from the `--no-` prefix.
105
+ .option(
106
+ '--no-cva',
107
+ 'Angular-only: suppress the auto ControlValueAccessor emit on single-model components (no-op for other targets)',
108
+ )
109
+ // Phase 26 — GLOBAL opt-out for the safe-interpolation wrap (default ON).
110
+ // --no-safe-interpolation → opts.safeInterpolation === false →
111
+ // compile({ safeInterpolation: false }). Commander auto-creates the
112
+ // inverted boolean from the `--no-` prefix. No-op for the Vue target.
113
+ .option(
114
+ '--no-safe-interpolation',
115
+ 'suppress the safe-interpolation rozieDisplay wrap (raw per-target emit; re-exposes the React object-child crash if a non-primitive is interpolated; no-op for Vue)',
116
+ )
117
+ .action(async (inputs: string[], opts: BuildCliOpts) => {
118
+ const ext: BuildOptionsExt = {
119
+ target: opts.target,
120
+ ...(opts.out !== undefined ? { out: opts.out } : {}),
121
+ ...(opts.sourceMap === true ? { sourceMap: true } : {}),
122
+ ...(opts.types === false ? { types: false } : {}),
123
+ ...(opts.pretty === true ? { pretty: true } : {}),
124
+ ...(opts.cva === false ? { cva: false } : {}),
125
+ ...(opts.safeInterpolation === false ? { safeInterpolation: false } : {}),
126
+ };
127
+ await runBuildMatrix(inputs, ext);
128
+ });
129
+
130
+ // `rozie watch <inputs...>` — chokidar-driven incremental recompile.
131
+ // Mirrors the build flag surface but is long-running. --out is required
132
+ // here (no sense streaming to stdout from a daemon); the watch command
133
+ // surfaces that as ROZ856 at the action layer, not via commander.
134
+ program
135
+ .command('watch <inputs...>')
136
+ .description(
137
+ 'Watch .rozie files and recompile on change (long-running; tsc --watch style)',
138
+ )
139
+ .option(
140
+ '-t, --target <names>',
141
+ 'target framework(s); comma-separated (vue|react|svelte|angular|solid|lit)',
142
+ parseTargets,
143
+ ['vue'] as Target[],
144
+ )
145
+ .option(
146
+ '-o, --out <path>',
147
+ 'output directory (required for watch mode)',
148
+ )
149
+ .option(
150
+ '--source-map',
151
+ 'emit .map sidecar files (default off per D-91)',
152
+ )
153
+ .option(
154
+ '--no-types',
155
+ 'skip .d.ts emission (React-only — no-op for inline-typed Vue/Svelte/Angular)',
156
+ )
157
+ .option(
158
+ '--pretty',
159
+ 'format emitted artefacts with prettier before write (off by default)',
160
+ )
161
+ // Phase 23 — Angular-only opt-out, mirrors `rozie build` (default ON).
162
+ .option(
163
+ '--no-cva',
164
+ 'Angular-only: suppress the auto ControlValueAccessor emit on single-model components (no-op for other targets)',
165
+ )
166
+ // Phase 26 — GLOBAL safe-interpolation opt-out, mirrors `rozie build`.
167
+ .option(
168
+ '--no-safe-interpolation',
169
+ 'suppress the safe-interpolation rozieDisplay wrap (raw per-target emit; re-exposes the React object-child crash if a non-primitive is interpolated; no-op for Vue)',
170
+ )
171
+ .action(async (inputs: string[], opts: BuildCliOpts) => {
172
+ const ext: WatchOptions = {
173
+ target: opts.target,
174
+ ...(opts.out !== undefined ? { out: opts.out } : {}),
175
+ ...(opts.sourceMap === true ? { sourceMap: true } : {}),
176
+ ...(opts.types === false ? { types: false } : {}),
177
+ ...(opts.pretty === true ? { pretty: true } : {}),
178
+ ...(opts.cva === false ? { cva: false } : {}),
179
+ ...(opts.safeInterpolation === false ? { safeInterpolation: false } : {}),
180
+ };
181
+ await runWatch(inputs, ext);
182
+ });
183
+
184
+ // commander 14 returns a promise from parseAsync; await so any thrown errors
185
+ // bubble to the bin wrapper.
186
+ await program.parseAsync([...argv]);
187
+ }
@@ -0,0 +1,98 @@
1
+ // expandInputs — D-88 input expansion for `rozie build <inputs...>`.
2
+ //
3
+ // Each positional arg auto-detects as one of:
4
+ // - file → resolved to absolute path, validated to end with `.rozie`
5
+ // - directory → fast-glob `${dir}/**/*.rozie`
6
+ // - glob → fast-glob direct (detected via `fg.isDynamicPattern`)
7
+ //
8
+ // Phase 06.2 P3 D-122: composing components emit as plain imports; downstream
9
+ // bundlers handle transitive resolution. v1's "one input file → one output
10
+ // unit" assumption holds — `<components>{ Foo: './Foo.rozie' }` does NOT
11
+ // auto-include `./Foo.rozie` in the input list. Authors enumerate the full
12
+ // component graph via the variadic args / glob pattern. v2 may add a
13
+ // `--resolve-transitive` flag.
14
+ //
15
+ // Carries forward security posture from `packages/unplugin/src/transform.ts`:
16
+ // - Null-byte injection rejected (lines 235-237 of transform.ts)
17
+ // - Top-level symlink args refused; recursive glob walk skips symlinks
18
+ // - The same ignore set used by `walkRozieFiles` (node_modules, .git, …)
19
+ //
20
+ // Results are deduplicated via a Set and sorted by absolute path so the
21
+ // downstream (input × target) tuple list is deterministic.
22
+ import fg from 'fast-glob';
23
+ import { lstatSync } from 'node:fs';
24
+ import { resolve as pathResolve } from 'node:path';
25
+
26
+ /**
27
+ * D-88: expand variadic positional args into a deduped, sorted list of
28
+ * absolute `.rozie` paths. Throws on invalid inputs.
29
+ *
30
+ * @public — used by `runBuildMatrix` (commands/build.ts) and CLI tests.
31
+ */
32
+ export async function expandInputs(args: string[]): Promise<string[]> {
33
+ const out = new Set<string>();
34
+ for (const arg of args) {
35
+ // Carry forward null-byte rejection from unplugin/transform.ts:235-237
36
+ // (T-05-04b-03 mitigation). ROZ853 surface in CLI; here we throw a plain
37
+ // Error so the caller (runBuildMatrix) can re-shape into a build exit.
38
+ if (arg.includes('\0')) {
39
+ throw new Error(
40
+ `[ROZ853] rozie build: refusing input with null byte: ${JSON.stringify(arg)}`,
41
+ );
42
+ }
43
+
44
+ // 1. Glob — fast-glob detects magic chars (`*`, `?`, `[]`, `{}`, …).
45
+ if (fg.isDynamicPattern(arg)) {
46
+ const matches = await fg(arg, {
47
+ absolute: true,
48
+ onlyFiles: true,
49
+ followSymbolicLinks: false,
50
+ });
51
+ for (const match of matches) {
52
+ if (match.endsWith('.rozie')) out.add(match);
53
+ }
54
+ continue;
55
+ }
56
+
57
+ // 2. File or directory — lstat (NOT stat) so a symlink doesn't get
58
+ // transparently followed. The walkRozieFiles posture in
59
+ // packages/unplugin/src/transform.ts:737 is the canonical reference.
60
+ const abs = pathResolve(arg);
61
+ let stat: ReturnType<typeof lstatSync>;
62
+ try {
63
+ stat = lstatSync(abs);
64
+ } catch {
65
+ throw new Error(`[ROZ851] rozie build: cannot stat input '${arg}'`);
66
+ }
67
+
68
+ if (stat.isSymbolicLink()) {
69
+ throw new Error(
70
+ `[ROZ851] rozie build: refusing symlink input '${arg}' (defense-in-depth from walkRozieFiles)`,
71
+ );
72
+ }
73
+
74
+ if (stat.isDirectory()) {
75
+ const matches = await fg(`${abs}/**/*.rozie`, {
76
+ absolute: true,
77
+ onlyFiles: true,
78
+ followSymbolicLinks: false,
79
+ ignore: [
80
+ '**/node_modules/**',
81
+ '**/dist/**',
82
+ '**/.git/**',
83
+ '**/.turbo/**',
84
+ '**/.planning/**',
85
+ ],
86
+ });
87
+ for (const match of matches) out.add(match);
88
+ } else if (stat.isFile()) {
89
+ if (!abs.endsWith('.rozie')) {
90
+ throw new Error(`[ROZ854] rozie build: file '${arg}' is not a .rozie file`);
91
+ }
92
+ out.add(abs);
93
+ } else {
94
+ throw new Error(`[ROZ851] rozie build: cannot stat input '${arg}'`);
95
+ }
96
+ }
97
+ return [...out].sort();
98
+ }