@animus-ui/cli 0.1.11

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/dist/index.mjs ADDED
@@ -0,0 +1,754 @@
1
+ import { AnimusConfigError, assertKnownOptionKeys, buildPathAliasesJson, contentHash, createExcludeMatcher, readTsconfigAliasPairs, resolveMode, runStructuralSelfCheck } from "@animus-ui/extract/pipeline";
2
+ import { parseArgs } from "node:util";
3
+ import { CLI_COMMIT_ARTIFACT, CLI_LOCK_ARTIFACT, ExtractionSession, MANIFEST_ARTIFACT, SESSION_ASSETS_DIR, STYLES_ARTIFACT, SYSTEM_PROPS_ARTIFACT, collectSessionAssets, getAnalyzedHashes, getManifestJson, getSessionArtifactDir, getSharedCss, getSharedSystemProps, startTurbopackWatcher, verifyCommitRecord } from "@animus-ui/extract/session";
4
+ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, unlinkSync, writeFileSync } from "fs";
5
+ import { dirname, isAbsolute, join, relative, resolve } from "path";
6
+ import { pathToFileURL } from "url";
7
+ //#region src/writer.ts
8
+ /**
9
+ * Deterministic fixed-path artifact writer (design D3): raw payload bytes —
10
+ * no session envelope, no per-invocation identity — staged into a private
11
+ * subdirectory, VERIFIED there, then renamed into place with `commit.json`
12
+ * LAST carrying content hashes of every payload (asset files included), so
13
+ * a reader holding only the record and the payload bytes can verify set
14
+ * completeness and consistency — and a publication that cannot verify
15
+ * never replaces the previous generation. A single-writer advisory lock
16
+ * fails loud instead of last-writer-wins; the lock never outlives the
17
+ * invocation, so repeated identical builds are byte-identical trees.
18
+ */
19
+ const STYLES_FILE = STYLES_ARTIFACT;
20
+ const SYSTEM_PROPS_FILE = SYSTEM_PROPS_ARTIFACT;
21
+ const MANIFEST_FILE = MANIFEST_ARTIFACT;
22
+ const COMMIT_FILE = CLI_COMMIT_ARTIFACT;
23
+ const LOCK_FILE = CLI_LOCK_ARTIFACT;
24
+ const ASSETS_DIR = SESSION_ASSETS_DIR;
25
+ /** Publication rejected by the pre-swap consistency check: the staged set
26
+ * did not verify, and the previous generation was left untouched. */
27
+ var PublishInconsistencyError = class extends Error {
28
+ constructor(failures) {
29
+ super(`Staged publication failed its consistency check:\n - ${failures.join("\n - ")}`);
30
+ this.failures = failures;
31
+ this.name = "PublishInconsistencyError";
32
+ }
33
+ };
34
+ /** Take the single-writer advisory lock, failing loud on a live holder.
35
+ * Returns a release function. */
36
+ function acquireLock(outDir) {
37
+ mkdirSync(outDir, { recursive: true });
38
+ const lockPath = join(outDir, LOCK_FILE);
39
+ try {
40
+ writeFileSync(lockPath, JSON.stringify({
41
+ pid: process.pid,
42
+ startedAt: (/* @__PURE__ */ new Date()).toISOString()
43
+ }), { flag: "wx" });
44
+ } catch (error) {
45
+ if (error.code !== "EEXIST") throw error;
46
+ let holder = {};
47
+ try {
48
+ holder = JSON.parse(readFileSync(lockPath, "utf-8"));
49
+ } catch {}
50
+ if (typeof holder.pid === "number" && isProcessAlive(holder.pid)) throw new AnimusConfigError(`Another animus process (pid ${holder.pid}, started ${holder.startedAt ?? "unknown"}) owns ${outDir} — wait for it or pass --out-dir to write elsewhere.`);
51
+ console.error(`[animus] Replacing stale lock left by dead pid ${holder.pid ?? "?"}`);
52
+ rmSync(lockPath, { force: true });
53
+ try {
54
+ writeFileSync(lockPath, JSON.stringify({
55
+ pid: process.pid,
56
+ startedAt: (/* @__PURE__ */ new Date()).toISOString()
57
+ }), { flag: "wx" });
58
+ } catch (retryError) {
59
+ if (retryError.code !== "EEXIST") throw retryError;
60
+ throw new AnimusConfigError(`Another animus process re-acquired ${outDir} while a stale lock was being replaced — wait for it or pass --out-dir.`);
61
+ }
62
+ }
63
+ return () => {
64
+ rmSync(lockPath, { force: true });
65
+ };
66
+ }
67
+ function isProcessAlive(pid) {
68
+ try {
69
+ process.kill(pid, 0);
70
+ return true;
71
+ } catch {
72
+ return false;
73
+ }
74
+ }
75
+ /** Asset names under `assets/` that outDir's current commit record
76
+ * published, or empty when no record is readable. */
77
+ function publishedAssetNames(outDir) {
78
+ try {
79
+ const record = JSON.parse(readFileSync(join(outDir, COMMIT_FILE), "utf-8"));
80
+ return new Set(Object.keys(record.payloads ?? {}).filter((name) => name.startsWith(`${ASSETS_DIR}/`)).map((name) => name.slice(ASSETS_DIR.length + 1)));
81
+ } catch {
82
+ return /* @__PURE__ */ new Set();
83
+ }
84
+ }
85
+ /**
86
+ * Publish the payload set. The set is STAGED into a private subdirectory,
87
+ * verified there (recompute-and-compare, the same check
88
+ * `verifyPublishedSet` runs), and only a verified set is renamed into
89
+ * place: payloads first, asset files next, the commit record LAST. A set
90
+ * that cannot verify throws `PublishInconsistencyError` with the previous
91
+ * generation untouched — "keeping last-good artifacts" must be literally
92
+ * true for the watch loop's rejection path. The record carries only
93
+ * content-derived data (asset entries sorted by name) so the full tree is
94
+ * byte-identical across identical-input runs.
95
+ */
96
+ function publishArtifacts(outDir, payloads) {
97
+ mkdirSync(outDir, { recursive: true });
98
+ const previouslyPublishedAssets = publishedAssetNames(outDir);
99
+ const staging = join(outDir, `.staging-${process.pid}`);
100
+ rmSync(staging, {
101
+ recursive: true,
102
+ force: true
103
+ });
104
+ mkdirSync(staging, { recursive: true });
105
+ try {
106
+ const namedPayloads = [
107
+ [STYLES_FILE, payloads.stylesCss],
108
+ [SYSTEM_PROPS_FILE, payloads.systemPropsJs],
109
+ [MANIFEST_FILE, payloads.manifestJson]
110
+ ];
111
+ const record = {
112
+ schema: 1,
113
+ payloads: {}
114
+ };
115
+ for (const [name, text] of namedPayloads) {
116
+ writeFileSync(join(staging, name), text);
117
+ record.payloads[name] = { hash: contentHash(text) };
118
+ }
119
+ const assets = payloads.assets ?? [];
120
+ if (assets.length > 0) {
121
+ mkdirSync(join(staging, ASSETS_DIR), { recursive: true });
122
+ for (const { name, bytes } of assets) {
123
+ writeFileSync(join(staging, ASSETS_DIR, name), bytes);
124
+ record.payloads[`${ASSETS_DIR}/${name}`] = { hash: contentHash(bytes) };
125
+ }
126
+ }
127
+ writeFileSync(join(staging, COMMIT_FILE), JSON.stringify(record, null, 2) + "\n");
128
+ const failures = verifyPublishedSet(staging);
129
+ if (failures.length > 0) throw new PublishInconsistencyError(failures);
130
+ for (const [name] of namedPayloads) renameSync(join(staging, name), join(outDir, name));
131
+ const publishedAssetsDir = join(outDir, ASSETS_DIR);
132
+ if (assets.length > 0) {
133
+ mkdirSync(publishedAssetsDir, { recursive: true });
134
+ for (const { name } of assets) renameSync(join(staging, ASSETS_DIR, name), join(publishedAssetsDir, name));
135
+ }
136
+ const expectedNames = new Set(assets.map((asset) => asset.name));
137
+ for (const name of previouslyPublishedAssets) {
138
+ if (expectedNames.has(name)) continue;
139
+ try {
140
+ unlinkSync(join(publishedAssetsDir, name));
141
+ } catch {}
142
+ }
143
+ renameSync(join(staging, COMMIT_FILE), join(outDir, COMMIT_FILE));
144
+ return record;
145
+ } finally {
146
+ rmSync(staging, {
147
+ recursive: true,
148
+ force: true
149
+ });
150
+ }
151
+ }
152
+ /** The documented consistency check — the session-shared implementation
153
+ * (published-set.ts): recompute every payload hash raw-bytes and compare
154
+ * against the commit record. Returns failure lines (empty = consistent). */
155
+ function verifyPublishedSet(outDir) {
156
+ return verifyCommitRecord(outDir);
157
+ }
158
+ //#endregion
159
+ //#region src/build.ts
160
+ /**
161
+ * `animus build` — one-shot extraction over the SAME drive loop every
162
+ * driver uses (`ExtractionSession`; NS1/NS4: no second composition), then
163
+ * export of the raw payloads to the fixed-path deterministic artifact
164
+ * contract (design D3) via the CLI writer. The one-shot session tree is
165
+ * removed after export — no session-dir accumulation per CI run.
166
+ *
167
+ * Failure policy (design D5): silent-empty success is impossible —
168
+ * system-load failure, zero discovered files, and structural emptiness are
169
+ * fatal in EVERY mode, independent of `strict`.
170
+ *
171
+ * The preflight, session construction, and shared-state publication steps
172
+ * are exported helpers: `animus watch` (watch.ts) runs the same checks and
173
+ * the same writer path per publication instead of forking them.
174
+ */
175
+ /** Thrown for failures whose exit class is "extraction failure" (1). */
176
+ var ExtractionFailure = class extends Error {};
177
+ /** Thrown for failures whose exit class is "config/usage error" (2). */
178
+ var UsageFailure = class extends Error {};
179
+ /** Thrown for failures whose exit class is "engine/environment" (3). */
180
+ var EnvironmentFailure = class extends Error {};
181
+ /** The CLI's one stderr prefix convention (stdout stays machine-only) —
182
+ * shared with watch.ts. */
183
+ const err = (...parts) => console.error("[animus]", ...parts);
184
+ /** Preflight shared by build and watch: root and system-module existence
185
+ * are usage errors (exit 2) decided before any engine work; a missing
186
+ * platform binary is an environment failure (exit 3) surfaced with the
187
+ * loader's own remediation text. */
188
+ async function runPreflight(config) {
189
+ const { root, options } = config;
190
+ if (!existsSync(root)) throw new UsageFailure(`Root directory not found: ${root}`);
191
+ const systemPath = resolve(root, options.system);
192
+ if (!existsSync(systemPath)) throw new UsageFailure(`System module not found: ${systemPath} (from \`system: ${options.system}\` against root ${root})`);
193
+ try {
194
+ await import("@animus-ui/extract");
195
+ } catch (error) {
196
+ throw new EnvironmentFailure(`Extraction engine failed to load: ${String(error)}`);
197
+ }
198
+ }
199
+ /** The one CLI session shape: self-ingestion guard applied (an outDir
200
+ * inside the root is force-excluded from discovery, loudly), driver label
201
+ * and root authority set, tsconfig `paths` harvested as the alias source
202
+ * (no live bundler config exists for this driver). */
203
+ function createCliSession(config) {
204
+ const { root, outDir, options } = config;
205
+ const session = new ExtractionSession(options);
206
+ const relOut = relative(root, outDir);
207
+ if (!relOut.startsWith("..") && !config.excludePatterns.some((p) => relOut.includes(p) || p === relOut)) {
208
+ const anchored = `${relOut.split("\\").join("/")}/**`;
209
+ session.structuralExclude = [anchored];
210
+ err(`outDir ${relOut} is inside the root — auto-excluded from discovery (pattern '${anchored}')`);
211
+ }
212
+ session.driverLabel = "animus";
213
+ session.rootDir = root;
214
+ const builtAliases = buildPathAliasesJson(readTsconfigAliasPairs(root), root);
215
+ if (builtAliases) session.pathAliasesJson = builtAliases.json;
216
+ return session;
217
+ }
218
+ /**
219
+ * Publish the session's shared-state payloads through the deterministic
220
+ * CLI writer (design D3) — the SINGLE publication path for build and every
221
+ * watch cycle. Zero analyzed files, an unreadable manifest, and structural
222
+ * emptiness throw ExtractionFailure (silent-empty success is impossible);
223
+ * a post-publish consistency failure throws EnvironmentFailure.
224
+ */
225
+ function publishSharedPayloads(config, session) {
226
+ const { root, outDir, options } = config;
227
+ const fileCount = getAnalyzedHashes()?.size ?? 0;
228
+ if (fileCount === 0) throw new ExtractionFailure(`Discovery found zero source files under ${root} (exclusions: ${config.excludePatterns.join(", ")})`);
229
+ const manifestJson = getManifestJson() ?? "";
230
+ const stylesCss = getSharedCss();
231
+ const systemPropsJs = getSharedSystemProps();
232
+ let componentCount = session.lastComponentCount ?? -1;
233
+ if (componentCount < 0) try {
234
+ componentCount = Object.keys(JSON.parse(manifestJson).components ?? {}).length;
235
+ } catch {
236
+ throw new ExtractionFailure("Analysis published no readable manifest");
237
+ }
238
+ const failures = runStructuralSelfCheck({
239
+ componentCount,
240
+ variableCss: stylesCss,
241
+ globalCss: "",
242
+ componentCss: "",
243
+ assembledCss: stylesCss,
244
+ layers: options.layers,
245
+ externalOutcomes: session.lastExternalOutcomes
246
+ });
247
+ if (failures.length > 0) throw new ExtractionFailure(`Structural self-check failed:\n - ${failures.join("\n - ")}`);
248
+ const assets = collectSessionAssets(session.sessionDir);
249
+ try {
250
+ publishArtifacts(outDir, {
251
+ stylesCss,
252
+ systemPropsJs,
253
+ manifestJson,
254
+ assets
255
+ });
256
+ } catch (error) {
257
+ if (error instanceof PublishInconsistencyError) throw new EnvironmentFailure(error.message);
258
+ throw error;
259
+ }
260
+ return {
261
+ componentCount,
262
+ fileCount
263
+ };
264
+ }
265
+ /** Discovery-outcome report (stderr): per-specifier accounting plus
266
+ * dead-pattern visibility — a user exclusion that matched nothing is
267
+ * named instead of silently inert. */
268
+ function reportDiscoveryOutcomes(config, session) {
269
+ for (const { specifier, outcome, fileCount: files } of session.lastExternalOutcomes) err(`include '${specifier}': ${outcome}${outcome === "resolved" ? ` (${files} files)` : ""}`);
270
+ const stats = session.getExcludeStats();
271
+ for (const pattern of config.options.exclude ?? []) if ((stats.get(pattern) ?? 0) === 0) err(`exclude pattern '${pattern}' matched nothing`);
272
+ }
273
+ async function runBuild(config) {
274
+ const { outDir } = config;
275
+ await runPreflight(config);
276
+ const release = acquireLock(outDir);
277
+ let session = null;
278
+ try {
279
+ session = createCliSession(config);
280
+ try {
281
+ await session.runFullPipeline();
282
+ } catch (error) {
283
+ throw new ExtractionFailure(String(error));
284
+ }
285
+ const { componentCount, fileCount } = publishSharedPayloads(config, session);
286
+ reportDiscoveryOutcomes(config, session);
287
+ err(`build complete: ${componentCount} components from ${fileCount} files → ${outDir}`);
288
+ return {
289
+ outDir,
290
+ componentCount,
291
+ fileCount
292
+ };
293
+ } finally {
294
+ release();
295
+ const dir = session?.sessionDir ?? getSessionArtifactDir();
296
+ if (dir) rmSync(dir, {
297
+ recursive: true,
298
+ force: true
299
+ });
300
+ }
301
+ }
302
+ //#endregion
303
+ //#region src/config.ts
304
+ /**
305
+ * CLI config resolution — the third driver over the shared option core
306
+ * (shared-driver-config): documented precedence flags > config file >
307
+ * defaults, one root authority, explicit emission mode (the CLI never
308
+ * sniffs NODE_ENV), and a fully inspectable resolved projection for
309
+ * `--print-config`.
310
+ */
311
+ /** Config filenames probed in order under the config-search root. */
312
+ const CONFIG_FILENAMES = [
313
+ "animus.config.json",
314
+ "animus.config.mjs",
315
+ "animus.config.js",
316
+ "animus.config.ts"
317
+ ];
318
+ async function loadConfigFile(path) {
319
+ if (path.endsWith(".json")) try {
320
+ return JSON.parse(readFileSync(path, "utf-8"));
321
+ } catch (error) {
322
+ throw new AnimusConfigError(`Failed to parse ${path}: ${String(error)}`);
323
+ }
324
+ try {
325
+ const mod = await import(pathToFileURL(path).href);
326
+ const value = mod.default ?? mod;
327
+ if (typeof value !== "object" || value === null) throw new AnimusConfigError(`${path} must default-export a config object`);
328
+ return value;
329
+ } catch (error) {
330
+ if (error instanceof AnimusConfigError) throw error;
331
+ const code = error.code;
332
+ if (path.endsWith(".ts") && code === "ERR_UNKNOWN_FILE_EXTENSION") throw new AnimusConfigError(`${path}: this Node runtime cannot evaluate TypeScript config files (native type stripping requires Node >= 23.6 unflagged; the supported floor for .ts configs). Use animus.config.mjs or animus.config.json instead.`);
333
+ throw new AnimusConfigError(`Failed to load config ${path}: ${String(error)}`);
334
+ }
335
+ }
336
+ /**
337
+ * Resolve the effective CLI configuration. Precedence: flags > config file
338
+ * > documented defaults. Scalar flags override; `--exclude` values merge
339
+ * (exclusion semantics are merge-only by contract).
340
+ */
341
+ async function resolveCliConfig(flags, cwd) {
342
+ const flagRoot = flags.root ? resolve(cwd, flags.root) : null;
343
+ let configFile = null;
344
+ if (flags.config) {
345
+ configFile = resolve(cwd, flags.config);
346
+ if (!existsSync(configFile)) throw new AnimusConfigError(`Config file not found: ${configFile}`);
347
+ } else for (const name of CONFIG_FILENAMES) {
348
+ const candidate = resolve(flagRoot ?? cwd, name);
349
+ if (existsSync(candidate)) {
350
+ configFile = candidate;
351
+ break;
352
+ }
353
+ }
354
+ const raw = configFile ? await loadConfigFile(configFile) : {};
355
+ assertKnownOptionKeys(raw);
356
+ const fileRoot = raw["root"];
357
+ const configDir = configFile ? dirname(configFile) : cwd;
358
+ const effectiveRoot = flagRoot ?? (typeof fileRoot === "string" ? resolve(configDir, fileRoot) : configFile ? configDir : cwd);
359
+ const provenance = {};
360
+ const pick = (key, flagValue) => {
361
+ if (flagValue !== void 0) {
362
+ provenance[key] = "explicit";
363
+ return flagValue;
364
+ }
365
+ const fileValue = raw[key];
366
+ if (fileValue !== void 0) {
367
+ provenance[key] = "explicit";
368
+ return fileValue;
369
+ }
370
+ provenance[key] = "default";
371
+ };
372
+ const system = pick("system", flags.system);
373
+ if (typeof system !== "string" || system.length === 0) throw new AnimusConfigError("Missing required option `system` — pass --system ./src/ds.ts or set \"system\" in animus.config.");
374
+ const exclude = [...Array.isArray(raw["exclude"]) ? raw["exclude"] : [], ...flags.exclude ?? []];
375
+ if (exclude.length > 0) provenance["exclude"] = "explicit";
376
+ else provenance["exclude"] = "default";
377
+ if (flags.mode !== void 0 && flags.mode !== "development" && flags.mode !== "production") throw new AnimusConfigError(`Invalid --mode "${flags.mode}" — expected "development" or "production".`);
378
+ const resolvedMode = resolveMode(flags.mode ?? raw["mode"], () => "production");
379
+ provenance["mode"] = resolvedMode.provenance;
380
+ const cliNamespace = raw["cli"] ?? {};
381
+ if (cliNamespace.outDir !== void 0 && typeof cliNamespace.outDir !== "string") throw new AnimusConfigError(`Invalid value for "cli.outDir" — expected a string path, got ${JSON.stringify(cliNamespace.outDir)}.`);
382
+ const outDirSetting = flags.outDir ?? cliNamespace.outDir ?? ".animus";
383
+ const outDir = isAbsolute(outDirSetting) ? outDirSetting : resolve(effectiveRoot, outDirSetting);
384
+ provenance["outDir"] = flags.outDir !== void 0 || cliNamespace.outDir !== void 0 ? "explicit" : "driver-default";
385
+ const options = {
386
+ system,
387
+ exclude: exclude.length > 0 ? exclude : void 0,
388
+ extensions: pick("extensions", void 0),
389
+ strict: pick("strict", flags.strict),
390
+ verbose: pick("verbose", flags.verbose),
391
+ prefix: pick("prefix", void 0),
392
+ targets: pick("targets", flags.targets),
393
+ minify: pick("minify", void 0),
394
+ staticCss: pick("staticCss", void 0),
395
+ layers: pick("layers", void 0),
396
+ mode: resolvedMode.mode
397
+ };
398
+ const excludePatterns = createExcludeMatcher(options.exclude).patterns;
399
+ return {
400
+ driver: "cli",
401
+ root: effectiveRoot,
402
+ configFile,
403
+ options,
404
+ outDir,
405
+ excludePatterns,
406
+ mode: resolvedMode.mode,
407
+ provenance
408
+ };
409
+ }
410
+ /** The `--print-config` projection: everything effective, nothing hidden. */
411
+ function projectResolvedConfig(config) {
412
+ return {
413
+ driver: config.driver,
414
+ root: config.root,
415
+ configFile: config.configFile,
416
+ outDir: config.outDir,
417
+ mode: config.mode,
418
+ system: config.options.system,
419
+ strict: config.options.strict ?? false,
420
+ verbose: config.options.verbose ?? false,
421
+ targets: config.options.targets ?? null,
422
+ minify: config.options.minify ?? null,
423
+ prefix: config.options.prefix ?? null,
424
+ layers: config.options.layers ?? null,
425
+ exclude: config.excludePatterns,
426
+ provenance: config.provenance
427
+ };
428
+ }
429
+ //#endregion
430
+ //#region src/watch.ts
431
+ /**
432
+ * `animus watch` — the session's watcher loop as a long-lived foreground
433
+ * process (openspec: standalone-extraction-cli, design D5 watch half).
434
+ *
435
+ * Contract:
436
+ * - Readiness is an explicit observable event distinct from idle: ONE
437
+ * structured stderr `watch ready` line after the first complete,
438
+ * consistent publication (plus the session status artifact's additive
439
+ * monotonic `ready` field, schema-bumped compatibly).
440
+ * - Mid-run failures keep last-good output and report per-cycle on stderr;
441
+ * every publication goes through the SAME deterministic CLI writer as
442
+ * `animus build` (design D3) and the advisory lock is held for the
443
+ * watch's lifetime.
444
+ * - Watch-capability degradation is loud and names roots: a persistent
445
+ * stderr warning per unwatched root at every publication, and
446
+ * `--fail-on-degraded` for orchestrators that would rather die (exit 3).
447
+ * - The process stays alive on purpose: the session watcher unrefs every
448
+ * handle (it must never hold a plugin host's process open), so the CLI
449
+ * holds its own ref'd keepalive. SIGINT exits 130, SIGTERM 143, both
450
+ * releasing the lock and removing the session tree — a watch keeps its
451
+ * session tree alive WHILE RUNNING (transform consumers may exist) and
452
+ * removes it on clean shutdown, unlike build's publish-then-remove.
453
+ *
454
+ * COORDINATION: `external-source-watch-ingestion` owns watch-ingestion
455
+ * semantics — this verb consumes `startTurbopackWatcher` and
456
+ * `handleWatchUpdate` as-is (the per-cycle wrapper below only observes
457
+ * cycle boundaries to drive publication policy; it forks no ingestion
458
+ * behavior).
459
+ */
460
+ /** Signal exit conventions (128 + signal number) plus the degraded-exit
461
+ * class: watch capability loss is an environment failure (taxonomy 3). */
462
+ const EXIT_SIGINT = 130;
463
+ const EXIT_SIGTERM = 143;
464
+ const EXIT_DEGRADED = 3;
465
+ /**
466
+ * The degradation list for one publication — every root whose edits the
467
+ * watch will NOT observe, with the reason. Sources:
468
+ * - the project root itself when the platform watcher could not start
469
+ * (recursive fs.watch unavailable / registration failed — the session
470
+ * degrades to no-watch with a warning and returns no handle);
471
+ * - external roots resolved through node_modules (documented unwatchable —
472
+ * the orchestrator never registers them; its own guardrail-G2 comment on
473
+ * `openExternalWatcher`'s early return);
474
+ * - external roots whose watcher failed after registration (the session's
475
+ * sticky `external-watch:` diagnostics, which carry the reason).
476
+ */
477
+ function collectDegradedRoots(inputs) {
478
+ const seen = /* @__PURE__ */ new Set();
479
+ const degraded = [];
480
+ const add = (root, reason) => {
481
+ if (seen.has(root)) return;
482
+ seen.add(root);
483
+ degraded.push({
484
+ root,
485
+ reason
486
+ });
487
+ };
488
+ if (!inputs.projectWatchActive) add(inputs.projectRoot, "platform watcher unavailable — NO source edits will be observed; restart the watch after changes");
489
+ for (const root of inputs.externalWatchRoots) if (root.split(/[\\/]/).includes("node_modules")) add(root, "resolved through node_modules (documented unwatchable) — edits in this package require a restart");
490
+ const stickyPrefix = "external-watch:";
491
+ for (const [key, message] of inputs.stickyDiagnostics) {
492
+ if (!key.startsWith(stickyPrefix)) continue;
493
+ add(key.slice(15), message);
494
+ }
495
+ return degraded;
496
+ }
497
+ /** One persistent stderr warning per unwatched root (design D5: loud,
498
+ * names the root and the consequence, repeated at every publication —
499
+ * never a one-shot). */
500
+ function formatDegradedRootLine(entry) {
501
+ return `watch degraded root=${entry.root} — ${entry.reason}`;
502
+ }
503
+ /** Composite key of the shared payload set — the cycle-level dedupe gate
504
+ * (a no-op analysis republishes nothing; disk bytes stay untouched). */
505
+ function currentPayloadKey() {
506
+ return [
507
+ contentHash(getManifestJson() ?? ""),
508
+ contentHash(getSharedCss()),
509
+ contentHash(getSharedSystemProps())
510
+ ].join(":");
511
+ }
512
+ /**
513
+ * Run the watch loop. Resolves with the process exit code on shutdown
514
+ * (130 SIGINT / 143 SIGTERM / 3 --fail-on-degraded trip). Startup
515
+ * failures — preflight, first pipeline, first publication — THROW with
516
+ * the same taxonomy as build: readiness is a contract (NS3), so a
517
+ * supervisor either observes the ready event or observes a nonzero exit,
518
+ * never a silent hang with no first publication.
519
+ */
520
+ async function runWatch(config, flags) {
521
+ const { outDir, root } = config;
522
+ await runPreflight(config);
523
+ const release = acquireLock(outDir);
524
+ let session;
525
+ let firstPublication;
526
+ try {
527
+ session = createCliSession(config);
528
+ try {
529
+ await session.runFullPipeline();
530
+ } catch (error) {
531
+ throw new ExtractionFailure(String(error));
532
+ }
533
+ firstPublication = publishSharedPayloads(config, session);
534
+ reportDiscoveryOutcomes(config, session);
535
+ } catch (error) {
536
+ release();
537
+ const dir = session?.sessionDir ?? getSessionArtifactDir();
538
+ if (dir) rmSync(dir, {
539
+ recursive: true,
540
+ force: true
541
+ });
542
+ throw error;
543
+ }
544
+ let publications = 1;
545
+ let lastPublishedKey = currentPayloadKey();
546
+ return new Promise((resolvePromise) => {
547
+ let settled = false;
548
+ let watcher = null;
549
+ const keepalive = setInterval(() => {}, 2 ** 30);
550
+ const shutdown = (code, reason) => {
551
+ if (settled) return;
552
+ settled = true;
553
+ process.removeListener("SIGINT", onSigint);
554
+ process.removeListener("SIGTERM", onSigterm);
555
+ watcher?.close();
556
+ (async () => {
557
+ try {
558
+ await Promise.race([watcher?.settle() ?? Promise.resolve(), new Promise((res) => setTimeout(res, 1e4).unref?.())]);
559
+ } catch {}
560
+ try {
561
+ rmSync(session.sessionDir, {
562
+ recursive: true,
563
+ force: true
564
+ });
565
+ } catch {}
566
+ release();
567
+ clearInterval(keepalive);
568
+ err(`watch shutdown reason=${reason} publications=${publications}`);
569
+ resolvePromise(code);
570
+ })();
571
+ };
572
+ const onSigint = () => shutdown(EXIT_SIGINT, "SIGINT");
573
+ const onSigterm = () => shutdown(EXIT_SIGTERM, "SIGTERM");
574
+ process.on("SIGINT", onSigint);
575
+ process.on("SIGTERM", onSigterm);
576
+ /** Print the per-publication degradation warnings. Returns true when
577
+ * --fail-on-degraded tripped (shutdown already initiated). */
578
+ const reportDegradation = () => {
579
+ const degraded = collectDegradedRoots({
580
+ projectRoot: root,
581
+ projectWatchActive: watcher !== null && !watcher.died,
582
+ externalWatchRoots: session.externalWatchRoots,
583
+ stickyDiagnostics: session.stickyDiagnostics
584
+ });
585
+ for (const entry of degraded) err(formatDegradedRootLine(entry));
586
+ if (degraded.length > 0 && flags.failOnDegraded) {
587
+ err(`watch degraded and --fail-on-degraded is set — exiting ${EXIT_DEGRADED}`);
588
+ shutdown(EXIT_DEGRADED, "fail-on-degraded");
589
+ return true;
590
+ }
591
+ return false;
592
+ };
593
+ const baseHandleWatchUpdate = session.handleWatchUpdate.bind(session);
594
+ session.handleWatchUpdate = async (changes) => {
595
+ if (settled) return;
596
+ try {
597
+ await baseHandleWatchUpdate(changes);
598
+ } catch (error) {
599
+ if (settled) return;
600
+ err(`watch cycle failed — keeping last-good artifacts in ${outDir}: ${String(error)}`);
601
+ return;
602
+ }
603
+ if (settled) return;
604
+ const key = currentPayloadKey();
605
+ if (key === lastPublishedKey) return;
606
+ let outcome;
607
+ try {
608
+ outcome = publishSharedPayloads(config, session);
609
+ } catch (error) {
610
+ err(`watch cycle publication rejected — keeping last-good artifacts in ${outDir}: ${String(error)}`);
611
+ return;
612
+ }
613
+ lastPublishedKey = key;
614
+ publications += 1;
615
+ err(`watch republished components=${outcome.componentCount} files=${outcome.fileCount} outDir=${outDir}`);
616
+ reportDegradation();
617
+ };
618
+ watcher = startTurbopackWatcher(session, root);
619
+ if (watcher) watcher.onDied = () => {
620
+ if (!settled) reportDegradation();
621
+ };
622
+ if (reportDegradation()) return;
623
+ err(`watch ready components=${firstPublication.componentCount} files=${firstPublication.fileCount} outDir=${outDir}`);
624
+ });
625
+ }
626
+ //#endregion
627
+ //#region src/index.ts
628
+ /**
629
+ * `animus` — the standalone extraction CLI (openspec:
630
+ * standalone-extraction-cli).
631
+ *
632
+ * Contract (design D5): stdout carries machine output ONLY (today: the
633
+ * `--print-config` JSON projection); every human-facing line goes to
634
+ * stderr. Exit taxonomy: 0 success · 1 extraction/strict failure · 2
635
+ * config/usage error · 3 engine/environment failure.
636
+ *
637
+ * UNSTABLE MODULE SURFACE: the package's programmatic exports (`main`,
638
+ * `exitCodeFor`, the EXIT_* constants) exist for the repo's own lanes and
639
+ * tests; the supported consumer surface is the `animus` binary. The module
640
+ * API may change without semver ceremony until a consumer contract ships
641
+ * (standalone-extraction-cli inc 07).
642
+ */
643
+ const EXIT_OK = 0;
644
+ const EXIT_EXTRACTION = 1;
645
+ const EXIT_USAGE = 2;
646
+ const EXIT_ENVIRONMENT = 3;
647
+ const USAGE = `animus — standalone Animus extraction
648
+
649
+ Usage:
650
+ animus build [options] One-shot extraction to the artifact directory
651
+ animus watch [options] Long-lived watch: republish on change; ready,
652
+ per-cycle failures, and degradation on stderr
653
+ animus print-config [opts] Print the fully resolved configuration (JSON)
654
+
655
+ Options:
656
+ --system <path> SystemInstance module (required unless configured)
657
+ --root <path> Root every relative input resolves against
658
+ --config <path> Explicit config file (default: animus.config.* in root)
659
+ --out-dir <path> Artifact directory (default: <root>/.animus)
660
+ --exclude <glob> Additional exclusion (repeatable; MERGES with defaults)
661
+ --mode <m> 'development' | 'production' (default: production)
662
+ --targets <query> Browserslist query for CSS lowering
663
+ --strict Escalate extraction warnings to failures
664
+ --fail-on-degraded watch only: exit 3 instead of running with unwatched
665
+ roots (degradation is otherwise reported and tolerated)
666
+ --verbose Verbose logging (stderr)
667
+ --print-config Alias of the print-config command
668
+ --help This text
669
+
670
+ Exit codes: 0 success · 1 extraction failure · 2 config error · 3 engine failure
671
+ Watch shutdown: SIGINT exits 130, SIGTERM 143 (lock released, last-good kept)
672
+ `;
673
+ /** Classify an error into the documented exit taxonomy. ExtractionFailure
674
+ * and unclassified errors deliberately share the extraction exit class —
675
+ * an unknown throw during a build IS a failed extraction to a supervisor. */
676
+ function exitCodeFor(error) {
677
+ if (error instanceof UsageFailure || error instanceof AnimusConfigError) return 2;
678
+ if (error instanceof EnvironmentFailure) return 3;
679
+ return 1;
680
+ }
681
+ async function main(argv = process.argv.slice(2)) {
682
+ let parsed;
683
+ try {
684
+ parsed = parseArgs({
685
+ args: argv,
686
+ allowPositionals: true,
687
+ options: {
688
+ system: { type: "string" },
689
+ root: { type: "string" },
690
+ config: { type: "string" },
691
+ "out-dir": { type: "string" },
692
+ exclude: {
693
+ type: "string",
694
+ multiple: true
695
+ },
696
+ mode: { type: "string" },
697
+ targets: { type: "string" },
698
+ strict: { type: "boolean" },
699
+ verbose: { type: "boolean" },
700
+ "fail-on-degraded": { type: "boolean" },
701
+ "print-config": { type: "boolean" },
702
+ help: { type: "boolean" }
703
+ }
704
+ });
705
+ } catch (error) {
706
+ console.error(`[animus] ${String(error.message)}`);
707
+ console.error(USAGE);
708
+ process.exitCode = 2;
709
+ return;
710
+ }
711
+ const { values, positionals } = parsed;
712
+ if (values.help || positionals.length === 0) {
713
+ console.error(USAGE);
714
+ process.exitCode = positionals.length === 0 && !values.help ? 2 : 0;
715
+ return;
716
+ }
717
+ const command = positionals[0];
718
+ const flags = {
719
+ system: values.system,
720
+ root: values.root,
721
+ config: values.config,
722
+ outDir: values["out-dir"],
723
+ strict: values.strict,
724
+ verbose: values.verbose,
725
+ mode: values.mode,
726
+ targets: values.targets,
727
+ exclude: values.exclude
728
+ };
729
+ try {
730
+ const config = await resolveCliConfig(flags, process.cwd());
731
+ if (command === "print-config" || values["print-config"]) {
732
+ console.log(JSON.stringify(projectResolvedConfig(config), null, 2));
733
+ process.exitCode = 0;
734
+ return;
735
+ }
736
+ if (command === "build") {
737
+ await runBuild(config);
738
+ process.exitCode = 0;
739
+ return;
740
+ }
741
+ if (command === "watch") {
742
+ process.exitCode = await runWatch(config, { failOnDegraded: values["fail-on-degraded"] === true });
743
+ return;
744
+ }
745
+ console.error(`[animus] Unknown command '${command}'`);
746
+ console.error(USAGE);
747
+ process.exitCode = 2;
748
+ } catch (error) {
749
+ console.error(`[animus] ${String(error.message ?? error)}`);
750
+ process.exitCode = exitCodeFor(error);
751
+ }
752
+ }
753
+ //#endregion
754
+ export { EXIT_ENVIRONMENT, EXIT_EXTRACTION, EXIT_OK, EXIT_USAGE, exitCodeFor, main };