@mindexed/cfact 1.1.0

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.
@@ -0,0 +1,1618 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * claude-factory — the installer front door for the PINNED, VENDORED engine.
4
+ *
5
+ * WHY THIS EXISTS (the third resolution mode — ADR-0093)
6
+ * ------------------------------------------------------
7
+ * A consumer resolves the framework in one of three ways:
8
+ *
9
+ * 1. symlink — .claude/engine -> a sibling claude-factory clone. The
10
+ * maintainer's live co-dev setup (setup-local.sh, ADR-0044).
11
+ * "Pull the clone, restart the session" is the update path.
12
+ * 2. PAT-clone — CI/Vercel clones the private repo into .claude/engine with
13
+ * FRAMEWORK_PAT, always-latest (ci.yml, ensure-engine.js).
14
+ * 3. VENDORED — THIS. .claude/engine is REAL FILES materialised at a PINNED
15
+ * version from a versioned tarball. No git, no symlink into a
16
+ * private repo. The path a NON-maintainer teammate gets.
17
+ *
18
+ * Modes 1 and 2 both hand a teammate the whole private repo (as a symlink or a
19
+ * clone). A team of non-maintainer users should get neither: they get a pinned,
20
+ * checksum-verified tarball of just the engine, extracted as real files, with
21
+ * the maintainer controlling which version the team is on. This is the general,
22
+ * all-consumer form of what agentic-os's .claude/local-hooks/ensure-engine.js
23
+ * does ad hoc for one Vercel deploy: fetch-then-materialise-as-real-files.
24
+ *
25
+ * WHAT IT DOES
26
+ * ------------
27
+ * init — materialise .claude/engine at a pinned version, then wire the
28
+ * consumer (pointer-symlinks, MCP entry, docs) via setup-local.sh
29
+ * in its --vendored-engine mode.
30
+ * update — re-materialise at a (new) pinned version and re-wire.
31
+ *
32
+ * THE CHANNEL IS A SEAM, NOT A HARDCODED HOST (ADR-0093, convention-first)
33
+ * -----------------------------------------------------------------------
34
+ * The source is resolved by PRIORITY so the whole mechanism is dogfoodable with
35
+ * no live channel at all:
36
+ *
37
+ * --from <file.tgz> a local tarball (what the publish script just built)
38
+ * --url <url> an explicit tarball URL (no bearer)
39
+ * $CFACT_ENGINE_URL same, from the environment ($CLAUDE_FACTORY_ENGINE_URL
40
+ * is still read)
41
+ * --channel <url> / pin channelUrl the RECOMMENDED agenticos.studio channel
42
+ * base (ADR-0096): a URL serving <base>/<asset>. No GitHub
43
+ * token; optional bearer via $CFACT_ENGINE_TOKEN (or the
44
+ * legacy $CLAUDE_FACTORY_ENGINE_TOKEN),
45
+ * scoped to this configured channel only.
46
+ * (default) DEFAULT_CHANNEL_URL — the same studio base, so the
47
+ * recommended channel is what an unconfigured install
48
+ * ACTUALLY gets (ADR-0168 narrowing ADR-0096).
49
+ * (opt-in fallback) GitHub Releases: <repo> tag engine-v<version>, reached
50
+ * only via an explicit `--channel github-releases`/`--repo`.
51
+ *
52
+ * Checksum verification is on by default when a checksum is known (from the pin
53
+ * file or a sibling release-metadata.json). This is a security boundary — an
54
+ * engine tarball becomes the code every gate runs — so an UNKNOWN checksum is a
55
+ * loud warning, and --require-checksum turns it into a refusal.
56
+ *
57
+ * Unlike ensure-engine.js (build-time, fails OPEN so a deploy degrades rather
58
+ * than breaks), this is an explicit install step and fails CLOSED: a teammate
59
+ * running `init` wants the engine present, so a fetch/verify failure is an error.
60
+ *
61
+ * Dependency-free (Node 18+ for global fetch); uses the system `tar`.
62
+ */
63
+ 'use strict';
64
+
65
+ const fs = require('fs');
66
+ const path = require('path');
67
+ const os = require('os');
68
+ const { execFileSync } = require('child_process');
69
+
70
+ const DEFAULT_REPO = 'Mindexed/cfact';
71
+
72
+ // A REPO SLUG IS ADDRESSED AT THE LINE ABOVE AND *READ* HERE (ADR-0192).
73
+ //
74
+ // ADR-0158 strips `repo` from the pin when it equals the default, so a tracked
75
+ // file in a consumer that may be shared outside the team does not name the
76
+ // private framework repo. That test is an equality against DEFAULT_REPO — so
77
+ // renaming DEFAULT_REPO silently broke it: a pin written before the rename holds
78
+ // the OLD default explicitly, no longer compares equal, and is therefore
79
+ // PRESERVED rather than dropped. It then never self-cleans, and because
80
+ // `usesGithubChannel` keys on `Boolean(pin.repo)`, that consumer stays routed to
81
+ // the token-gated GitHub fallback for good.
82
+ //
83
+ // This is the one site where the old slug ARRIVES FROM OUTSIDE — off a consumer's
84
+ // disk — rather than being a destination we choose, which is why it needs to
85
+ // recognise both values while DEFAULT_REPO does not. Read the general rule off
86
+ // this: the read-vs-address question is asked PER SITE, never per name.
87
+ const LEGACY_DEFAULT_REPOS = ['Mindexed/claude-factory'];
88
+ const isDefaultRepo = (r) => r === DEFAULT_REPO || LEGACY_DEFAULT_REPOS.includes(r);
89
+
90
+ // THE CANONICAL ENGINE CHANNEL BASE — DATA, NOT PROSE (ADR-0168, FF-240).
91
+ //
92
+ // This host was stated in three places and only two of them agreed. Both correct
93
+ // statements were PROSE IN A CODE COMMENT (resolveSource below, and
94
+ // publish-release.js's INGEST_URL); the third was agentic-os's Get Started page,
95
+ // which re-derived it independently, hardcoded the APEX, and is the one a human
96
+ // copy-pastes. So the two statements that were right were the two nobody read.
97
+ //
98
+ // NAME THE HOST THAT ANSWERS DIRECTLY — NOT ONE THAT REDIRECTS TO IT. A redirect
99
+ // between the apex and `www.` (or any host change) is CROSS-ORIGIN, and per the
100
+ // Fetch spec the `Authorization` header is STRIPPED when one is followed. The
101
+ // bearer would then never arrive and the channel answers 401 — a URL problem that
102
+ // reads exactly like a bad token. Measured: the apex 307s to `www.`, which 401s
103
+ // directly. `crossOriginRedirectTarget` turns that into a self-diagnosing error
104
+ // rather than an opaque 401.
105
+ //
106
+ // It lives HERE, above HELP, because HELP interpolates it — defining it beside
107
+ // the resolution logic further down is a temporal-dead-zone ReferenceError at
108
+ // module load, which breaks every invocation including `--help`.
109
+ //
110
+ // EXPORTED (see module.exports) so another Node consumer can READ it instead of
111
+ // restating it. Note the better fix for the studio is not to import this at all
112
+ // but to stop emitting `--channel`: this constant makes the flag optional, and a
113
+ // fact nobody restates cannot drift (node-contract Policy 2).
114
+ const DEFAULT_CHANNEL_URL = 'https://www.agenticos.studio/api/engine';
115
+ // The one label that opts OUT of the default and back to the GitHub-Releases
116
+ // fallback. Deliberately read from THIS RUN's flags only — never from pin.channel,
117
+ // which is a DERIVED provenance label (see DERIVED_CHANNEL_LABELS): treating it as
118
+ // config is the stale-label-beats-derivation defect ADR-0124 fixed.
119
+ const GITHUB_CHANNEL_LABEL = 'github-releases';
120
+
121
+ // A hard process.exit() tears down the libuv loop synchronously. On Windows
122
+ // Node 24, doing that while an undici/fetch socket is still closing trips a
123
+ // native assertion ("!(handle->flags & UV_HANDLE_CLOSING)", src\win\async.c,
124
+ // line 94) — so a 401 on the engine tarball fetch surfaced as an unrelated-
125
+ // looking native crash trace instead of the real error (DISC-042). Fix: never
126
+ // hard-exit. die() sets process.exitCode and UNWINDS the stack via a sentinel
127
+ // throw — which preserves its never-returns contract for the synchronous
128
+ // callers that rely on it (e.g. valFor, resolveSource) — then the event loop
129
+ // drains naturally (undici unrefs its idle sockets, so there is no lingering
130
+ // delay) and Node exits with the code we set. The top-level catch recognises
131
+ // the sentinel so an already-reported failure is neither reprinted nor turned
132
+ // into an unhandled rejection.
133
+ class DieError extends Error {
134
+ constructor(msg, code) {
135
+ super(msg);
136
+ this.name = 'DieError';
137
+ this.code = code;
138
+ }
139
+ }
140
+
141
+ function die(msg, code = 1) {
142
+ console.error(`❌ claude-factory: ${msg}`);
143
+ process.exitCode = code;
144
+ throw new DieError(msg, code);
145
+ }
146
+
147
+ function info(msg) {
148
+ console.log(msg);
149
+ }
150
+
151
+ // --- argument parsing -------------------------------------------------------
152
+ function parseArgs(argv) {
153
+ const opts = {
154
+ command: null,
155
+ consumer: null,
156
+ version: null,
157
+ channel: null,
158
+ // null, not DEFAULT_REPO: the fallback is applied at the use sites as
159
+ // `opts.repo || pin.repo || DEFAULT_REPO`, so seeding it here would make
160
+ // opts.repo always truthy and render a consumer's pinned `repo` dead.
161
+ repo: null,
162
+ from: null,
163
+ url: null,
164
+ requireChecksum: false,
165
+ passthrough: [], // forwarded to setup-local.sh (e.g. --with-supabase)
166
+ unwire: [], // persisted wiring flags to REMOVE this run (ADR-0178)
167
+ };
168
+ const rest = argv.slice(2);
169
+ // Read the value that follows a value-taking flag, failing loudly when it is
170
+ // missing or is itself a flag — otherwise `--version --from x` silently sets
171
+ // version="--from" and fails later with an unrelated, confusing error.
172
+ const valFor = (flag, i) => {
173
+ const v = rest[i + 1];
174
+ if (v === undefined || v.startsWith('-')) die(`missing value for ${flag}`, 2);
175
+ return v;
176
+ };
177
+ for (let i = 0; i < rest.length; i++) {
178
+ const a = rest[i];
179
+ switch (a) {
180
+ case 'init':
181
+ case 'update':
182
+ if (!opts.command) opts.command = a;
183
+ else opts.consumer = a; // unlikely, but keep positional handling sane
184
+ break;
185
+ case '--version':
186
+ opts.version = valFor(a, i);
187
+ i++;
188
+ break;
189
+ case '--channel':
190
+ opts.channel = valFor(a, i);
191
+ i++;
192
+ break;
193
+ case '--mcp-url':
194
+ // Validate at the boundary, like --url. This value is PERSISTED to the
195
+ // pin and carried across every future update, so a schemeless typo would
196
+ // propagate to every teammate and survive the updates that might
197
+ // otherwise have corrected it. Rejecting here costs one run; accepting
198
+ // costs a silent re-pin on every machine.
199
+ opts.mcpUrl = valFor(a, i);
200
+ if (!isUrl(opts.mcpUrl)) {
201
+ die(`--mcp-url must be an http(s) URL — got "${opts.mcpUrl}". Name the host that answers DIRECTLY: a cross-origin apex-to-www redirect strips the Authorization header and 401s.`);
202
+ }
203
+ i++;
204
+ break;
205
+ case '--repo':
206
+ opts.repo = valFor(a, i);
207
+ i++;
208
+ break;
209
+ case '--from':
210
+ opts.from = valFor(a, i);
211
+ i++;
212
+ break;
213
+ case '--url':
214
+ opts.url = valFor(a, i);
215
+ i++;
216
+ break;
217
+ case '--require-checksum':
218
+ opts.requireChecksum = true;
219
+ break;
220
+ case '-h':
221
+ case '--help':
222
+ opts.command = opts.command || 'help';
223
+ break;
224
+ // Flags we forward verbatim to setup-local.sh's vendored run.
225
+ case '--with-supabase':
226
+ case '--with-antigravity':
227
+ case '--shared-docs':
228
+ case '--force-mcp':
229
+ opts.passthrough.push(a);
230
+ break;
231
+ // Unwire the persisted flag (ADR-0178). NOT forwarded to setup-local.sh,
232
+ // which has no negative form: it subtracts from the pin's recorded wiring,
233
+ // and the absence of the positive flag is what setup-local.sh then sees.
234
+ // It exists because wiring is carried forward by UNION, so a bare `update`
235
+ // can never mean "unwire" — that reading is the defect ADR-0178 fixes — and
236
+ // unwiring therefore needs a way to be said out loud.
237
+ case '--no-antigravity':
238
+ opts.unwire.push('--with-antigravity');
239
+ break;
240
+ default:
241
+ if (a.startsWith('-')) die(`unknown flag: ${a}`, 2);
242
+ else if (!opts.consumer) opts.consumer = a;
243
+ else die(`unexpected extra argument: ${a}`, 2);
244
+ }
245
+ }
246
+ return opts;
247
+ }
248
+
249
+ const HELP = `claude-factory — install/update a pinned, vendored Claude Factory engine
250
+
251
+ Usage:
252
+ npx @mindexed/cfact init [consumer-path] --version <v> [options]
253
+ npx @mindexed/cfact update [consumer-path] [--version <v>] [options]
254
+
255
+ Also published as @mindexed/claude-factory (the kept-alive legacy name); both
256
+ serve the identical tree at the identical version.
257
+
258
+ Materialises .claude/engine as REAL FILES at a pinned version (never a clone or
259
+ symlink of the private repo), then wires the consumer (pointer-symlinks, the
260
+ agentic-os MCP entry, the docs folder) via the engine's own setup-local.sh.
261
+
262
+ Version & channel:
263
+ --version <v> engine version to pin to (e.g. 3.6). Required for init
264
+ unless .claude/engine.pin.json already records one.
265
+ --channel <url|label> engine channel: an https:// BASE (the recommended
266
+ agenticos.studio endpoint) makes the installer fetch
267
+ <base>/cfact-engine-<version>.tar.gz; works on
268
+ the first init and is recorded in the pin. OPTIONAL —
269
+ defaults to ${DEFAULT_CHANNEL_URL}.
270
+ Pass \`--channel ${GITHUB_CHANNEL_LABEL}\` to opt out of
271
+ that default and use the GitHub-Releases fallback.
272
+ --mcp-url <url> Agentic OS MCP endpoint for this consumer, recorded in
273
+ the pin as "mcpUrl" and read by setup-local.sh. Omit to
274
+ use the studio default. Carried across updates. Name the
275
+ host that answers DIRECTLY: a cross-origin apex-to-www
276
+ redirect strips the Authorization header and 401s.
277
+ --repo <owner/name> GitHub repo for the fallback channel (default ${DEFAULT_REPO}).
278
+ Naming a repo also selects that fallback — but only when no
279
+ channel URL is configured by a flag or the pin, which still
280
+ win. To leave a configured channel, pass
281
+ \`--channel ${GITHUB_CHANNEL_LABEL}\`.
282
+ --url <url> explicit tarball URL (overrides every channel; no bearer).
283
+ --from <file.tgz> a LOCAL tarball — no network at all (dogfooding).
284
+ --require-checksum refuse to proceed if the checksum can't be verified.
285
+
286
+ Channel: the recommended path is the agenticos.studio engine endpoint, and it is now
287
+ the DEFAULT — you do not have to pass --channel at all. Override it with
288
+ --channel https://… (or record it in .claude/engine.pin.json as "channelUrl"); the
289
+ installer fetches <base>/cfact-engine-<version>.tar.gz. GitHub Releases is
290
+ the opt-in token-based fallback (--channel ${GITHUB_CHANNEL_LABEL}).
291
+
292
+ Run this from the CONSUMER directory, never from inside the claude-factory repo:
293
+ npm resolves the repo's own package.json bin entry there and reports
294
+ "'claude-factory' is not recognized", which reads as a broken package. Passing an
295
+ explicit consumer path does NOT help — resolution happens before the path is read.
296
+
297
+ Forwarded to the engine's setup-local.sh (vendored mode):
298
+ --with-antigravity wire .agents/ for the Google Antigravity IDE. PERSISTED in
299
+ the pin and re-applied on every later run, so a bare
300
+ \`update\` no longer silently drops it (ADR-0178).
301
+ --with-supabase wire the scoped supabase-auditor MCP server. One-shot: it
302
+ only ADDS an entry, and omitting it removes nothing, so
303
+ there is nothing to carry forward.
304
+ --shared-docs print the private-docs migration steps. Advisory, one-shot.
305
+ --force-mcp overwrite a DIVERGED agentic-os MCP entry (ADR-0070).
306
+ Deliberately NOT persisted: it must be meant each time, or
307
+ it would silently re-clobber a consumer's own fix forever.
308
+
309
+ Handled by the installer itself (NOT forwarded to setup-local.sh):
310
+ --no-antigravity remove --with-antigravity from the pin's recorded wiring.
311
+ Wiring is carried forward by UNION, so an omitted flag never
312
+ means "unwire" — say it with this. Note .agents/ is left ON
313
+ DISK and stops being git-ignored; delete it or ignore it
314
+ yourself.
315
+
316
+ Environment:
317
+ CFACT_ENGINE_URL explicit tarball URL (same as --url).
318
+ CFACT_ENGINE_TOKEN bearer for the studio channel. Lives in the consumer's own
319
+ .env / .env.local — the installer reads those files itself,
320
+ so no export is needed; an exported shell value still wins.
321
+ The studio channel 401s without it. See FRAMEWORK.md →
322
+ "Authenticating the studio channel". A quoted value is
323
+ stripped automatically.
324
+ CLAUDE_FACTORY_ENGINE_URL / CLAUDE_FACTORY_ENGINE_TOKEN
325
+ the legacy names for the two above. Both are still read, and
326
+ nothing needs changing. Where both are set the CFACT_ name
327
+ wins WITHIN a source tier — an exported legacy name still
328
+ outranks a CFACT_ name in a file.
329
+ FRAMEWORK_PAT / GITHUB_TOKEN token for the private GitHub Release fallback.
330
+ `;
331
+
332
+ // --- pin file (.claude/engine.pin.json) -------------------------------------
333
+ // The maintainer pins the TEAM to a version here; a teammate's `update` reads it
334
+ // unless they pass --version explicitly. It also carries the expected checksum,
335
+ // so a re-materialise can verify without a network round-trip to the metadata.
336
+ function readPin(consumerRoot) {
337
+ const p = path.join(consumerRoot, '.claude', 'engine.pin.json');
338
+ if (!fs.existsSync(p)) return {};
339
+ try {
340
+ return JSON.parse(fs.readFileSync(p, 'utf8'));
341
+ } catch (e) {
342
+ die(`.claude/engine.pin.json is not valid JSON — ${e.message}`);
343
+ }
344
+ }
345
+
346
+ /**
347
+ * The matrix version of the engine that was just materialised.
348
+ *
349
+ * Distinct from the pin's `version`, which is the RELEASE id
350
+ * (`<matrixVersion>.<N>`, ADR-0128) — the two are normally unequal and that is
351
+ * not drift. Recording both is what lets a reader tell "this consumer is behind"
352
+ * from "this consumer is current and the matrix simply has not moved".
353
+ */
354
+ function engineMatrixVersion(enginePath) {
355
+ try {
356
+ const m = path.join(enginePath, 'matrix.json');
357
+ if (!fs.existsSync(m)) return null;
358
+ return JSON.parse(fs.readFileSync(m, 'utf8')).version || null;
359
+ } catch (e) {
360
+ return null;
361
+ }
362
+ }
363
+
364
+ function writePin(consumerRoot, pin) {
365
+ const p = path.join(consumerRoot, '.claude', 'engine.pin.json');
366
+ fs.writeFileSync(p, JSON.stringify(pin, null, 2) + '\n');
367
+ info(`✅ pinned engine version ${pin.version} in .claude/engine.pin.json`);
368
+ }
369
+
370
+ // --- wiring flags (ADR-0178) -------------------------------------------------
371
+ // WHICH setup-local.sh flags survive a run, and — the half that decides the
372
+ // whole design — which must NOT.
373
+ //
374
+ // A consumer wired once with `--with-antigravity` and updated later with a bare
375
+ // `npx @mindexed/cfact update` lost that wiring silently: setup-local.sh
376
+ // merges AG_GENERATED into GENERATED only inside its --with-antigravity branch,
377
+ // and regenerates the managed .git/info/exclude block WHOLESALE, so the flag's
378
+ // absence did not leave .agents/ alone — it dropped every .agents/* exclude line
379
+ // while the files stayed on disk. 87 framework-owned files, untracked AND
380
+ // unignored, one `git add -A` away from being committed into an application
381
+ // repo. That is the "zero tracked framework content beyond the pin" guarantee
382
+ // (ADR-0093/0158) broken by a routine update, with a success message printed.
383
+ //
384
+ // This is the SAME hazard writePin's mcpUrl comment already describes — a file
385
+ // rewritten wholesale silently deletes whatever the current run did not restate
386
+ // — and the remedy is the one already applied there: persist the setting and
387
+ // carry it forward. The flags are therefore recorded in the pin, which is the
388
+ // consumer's own tracked declaration of what it is wired for, rather than
389
+ // inferred afresh on every run.
390
+ //
391
+ // PERSISTED IS A NARROW SUBSET OF PASSTHROUGH, AND THE TEST IS DESTRUCTION, NOT
392
+ // DURABILITY. The tempting rule is "persist the flags that create durable
393
+ // state", which admits three of the four. The correct rule is narrower: persist
394
+ // only a flag whose OMISSION ACTIVELY DESTROYS state.
395
+ //
396
+ // --with-antigravity PERSISTED. Its omission rewrites the managed exclude
397
+ // block without .agents/*, so not passing it unwires a
398
+ // wired consumer. This is the whole defect.
399
+ // --with-supabase NOT persisted. It only ever ADDS a supabase-auditor MCP
400
+ // entry, and setup-local.sh leaves an existing one alone;
401
+ // omitting it removes nothing. So persisting it would fix
402
+ // no defect while re-adding, on every update, an entry a
403
+ // consumer may have deliberately deleted — the same
404
+ // re-clobber failure that rules out --force-mcp below.
405
+ // --force-mcp NOT persisted. A ONE-SHOT DESTRUCTIVE OVERRIDE whose
406
+ // entire purpose is to overwrite a consumer's DIVERGED
407
+ // agentic-os MCP entry (ADR-0070). Carrying it forward
408
+ // would re-clobber that consumer's own fix on every future
409
+ // update, silently — precisely the failure ADR-0070 exists
410
+ // to stop. It must be meant afresh each time.
411
+ // --shared-docs NOT persisted. PRINT-ONLY advisory: it emits migration
412
+ // instructions and changes no state.
413
+ //
414
+ // So the rule is "persist only what its own ABSENCE would destroy". A flag that
415
+ // merely adds is safe to omit and must not be carried, because carrying it turns
416
+ // a consumer's deletion into something the installer silently reverses. Adding a
417
+ // fifth flag means asking that one question about it.
418
+ const PERSISTED_WIRING_FLAGS = ['--with-antigravity'];
419
+
420
+ /**
421
+ * Resolve the wiring flags for this run: those given explicitly, unioned with
422
+ * those the pin already records, plus a one-time migration for a consumer wired
423
+ * before the pin carried the field.
424
+ *
425
+ * UNION, NOT OVERRIDE — deliberately. A flag adds wiring and no flag removes it,
426
+ * so a bare `update` cannot mean "unwire": it is what every routine update looks
427
+ * like, and reading it as a removal is the defect itself. Unwiring is a separate,
428
+ * deliberate act, not the default reading of an omission.
429
+ *
430
+ * The disk probe exists ONLY for consumers already wired when this shipped —
431
+ * their pin cannot record what predates the field, so without it the first
432
+ * update after this change reproduces the bug once more, for exactly the
433
+ * population that reported it. It looks for the artifacts setup-local.sh itself
434
+ * writes, and it runs BEFORE the pin is rewritten, so it self-heals on the first
435
+ * run and the pin is authoritative on every run after.
436
+ */
437
+ function resolveWiringFlags(opts, pin, consumerRoot) {
438
+ const wiring = new Set();
439
+ const pinned = Array.isArray(pin.wiring) ? pin.wiring : [];
440
+ for (const f of pinned) if (PERSISTED_WIRING_FLAGS.includes(f)) wiring.add(f);
441
+ for (const f of opts.passthrough) if (PERSISTED_WIRING_FLAGS.includes(f)) wiring.add(f);
442
+ // An explicit negation is the ONLY way wiring comes off, and it is checked
443
+ // last so `--with-antigravity --no-antigravity` resolves to off rather than
444
+ // depending on argument order.
445
+ //
446
+ // UNWIRING REPRODUCES THE HAZARD THIS FILE EXISTS TO FIX, and that is inherent
447
+ // rather than a defect: the exclude block is regenerated without .agents/*
448
+ // while the tree stays on disk, which is exactly the untracked-and-unignored
449
+ // state FF-257 reported. The difference is that here it was ASKED FOR — so the
450
+ // only honest thing is to say what is being left behind, since the installer
451
+ // must not delete a consumer's files on the strength of a flag.
452
+ for (const f of opts.unwire) {
453
+ const had = wiring.delete(f);
454
+ if (had && f === '--with-antigravity' && consumerRoot) {
455
+ const agents = path.join(consumerRoot, '.agents');
456
+ if (fs.existsSync(agents)) {
457
+ info(` ⚠️ unwiring ${f}: '${agents}' stays ON DISK and will no longer be`);
458
+ info(' git-ignored. Delete it, or add it to the consumer\'s own .gitignore —');
459
+ info(' otherwise `git add -A` can commit framework content (ADR-0178).');
460
+ }
461
+ }
462
+ }
463
+ // Migration probe: Antigravity wiring is detectable on disk because
464
+ // setup-local.sh writes .agents/mcp_config.json unconditionally when it runs.
465
+ // Supabase wiring has no equivalent unambiguous marker (a consumer may have
466
+ // authored its own supabase-auditor entry by hand), so it is NOT probed —
467
+ // inferring it would re-add an entry someone deliberately removed.
468
+ // The negation must beat the probe: .agents/ is still on disk at the moment
469
+ // someone runs --no-antigravity to unwire it, so a probe that ignored the
470
+ // negation would re-add the flag it was invoked to remove, every time.
471
+ if (
472
+ !wiring.has('--with-antigravity') &&
473
+ !opts.unwire.includes('--with-antigravity') &&
474
+ consumerRoot
475
+ ) {
476
+ if (fs.existsSync(path.join(consumerRoot, '.agents', 'mcp_config.json'))) {
477
+ wiring.add('--with-antigravity');
478
+ info(
479
+ ' ℹ️ detected existing .agents/ wiring not recorded in the pin — carrying ' +
480
+ '--with-antigravity forward and recording it. Pass --no-antigravity to unwire.'
481
+ );
482
+ }
483
+ }
484
+ // Stable order so the pin is diff-stable across runs regardless of arg order.
485
+ return PERSISTED_WIRING_FLAGS.filter((f) => wiring.has(f));
486
+ }
487
+
488
+ // --- tarball source resolution (the seam) -----------------------------------
489
+ // Priority: --from (local) > --url/$CFACT_ENGINE_URL (exact) > a
490
+ // URL-based channel (the RECOMMENDED agenticos.studio path, ADR-0096) > a GitHub
491
+ // Release asset (token fallback, ADR-0093).
492
+ //
493
+ // The asset name the installer REQUESTS. TWO names are served (ADR-0194): this
494
+ // one, and the legacy `claude-factory-engine-<version>.tar.gz` that every
495
+ // already-published installer still asks for. A URL-based channel is a BASE
496
+ // that serves both, so the studio endpoint and the installer still agree
497
+ // without a second contract.
498
+ //
499
+ // Requesting the NEW name outright, rather than dual-attempting, is ADR-0192's
500
+ // read-vs-address test applied here: this is an ADDRESSED site — the installer
501
+ // CHOOSES what it fetches — and that rule's proviso holds, because something
502
+ // outside this repo keeps the old name resolving for the installers we cannot
503
+ // reach. WHERE it holds differs by channel, and the asymmetry is the whole of
504
+ // ADR-0194:
505
+ // * studio (default) — STRUCTURAL. The endpoint is VERSION-keyed end to end:
506
+ // its ingest stores under the version read from the metadata part, never
507
+ // from the multipart filename, so both prefixes resolve to the SAME stored
508
+ // object and the publisher needed no change at all for this channel.
509
+ // * github-releases (opt-in fallback) — NOT structural. That channel is
510
+ // NAME-keyed (an asset is named by its file's basename), so it only keeps
511
+ // working because publish-release.js attaches BOTH names to every release.
512
+ // Stop attaching the alias and this constant silently 404s the fallback.
513
+ const ASSET = (version) => `cfact-engine-${version}.tar.gz`;
514
+ // The name every release published BEFORE ADR-0194 carries, and the only name
515
+ // those releases will ever carry. Used solely as the github-releases 404
516
+ // fallback below — never requested first, and never on the studio channel.
517
+ const LEGACY_ASSET = (version) => `claude-factory-engine-${version}.tar.gz`;
518
+ // The env namespace accepts CFACT_* and CLAUDE_FACTORY_*, the new name winning
519
+ // WITHIN a source tier (ADR-0189). These are ORDERED EXACT NAMES and must never
520
+ // become a pattern, prefix, or regex: the two names share the suffix
521
+ // _ENGINE_TOKEN, so "match anything ending in _ENGINE_TOKEN" is the
522
+ // natural-looking implementation and is exactly the bug — it would let
523
+ // CFACT_ENGINE_TOKEN_OLD answer the lookup and hand over a stale token with
524
+ // nothing to signal it, for BOTH names at once. rules/env-name-compat.md is the
525
+ // source of truth for the rule; this table is one of its two implementations.
526
+ const ENGINE_TOKEN_NAMES = ['CFACT_ENGINE_TOKEN', 'CLAUDE_FACTORY_ENGINE_TOKEN'];
527
+ const ENGINE_URL_NAMES = ['CFACT_ENGINE_URL', 'CLAUDE_FACTORY_ENGINE_URL'];
528
+ // The name to PREFER in remediation text that names ONE variable rather than
529
+ // enumerating both. A diagnostic reporting what was actually READ must use the
530
+ // resolved name instead — see bearerNameFor().
531
+ const BEARER_ENV = ENGINE_TOKEN_NAMES[0];
532
+ /**
533
+ * This installer's own version, for the pin's provenance block (ADR-0184).
534
+ *
535
+ * npm always packs package.json even though `files` ships only bin/, so this
536
+ * resolves in a published install exactly as it does in the repo. Best-effort:
537
+ * a null here degrades the pin to what it carried before, never a hard failure,
538
+ * because provenance is diagnostic and must not be able to block an install.
539
+ */
540
+ function installerVersion() {
541
+ try {
542
+ return require('../package.json').version || null;
543
+ } catch (e) {
544
+ return null;
545
+ }
546
+ }
547
+ // Labels this installer DERIVES from a source's provenance. They describe one
548
+ // install and must never be inherited by the next, unlike a custom label a
549
+ // maintainer authored (e.g. 'internal-mirror'), which is a standing name for a
550
+ // channel and should survive. Kept in sync with resolveSource()'s `via` values.
551
+ const DERIVED_CHANNEL_LABELS = new Set(['local-file', 'explicit-url', 'studio', 'github-releases']);
552
+ const isUrl = (s) => typeof s === 'string' && /^https?:\/\//.test(s);
553
+
554
+ // The configured channel BASE, resolved from the FLAG first (so `--channel <url>`
555
+ // works on the very first `init`, before any pin exists — the review found the
556
+ // flag was silently ignored), then the pin's channelUrl, then a pin.channel set
557
+ // to a URL, then the canonical DEFAULT.
558
+ //
559
+ // Returns null ONLY on an explicit opt-out, which is what routes to the GitHub
560
+ // fallback. Before ADR-0168 it returned null whenever nothing was configured —
561
+ // so the "recommended" channel required configuration while the "token fallback"
562
+ // was what an unconfigured install actually got, which is the inverse of what
563
+ // ADR-0096 decided. That hit exactly one population: a teammate running their
564
+ // FIRST `init`, who has no pin yet and (by design) no GitHub token.
565
+ // The base a consumer has EXPLICITLY configured — from a flag this run or from the
566
+ // pin. Null when nothing is configured, which is precisely the case the default
567
+ // fills in.
568
+ //
569
+ // This is split out from channelBase() because THE DEFAULT MUST NEVER BE WRITTEN
570
+ // BACK INTO THE PIN. `channelUrl` outranks the constant, so a pin that records the
571
+ // default freezes it: the constant would stop being authoritative the moment a
572
+ // consumer is first initialised, and a later host move would never reach it. That
573
+ // is the exact drift ADR-0168 exists to remove, reintroduced by the fix for it.
574
+ function configuredChannelBase(opts, pin) {
575
+ // A flag THIS RUN outranks stored config, in both directions. `--channel
576
+ // github-releases` must be checked BEFORE pin.channelUrl or it is silently
577
+ // ignored for every consumer already pinned to the studio — which is the same
578
+ // "the flag was silently ignored" defect a review caught once already for
579
+ // `--channel <url>`, re-entering through the opt-out added beside it.
580
+ if (opts.channel === GITHUB_CHANNEL_LABEL) return null;
581
+ if (isUrl(opts.channel)) return opts.channel;
582
+ if (typeof pin.channelUrl === 'string' && pin.channelUrl) return pin.channelUrl;
583
+ if (isUrl(pin.channel)) return pin.channel;
584
+ return null;
585
+ }
586
+
587
+ // Does this run opt OUT of the default and back to the GitHub-Releases fallback?
588
+ // `--repo` is a WEAKER signal than an explicit channel and is deliberately ranked
589
+ // below stored config (see channelBase): it names a repo rather than demanding a
590
+ // channel, so it must not silently switch a studio-pinned consumer onto a channel
591
+ // that needs a GitHub token.
592
+ //
593
+ // `pin.repo` counts alongside `opts.repo`. It is written only when NON-DEFAULT
594
+ // (ADR-0158), so its presence means a maintainer deliberately named a fork — and
595
+ // reading only the flag would send that consumer's next plain `update` to the
596
+ // studio instead of their fork, failing on a checksum mismatch or a 404. Unlike
597
+ // pin.channel, this is authored config rather than a derived provenance label, so
598
+ // reading it back is correct rather than the ADR-0124 defect.
599
+ function optsOutOfDefaultChannel(opts, pin) {
600
+ return opts.channel === GITHUB_CHANNEL_LABEL || Boolean(opts.repo) || Boolean(pin.repo);
601
+ }
602
+
603
+ function channelBase(opts, pin) {
604
+ const configured = configuredChannelBase(opts, pin);
605
+ if (configured) return configured;
606
+ if (optsOutOfDefaultChannel(opts, pin)) return null;
607
+ return DEFAULT_CHANNEL_URL;
608
+ }
609
+
610
+ function resolveSource(opts, pin) {
611
+ if (opts.from) return { kind: 'file', via: 'local-file', ref: path.resolve(opts.from) };
612
+
613
+ // An exact URL override is used verbatim and gets NO bearer: a stale env token
614
+ // must never be sent to an arbitrary --url host (review finding). A gated
615
+ // download belongs to the configured channel base below, not an exact override.
616
+ const url = opts.url || resolveEnvName(ENGINE_URL_NAMES).value;
617
+ if (url) return { kind: 'url', via: 'explicit-url', ref: url };
618
+
619
+ const version = opts.version || pin.version;
620
+ if (!version) {
621
+ die(
622
+ 'no engine version to install. Pass --version <v>, or run in a consumer ' +
623
+ 'whose .claude/engine.pin.json records one, or point at a tarball with ' +
624
+ '--from / --url.'
625
+ );
626
+ }
627
+ const asset = ASSET(version);
628
+
629
+ // RECOMMENDED channel (ADR-0096): a URL BASE from --channel, the pin, or the
630
+ // canonical DEFAULT_CHANNEL_URL. No GitHub token needed; the studio is the auth
631
+ // boundary, and the bearer ($CFACT_ENGINE_TOKEN) is scoped to THIS
632
+ // configured channel only — never to a bare --url. The host-that-answers-
633
+ // directly rule, and why it is load-bearing, is stated once on
634
+ // DEFAULT_CHANNEL_URL rather than restated here.
635
+ // A non-URL `--channel` that is not the opt-out label resolves nowhere of its
636
+ // own, so it lands on the default. That is right for a maintainer's custom
637
+ // standing label (e.g. 'internal-mirror', which the pin deliberately preserves)
638
+ // and WRONG-LOOKING for a typo — and the two are indistinguishable here. Say so
639
+ // rather than resolving silently: a mistyped `--channel githubreleases` would
640
+ // otherwise fetch from the studio while the operator believes they opted out,
641
+ // which is the "wrong only when read" shape ADR-0124 warns about.
642
+ if (opts.channel && !isUrl(opts.channel) && opts.channel !== GITHUB_CHANNEL_LABEL) {
643
+ info(
644
+ `⚠️ --channel "${opts.channel}" is not a URL and is not "${GITHUB_CHANNEL_LABEL}", so it ` +
645
+ `names no channel to fetch from. Treating it as a label only and resolving the channel ` +
646
+ `normally. If you meant the GitHub fallback, pass --channel ${GITHUB_CHANNEL_LABEL}.`
647
+ );
648
+ }
649
+ const base = channelBase(opts, pin);
650
+ if (base) {
651
+ return { kind: 'url', via: 'studio', ref: `${base.replace(/\/+$/, '')}/${asset}`, bearerEnv: ENGINE_TOKEN_NAMES };
652
+ }
653
+
654
+ // OPT-IN FALLBACK channel (ADR-0093, narrowed by ADR-0168): a GitHub Release
655
+ // asset on the repo, token via FRAMEWORK_PAT/GITHUB_TOKEN. Reached only when
656
+ // channelBase() returned null — i.e. an explicit `--channel github-releases` or
657
+ // `--repo` this run — since the default is now the studio base above.
658
+ // CAVEAT for a PRIVATE repo — the browser-download
659
+ // URL below authenticates for a PUBLIC release; a private asset generally
660
+ // needs the REST assets endpoint (GET /repos/{repo}/releases/assets/{id} with
661
+ // Accept: application/octet-stream), an asset-id lookup this does not do. For a
662
+ // private repo, prefer the studio channel above, or --url with the exact asset.
663
+ const repo = opts.repo || pin.repo || DEFAULT_REPO;
664
+ const releaseBase = `https://github.com/${repo}/releases/download/engine-v${version}`;
665
+ // THIS CHANNEL NEEDS A FALLBACK AND THE STUDIO DOES NOT (ADR-0194). The
666
+ // publisher attaches both asset names, but only to releases it publishes FROM
667
+ // NOW ON — and a published release is immutable (ADR-0128: an existing tag is
668
+ // an idempotent skip, never an asset re-upload). So on a NAME-keyed channel the
669
+ // new name resolves forward and not BACKWARD: every already-published release
670
+ // carries the legacy asset alone, and a pin naming one of those versions would
671
+ // 404 on a name this installer had only just started requesting.
672
+ //
673
+ // That is ADR-0192's read-vs-address proviso failing in the one direction the
674
+ // ADR did not take it. Its test asks whether something outside this repo keeps
675
+ // the old value resolving; the answer is structural for the studio (version-
676
+ // keyed, so both names always reach the same object) and merely FORWARD-LOOKING
677
+ // here. Where the proviso holds in only one direction, the addressed site owes a
678
+ // fallback for the other.
679
+ //
680
+ // Scoped to this branch on purpose: the studio needs no fallback, and giving it
681
+ // one would spend a wasted round-trip on the DEFAULT channel to paper over a
682
+ // problem it does not have.
683
+ return {
684
+ kind: 'url',
685
+ via: 'github-releases',
686
+ ref: `${releaseBase}/${asset}`,
687
+ fallbackRef: `${releaseBase}/${LEGACY_ASSET(version)}`,
688
+ private: true,
689
+ };
690
+ }
691
+
692
+ // Did this failed response come back through a CROSS-ORIGIN redirect while we
693
+ // were sending a bearer? If so the bearer was stripped in flight and the status
694
+ // says nothing about the token's validity (ADR-0168).
695
+ //
696
+ // Returns null (→ report the plain status) unless ALL THREE hold, because each one
697
+ // alone produces a false accusation:
698
+ // - the source is the CONFIGURED CHANNEL, identified by `bearerEnv`, which
699
+ // resolveSource sets on that branch and no other. THE GITHUB FALLBACK ALSO
700
+ // SENDS AUTHORIZATION, and github.com → objects.githubusercontent.com is its
701
+ // NORMAL redirect — so keying on "a bearer was sent" alone makes every GitHub
702
+ // failure blame the channel URL, name the wrong env var, and suggest
703
+ // `--channel https://objects.githubusercontent.com/…`. Confidently wrong
704
+ // advice is worse than the bare status this replaces.
705
+ // - we actually sent an Authorization header — otherwise nothing was strippable,
706
+ // and blaming the URL for an unauthenticated 404 would be simply wrong
707
+ // - the FINAL origin differs from the requested one — which is itself the
708
+ // evidence a redirect was followed, and is the discriminating half: a
709
+ // SAME-origin redirect preserves the header, so a 401 after one really is
710
+ // the token and must keep saying so
711
+ // `suggestedBase` is the final URL minus its last path segment, which reconstructs
712
+ // the `--channel` BASE rather than the asset URL — the base is what a pin records.
713
+
714
+ function crossOriginRedirectTarget(source, res, headers) {
715
+ if (!source || !source.bearerEnv) return null;
716
+ const requestedRef = source.ref;
717
+ if (!headers || !headers.Authorization) return null;
718
+ const finalUrl = res && typeof res.url === 'string' ? res.url : '';
719
+ if (!finalUrl) return null;
720
+ let requested, final;
721
+ try {
722
+ requested = new URL(requestedRef);
723
+ final = new URL(finalUrl);
724
+ } catch {
725
+ return null;
726
+ }
727
+ if (requested.origin === final.origin) return null;
728
+ const segments = final.pathname.split('/');
729
+ segments.pop(); // drop the asset filename, leaving the channel base path
730
+ const basePath = segments.join('/').replace(/\/+$/, '');
731
+ return { finalOrigin: final.origin, suggestedBase: `${final.origin}${basePath}` };
732
+ }
733
+
734
+ // --- bearer normalisation (ADR-0178; FF-256) ---------------------------------
735
+ // The token is read from the environment, and the overwhelmingly common way it
736
+ // gets there is a shell extraction out of a .env / .env.local file. The standard
737
+ // quoted form —
738
+ //
739
+ // CLAUDE_FACTORY_ENGINE_TOKEN=<the value, wrapped in quotes>
740
+ //
741
+ // — captures its own quotes under a naive `grep`/`cut`, so the request goes out
742
+ // as `Authorization: Bearer "abc123"`. The endpoint rejects that with a bare 401,
743
+ // which is TYPOGRAPHICALLY IDENTICAL to a wrong or expired token: the same status,
744
+ // the same message, and nothing pointing at the value's shape. That is the same
745
+ // indistinguishable-401 class ADR-0168 already fixed for the cross-origin
746
+ // redirect, and it is fixed the same way — by making the installer say what it
747
+ // can actually see.
748
+ //
749
+ // NORMALISED, NOT MERELY WARNED ABOUT. RFC 6750 restricts a bearer to
750
+ // [A-Za-z0-9-._~+/]=*, which excludes quotes, whitespace and newlines outright —
751
+ // so none of what is stripped here can be part of a legitimate token, and
752
+ // stripping cannot corrupt a good one. A warning alone would leave the run
753
+ // failing for a reason the operator has already been told about, which is a worse
754
+ // trade than silently doing the only correct thing and saying so.
755
+ //
756
+ // A redundant `Bearer ` prefix is handled too: it is what someone pastes when
757
+ // they copy a whole header rather than a value, and it produces the identical
758
+ // bare 401.
759
+ // Applied to a FIXED POINT rather than as a fixed sequence, because the two
760
+ // malformations nest in both orders and a single pass is asymmetric: someone who
761
+ // copies a whole header out of a quoted .env produces `"Bearer abc"`, and someone
762
+ // who quotes the value inside a header produces `Bearer "abc"`. A
763
+ // quotes-then-prefix pass repairs the first and leaves the second, which is one
764
+ // reordering away from repairing the second and leaving the first — so neither
765
+ // order is correct and iterating to stability is.
766
+ function normalizeBearer(raw) {
767
+ if (typeof raw !== 'string') return { value: '', fixes: [] };
768
+ const fixes = new Set();
769
+ let v = raw;
770
+ // Bounded: each pass must shorten the string or the loop exits, so a
771
+ // pathological value cannot spin.
772
+ for (let guard = 0; guard < 8; guard++) {
773
+ const before = v;
774
+ const trimmed = v.trim();
775
+ if (trimmed !== v) { v = trimmed; fixes.add('surrounding whitespace'); }
776
+ // One matching pair only — a token cannot contain quotes, so a LONE quote is
777
+ // left in place and reported by the 401 diagnostic rather than half-stripped.
778
+ const m = /^(["'])([\s\S]*)\1$/.exec(v);
779
+ if (m) { v = m[2]; fixes.add('surrounding quote characters'); }
780
+ if (/^Bearer\s+/i.test(v)) {
781
+ v = v.replace(/^Bearer\s+/i, '');
782
+ fixes.add('a redundant "Bearer " prefix');
783
+ }
784
+ if (v === before) break;
785
+ }
786
+ return { value: v, fixes: [...fixes] };
787
+ }
788
+
789
+ /**
790
+ * Describe what is visibly wrong with a bearer value, for the 401 path.
791
+ *
792
+ * Runs on the NORMALISED value, so anything it reports is something normalisation
793
+ * could not silently repair — a lone quote, an embedded space, a newline. Returns
794
+ * null when the value looks well-formed, so a genuinely invalid token keeps
795
+ * blaming the token, exactly as the cross-origin diagnosis keeps blaming the
796
+ * token on a same-origin 401.
797
+ */
798
+ function malformedBearerReason(value) {
799
+ if (typeof value !== 'string' || value === '') return null;
800
+ if (/[\r\n]/.test(value)) return 'it contains a line break';
801
+ if (/\s/.test(value)) return 'it contains whitespace';
802
+ if (/["']/.test(value)) return 'it contains a quote character';
803
+ return null;
804
+ }
805
+
806
+ /**
807
+ * Read one key out of a .env-style file, or null.
808
+ *
809
+ * FIRST match wins within a file, matching dotenv (and the `grep -m1` the 401
810
+ * message has always suggested by hand). Quotes are stripped here only so the
811
+ * "is it set at all" question is answered correctly; `normalizeBearer` remains
812
+ * the authority on the value's shape, and still runs on whatever this returns.
813
+ */
814
+ function readDotenvValue(file, key) {
815
+ if (!fs.existsSync(file)) return null;
816
+ let text;
817
+ try {
818
+ text = fs.readFileSync(file, 'utf8');
819
+ } catch (e) {
820
+ return null;
821
+ }
822
+ for (const line of text.split(/\r?\n/)) {
823
+ const m = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*([\s\S]*)$/.exec(line);
824
+ if (!m || m[1] !== key) continue;
825
+ const raw = m[2].trim();
826
+ const quoted = /^(["'])([\s\S]*)\1$/.exec(raw);
827
+ return quoted ? quoted[2] : raw.replace(/\s+#.*$/, '');
828
+ }
829
+ return null;
830
+ }
831
+
832
+ /**
833
+ * Where the studio bearer actually comes from — the shell, or the consumer's own
834
+ * dotenv files.
835
+ *
836
+ * ADR-0178 documented "nothing sources a .env for the installer" and left it at
837
+ * that. FF-265 measured the cost: the token lives in .env.local in every
838
+ * consumer that has one, so EVERY first run 401s, and each of the four failure
839
+ * modes (unset, quoted, wrong shape, wrong host) produced the same one-line bare
840
+ * 401. ADR-0184 reverses the documentation-only half — the reading half — and
841
+ * keeps everything else that ADR decided.
842
+ *
843
+ * Precedence is `.env` then `.env.local`, LATER WINS, which is not a new
844
+ * convention: it is exactly what setup-local.sh's MCP launch command already
845
+ * does and what @next/env and Vite do. An exported shell variable outranks both,
846
+ * because an explicit export is the most specific thing the caller said.
847
+ *
848
+ * Returns `{ value, origin }` and NEVER logs the value. `origin` exists because
849
+ * of rules/domain-5-devops.md's "report what will LOAD, never what you wrote" —
850
+ * the whole failure this repairs is a caller who cannot tell which of several
851
+ * sources answered.
852
+ */
853
+ /**
854
+ * Resolve one value across an ORDERED list of EXACT env names, SOURCE-MAJOR:
855
+ * every name is tried at the shell tier before any file tier is opened.
856
+ *
857
+ * Source-major, not name-major, and the difference is not cosmetic. Name-major
858
+ * ("the new name wins wherever it is") inverts the tested escape hatch that an
859
+ * exported shell value outranks .env.local — so a stale CFACT_ENGINE_TOKEN in a
860
+ * checked-in .env would beat an explicit `export CLAUDE_FACTORY_ENGINE_TOKEN`,
861
+ * silently, by handing over the wrong token. rules/env-name-compat.md carries
862
+ * the rule and both of its cross-tier consequences.
863
+ *
864
+ * Names are matched EXACTLY, one readDotenvValue() call per name, so precedence
865
+ * lives above the parser and the parser stays a plain equality check.
866
+ */
867
+ // WHICH NAME FAMILY actually answered a lookup this run. Recorded so a later
868
+ // change can PROVE the compat window is safe to close rather than assuming it —
869
+ // evidence only accumulates if collection starts when the window OPENS.
870
+ //
871
+ // Indexed, NOT prefix-tested: index 0 of every table is the CFACT_ name and the
872
+ // rest are legacy. A `name.startsWith('CFACT_')` here would be the exact
873
+ // reasoning this file forbids for lookups, sitting one function from the lookup,
874
+ // waiting to be copied into one.
875
+ let sawPreferredName = false;
876
+ let sawLegacyName = false;
877
+
878
+ function resolveEnvName(names, { consumerRoot = null, files = [] } = {}) {
879
+ const hit = (i, name, source, value) => {
880
+ if (i === 0) sawPreferredName = true;
881
+ else sawLegacyName = true;
882
+ return { value, name, source };
883
+ };
884
+ for (let i = 0; i < names.length; i++) {
885
+ const v = process.env[names[i]];
886
+ if (v) return hit(i, names[i], 'shell', v);
887
+ }
888
+ const root = consumerRoot || process.cwd();
889
+ for (const file of files) {
890
+ for (let i = 0; i < names.length; i++) {
891
+ const v = readDotenvValue(path.join(root, file), names[i]);
892
+ if (v) return hit(i, names[i], file, v);
893
+ }
894
+ }
895
+ return { value: null, name: null, source: null };
896
+ }
897
+
898
+ /**
899
+ * What this run OBSERVED, for the pin. null means nothing namespaced was read —
900
+ * a local --from install reads no env at all — which the reader must report as
901
+ * "no data" and never as clean (ADR-0174's third state).
902
+ */
903
+ function envNamespaceObserved() {
904
+ if (sawPreferredName && sawLegacyName) return 'mixed';
905
+ if (sawPreferredName) return 'cfact';
906
+ if (sawLegacyName) return 'legacy';
907
+ return null;
908
+ }
909
+
910
+ /**
911
+ * Clear the observation. Exists ONLY for the fixture: the flags accumulate over
912
+ * a process and a CLI process resolves once, so nothing in the installer resets
913
+ * them. Without it every assertion below would depend on every earlier
914
+ * resolveEnvName() call in the same process, and the suite would pass or fail on
915
+ * test ORDER.
916
+ */
917
+ function resetEnvNamespaceObservation() {
918
+ sawPreferredName = false;
919
+ sawLegacyName = false;
920
+ }
921
+
922
+ function resolveBearer(bearerEnv, consumerRoot) {
923
+ // Accepts a scalar so every existing caller and assertion survives verbatim
924
+ // as the legacy-name-alone case.
925
+ const names = Array.isArray(bearerEnv) ? bearerEnv : [bearerEnv];
926
+ const r = resolveEnvName(names, { consumerRoot, files: ['.env.local', '.env'] });
927
+ if (!r.value) return { value: null, origin: null, name: null, source: null };
928
+ return {
929
+ value: r.value,
930
+ // SHAPE UNCHANGED — '$NAME' or a bare filename. Widening it would break the
931
+ // existing `origin === '$' + KEY` assertion. The ambiguity a bare filename
932
+ // introduces (WHICH name answered?) is resolved by `name` below, ADDED
933
+ // rather than folded in — "report what will LOAD" satisfied additively.
934
+ origin: r.source === 'shell' ? `$${r.name}` : r.source,
935
+ name: r.name,
936
+ source: r.source,
937
+ };
938
+ }
939
+
940
+ /**
941
+ * The variable name a diagnostic should NAME: the one that actually answered
942
+ * where a value was found, else the preferred name. Exists because
943
+ * `source.bearerEnv` is now an ARRAY — interpolating it directly yields
944
+ * 'CFACT_ENGINE_TOKEN,CLAUDE_FACTORY_ENGINE_TOKEN', which is never what a
945
+ * reader should be told to set.
946
+ */
947
+ function bearerNameFor(source) {
948
+ if (!source || !source.bearerEnv) return ENGINE_TOKEN_NAMES[0];
949
+ const names = Array.isArray(source.bearerEnv) ? source.bearerEnv : [source.bearerEnv];
950
+ return resolveBearer(source.bearerEnv, source.consumerRoot).name || names[0];
951
+ }
952
+
953
+ async function fetchToFile(source, destFile) {
954
+ if (source.kind === 'file') {
955
+ if (!fs.existsSync(source.ref)) die(`local tarball not found: ${source.ref}`);
956
+ fs.copyFileSync(source.ref, destFile);
957
+ return;
958
+ }
959
+ if (typeof fetch !== 'function') {
960
+ die('global fetch is unavailable — Node 18+ is required to download a tarball (or use --from).');
961
+ }
962
+ const headers = { 'User-Agent': 'claude-factory-installer' };
963
+ if (source.private) {
964
+ // GitHub Release fallback: token via FRAMEWORK_PAT/GITHUB_TOKEN.
965
+ const token = process.env.FRAMEWORK_PAT || process.env.GITHUB_TOKEN;
966
+ if (token) {
967
+ headers.Authorization = `token ${token}`;
968
+ headers.Accept = 'application/octet-stream';
969
+ } else {
970
+ info(
971
+ '⚠️ fetching from a private GitHub Release but neither FRAMEWORK_PAT nor ' +
972
+ 'GITHUB_TOKEN is set — the download will fail if the repo is private.'
973
+ );
974
+ }
975
+ } else if (source.bearerEnv && resolveBearer(source.bearerEnv, source.consumerRoot).value) {
976
+ // Studio channel (the configured channel BASE only — never a bare --url, so a
977
+ // stale token can't leak to an arbitrary host): optional bearer, so the
978
+ // studio can gate the download.
979
+ const bearer = resolveBearer(source.bearerEnv, source.consumerRoot);
980
+ const { value, fixes } = normalizeBearer(bearer.value);
981
+ if (bearer.source !== 'shell') {
982
+ info(` ℹ️ read $${bearer.name} from ${bearer.origin} (not exported in this shell).`);
983
+ }
984
+ if (fixes.length) {
985
+ info(
986
+ ` ℹ️ stripped ${fixes.join(' and ')} from the ${bearer.name} value read from ` +
987
+ `${bearer.origin}. Harmless — the bare token was used.`
988
+ );
989
+ }
990
+ headers.Authorization = `Bearer ${value}`;
991
+ }
992
+ info(` fetching ${source.ref}`);
993
+ let res;
994
+ try {
995
+ res = await fetch(source.ref, { headers, redirect: 'follow' });
996
+ } catch (e) {
997
+ die(`download failed — ${e.message}`);
998
+ }
999
+ // A 404 with a fallback is the pre-ADR-0194 release: retry ONCE on the legacy
1000
+ // asset name. Deliberately narrow on all three axes — only 404 (a 401 means the
1001
+ // token, and retrying would report the wrong cause), only when resolveSource set
1002
+ // a fallbackRef (the github channel and no other), and only once (two names
1003
+ // exist, so a loop could only re-request one of them).
1004
+ if (res.status === 404 && source.fallbackRef) {
1005
+ info(` not found under the current asset name; retrying ${source.fallbackRef}`);
1006
+ try {
1007
+ res = await fetch(source.fallbackRef, { headers, redirect: 'follow' });
1008
+ } catch (e) {
1009
+ die(`download failed — ${e.message}`);
1010
+ }
1011
+ // Report what was actually fetched, not what was first requested: every
1012
+ // diagnosis below reads source.ref, and a checksum or extraction failure that
1013
+ // named the un-fetched URL would send a reader to the wrong artifact.
1014
+ if (res.ok) source.ref = source.fallbackRef;
1015
+ }
1016
+
1017
+ if (!res.ok) {
1018
+ // SELF-DIAGNOSE THE STRIPPED-BEARER 401 (ADR-0168, FF-240) before reporting a
1019
+ // bare HTTP status. When a channel host redirects CROSS-ORIGIN, the Fetch spec
1020
+ // strips `Authorization`, so a correctly-configured token never arrives and the
1021
+ // endpoint answers 401 — indistinguishable from a bad token, and measured to
1022
+ // cost a full debugging cycle to attribute (the reporter checked the npm
1023
+ // registry before suspecting the URL).
1024
+ //
1025
+ // Deliberately NOT fixed by re-attaching the header across origins: that would
1026
+ // weaken a real security boundary to paper over a config error. The point is to
1027
+ // make the 401 name its own cause and the corrected host.
1028
+ //
1029
+ // Checked only on failure, and only when we actually sent a bearer — a
1030
+ // cross-origin redirect that SUCCEEDS is nobody's problem, and one on an
1031
+ // unauthenticated fetch cannot have been caused by stripping.
1032
+ const corrected = crossOriginRedirectTarget(source, res, headers);
1033
+ if (corrected) {
1034
+ die(
1035
+ `download failed — HTTP ${res.status} ${res.statusText}, and the cause is almost ` +
1036
+ `certainly the CHANNEL URL rather than your token.\n` +
1037
+ ` ${source.ref}\n` +
1038
+ ` redirected CROSS-ORIGIN to ${corrected.finalOrigin}, which strips the ` +
1039
+ `Authorization header (Fetch spec), so $${bearerNameFor(source)} never arrived.\n` +
1040
+ ` Point the channel at the host that answers DIRECTLY:\n` +
1041
+ ` --channel ${corrected.suggestedBase}`
1042
+ );
1043
+ }
1044
+ // A 401/403 on a bearer channel is the one status whose cause the installer
1045
+ // can sometimes SEE. Checked after the cross-origin diagnosis above, which is
1046
+ // the more specific of the two, and only when a bearer was actually sent.
1047
+ if ((res.status === 401 || res.status === 403) && source.bearerEnv) {
1048
+ const names = Array.isArray(source.bearerEnv) ? source.bearerEnv : [source.bearerEnv];
1049
+ const found = resolveBearer(source.bearerEnv, source.consumerRoot);
1050
+ const sent = found.value;
1051
+ if (!sent) {
1052
+ die(
1053
+ `download failed — HTTP ${res.status} ${res.statusText} for ${source.ref}\n` +
1054
+ ` No bearer was sent: none of ${names.map((n) => `$${n}`).join(', ')} is set in\n` +
1055
+ ` this shell, and none is present in .env / .env.local in\n` +
1056
+ ` ${source.consumerRoot || process.cwd()}.\n` +
1057
+ ` The studio channel needs one. The installer already looked in both files,\n` +
1058
+ ` so re-extracting from them will not help — the value is not there yet.\n` +
1059
+ ` Add it to .env.local (no export needed, the installer reads that file):\n` +
1060
+ ` echo '${names[0]}=<your-token>' >> .env.local\n` +
1061
+ ` or export it for this shell: export ${names[0]}=<your-token>\n` +
1062
+ ` See FRAMEWORK.md → "Authenticating the studio channel".`
1063
+ );
1064
+ }
1065
+ const reason = malformedBearerReason(normalizeBearer(sent).value);
1066
+ if (reason) {
1067
+ die(
1068
+ `download failed — HTTP ${res.status} ${res.statusText} for ${source.ref}\n` +
1069
+ ` The cause is probably the TOKEN'S SHAPE rather than the token itself:\n` +
1070
+ ` $${found.name} ${reason}, which a bearer token may not (RFC 6750).\n` +
1071
+ ` A quoted or multi-line .env value is the usual cause — make the variable\n` +
1072
+ ` hold the bare token. See FRAMEWORK.md → "Authenticating the studio channel".`
1073
+ );
1074
+ }
1075
+ }
1076
+ die(`download failed — HTTP ${res.status} ${res.statusText} for ${source.ref}`);
1077
+ }
1078
+ const buf = Buffer.from(await res.arrayBuffer());
1079
+ fs.writeFileSync(destFile, buf);
1080
+ }
1081
+
1082
+ function sha256(file) {
1083
+ // Use node's crypto without pulling a dependency.
1084
+ const crypto = require('crypto');
1085
+ const h = crypto.createHash('sha256');
1086
+ h.update(fs.readFileSync(file));
1087
+ return h.digest('hex');
1088
+ }
1089
+
1090
+ // --- symlink stripping (defense in depth) -----------------------------------
1091
+ // The publish step already strips symlinks before taring, but an older tarball,
1092
+ // or a hand-rolled one, might not. A tracked symlink in the engine is exactly
1093
+ // what breaks a consumer that later packages the tree (ADR-0068), so strip on
1094
+ // the way in too. Identical semantics to ensure-engine.js's stripSymlinks:
1095
+ // lstat-based detection, unlink the LINK (never follow it).
1096
+ function stripSymlinks(dir) {
1097
+ const removed = [];
1098
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
1099
+ const full = path.join(dir, entry.name);
1100
+ if (entry.isSymbolicLink()) {
1101
+ fs.rmSync(full, { force: true });
1102
+ removed.push(full);
1103
+ } else if (entry.isDirectory()) {
1104
+ removed.push(...stripSymlinks(full));
1105
+ }
1106
+ }
1107
+ return removed;
1108
+ }
1109
+
1110
+ // --- tar extraction ---------------------------------------------------------
1111
+ // THE ARCHIVE ARGUMENT MUST NOT CARRY A DRIVE LETTER (FF-227). GNU tar parses
1112
+ // `-f`'s value as a `host:path` remote-tape spec whenever it contains a colon,
1113
+ // so a native Windows temp path is read as host "C" and handed to the `rmt`
1114
+ // child:
1115
+ //
1116
+ // tar (child): Cannot connect to C: resolve failed
1117
+ // tar: Child returned status 128
1118
+ // ❌ claude-factory: tar extraction failed — Command failed: tar -xzf
1119
+ // C:\Users\...\AppData\Local\Temp\cf-engine-XXXXXX\engine.tar.gz ...
1120
+ //
1121
+ // That fully blocks `init`/`update` for any Windows user whose PATH resolves
1122
+ // Git for Windows' usr/bin/tar.exe (GNU) ahead of System32's tar.exe (bsdtar) —
1123
+ // the default outcome of a standard Git for Windows install. So the archive is
1124
+ // passed as a BARE BASENAME with `cwd` carrying its directory: no colon in the
1125
+ // argument, nothing to misparse, identical behaviour on every tar flavour.
1126
+ //
1127
+ // `-C destDir` stays ABSOLUTE on purpose. The host:path parse applies to the
1128
+ // archive only — the error above comes from the `rmt` child tar spawns for `-f`
1129
+ // — and `-C` is a plain chdir no tar treats as remote. Making it relative too
1130
+ // would buy nothing and would break the cross-drive case, where path.relative()
1131
+ // returns a drive-lettered path regardless.
1132
+ //
1133
+ // Both alternatives were measured and rejected:
1134
+ // - `--force-local`, the GNU flag for exactly this, is REJECTED BY BSDTAR
1135
+ // ("Option --force-local is not supported", exit 1). It needs flavour
1136
+ // detection, and the detector that exists lives in maintainer-hooks/, which
1137
+ // package.json's files:["bin/"] does not ship — so it would mean a second
1138
+ // copy of one fact, in the half that cannot import the first.
1139
+ // - A Node-native extractor has to handle two format families: a local
1140
+ // (bsdtar) build emits pax `x` headers — 429 of them, measured — while CI's
1141
+ // GNU tar emits `L` longname entries. Neither is avoidable: the longest
1142
+ // tracked path is 101 chars against ustar's 100-byte name field.
1143
+ //
1144
+ // Pure and exported so scripts/test-tar-invocation.js can assert the SHAPE on a
1145
+ // simulated Windows path. The failure itself is unreproducible off-Windows, so
1146
+ // the invariant is what gets tested, never the extraction's outcome — the same
1147
+ // static-guard-plus-dynamic-proof split test-installer-exit.js uses for the
1148
+ // other Windows-only defect in this file (ADR-0100).
1149
+ function tarExtractInvocation(tarball, destDir) {
1150
+ return {
1151
+ args: ['-xzf', path.basename(tarball), '-C', destDir, '--strip-components', '1'],
1152
+ cwd: path.dirname(tarball),
1153
+ };
1154
+ }
1155
+
1156
+ function extractTarball(tarball, destDir) {
1157
+ // Resolve BEFORE anything uses it. Setting `cwd` below means a relative
1158
+ // destDir would resolve against the tarball's directory for `-C`, while the
1159
+ // mkdir here resolves against process.cwd() — so the two would disagree and
1160
+ // the extraction would land somewhere the caller never looks. Absolute makes
1161
+ // them agree by construction. The one caller already passes an absolute path,
1162
+ // so this changes no behaviour today; it stops a future relative caller from
1163
+ // failing silently.
1164
+ destDir = path.resolve(destDir);
1165
+ fs.mkdirSync(destDir, { recursive: true });
1166
+ // --strip-components 1 because the tarball's top level is a single
1167
+ // `claude-factory-engine-<v>/` directory (see publish-release.js).
1168
+ //
1169
+ // That legacy name is DELIBERATE and is not drift: ADR-0194 decoupled the
1170
+ // tarball's internal directory from its published asset name, which were one
1171
+ // string. --strip-components 1 means no consumer ever sees this one, so
1172
+ // renaming it would change every future tarball's bytes — and therefore its
1173
+ // checksum — to no observable end. Do NOT "tidy" it to match ASSET above;
1174
+ // it moves when the legacy asset name is retired.
1175
+ const { args, cwd } = tarExtractInvocation(tarball, destDir);
1176
+ try {
1177
+ execFileSync('tar', args, {
1178
+ cwd,
1179
+ stdio: ['ignore', 'ignore', 'inherit'],
1180
+ });
1181
+ } catch (e) {
1182
+ die(`tar extraction failed — ${e.message}`);
1183
+ }
1184
+ }
1185
+
1186
+ // --- the two commands -------------------------------------------------------
1187
+ async function materialise(opts) {
1188
+ const consumerRoot = path.resolve(opts.consumer || process.cwd());
1189
+ if (!fs.existsSync(path.join(consumerRoot, '.claude'))) {
1190
+ die(`'${consumerRoot}/.claude' not found — run inside a consumer repo, or pass its path.`);
1191
+ }
1192
+ const enginePath = path.join(consumerRoot, '.claude', 'engine');
1193
+
1194
+ // Never clobber a maintainer's live co-dev symlink (mode 1). That is a
1195
+ // deliberate setup, not something a vendored install should overwrite.
1196
+ //
1197
+ // THE REMEDIATION MUST BE PLATFORM-AWARE, AND THE WARNING IS THE LOAD-BEARING
1198
+ // HALF (ADR-0168, FF-241). This message used to print a bare `rm .claude/engine`
1199
+ // on every platform. On Windows that path is a DIRECTORY symlink: `rm` without
1200
+ // a recursive flag fails with "is a directory", and the natural escalation from
1201
+ // that failure is `rm -rf` / `Remove-Item -Recurse` — the form that has
1202
+ // historically followed a Windows directory symlink INTO its target. Here the
1203
+ // target is the maintainer's live claude-factory clone, so the wrong escalation
1204
+ // is not a failed command, it is data loss in a different repo.
1205
+ //
1206
+ // So: name the command that removes the LINK without following it, per platform,
1207
+ // and name the dangerous neighbouring action explicitly. The old message
1208
+ // explained why it refused but not what not to do next, and that second half is
1209
+ // what a hurried operator needs.
1210
+ if (fs.existsSync(enginePath) && fs.lstatSync(enginePath).isSymbolicLink()) {
1211
+ const removeCmd =
1212
+ process.platform === 'win32'
1213
+ ? `cmd /c rmdir "${enginePath}"`
1214
+ : `rm "${enginePath}"`;
1215
+ die(
1216
+ `.claude/engine is a SYMLINK (a maintainer co-dev clone, ADR-0044). This installer ` +
1217
+ `materialises real files and refuses to replace a symlink.\n` +
1218
+ ` If you really want the vendored engine here, remove the LINK first:\n` +
1219
+ ` ${removeCmd}\n` +
1220
+ ` ⚠️ Do NOT use a recursive delete (rm -rf / Remove-Item -Recurse). It can ` +
1221
+ `follow the link into the maintainer's claude-factory clone and delete that ` +
1222
+ `repo's contents instead of the link.\n` +
1223
+ ` Then confirm the clone is intact before re-running.`
1224
+ );
1225
+ }
1226
+
1227
+ const pin = readPin(consumerRoot);
1228
+ const source = resolveSource(opts, pin);
1229
+ // Where to look for .env / .env.local when the bearer is not exported (ADR-0184,
1230
+ // FF-265). Stamped here rather than threaded through resolveSource's five return
1231
+ // sites: it is a property of WHERE THIS RUN IS, not of which channel was chosen.
1232
+ source.consumerRoot = consumerRoot;
1233
+ const targetVersion = opts.version || pin.version || null;
1234
+
1235
+ // Download / copy the tarball into a temp file.
1236
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cf-engine-'));
1237
+ const tarball = path.join(tmpDir, 'engine.tar.gz');
1238
+ await fetchToFile(source, tarball);
1239
+
1240
+ // Checksum: verify against the pin's recorded checksum when we have one — but
1241
+ // only when it DESCRIBES THE VERSION WE ARE INSTALLING. The pin's checksum is
1242
+ // for pin.version; if the caller asked for a different version (`update
1243
+ // --version X` against a pin still on Y), that checksum cannot match by
1244
+ // construction, and using it would produce a guaranteed, misleading "mismatch"
1245
+ // rather than the honest "no checksum for this version" state. The clean pin-a-
1246
+ // team flow updates BOTH version and checksum in the pin, then runs plain
1247
+ // `update` — where the versions match and this verifies.
1248
+ const actual = sha256(tarball);
1249
+ const pinDescribesTarget = !opts.version || !pin.version || opts.version === pin.version;
1250
+ const expected = pinDescribesTarget ? (pin.checksum || '').replace(/^sha256:/, '') : '';
1251
+ if (expected) {
1252
+ if (actual !== expected) {
1253
+ die(
1254
+ `checksum mismatch — expected sha256:${expected}, got sha256:${actual}. ` +
1255
+ `The tarball does not match the pinned checksum. Refusing to install.`
1256
+ );
1257
+ }
1258
+ info(`✅ checksum verified (sha256:${actual.slice(0, 12)}…)`);
1259
+ } else if (opts.requireChecksum) {
1260
+ die(
1261
+ pinDescribesTarget
1262
+ ? 'no checksum recorded in .claude/engine.pin.json and --require-checksum was given.'
1263
+ : `--require-checksum was given but the pin's checksum describes version ${pin.version}, ` +
1264
+ `not the requested ${opts.version}. Update the pin (version + checksum) and re-run.`
1265
+ );
1266
+ } else {
1267
+ // The remedy half of this line must describe what THIS RUN does, not a
1268
+ // worst case (ADR-0188). It used to read "Record it in
1269
+ // .claude/engine.pin.json to verify future runs" — an instruction to
1270
+ // perform an action `writePin` below performs unconditionally, in this same
1271
+ // run, ~170 lines later. Following it found the value already there; NOT
1272
+ // following it left a reader believing the install was unverifiable and
1273
+ // that every future run would warn the same way, when the next plain
1274
+ // `update` verifies cleanly (versions match, so `pinDescribesTarget` is
1275
+ // true and `expected` is this very checksum).
1276
+ //
1277
+ // The measured cost is the whole reason this is not cosmetic: a maintainer
1278
+ // read a fully-successful update and asked which part had failed. None had.
1279
+ //
1280
+ // "on success" is load-bearing, not hedging. This prints ~160 lines before
1281
+ // `writePin`, with two `die()` calls between them (a tarball with no
1282
+ // matrix.json, a failed swap) — so a bare "this run records it" is an
1283
+ // assertion of fact that an abort makes false, leaving `.claude/` empty and
1284
+ // no pin. The old wording survived that path only because an instruction
1285
+ // stays harmless when the run dies; an assertion does not.
1286
+ //
1287
+ // It also stops at the recording and deliberately does NOT promise that a
1288
+ // later `update` verifies. That holds for a channel install and is false for
1289
+ // `init --from <local tarball>` with no `--version`, where the pin carries
1290
+ // that tarball's checksum against an asset the channel may not serve — the
1291
+ // same must-hold-for-every-member rule this change exists to establish.
1292
+ //
1293
+ // Scoped deliberately: the `--require-checksum` die() above keeps its
1294
+ // "update the pin and re-run" remedy, because that path REFUSES and writes
1295
+ // no pin, so there the instruction is the only thing that resolves it.
1296
+ info(
1297
+ `⚠️ checksum not verified — ${
1298
+ pinDescribesTarget
1299
+ ? 'no checksum recorded in the pin file'
1300
+ : `the pin's checksum describes version ${pin.version}, not ${opts.version}`
1301
+ }. Computed sha256:${actual.slice(0, 12)}… — on success this run records it ` +
1302
+ `in .claude/engine.pin.json.`
1303
+ );
1304
+ }
1305
+
1306
+ // Replace the engine directory atomically-ish: extract to a temp sibling,
1307
+ // then swap. A partial extraction must never leave a half-engine in place.
1308
+ const stageDir = path.join(tmpDir, 'engine');
1309
+ extractTarball(tarball, stageDir);
1310
+ const stripped = stripSymlinks(stageDir);
1311
+ if (stripped.length) {
1312
+ info(` stripped ${stripped.length} symlink(s) from the vendored engine (defense in depth)`);
1313
+ }
1314
+ if (!fs.existsSync(path.join(stageDir, 'matrix.json'))) {
1315
+ die('extracted tarball has no matrix.json at its root — it is not a valid engine tarball.');
1316
+ }
1317
+
1318
+ // Swap in the new engine WITHOUT ever leaving the consumer with no engine.
1319
+ // The cross-device copy (stageDir lives in os.tmpdir(), usually a different
1320
+ // filesystem) is the failure-prone part — disk-full, permissions — so it must
1321
+ // happen to a sibling of enginePath BEFORE the old engine is touched. After
1322
+ // that, the swap is same-filesystem renames (atomic, no partial state), and a
1323
+ // failed final rename restores the backup. Deleting the old engine first (as
1324
+ // an earlier version did) risked a mid-copy failure leaving nothing behind.
1325
+ const engineParent = path.dirname(enginePath);
1326
+ const incoming = path.join(engineParent, '.engine.incoming');
1327
+ const backup = path.join(engineParent, '.engine.backup');
1328
+ fs.rmSync(incoming, { recursive: true, force: true });
1329
+ fs.rmSync(backup, { recursive: true, force: true });
1330
+ try {
1331
+ fs.renameSync(stageDir, incoming); // same-fs fast path
1332
+ } catch {
1333
+ fs.cpSync(stageDir, incoming, { recursive: true }); // cross-device fallback
1334
+ }
1335
+ const hadOld = fs.existsSync(enginePath);
1336
+ if (hadOld) fs.renameSync(enginePath, backup);
1337
+ try {
1338
+ fs.renameSync(incoming, enginePath);
1339
+ } catch (e) {
1340
+ if (hadOld) fs.renameSync(backup, enginePath); // restore the old engine
1341
+ fs.rmSync(incoming, { recursive: true, force: true });
1342
+ die(`failed to install the new engine (old engine restored) — ${e.message}`);
1343
+ }
1344
+ fs.rmSync(backup, { recursive: true, force: true });
1345
+ fs.rmSync(tmpDir, { recursive: true, force: true });
1346
+
1347
+ // THE PIN RECORDS THE RELEASE THAT WAS FETCHED, NOT THE ENGINE'S MATRIX
1348
+ // VERSION (ADR-0128). These used to be the same string, so reading the
1349
+ // extracted `matrix.json` was a harmless way to confirm what landed. They are
1350
+ // no longer: a release is `<matrixVersion>.<N>`, so an engine shipped as
1351
+ // `3.8.12` carries a `matrix.json` still reading `3.8`.
1352
+ //
1353
+ // Reading the engine's matrix.json here would therefore write `3.8` into the
1354
+ // pin, and the next PLAIN `update` — which resolves `opts.version ||
1355
+ // pin.version` and has no `latest` lookup — would re-request the OLD `3.8`
1356
+ // asset and silently install an older tree. Install prints success; the
1357
+ // failure surfaces one command later, detached from its cause.
1358
+ //
1359
+ // So prefer `targetVersion` (what this run actually resolved and fetched) and
1360
+ // fall back to the engine's own matrix.json only when there is no target to
1361
+ // record — a `--from <file>` install of an unlabelled tarball.
1362
+ // ONE implementation of "what matrix version is this engine?": the exported,
1363
+ // unit-tested module-level helper. A second local copy of the same computation
1364
+ // used to live here under the SAME NAME — which shadowed that helper for the
1365
+ // whole of materialise(), so the pin-writing call at the bottom of this
1366
+ // function threw `engineMatrixVersion is not a function` on EVERY run
1367
+ // (FF-269, shipped as 1.0.7). The distinct name is what keeps the shadow from
1368
+ // coming back: a future edit anywhere in this function can now call the helper.
1369
+ const matrixVersionOfEngine = engineMatrixVersion(enginePath);
1370
+ const installedVersion = targetVersion || matrixVersionOfEngine;
1371
+ info(
1372
+ `✅ .claude/engine materialised as real files (release ${installedVersion}` +
1373
+ (matrixVersionOfEngine && matrixVersionOfEngine !== installedVersion ? `, matrix ${matrixVersionOfEngine}` : '') +
1374
+ ')'
1375
+ );
1376
+ if (!targetVersion && matrixVersionOfEngine) {
1377
+ // Reached only by `--from <tarball>` with no --version and no pin: there is
1378
+ // no release id to record, so the pin gets the engine's matrix version as a
1379
+ // best guess. Say so — an `update` against it requests an asset that may
1380
+ // not exist, and a loud line here is cheaper than a 404 nobody can explain.
1381
+ info(
1382
+ `⚠️ no release version was resolved this run — pinning the engine's matrix ` +
1383
+ `version (${matrixVersionOfEngine}) as a placeholder. Set the real release id with ` +
1384
+ `\`--version <v>\` before relying on \`update\`.`
1385
+ );
1386
+ }
1387
+
1388
+ // Record/refresh the pin so `update` and `/sync-engine` have a version to
1389
+ // compare against (the non-git vendored engine has no SHA to read). The
1390
+ // `channel` LABEL must reflect where the engine was ACTUALLY fetched from
1391
+ // (review finding): 'studio' when a URL channel base was used (the URL itself
1392
+ // is in channelUrl), else 'github-releases'. A non-URL --channel/pin label the
1393
+ // maintainer chose is honoured verbatim.
1394
+ // A label the maintainer chose THIS RUN wins; a label INHERITED from the prior
1395
+ // pin must not (ADR-0124). Folding both into one fallback let a stale label beat
1396
+ // the derivation: on the first init support-ops recorded 'github-releases', and
1397
+ // the later `update --channel https://…studio/api/engine` kept that label while
1398
+ // writing the studio channelUrl beside it. Behaviour stayed correct (channelUrl
1399
+ // takes precedence when resolving), so nothing failed — the pin simply answered
1400
+ // "which channel is this consumer on?" with the one channel that CANNOT work for
1401
+ // a private repo. A record that is wrong only when read is the kind that gets
1402
+ // believed.
1403
+ // The label answers "where did this engine ACTUALLY come from?", so it is read
1404
+ // from `source.via` — the provenance resolveSource() recorded when it chose —
1405
+ // never re-derived from the channel CONFIG. Config says what a fetch *would*
1406
+ // use; `--from` and `--url` both outrank it in resolveSource, so deriving from
1407
+ // config records a channel that was never contacted.
1408
+ //
1409
+ // A DERIVED label must never be inherited, which is the same stale-label-beats-
1410
+ // derivation defect this hunk originally fixed for 'github-releases', re-entering
1411
+ // through the field the fix itself writes: `init --from …` records 'local-file',
1412
+ // and a later plain `update` would read that back as an "explicit" label and keep
1413
+ // it while fetching from somewhere else entirely. Only a CUSTOM label a
1414
+ // maintainer authored (e.g. 'internal-mirror') survives across runs.
1415
+ // CONFIGURED, not resolved — the pin records only a base someone actually chose.
1416
+ // Writing the built-in default here would freeze it into every consumer's tracked
1417
+ // pin, where it outranks the constant on every later run, so a future host move
1418
+ // could never reach an already-initialised consumer (ADR-0168). The pin's absence
1419
+ // of `channelUrl` is what keeps the constant authoritative; `channel: "studio"`
1420
+ // still records the provenance, so nothing about the record becomes less honest.
1421
+ const base = configuredChannelBase(opts, pin);
1422
+ const explicitLabel = isUrl(opts.channel) ? null : opts.channel;
1423
+ const inheritedLabel =
1424
+ !isUrl(pin.channel) && pin.channel && !DERIVED_CHANNEL_LABELS.has(pin.channel)
1425
+ ? pin.channel
1426
+ : null;
1427
+ // Provenance wins outright when the engine did NOT come from the configured
1428
+ // channel: no label a maintainer chose for a channel can truthfully describe a
1429
+ // tarball that came off local disk or an ad-hoc URL.
1430
+ const offChannel = source.via === 'local-file' || source.via === 'explicit-url';
1431
+ // The MCP endpoint this consumer talks to (ADR-0125). Unlike `channel`, this is
1432
+ // NOT derived from anything the install did — it is pure authored config, so a
1433
+ // flag this run wins and an existing pin value is otherwise CARRIED FORWARD.
1434
+ // Preserving it is the load-bearing half: writePin rewrites the file wholesale,
1435
+ // so omitting the fallback would make every plain `update` silently delete a
1436
+ // maintainer's endpoint and drop the consumer back to the studio default —
1437
+ // the same regenerate-whole-and-under-read hazard as ADR-0122, in a file whose
1438
+ // whole job is to survive updates. Absent entirely is fine: setup-local.sh
1439
+ // degrades to the studio default rather than failing.
1440
+ const mcpUrl = opts.mcpUrl || pin.mcpUrl || null;
1441
+ // `repo` is written ONLY when it is not the default (ADR-0158). It is read at
1442
+ // exactly one place — the GitHub-Releases FALLBACK channel — so on the studio
1443
+ // channel, which is the recommended one and what every vendored consumer uses,
1444
+ // it is never read at all. It is also the single field in this TRACKED file that
1445
+ // names the PRIVATE framework repo, in consumers that may be shared outside the
1446
+ // team (`support-ops` is documented as exactly that). Dropping the default costs
1447
+ // nothing: the read site is `opts.repo || pin.repo || DEFAULT_REPO`, so a
1448
+ // fallback-channel install still resolves it. An explicitly-supplied `--repo`, or
1449
+ // a non-default value already in the pin, is still carried forward — the same
1450
+ // don't-silently-delete-a-maintainer's-setting rule `mcpUrl` follows above.
1451
+ const repo = opts.repo || pin.repo || null;
1452
+ // The setup-local.sh flags this consumer is wired for (ADR-0178). Resolved
1453
+ // BEFORE writePin, because writePin rewrites the file wholesale and the pin's
1454
+ // own previous value is one of the inputs.
1455
+ const wiring = resolveWiringFlags(opts, pin, consumerRoot);
1456
+ writePin(consumerRoot, {
1457
+ version: installedVersion,
1458
+ channel: offChannel ? source.via : explicitLabel || inheritedLabel || source.via,
1459
+ ...(base ? { channelUrl: base } : {}),
1460
+ ...(mcpUrl ? { mcpUrl } : {}),
1461
+ ...(repo && !isDefaultRepo(repo) ? { repo } : {}),
1462
+ // Omitted when empty, so an unwired consumer's pin is byte-identical to what
1463
+ // it was before this field existed — the same don't-write-a-default rule
1464
+ // `channelUrl` follows, and what keeps this change additive for the majority
1465
+ // of consumers that pass no wiring flags at all.
1466
+ ...(wiring.length ? { wiring } : {}),
1467
+ checksum: `sha256:${actual}`,
1468
+ resolution: 'vendored',
1469
+ // PROVENANCE — what produced this install, not what it installed (ADR-0184).
1470
+ //
1471
+ // `version` above is the ENGINE release. These two are the other halves, and
1472
+ // the reason they are here is measured: FF-265 and FF-266 were both filed
1473
+ // against a consumer whose pin read a perfectly current 3.8.57 while the
1474
+ // thing that wrote it was installer 1.0.5 — eleven releases of bin/ behind,
1475
+ // missing the very mechanisms the reports went looking for. The pin recorded
1476
+ // the engine and said nothing about the installer, so from inside the
1477
+ // consumer the two were indistinguishable and both reports reached a
1478
+ // confident wrong diagnosis. A version field alone would not have helped;
1479
+ // it is specifically the INSTALLER's that was stale.
1480
+ //
1481
+ // Both are omitted when unresolvable rather than written as null, so a pin
1482
+ // stays byte-identical for anything that cannot answer — the same
1483
+ // don't-write-a-default rule `channelUrl` and `wiring` already follow.
1484
+ // Which env-var NAME FAMILY this install actually read (ADR-0189), so the
1485
+ // change that eventually REMOVES the legacy names can be evidenced rather
1486
+ // than assumed. Deliberately NOT carried forward from the previous pin,
1487
+ // unlike `wiring` above: that one persists because its omission destroys
1488
+ // state, whereas this is an observation of THIS run and carrying it forward
1489
+ // would preserve a stale reading forever. Omitted when nothing namespaced
1490
+ // was read, which a reader reports as "no data", never as clean.
1491
+ ...(envNamespaceObserved() ? { envNamespace: envNamespaceObserved() } : {}),
1492
+ ...(installerVersion() ? { installerVersion: installerVersion() } : {}),
1493
+ ...(matrixVersionOfEngine ? { matrixVersion: matrixVersionOfEngine } : {}),
1494
+ });
1495
+
1496
+ // Wire the consumer through the engine's own setup-local.sh, vendored mode.
1497
+ const setupLocal = path.join(enginePath, 'maintainer-hooks', 'setup-local.sh');
1498
+ if (!fs.existsSync(setupLocal)) {
1499
+ die('the vendored engine has no maintainer-hooks/setup-local.sh — cannot wire the consumer.');
1500
+ }
1501
+ info(' wiring the consumer (pointer-symlinks, MCP entry, docs) …');
1502
+ // The resolved (carried-forward) wiring flags, plus the one-shot flags given
1503
+ // THIS run. Splitting them is the point: a one-shot flag that leaked into the
1504
+ // persisted set would re-fire on every future update — see
1505
+ // PERSISTED_WIRING_FLAGS for why --force-mcp in particular must not.
1506
+ const oneShot = opts.passthrough.filter((f) => !PERSISTED_WIRING_FLAGS.includes(f));
1507
+ try {
1508
+ execFileSync(
1509
+ 'bash',
1510
+ [setupLocal, consumerRoot, '--vendored-engine', ...wiring, ...oneShot],
1511
+ { stdio: 'inherit' }
1512
+ );
1513
+ } catch (e) {
1514
+ die(`setup-local.sh wiring failed — ${e.message}`);
1515
+ }
1516
+
1517
+ info('');
1518
+ info(`Done (${opts.command}).`);
1519
+ for (const line of requiredNextSteps({ consumerRoot, wiring })) info(line);
1520
+ }
1521
+
1522
+ // --- required next steps (ADR-0178) ------------------------------------------
1523
+ // Everything above prints a running commentary of ✅/⚠️/ℹ️ lines and then ends
1524
+ // with a single trailing sentence about restarting the session. The other
1525
+ // follow-ups a run can imply — restarting Antigravity after a wiring change
1526
+ // (ADR-0066's own stated requirement), merging a diverged AGENTS.md — were
1527
+ // scattered through that commentary or not stated at all, leaving a human or an
1528
+ // agent to collect them by re-reading the whole transcript.
1529
+ //
1530
+ // Conditional, never a fixed list: a block that always names every possible step
1531
+ // trains the reader to skip it, which costs the one step that mattered. The
1532
+ // Antigravity line appears only when this run actually wired it.
1533
+ //
1534
+ // AGENTS.md is deliberately NOT a step here, and that is a Policy 2 call rather
1535
+ // than an omission. setup-local.sh already byte-compares the consumer's
1536
+ // AGENTS.md against the release template and prints the result — it owns the
1537
+ // file, it writes it, and it is the only side that knows which template version
1538
+ // was applied. A second check here would be a second authority over one fact,
1539
+ // and the obvious cheap form of it (does the file exist?) is true of every wired
1540
+ // consumer, which makes the advice permanent noise.
1541
+ function requiredNextSteps({ consumerRoot, wiring }) {
1542
+ const steps = [
1543
+ `restart the Claude Code session in '${consumerRoot}' — this is what loads the new engine`,
1544
+ ];
1545
+ if (wiring.includes('--with-antigravity')) {
1546
+ steps.push(
1547
+ 'restart Antigravity — MCP servers are read at language_server startup, so a stale ' +
1548
+ "session's `NO MCP` is indistinguishable from a broken config (ADR-0066)"
1549
+ );
1550
+ }
1551
+ return ['', 'Required next steps:', ...steps.map((s, i) => ` ${i + 1}. ${s}`)];
1552
+ }
1553
+
1554
+
1555
+ // --- main -------------------------------------------------------------------
1556
+ // Guarded by `require.main === module` so the regression fixture can require
1557
+ // this file for tarExtractInvocation() without executing the installer. npx and
1558
+ // a plain `node bin/claude-factory.js` both satisfy the guard, so the shipped
1559
+ // entry point behaves exactly as before.
1560
+ async function main() {
1561
+ const opts = parseArgs(process.argv);
1562
+ if (!opts.command || opts.command === 'help') {
1563
+ info(HELP);
1564
+ // Set the code and return rather than process.exit — same no-hard-exit
1565
+ // discipline as die() (see DieError). `help` is a clean 0; a bare
1566
+ // invocation with no command at all is a usage error (2).
1567
+ process.exitCode = opts.command ? 0 : 2;
1568
+ return;
1569
+ }
1570
+ if (opts.command !== 'init' && opts.command !== 'update') {
1571
+ die(`unknown command '${opts.command}'. Use 'init' or 'update' (see --help).`, 2);
1572
+ }
1573
+ await materialise(opts);
1574
+ }
1575
+
1576
+ if (require.main === module) {
1577
+ main().catch((e) => {
1578
+ // A DieError was already reported by die() and process.exitCode is already
1579
+ // set — just stop (calling die() here would throw a fresh DieError into an
1580
+ // unhandled rejection). Any OTHER throw is unexpected: report it and set a
1581
+ // failing code, still WITHOUT a hard process.exit (same Windows-handle
1582
+ // reason as die()).
1583
+ if (e instanceof DieError) return;
1584
+ console.error(`❌ claude-factory: ${e && e.stack ? e.stack : String(e)}`);
1585
+ process.exitCode = 1;
1586
+ });
1587
+ }
1588
+
1589
+ // `DEFAULT_CHANNEL_URL` and `channelBase` are exported so the canonical channel
1590
+ // base is DATA another Node consumer can read rather than prose it must re-derive
1591
+ // (ADR-0168, FF-240) — and so its resolution is testable without a network call.
1592
+ module.exports = {
1593
+ tarExtractInvocation,
1594
+ ASSET,
1595
+ LEGACY_ASSET,
1596
+ resolveSource,
1597
+ DEFAULT_CHANNEL_URL,
1598
+ GITHUB_CHANNEL_LABEL,
1599
+ channelBase,
1600
+ configuredChannelBase,
1601
+ crossOriginRedirectTarget,
1602
+ DEFAULT_REPO,
1603
+ LEGACY_DEFAULT_REPOS,
1604
+ isDefaultRepo,
1605
+ PERSISTED_WIRING_FLAGS,
1606
+ resolveWiringFlags,
1607
+ normalizeBearer,
1608
+ malformedBearerReason,
1609
+ readDotenvValue,
1610
+ resolveBearer,
1611
+ resolveEnvName,
1612
+ ENGINE_TOKEN_NAMES,
1613
+ ENGINE_URL_NAMES,
1614
+ envNamespaceObserved,
1615
+ resetEnvNamespaceObservation,
1616
+ engineMatrixVersion,
1617
+ requiredNextSteps,
1618
+ };