@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/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.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
|
-
|
|
27
|
-
|
|
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
|
-
|
|
36
|
-
|
|
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,
|
|
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
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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,
|
|
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
|
|
61
|
-
throw new
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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,
|
|
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 =
|
|
208
|
+
const failures = (0, _animus_ui_extract_session.verifyCommitRecord)(staging);
|
|
130
209
|
if (failures.length > 0) throw new PublishInconsistencyError(failures);
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
/**
|
|
186
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
267
|
-
*
|
|
268
|
-
|
|
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
|
-
|
|
296
|
-
|
|
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
|
-
|
|
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
|
|
356
|
-
|
|
357
|
-
const fileRoot = raw
|
|
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
|
-
|
|
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 (
|
|
375
|
-
const
|
|
376
|
-
|
|
377
|
-
|
|
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
|
|
530
|
+
const resolvedMode = (0, _animus_ui_extract_pipeline.resolveMode)(flags.mode ?? raw.mode, () => "production");
|
|
380
531
|
provenance["mode"] = resolvedMode.provenance;
|
|
381
|
-
const
|
|
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 ||
|
|
534
|
+
provenance["outDir"] = flags.outDir !== void 0 || loaded.cli.outDir !== void 0 ? "explicit" : "driver-default";
|
|
386
535
|
const options = {
|
|
387
536
|
system,
|
|
388
|
-
exclude:
|
|
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
|
-
/**
|
|
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
|
-
|
|
468
|
-
|
|
469
|
-
|
|
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 (
|
|
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
|
-
|
|
505
|
-
|
|
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
|
-
*
|
|
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
|
|
526
|
-
|
|
634
|
+
let constructed = null;
|
|
635
|
+
const releaseClaims = createRunClaimRelease(() => constructed, release);
|
|
636
|
+
let watcher = null;
|
|
637
|
+
let startup;
|
|
527
638
|
try {
|
|
528
|
-
|
|
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
|
|
644
|
+
await created.runFullPipeline();
|
|
531
645
|
} catch (error) {
|
|
532
646
|
throw new ExtractionFailure(String(error));
|
|
533
647
|
}
|
|
534
|
-
|
|
535
|
-
reportDiscoveryOutcomes(config,
|
|
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
|
-
|
|
538
|
-
|
|
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
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
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
|
-
(
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
(
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
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
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
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
|
-
|
|
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
|
-
|
|
595
|
-
|
|
596
|
-
if (
|
|
597
|
-
|
|
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(
|
|
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 (
|
|
745
|
+
if (shuttingDown === null) reportDegradation();
|
|
622
746
|
};
|
|
623
|
-
|
|
624
|
-
|
|
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
|
-
*
|
|
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
|
|
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
|
|
672
|
-
|
|
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
|
-
|
|
675
|
-
|
|
676
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
|
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;
|