@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.
- package/LICENSE +21 -0
- package/README.md +110 -0
- package/bin/claude-factory.js +1618 -0
- package/package.json +77 -0
|
@@ -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
|
+
};
|