@animus-ui/cli 0.1.11 → 0.1.13

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 CHANGED
@@ -1,113 +1,192 @@
1
- import { AnimusConfigError, assertKnownOptionKeys, buildPathAliasesJson, contentHash, createExcludeMatcher, readTsconfigAliasPairs, resolveMode, runStructuralSelfCheck } from "@animus-ui/extract/pipeline";
1
+ import { AnimusConfigError, assertKnownOptionKeys, assertNoRetiredEngineSelection, buildPathAliasesJson, contentHash, createExcludeMatcher, readTsconfigAliasPairs, resolveMode, runStructuralSelfCheck } from "@animus-ui/extract/pipeline";
2
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";
3
+ import { ANIMUS_ARTIFACT_DIR, CLI_COMMIT_ARTIFACT, CLI_LOCK_ARTIFACT, ExtractionSession, MANIFEST_ARTIFACT, SESSION_ASSETS_DIR, STYLES_ARTIFACT, SYSTEM_PROPS_ARTIFACT, checkLockLiveness, collectSessionAssets, decodeCommitRecord, getAnalyzedHashes, getManifestJson, getSharedCss, getSharedSystemProps, holdDirectoryClaim, isLockHolderAlive, lockRecordBytes, readCliLockRecord, startTurbopackWatcher, verifyCommitRecord } from "@animus-ui/extract/session";
4
+ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, unlinkSync, writeFileSync } from "fs";
5
5
  import { dirname, isAbsolute, join, relative, resolve } from "path";
6
6
  import { pathToFileURL } from "url";
7
+ const SHUTDOWN_SIGNALS = [["SIGINT", 130], ["SIGTERM", 143]];
8
+ /** Removal is mandatory on the normal path: `main()` is re-enterable in
9
+ * process, so a listener left behind accumulates one per call. */
10
+ function installShutdownSignals({ release, drain }) {
11
+ let stage = "armed";
12
+ const registered = SHUTDOWN_SIGNALS.map(([signal, exitCode]) => {
13
+ const listener = () => {
14
+ if (stage === "released") return;
15
+ if (stage === "draining") {
16
+ stage = "released";
17
+ release({
18
+ exitCode,
19
+ signal,
20
+ abandoned: true
21
+ });
22
+ process.exit(exitCode);
23
+ } else if (drain === void 0) {
24
+ stage = "released";
25
+ release({
26
+ exitCode,
27
+ signal,
28
+ abandoned: false
29
+ });
30
+ } else {
31
+ stage = "draining";
32
+ (async () => {
33
+ try {
34
+ await drain(exitCode, signal);
35
+ } catch {}
36
+ if (stage !== "draining") return;
37
+ stage = "released";
38
+ release({
39
+ exitCode,
40
+ signal,
41
+ abandoned: false
42
+ });
43
+ })();
44
+ }
45
+ };
46
+ process.on(signal, listener);
47
+ return {
48
+ signal,
49
+ listener
50
+ };
51
+ });
52
+ return () => {
53
+ for (const { signal, listener } of registered) process.removeListener(signal, listener);
54
+ };
55
+ }
56
+ //#endregion
7
57
  //#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
58
  const STYLES_FILE = STYLES_ARTIFACT;
20
59
  const SYSTEM_PROPS_FILE = SYSTEM_PROPS_ARTIFACT;
21
60
  const MANIFEST_FILE = MANIFEST_ARTIFACT;
22
61
  const COMMIT_FILE = CLI_COMMIT_ARTIFACT;
23
62
  const LOCK_FILE = CLI_LOCK_ARTIFACT;
24
63
  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. */
64
+ function isFileExistsError(error) {
65
+ return error instanceof Object && "code" in error && error.code === "EEXIST";
66
+ }
67
+ /** Rejected by the pre-swap consistency check: the staged set did not
68
+ * verify, and the previous generation was left untouched. */
27
69
  var PublishInconsistencyError = class extends Error {
70
+ failures;
28
71
  constructor(failures) {
29
72
  super(`Staged publication failed its consistency check:\n - ${failures.join("\n - ")}`);
30
73
  this.failures = failures;
31
74
  this.name = "PublishInconsistencyError";
32
75
  }
33
76
  };
34
- /** Take the single-writer advisory lock, failing loud on a live holder.
35
- * Returns a release function. */
77
+ var PublishSwapIncompleteError = class extends Error {
78
+ outDir;
79
+ landed;
80
+ failure;
81
+ constructor(outDir, landed, failure) {
82
+ super(`Publication of ${outDir} stopped mid-swap after replacing ${landed.join(", ")} — the directory now mixes two generations and its commit record does not describe it. The next successful publication rewrites the whole set. Cause: ${String(failure)}`);
83
+ this.outDir = outDir;
84
+ this.landed = landed;
85
+ this.failure = failure;
86
+ this.name = "PublishSwapIncompleteError";
87
+ }
88
+ };
89
+ /** A refused claim on the output tree: another process owns it, or its lock
90
+ * cannot be proven dead. Extends `AnimusConfigError`, so it exits 2. */
91
+ var AnimusLockConflictError = class extends AnimusConfigError {
92
+ constructor(message) {
93
+ super(message);
94
+ this.name = "AnimusLockConflictError";
95
+ }
96
+ };
97
+ const STAGING_PREFIX = ".staging-";
98
+ /** One list drives the staged write, the commit record, the rename, and the
99
+ * unfinished-publication probe; the order is swap order. */
100
+ const NAMED_PAYLOAD_FIELDS = [
101
+ [STYLES_FILE, "stylesCss"],
102
+ [SYSTEM_PROPS_FILE, "systemPropsJs"],
103
+ [MANIFEST_FILE, "manifestJson"]
104
+ ];
105
+ const PUBLISHED_NAMES = [...NAMED_PAYLOAD_FIELDS.map(([name]) => name), COMMIT_FILE];
106
+ /** Called only while the claim is held — deciding a tree is debris and
107
+ * deleting it is a write. A live pid's tree is its own working set. */
108
+ function reapAbandonedStaging(outDir) {
109
+ let entries;
110
+ try {
111
+ entries = readdirSync(outDir);
112
+ } catch {
113
+ return;
114
+ }
115
+ for (const entry of entries) {
116
+ if (!entry.startsWith(STAGING_PREFIX)) continue;
117
+ const pid = Number(entry.slice(9));
118
+ if (!Number.isInteger(pid) || isLockHolderAlive(pid)) continue;
119
+ console.error(`[animus] Removing staging directory ${entry} left by dead pid ${pid}`);
120
+ rmSync(join(outDir, entry), {
121
+ recursive: true,
122
+ force: true
123
+ });
124
+ }
125
+ }
126
+ /** Reported, not refused: this run republishes the whole set, which is the
127
+ * repair; refusing would leave the directory wedged. */
128
+ function reportUnfinishedPublication(outDir) {
129
+ if (!PUBLISHED_NAMES.some((name) => existsSync(join(outDir, name)))) return;
130
+ const failures = verifyCommitRecord(outDir);
131
+ if (failures.length === 0) return;
132
+ console.error(`[animus] ${outDir} holds artifacts that do not match its commit record — a previous publication did not finish:\n - ${failures.join("\n - ")}\n[animus] This run replaces the whole set; until it does, nothing in that directory is consistent.`);
133
+ }
134
+ /** Take the single-writer advisory lock; a live holder is a hard failure. */
36
135
  function acquireLock(outDir) {
37
136
  mkdirSync(outDir, { recursive: true });
38
137
  const lockPath = join(outDir, LOCK_FILE);
138
+ const startedAt = (/* @__PURE__ */ new Date()).toISOString();
39
139
  try {
40
- writeFileSync(lockPath, JSON.stringify({
41
- pid: process.pid,
42
- startedAt: (/* @__PURE__ */ new Date()).toISOString()
43
- }), { flag: "wx" });
140
+ writeFileSync(lockPath, lockRecordBytes(startedAt), { flag: "wx" });
44
141
  } 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 ?? "?"}`);
142
+ if (!isFileExistsError(error)) throw error;
143
+ const lock = readCliLockRecord(outDir);
144
+ let staleReason = "the lock file was removed between the failed claim and the read";
145
+ if (lock.kind === "held") {
146
+ const liveness = checkLockLiveness(lock.record);
147
+ if (liveness.live) throw new AnimusLockConflictError(`Another animus process (pid ${lock.record.pid}, started ${lock.record.startedAt ?? "unknown"}) owns ${outDir} — wait for it or pass --out-dir to write elsewhere.`);
148
+ staleReason = liveness.reason;
149
+ }
150
+ if (lock.kind === "indeterminate") throw new AnimusLockConflictError(`${join(outDir, LOCK_FILE)} exists but is not a readable lock record — its holder cannot be identified. Remove the file if no animus process is running, or pass --out-dir to write elsewhere.`);
151
+ console.error(`[animus] Replacing stale lock: ${staleReason}`);
52
152
  rmSync(lockPath, { force: true });
53
153
  try {
54
- writeFileSync(lockPath, JSON.stringify({
55
- pid: process.pid,
56
- startedAt: (/* @__PURE__ */ new Date()).toISOString()
57
- }), { flag: "wx" });
154
+ writeFileSync(lockPath, lockRecordBytes(startedAt), { flag: "wx" });
58
155
  } 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.`);
156
+ if (!isFileExistsError(retryError)) throw retryError;
157
+ throw new AnimusLockConflictError(`Another animus process re-acquired ${outDir} while a stale lock was being replaced — wait for it or pass --out-dir.`);
61
158
  }
62
159
  }
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
- }
160
+ reapAbandonedStaging(outDir);
161
+ reportUnfinishedPublication(outDir);
162
+ return holdDirectoryClaim(outDir, {
163
+ write: "rename",
164
+ startedAt
165
+ });
74
166
  }
75
- /** Asset names under `assets/` that outDir's current commit record
76
- * published, or empty when no record is readable. */
77
167
  function publishedAssetNames(outDir) {
168
+ let record;
78
169
  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)));
170
+ record = decodeCommitRecord(readFileSync(join(outDir, COMMIT_FILE), "utf-8"));
81
171
  } catch {
82
172
  return /* @__PURE__ */ new Set();
83
173
  }
174
+ if (record === null) return /* @__PURE__ */ new Set();
175
+ return new Set(Object.keys(record.payloads).filter((name) => name.startsWith(`${ASSETS_DIR}/`)).map((name) => name.slice(ASSETS_DIR.length + 1)));
84
176
  }
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
- */
177
+ /** Staged, verified, then renamed into place with the commit record LAST. A
178
+ * set that cannot verify throws with the previous generation untouched. */
96
179
  function publishArtifacts(outDir, payloads) {
97
180
  mkdirSync(outDir, { recursive: true });
98
181
  const previouslyPublishedAssets = publishedAssetNames(outDir);
99
- const staging = join(outDir, `.staging-${process.pid}`);
182
+ const staging = join(outDir, `${STAGING_PREFIX}${process.pid}`);
100
183
  rmSync(staging, {
101
184
  recursive: true,
102
185
  force: true
103
186
  });
104
187
  mkdirSync(staging, { recursive: true });
105
188
  try {
106
- const namedPayloads = [
107
- [STYLES_FILE, payloads.stylesCss],
108
- [SYSTEM_PROPS_FILE, payloads.systemPropsJs],
109
- [MANIFEST_FILE, payloads.manifestJson]
110
- ];
189
+ const namedPayloads = NAMED_PAYLOAD_FIELDS.map(([name, field]) => [name, payloads[field]]);
111
190
  const record = {
112
191
  schema: 1,
113
192
  payloads: {}
@@ -125,22 +204,35 @@ function publishArtifacts(outDir, payloads) {
125
204
  }
126
205
  }
127
206
  writeFileSync(join(staging, COMMIT_FILE), JSON.stringify(record, null, 2) + "\n");
128
- const failures = verifyPublishedSet(staging);
207
+ const failures = verifyCommitRecord(staging);
129
208
  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 {}
209
+ const landed = [];
210
+ try {
211
+ for (const [name] of namedPayloads) {
212
+ renameSync(join(staging, name), join(outDir, name));
213
+ landed.push(name);
214
+ }
215
+ const publishedAssetsDir = join(outDir, ASSETS_DIR);
216
+ if (assets.length > 0) {
217
+ mkdirSync(publishedAssetsDir, { recursive: true });
218
+ for (const { name } of assets) {
219
+ renameSync(join(staging, ASSETS_DIR, name), join(publishedAssetsDir, name));
220
+ landed.push(`${ASSETS_DIR}/${name}`);
221
+ }
222
+ }
223
+ const expectedNames = new Set(assets.map((asset) => asset.name));
224
+ for (const name of previouslyPublishedAssets) {
225
+ if (expectedNames.has(name)) continue;
226
+ try {
227
+ unlinkSync(join(publishedAssetsDir, name));
228
+ } catch {}
229
+ }
230
+ renameSync(join(staging, COMMIT_FILE), join(outDir, COMMIT_FILE));
231
+ landed.push(COMMIT_FILE);
232
+ } catch (error) {
233
+ if (landed.length === 0) throw error;
234
+ throw new PublishSwapIncompleteError(outDir, landed, error);
142
235
  }
143
- renameSync(join(staging, COMMIT_FILE), join(outDir, COMMIT_FILE));
144
236
  return record;
145
237
  } finally {
146
238
  rmSync(staging, {
@@ -149,42 +241,14 @@ function publishArtifacts(outDir, payloads) {
149
241
  });
150
242
  }
151
243
  }
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
244
  //#endregion
159
245
  //#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
246
  var ExtractionFailure = class extends Error {};
177
- /** Thrown for failures whose exit class is "config/usage error" (2). */
178
247
  var UsageFailure = class extends Error {};
179
- /** Thrown for failures whose exit class is "engine/environment" (3). */
180
248
  var EnvironmentFailure = class extends Error {};
181
- /** The CLI's one stderr prefix convention (stdout stays machine-only) —
182
- * shared with watch.ts. */
183
249
  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. */
250
+ /** Existence checks run before the engine loads, so a misconfigured
251
+ * project exits 2 rather than 3. */
188
252
  async function runPreflight(config) {
189
253
  const { root, options } = config;
190
254
  if (!existsSync(root)) throw new UsageFailure(`Root directory not found: ${root}`);
@@ -196,14 +260,11 @@ async function runPreflight(config) {
196
260
  throw new EnvironmentFailure(`Extraction engine failed to load: ${String(error)}`);
197
261
  }
198
262
  }
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
263
  function createCliSession(config) {
204
264
  const { root, outDir, options } = config;
205
265
  const session = new ExtractionSession(options);
206
266
  const relOut = relative(root, outDir);
267
+ if (relOut === "") throw new UsageFailure(`The artifact directory is the root itself (${outDir}) — the published ${STYLES_FILE}/${SYSTEM_PROPS_FILE}/${MANIFEST_FILE} would be re-ingested as source on the next run. Pass --out-dir with a subdirectory (the default is ${ANIMUS_ARTIFACT_DIR}) or point --root at the source tree.`);
207
268
  if (!relOut.startsWith("..") && !config.excludePatterns.some((p) => relOut.includes(p) || p === relOut)) {
208
269
  const anchored = `${relOut.split("\\").join("/")}/**`;
209
270
  session.structuralExclude = [anchored];
@@ -211,17 +272,12 @@ function createCliSession(config) {
211
272
  }
212
273
  session.driverLabel = "animus";
213
274
  session.rootDir = root;
214
- const builtAliases = buildPathAliasesJson(readTsconfigAliasPairs(root), root);
275
+ session.staleAssetPruning = "every-cycle";
276
+ const aliasPairs = readTsconfigAliasPairs(root);
277
+ const builtAliases = buildPathAliasesJson(aliasPairs, root);
215
278
  if (builtAliases) session.pathAliasesJson = builtAliases.json;
216
279
  return session;
217
280
  }
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
281
  function publishSharedPayloads(config, session) {
226
282
  const { root, outDir, options } = config;
227
283
  const fileCount = getAnalyzedHashes()?.size ?? 0;
@@ -231,7 +287,8 @@ function publishSharedPayloads(config, session) {
231
287
  const systemPropsJs = getSharedSystemProps();
232
288
  let componentCount = session.lastComponentCount ?? -1;
233
289
  if (componentCount < 0) try {
234
- componentCount = Object.keys(JSON.parse(manifestJson).components ?? {}).length;
290
+ const manifest = JSON.parse(manifestJson);
291
+ componentCount = Object.keys(manifest.components).length;
235
292
  } catch {
236
293
  throw new ExtractionFailure("Analysis published no readable manifest");
237
294
  }
@@ -262,9 +319,28 @@ function publishSharedPayloads(config, session) {
262
319
  fileCount
263
320
  };
264
321
  }
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. */
322
+ /** Synchronous: the signal paths call `process.exit` next, which runs no
323
+ * pending microtask. The tree goes first, with the lock still held. */
324
+ function releaseRunClaims(session, releaseLock) {
325
+ if (session) {
326
+ try {
327
+ rmSync(session.sessionDir, {
328
+ recursive: true,
329
+ force: true
330
+ });
331
+ } catch {}
332
+ session.close();
333
+ }
334
+ releaseLock();
335
+ }
336
+ function createRunClaimRelease(getSession, releaseLock) {
337
+ let released = false;
338
+ return () => {
339
+ if (released) return;
340
+ released = true;
341
+ releaseRunClaims(getSession(), releaseLock);
342
+ };
343
+ }
268
344
  function reportDiscoveryOutcomes(config, session) {
269
345
  for (const { specifier, outcome, fileCount: files } of session.lastExternalOutcomes) err(`include '${specifier}': ${outcome}${outcome === "resolved" ? ` (${files} files)` : ""}`);
270
346
  const stats = session.getExcludeStats();
@@ -275,6 +351,12 @@ async function runBuild(config) {
275
351
  await runPreflight(config);
276
352
  const release = acquireLock(outDir);
277
353
  let session = null;
354
+ const releaseClaims = createRunClaimRelease(() => session, release);
355
+ const removeShutdownSignals = installShutdownSignals({ release: ({ exitCode, signal }) => {
356
+ err(`build interrupted by ${signal} — releasing ${outDir}`);
357
+ releaseClaims();
358
+ process.exit(exitCode);
359
+ } });
278
360
  try {
279
361
  session = createCliSession(config);
280
362
  try {
@@ -291,53 +373,106 @@ async function runBuild(config) {
291
373
  fileCount
292
374
  };
293
375
  } finally {
294
- release();
295
- const dir = session?.sessionDir ?? getSessionArtifactDir();
296
- if (dir) rmSync(dir, {
297
- recursive: true,
298
- force: true
299
- });
376
+ removeShutdownSignals();
377
+ releaseClaims();
300
378
  }
301
379
  }
302
380
  //#endregion
303
381
  //#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
382
  const CONFIG_FILENAMES = [
313
383
  "animus.config.json",
314
384
  "animus.config.mjs",
315
385
  "animus.config.js",
316
386
  "animus.config.ts"
317
387
  ];
388
+ const isConfigReference = (value) => Object(value) === value;
389
+ const isConfigCallable = (value) => {
390
+ if (!isConfigReference(value)) return false;
391
+ try {
392
+ Function.prototype.toString.call(value);
393
+ return true;
394
+ } catch {
395
+ return false;
396
+ }
397
+ };
398
+ const isConfigRecord = (value) => isConfigReference(value) && !Array.isArray(value) && !isConfigCallable(value);
399
+ const acceptsConfigPrimitive = (value, read) => {
400
+ if (isConfigReference(value)) return false;
401
+ try {
402
+ read();
403
+ return true;
404
+ } catch {
405
+ return false;
406
+ }
407
+ };
408
+ const isConfigString = (value) => acceptsConfigPrimitive(value, () => String.prototype.valueOf.call(value));
409
+ const isConfigBoolean = (value) => acceptsConfigPrimitive(value, () => Boolean.prototype.valueOf.call(value));
410
+ const isConfigNumber = (value) => acceptsConfigPrimitive(value, () => Number.prototype.valueOf.call(value));
411
+ const isConfigStringArray = (value) => Array.isArray(value) && value.every((entry) => isConfigString(entry));
412
+ function isStaticCssComponentOverride(value) {
413
+ if (!isConfigRecord(value)) return false;
414
+ const variants = value.variants;
415
+ if (variants !== void 0 && variants !== "*" && (!isConfigRecord(variants) || Object.values(variants).some((entry) => entry !== "*" && !isConfigStringArray(entry)))) return false;
416
+ const states = value.states;
417
+ if (states !== void 0 && states !== "*" && !isConfigStringArray(states)) return false;
418
+ return value.dynamicProps === void 0 || isConfigStringArray(value.dynamicProps);
419
+ }
420
+ function isStaticCssConfig(value) {
421
+ if (!isConfigRecord(value)) return false;
422
+ if (value.components !== void 0 && (!isConfigRecord(value.components) || Object.values(value.components).some((component) => !isStaticCssComponentOverride(component)))) return false;
423
+ if (value.systemProps === void 0) return true;
424
+ if (!isConfigRecord(value.systemProps)) return false;
425
+ return Object.values(value.systemProps).every((values) => Array.isArray(values) && values.every((entry) => isConfigString(entry) || isConfigNumber(entry) || isConfigRecord(entry) && Object.values(entry).every((responsiveValue) => isConfigString(responsiveValue) || isConfigNumber(responsiveValue))));
426
+ }
427
+ function configObject(value, message) {
428
+ if (isConfigRecord(value)) return value;
429
+ throw new AnimusConfigError(message);
430
+ }
431
+ function configStaticCss(value) {
432
+ if (value === void 0) return void 0;
433
+ if (isStaticCssConfig(value)) return value;
434
+ throw new AnimusConfigError(`Invalid value for "staticCss" — expected a static CSS declaration object, got ${JSON.stringify(value)}.`);
435
+ }
436
+ function parseLoadedCliConfig(raw) {
437
+ assertNoRetiredEngineSelection(isConfigString(raw.engine) ? raw.engine : void 0);
438
+ assertKnownOptionKeys(raw);
439
+ const cliValue = raw.cli;
440
+ const cli = isConfigRecord(cliValue) ? cliValue : {};
441
+ if (cli.outDir !== void 0 && !isConfigString(cli.outDir)) throw new AnimusConfigError(`Invalid value for "cli.outDir" — expected a string path, got ${JSON.stringify(cli.outDir)}.`);
442
+ return {
443
+ core: {
444
+ system: isConfigString(raw.system) ? raw.system : void 0,
445
+ root: isConfigString(raw.root) ? raw.root : void 0,
446
+ exclude: isConfigStringArray(raw.exclude) ? raw.exclude : void 0,
447
+ extensions: isConfigStringArray(raw.extensions) ? raw.extensions : void 0,
448
+ strict: isConfigBoolean(raw.strict) ? raw.strict : void 0,
449
+ verbose: isConfigBoolean(raw.verbose) ? raw.verbose : void 0,
450
+ prefix: isConfigString(raw.prefix) ? raw.prefix : void 0,
451
+ targets: isConfigString(raw.targets) || isConfigStringArray(raw.targets) ? raw.targets : void 0,
452
+ minify: isConfigBoolean(raw.minify) ? raw.minify : void 0,
453
+ staticCss: configStaticCss(raw.staticCss),
454
+ layers: isConfigStringArray(raw.layers) ? raw.layers : void 0,
455
+ mode: raw.mode === "development" || raw.mode === "production" ? raw.mode : void 0
456
+ },
457
+ cli: { outDir: cli.outDir }
458
+ };
459
+ }
318
460
  async function loadConfigFile(path) {
319
461
  if (path.endsWith(".json")) try {
320
- return JSON.parse(readFileSync(path, "utf-8"));
462
+ return configObject(JSON.parse(readFileSync(path, "utf-8")), `${path} must contain a config object`);
321
463
  } catch (error) {
322
464
  throw new AnimusConfigError(`Failed to parse ${path}: ${String(error)}`);
323
465
  }
324
466
  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;
467
+ const mod = configObject(await import(pathToFileURL(path).href), `${path} must evaluate to a module object`);
468
+ return configObject(mod.default ?? mod, `${path} must default-export a config object`);
329
469
  } catch (error) {
330
470
  if (error instanceof AnimusConfigError) throw error;
331
- const code = error.code;
471
+ const code = isConfigRecord(error) && isConfigString(error.code) ? error.code : void 0;
332
472
  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
473
  throw new AnimusConfigError(`Failed to load config ${path}: ${String(error)}`);
334
474
  }
335
475
  }
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
476
  async function resolveCliConfig(flags, cwd) {
342
477
  const flagRoot = flags.root ? resolve(cwd, flags.root) : null;
343
478
  let configFile = null;
@@ -351,11 +486,25 @@ async function resolveCliConfig(flags, cwd) {
351
486
  break;
352
487
  }
353
488
  }
354
- const raw = configFile ? await loadConfigFile(configFile) : {};
355
- assertKnownOptionKeys(raw);
356
- const fileRoot = raw["root"];
489
+ const loaded = parseLoadedCliConfig(configFile ? await loadConfigFile(configFile) : {});
490
+ const raw = loaded.core;
491
+ const fileRoot = raw.root;
357
492
  const configDir = configFile ? dirname(configFile) : cwd;
358
- const effectiveRoot = flagRoot ?? (typeof fileRoot === "string" ? resolve(configDir, fileRoot) : configFile ? configDir : cwd);
493
+ let rootSource;
494
+ let effectiveRoot;
495
+ if (flagRoot !== null) {
496
+ rootSource = "flag";
497
+ effectiveRoot = flagRoot;
498
+ } else if (fileRoot !== void 0) {
499
+ rootSource = "config-file";
500
+ effectiveRoot = resolve(configDir, fileRoot);
501
+ } else if (configFile) {
502
+ rootSource = "config-dir";
503
+ effectiveRoot = configDir;
504
+ } else {
505
+ rootSource = "cwd";
506
+ effectiveRoot = cwd;
507
+ }
359
508
  const provenance = {};
360
509
  const pick = (key, flagValue) => {
361
510
  if (flagValue !== void 0) {
@@ -370,21 +519,21 @@ async function resolveCliConfig(flags, cwd) {
370
519
  provenance[key] = "default";
371
520
  };
372
521
  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";
522
+ if (system === void 0 || system.length === 0) throw new AnimusConfigError("Missing required option `system` — pass --system ./src/ds.ts or set \"system\" in animus.config.");
523
+ const fileExclude = raw.exclude;
524
+ const flagExclude = flags.exclude;
525
+ const excludeIsExplicit = fileExclude !== void 0 || flagExclude !== void 0 && flagExclude.length > 0;
526
+ const exclude = [...fileExclude ?? [], ...flagExclude ?? []];
527
+ provenance["exclude"] = excludeIsExplicit ? "explicit" : "default";
377
528
  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");
529
+ const resolvedMode = resolveMode(flags.mode ?? raw.mode, () => "production");
379
530
  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";
531
+ const outDirSetting = flags.outDir ?? loaded.cli.outDir ?? ANIMUS_ARTIFACT_DIR;
383
532
  const outDir = isAbsolute(outDirSetting) ? outDirSetting : resolve(effectiveRoot, outDirSetting);
384
- provenance["outDir"] = flags.outDir !== void 0 || cliNamespace.outDir !== void 0 ? "explicit" : "driver-default";
533
+ provenance["outDir"] = flags.outDir !== void 0 || loaded.cli.outDir !== void 0 ? "explicit" : "driver-default";
385
534
  const options = {
386
535
  system,
387
- exclude: exclude.length > 0 ? exclude : void 0,
536
+ exclude: excludeIsExplicit ? exclude : void 0,
388
537
  extensions: pick("extensions", void 0),
389
538
  strict: pick("strict", flags.strict),
390
539
  verbose: pick("verbose", flags.verbose),
@@ -399,6 +548,7 @@ async function resolveCliConfig(flags, cwd) {
399
548
  return {
400
549
  driver: "cli",
401
550
  root: effectiveRoot,
551
+ rootSource,
402
552
  configFile,
403
553
  options,
404
554
  outDir,
@@ -407,7 +557,12 @@ async function resolveCliConfig(flags, cwd) {
407
557
  provenance
408
558
  };
409
559
  }
410
- /** The `--print-config` projection: everything effective, nothing hidden. */
560
+ /** Announced only for the root authority the invocation does not state:
561
+ * `--root` and a config-file `root` are the user's own words. */
562
+ function inferredRootNotice(config, cwd) {
563
+ if (config.rootSource !== "config-dir" || config.root === cwd) return null;
564
+ return `root ${config.root} was inferred from the directory of ${config.configFile} — every relative path resolves against it, not against ${cwd}. Pass --root to choose the root yourself.`;
565
+ }
411
566
  function projectResolvedConfig(config) {
412
567
  return {
413
568
  driver: config.driver,
@@ -422,58 +577,19 @@ function projectResolvedConfig(config) {
422
577
  minify: config.options.minify ?? null,
423
578
  prefix: config.options.prefix ?? null,
424
579
  layers: config.options.layers ?? null,
580
+ extensions: config.options.extensions ?? null,
581
+ staticCss: config.options.staticCss ?? null,
425
582
  exclude: config.excludePatterns,
426
583
  provenance: config.provenance
427
584
  };
428
585
  }
429
586
  //#endregion
430
587
  //#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
588
  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
- */
589
+ const PROJECT_WATCH_REASONS = {
590
+ unavailable: "platform watcher unavailable — NO source edits will be observed; restart the watch after changes",
591
+ "already-watched": "root already claimed by another watcher in this process — NO source edits will be observed by THIS watch; run one watch per root (restarting collides the same way)"
592
+ };
477
593
  function collectDegradedRoots(inputs) {
478
594
  const seen = /* @__PURE__ */ new Set();
479
595
  const degraded = [];
@@ -485,7 +601,7 @@ function collectDegradedRoots(inputs) {
485
601
  reason
486
602
  });
487
603
  };
488
- if (!inputs.projectWatchActive) add(inputs.projectRoot, "platform watcher unavailable — NO source edits will be observed; restart the watch after changes");
604
+ if (inputs.projectWatch !== "active") add(inputs.projectRoot, PROJECT_WATCH_REASONS[inputs.projectWatch]);
489
605
  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
606
  const stickyPrefix = "external-watch:";
491
607
  for (const [key, message] of inputs.stickyDiagnostics) {
@@ -494,14 +610,13 @@ function collectDegradedRoots(inputs) {
494
610
  }
495
611
  return degraded;
496
612
  }
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
613
  function formatDegradedRootLine(entry) {
501
614
  return `watch degraded root=${entry.root} — ${entry.reason}`;
502
615
  }
503
- /** Composite key of the shared payload set — the cycle-level dedupe gate
504
- * (a no-op analysis republishes nothing; disk bytes stay untouched). */
616
+ function formatCyclePublishFailure(outDir, error) {
617
+ if (error instanceof PublishSwapIncompleteError) return `watch cycle publication failed mid-swap — ${outDir} is NOT last-good: ${String(error)}`;
618
+ return `watch cycle publication rejected — keeping last-good artifacts in ${outDir}: ${String(error)}`;
619
+ }
505
620
  function currentPayloadKey() {
506
621
  return [
507
622
  contentHash(getManifestJson() ?? ""),
@@ -509,76 +624,90 @@ function currentPayloadKey() {
509
624
  contentHash(getSharedSystemProps())
510
625
  ].join(":");
511
626
  }
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
- */
627
+ /** Resolves with the process exit code at shutdown. Startup failures throw,
628
+ * so a supervisor sees either `watch ready` or a nonzero exit, never a hang. */
520
629
  async function runWatch(config, flags) {
521
630
  const { outDir, root } = config;
522
631
  await runPreflight(config);
523
632
  const release = acquireLock(outDir);
524
- let session;
525
- let firstPublication;
633
+ let constructed = null;
634
+ const releaseClaims = createRunClaimRelease(() => constructed, release);
635
+ let watcher = null;
636
+ let startup;
526
637
  try {
527
- session = createCliSession(config);
638
+ const created = createCliSession(config);
639
+ constructed = created;
640
+ const claim = startTurbopackWatcher(created, root, { holdEvents: true });
641
+ if (claim.kind === "started") watcher = claim.handle;
528
642
  try {
529
- await session.runFullPipeline();
643
+ await created.runFullPipeline();
530
644
  } catch (error) {
531
645
  throw new ExtractionFailure(String(error));
532
646
  }
533
- firstPublication = publishSharedPayloads(config, session);
534
- reportDiscoveryOutcomes(config, session);
647
+ const publication = publishSharedPayloads(config, created);
648
+ reportDiscoveryOutcomes(config, created);
649
+ startup = {
650
+ session: created,
651
+ claim,
652
+ publication
653
+ };
535
654
  } catch (error) {
536
- release();
537
- const dir = session?.sessionDir ?? getSessionArtifactDir();
538
- if (dir) rmSync(dir, {
539
- recursive: true,
540
- force: true
541
- });
655
+ watcher?.close();
656
+ releaseClaims();
542
657
  throw error;
543
658
  }
659
+ const { session, claim } = startup;
660
+ let lastPublication = startup.publication;
544
661
  let publications = 1;
545
662
  let lastPublishedKey = currentPayloadKey();
546
663
  return new Promise((resolvePromise) => {
547
- let settled = false;
548
- let watcher = null;
549
664
  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);
665
+ let shuttingDown = null;
666
+ let finished = false;
667
+ const reportShutdown = (reason, drain) => {
668
+ err(`watch shutdown reason=${reason} publications=${publications}` + (drain === void 0 ? "" : ` drain=${drain}`));
669
+ };
670
+ const finish = (code, reason, drain) => {
671
+ if (finished) return;
672
+ finished = true;
673
+ releaseClaims();
674
+ removeShutdownSignals();
675
+ clearInterval(keepalive);
676
+ reportShutdown(reason, drain);
677
+ resolvePromise(code);
678
+ };
679
+ const drainThenFinish = async (code, reason) => {
555
680
  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
- })();
681
+ err(`watch shutdown starting reason=${reason} — draining the in-flight cycle before releasing ${outDir} (signal again to exit at once)`);
682
+ let bound = null;
683
+ try {
684
+ await Promise.race([watcher?.settle() ?? Promise.resolve(), new Promise((res) => {
685
+ bound = setTimeout(res, 1e4);
686
+ bound.unref?.();
687
+ })]);
688
+ } catch {} finally {
689
+ if (bound !== null) clearTimeout(bound);
690
+ }
691
+ finish(code, reason);
571
692
  };
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). */
693
+ const shutdown = (code, reason) => {
694
+ if (shuttingDown !== null) return shuttingDown;
695
+ shuttingDown = drainThenFinish(code, reason);
696
+ return shuttingDown;
697
+ };
698
+ const removeShutdownSignals = installShutdownSignals({
699
+ drain: shutdown,
700
+ release: ({ exitCode, signal, abandoned }) => {
701
+ if (!abandoned) return;
702
+ err(`watch ${signal} during shutdown — abandoning the drain and releasing ${outDir}`);
703
+ finish(exitCode, signal, "abandoned");
704
+ }
705
+ });
706
+ /** True when --fail-on-degraded tripped and shutdown has begun. */
578
707
  const reportDegradation = () => {
579
708
  const degraded = collectDegradedRoots({
580
709
  projectRoot: root,
581
- projectWatchActive: watcher !== null && !watcher.died,
710
+ projectWatch: claim.kind === "already-watched" ? "already-watched" : watcher !== null && !watcher.died ? "active" : "unavailable",
582
711
  externalWatchRoots: session.externalWatchRoots,
583
712
  stickyDiagnostics: session.stickyDiagnostics
584
713
  });
@@ -590,60 +719,51 @@ async function runWatch(config, flags) {
590
719
  }
591
720
  return false;
592
721
  };
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)}`);
722
+ session.onCycleSettled = (cause) => {
723
+ if (shuttingDown !== null) return;
724
+ if (cause !== null) {
725
+ err(`watch cycle failed — keeping last-good artifacts in ${outDir}: ${String(cause)}`);
601
726
  return;
602
727
  }
603
- if (settled) return;
604
728
  const key = currentPayloadKey();
605
729
  if (key === lastPublishedKey) return;
606
730
  let outcome;
607
731
  try {
608
732
  outcome = publishSharedPayloads(config, session);
609
733
  } catch (error) {
610
- err(`watch cycle publication rejected — keeping last-good artifacts in ${outDir}: ${String(error)}`);
734
+ err(formatCyclePublishFailure(outDir, error));
611
735
  return;
612
736
  }
613
737
  lastPublishedKey = key;
738
+ lastPublication = outcome;
614
739
  publications += 1;
615
740
  err(`watch republished components=${outcome.componentCount} files=${outcome.fileCount} outDir=${outDir}`);
616
741
  reportDegradation();
617
742
  };
618
- watcher = startTurbopackWatcher(session, root);
619
743
  if (watcher) watcher.onDied = () => {
620
- if (!settled) reportDegradation();
744
+ if (shuttingDown === null) reportDegradation();
621
745
  };
622
- if (reportDegradation()) return;
623
- err(`watch ready components=${firstPublication.componentCount} files=${firstPublication.fileCount} outDir=${outDir}`);
746
+ (async () => {
747
+ try {
748
+ await watcher?.deliverHeldEvents();
749
+ } catch {}
750
+ if (shuttingDown !== null) return;
751
+ if (reportDegradation()) return;
752
+ err(`watch ready components=${lastPublication.componentCount} files=${lastPublication.fileCount} outDir=${outDir}`);
753
+ })();
624
754
  });
625
755
  }
626
756
  //#endregion
627
757
  //#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
- */
758
+ /** The supported consumer surface is the `animus` binary; the programmatic
759
+ * exports may change without semver ceremony. */
643
760
  const EXIT_OK = 0;
644
761
  const EXIT_EXTRACTION = 1;
645
762
  const EXIT_USAGE = 2;
646
763
  const EXIT_ENVIRONMENT = 3;
764
+ /** Nothing in this module returns it: `bin/animus.mjs` decides the install
765
+ * failure class, since this module is the one that did not load. */
766
+ const EXIT_INSTALL = 4;
647
767
  const USAGE = `animus — standalone Animus extraction
648
768
 
649
769
  Usage:
@@ -657,27 +777,36 @@ Options:
657
777
  --root <path> Root every relative input resolves against
658
778
  --config <path> Explicit config file (default: animus.config.* in root)
659
779
  --out-dir <path> Artifact directory (default: <root>/.animus)
660
- --exclude <glob> Additional exclusion (repeatable; MERGES with defaults)
780
+ --exclude <glob> Additional exclusion (repeatable; MERGES with defaults;
781
+ an explicit exclude: [] in the config file means none)
661
782
  --mode <m> 'development' | 'production' (default: production)
662
783
  --targets <query> Browserslist query for CSS lowering
663
- --strict Escalate extraction warnings to failures
784
+ --strict Fail on inputs that could not be read or resolved
664
785
  --fail-on-degraded watch only: exit 3 instead of running with unwatched
665
786
  roots (degradation is otherwise reported and tolerated)
666
787
  --verbose Verbose logging (stderr)
667
788
  --print-config Alias of the print-config command
668
789
  --help This text
669
790
 
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)
791
+ Exit codes: 0 success · 1 extraction failure · 2 config error · 3 engine
792
+ failure · 4 the animus install could not be loaded
793
+ Shutdown signals: SIGINT exits 130, SIGTERM 143 (lock released, last-good kept)
672
794
  `;
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. */
795
+ function readThrownMessage(error) {
796
+ return error.message;
797
+ }
798
+ /** Unclassified throws share the extraction class: an unknown throw during
799
+ * a build is a failed extraction to a supervisor. */
676
800
  function exitCodeFor(error) {
677
801
  if (error instanceof UsageFailure || error instanceof AnimusConfigError) return 2;
678
802
  if (error instanceof EnvironmentFailure) return 3;
679
803
  return 1;
680
804
  }
805
+ function reportUsageError(reason) {
806
+ console.error(`[animus] ${reason}`);
807
+ console.error(USAGE);
808
+ process.exitCode = 2;
809
+ }
681
810
  async function main(argv = process.argv.slice(2)) {
682
811
  let parsed;
683
812
  try {
@@ -703,9 +832,7 @@ async function main(argv = process.argv.slice(2)) {
703
832
  }
704
833
  });
705
834
  } catch (error) {
706
- console.error(`[animus] ${String(error.message)}`);
707
- console.error(USAGE);
708
- process.exitCode = 2;
835
+ reportUsageError(String(readThrownMessage(error)));
709
836
  return;
710
837
  }
711
838
  const { values, positionals } = parsed;
@@ -715,6 +842,14 @@ async function main(argv = process.argv.slice(2)) {
715
842
  return;
716
843
  }
717
844
  const command = positionals[0];
845
+ if (positionals.length > 1) {
846
+ reportUsageError(`Unexpected argument '${positionals[1]}' — one command per invocation`);
847
+ return;
848
+ }
849
+ if (command !== "build" && command !== "watch" && command !== "print-config") {
850
+ reportUsageError(`Unknown command '${command}'`);
851
+ return;
852
+ }
718
853
  const flags = {
719
854
  system: values.system,
720
855
  root: values.root,
@@ -726,8 +861,11 @@ async function main(argv = process.argv.slice(2)) {
726
861
  targets: values.targets,
727
862
  exclude: values.exclude
728
863
  };
864
+ const cwd = process.cwd();
729
865
  try {
730
- const config = await resolveCliConfig(flags, process.cwd());
866
+ const config = await resolveCliConfig(flags, cwd);
867
+ const rootNotice = inferredRootNotice(config, cwd);
868
+ if (rootNotice !== null) err(rootNotice);
731
869
  if (command === "print-config" || values["print-config"]) {
732
870
  console.log(JSON.stringify(projectResolvedConfig(config), null, 2));
733
871
  process.exitCode = 0;
@@ -738,17 +876,11 @@ async function main(argv = process.argv.slice(2)) {
738
876
  process.exitCode = 0;
739
877
  return;
740
878
  }
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;
879
+ process.exitCode = await runWatch(config, { failOnDegraded: values["fail-on-degraded"] === true });
748
880
  } catch (error) {
749
- console.error(`[animus] ${String(error.message ?? error)}`);
881
+ console.error(`[animus] ${String(readThrownMessage(error) ?? error)}`);
750
882
  process.exitCode = exitCodeFor(error);
751
883
  }
752
884
  }
753
885
  //#endregion
754
- export { EXIT_ENVIRONMENT, EXIT_EXTRACTION, EXIT_OK, EXIT_USAGE, exitCodeFor, main };
886
+ export { EXIT_ENVIRONMENT, EXIT_EXTRACTION, EXIT_INSTALL, EXIT_OK, EXIT_USAGE, exitCodeFor, main };