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