@animus-ui/cli 0.1.11 → 0.1.14

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