@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/README.md +33 -13
- package/bin/animus.mjs +13 -4
- package/dist/build.d.ts +3 -38
- package/dist/build.d.ts.map +1 -1
- package/dist/config.d.ts +24 -20
- package/dist/config.d.ts.map +1 -1
- package/dist/index.cjs +445 -312
- package/dist/index.d.ts +8 -19
- package/dist/index.d.ts.map +1 -1
- package/dist/index.mjs +448 -316
- package/dist/signals.d.ts +23 -0
- package/dist/signals.d.ts.map +1 -0
- package/dist/watch.d.ts +5 -54
- package/dist/watch.d.ts.map +1 -1
- package/dist/writer.d.ts +22 -39
- package/dist/writer.d.ts.map +1 -1
- package/package.json +2 -2
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,
|
|
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
|
-
|
|
26
|
-
|
|
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
|
-
|
|
35
|
-
|
|
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,
|
|
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
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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,
|
|
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
|
|
60
|
-
throw new
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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,
|
|
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 =
|
|
207
|
+
const failures = verifyCommitRecord(staging);
|
|
129
208
|
if (failures.length > 0) throw new PublishInconsistencyError(failures);
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
-
/**
|
|
185
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
266
|
-
*
|
|
267
|
-
|
|
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
|
-
|
|
295
|
-
|
|
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
|
-
|
|
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
|
|
355
|
-
|
|
356
|
-
const fileRoot = raw
|
|
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
|
-
|
|
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 (
|
|
374
|
-
const
|
|
375
|
-
|
|
376
|
-
|
|
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
|
|
529
|
+
const resolvedMode = resolveMode(flags.mode ?? raw.mode, () => "production");
|
|
379
530
|
provenance["mode"] = resolvedMode.provenance;
|
|
380
|
-
const
|
|
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 ||
|
|
533
|
+
provenance["outDir"] = flags.outDir !== void 0 || loaded.cli.outDir !== void 0 ? "explicit" : "driver-default";
|
|
385
534
|
const options = {
|
|
386
535
|
system,
|
|
387
|
-
exclude:
|
|
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
|
-
/**
|
|
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
|
-
|
|
467
|
-
|
|
468
|
-
|
|
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 (
|
|
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
|
-
|
|
504
|
-
|
|
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
|
-
*
|
|
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
|
|
525
|
-
|
|
633
|
+
let constructed = null;
|
|
634
|
+
const releaseClaims = createRunClaimRelease(() => constructed, release);
|
|
635
|
+
let watcher = null;
|
|
636
|
+
let startup;
|
|
526
637
|
try {
|
|
527
|
-
|
|
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
|
|
643
|
+
await created.runFullPipeline();
|
|
530
644
|
} catch (error) {
|
|
531
645
|
throw new ExtractionFailure(String(error));
|
|
532
646
|
}
|
|
533
|
-
|
|
534
|
-
reportDiscoveryOutcomes(config,
|
|
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
|
-
|
|
537
|
-
|
|
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
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
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
|
-
(
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
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
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
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
|
-
|
|
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
|
-
|
|
594
|
-
|
|
595
|
-
if (
|
|
596
|
-
|
|
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(
|
|
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 (
|
|
744
|
+
if (shuttingDown === null) reportDegradation();
|
|
621
745
|
};
|
|
622
|
-
|
|
623
|
-
|
|
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
|
-
*
|
|
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
|
|
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
|
|
671
|
-
|
|
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
|
-
|
|
674
|
-
|
|
675
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
|
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 };
|