@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.
- package/LICENSE +21 -0
- package/README.md +51 -0
- package/dist/bin.cjs +7 -0
- package/dist/bin.mjs +9 -0
- package/dist/index.cjs +7 -0
- package/dist/index.d.cts +141 -0
- package/dist/index.d.mts +141 -0
- package/dist/index.mjs +2 -0
- package/dist/src-CIv3UOaa.cjs +55302 -0
- package/dist/src-WZKv4m5y.mjs +55192 -0
- package/package.json +85 -0
- package/src/bin.ts +14 -0
- package/src/commands/build.ts +438 -0
- package/src/commands/watch.ts +407 -0
- package/src/index.ts +187 -0
- package/src/utils/expandInputs.ts +98 -0
- package/src/utils/outputPath.ts +67 -0
- package/src/utils/parseTargets.ts +31 -0
- package/src/utils/prettyFormat.ts +138 -0
|
@@ -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
|
+
}
|