contextecf-codeledger 0.10.80 → 0.10.82

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/bin/codeledger.js CHANGED
@@ -1,25 +1,39 @@
1
1
  #!/usr/bin/env node
2
2
  /*
3
- * ContextECF CodeLedger npm launcher — thin shim (safe unscoped alias).
3
+ * CodeLedger npm launcher — thin shim.
4
4
  *
5
- * This is the safe unscoped alias of @codeledger/cli. Both packages install
6
- * the exact same hardened binary. You can install either:
5
+ * This launcher and its installation helpers ship to npm under @codeledger/cli.
6
+ * It contains no engine code, no scoring logic, no formulas. All CodeLedger
7
+ * functionality lives inside the hardened binary that is downloaded during
8
+ * `postinstall` from the GitHub Release for this exact version, verified
9
+ * via SHA256, and cached under ~/.cache/codeledger/bin/.
7
10
  *
8
- * npm install -g contextecf-codeledger@latest (this package)
9
- * npm install -g @codeledger/cli@latest (scoped original)
10
- *
11
- * This file contains no engine code. All CodeLedger functionality lives
12
- * inside the hardened binary downloaded during postinstall.
11
+ * postinstall soft-fails (see scripts/postinstall.js) rather than blocking
12
+ * `npm install`, so the binary may not be cached yet the first time this
13
+ * shim runs. When that happens, this shim first looks for a repo-local
14
+ * runtime (an already-initialized repo's `.codeledger/bin/`) before making
15
+ * one synchronous, on-demand download attempt itself.
13
16
  */
14
17
 
15
18
  'use strict';
16
19
 
17
20
  const { spawnSync } = require('node:child_process');
18
- const { existsSync } = require('node:fs');
19
- const { dirname, join, resolve } = require('node:path');
21
+ const fs = require('node:fs');
22
+ const path = require('node:path');
23
+ const crypto = require('node:crypto');
24
+ const { resolveRuntimePin, compareRuntimeVersions, isRuntimePinTrusted } = require('../scripts/runtime-pin.js');
25
+ const { existsSync, realpathSync, statSync } = require('node:fs');
26
+ const { basename, dirname, join, resolve } = require('node:path');
20
27
  const { homedir, platform } = require('node:os');
21
28
 
22
- const { version } = require('../package.json');
29
+ const { name: packageName, version } = require('../package.json');
30
+
31
+ // `codeledger-mcp` bin alias (same file, no extra JS): for MCP registries
32
+ // that cannot pass arguments, it always runs `mcp start` + the user's args.
33
+ // Every other invocation forwards argv unchanged, including a bare launch,
34
+ // so the CLI's own bare-launch MCP detection applies.
35
+ const MCP_ALIAS = /^codeledger-mcp(\.c?js)?$/i.test(basename(process.argv[1] || ''));
36
+ const ARGS = MCP_ALIAS ? ['mcp', 'start', ...process.argv.slice(2)] : process.argv.slice(2);
23
37
 
24
38
  const CACHE_DIR =
25
39
  process.env.CODELEDGER_CACHE_DIR ||
@@ -37,14 +51,51 @@ function die(msg, code = 1) {
37
51
  process.exit(code);
38
52
  }
39
53
 
54
+ // Repair commands use the installed version, so an old pin cannot trap an upgrade.
55
+ function repairing() {
56
+ const commands = [];
57
+ for (let i = 0; i < ARGS.length && ARGS[i] !== '--'; i++) {
58
+ if (ARGS[i] === '--cwd') { i++; continue; }
59
+ if (!ARGS[i].startsWith('-')) commands.push(ARGS[i]);
60
+ }
61
+ return ['vendor', 'upgrade', 'update', 'init'].includes(commands[0]);
62
+ }
63
+
64
+ function findPinnedRuntime(startDir) {
65
+ if (process.env.CODELEDGER_GLOBAL_VERSION_PROBE === '1' || repairing()) return null;
66
+ let root = resolve(startDir);
67
+ while (true) {
68
+ const pin = resolveRuntimePin(join(root, '.codeledger'), { fs, path, crypto }, compareRuntimeVersions);
69
+ if (pin) {
70
+ const trustDir = process.env.CODELEDGER_RUNTIME_TRUST_DIR || join(homedir(), '.cache', 'codeledger', 'runtime-trust');
71
+ if (isRuntimePinTrusted(root, pin.manifest, { fs, path, crypto }, trustDir)) return { kind: 'standalone', path: pin.path, repoRoot: root };
72
+ process.stderr.write('codeledger: repository runtime has no installed trust receipt; using the global installation. Run the installed codeledger vendor command to install and trust its verified runtime.\n');
73
+ return null;
74
+ }
75
+ if (existsSync(join(root, '.git'))) return null;
76
+ const parent = dirname(root);
77
+ if (parent === root) return null;
78
+ root = parent;
79
+ }
80
+ }
81
+
82
+ // Repo-local fallback: an already-`codeledger init`'d repo vendors its own
83
+ // runtime under .codeledger/bin/. Checked before attempting a network
84
+ // download since it's instant and needs no connectivity — the global cache
85
+ // miss just means this particular machine hasn't installed globally yet,
86
+ // not that the user has no working CodeLedger available to them.
40
87
  function findRepoLocalRuntime(startDir) {
41
88
  if (process.env.CODELEDGER_GLOBAL_VERSION_PROBE === '1') return null;
42
89
  let current = resolve(startDir);
43
90
  while (true) {
91
+ if (existsSync(join(current, '.codeledger', 'runtime.json'))) return null;
92
+ // Legacy scripts require an explicit environment-level repository trust decision.
93
+ if (process.env.CODELEDGER_TRUST_LEGACY_RUNTIME !== '1') return null;
44
94
  const wrapper = join(current, '.codeledger', 'bin', 'codeledger');
45
95
  const standalone = join(current, '.codeledger', 'bin', 'codeledger-standalone.cjs');
46
96
  if (existsSync(wrapper)) return { kind: 'wrapper', path: wrapper, repoRoot: current };
47
97
  if (existsSync(standalone)) return { kind: 'standalone', path: standalone, repoRoot: current };
98
+ if (existsSync(join(current, '.git'))) return null;
48
99
  const parent = dirname(current);
49
100
  if (parent === current) return null;
50
101
  current = parent;
@@ -54,20 +105,19 @@ function findRepoLocalRuntime(startDir) {
54
105
  function runRepoLocal(runtime) {
55
106
  process.stderr.write(
56
107
  [
57
- `codeledger: ContextECF CodeLedger hardened binary for v${version} is not installed in the global cache.`,
108
+ `codeledger: using the repository runtime instead of the global v${version} installation.`,
58
109
  `codeledger: using repo-local runtime at ${runtime.path}`,
59
110
  '',
60
111
  ].join('\n'),
61
112
  );
62
113
  const command = runtime.kind === 'standalone' ? process.execPath : runtime.path;
63
114
  const args = runtime.kind === 'standalone'
64
- ? [runtime.path, ...process.argv.slice(2)]
65
- : process.argv.slice(2);
115
+ ? [runtime.path, ...ARGS]
116
+ : ARGS;
66
117
  const result = spawnSync(command, args, {
67
118
  stdio: 'inherit',
68
- cwd: runtime.repoRoot,
119
+ cwd: process.cwd(),
69
120
  env: { ...process.env, CODELEDGER_DELEGATED: '1' },
70
- shell: runtime.kind === 'wrapper',
71
121
  });
72
122
  if (result.error) {
73
123
  die(`failed to spawn repo-local runtime: ${result.error.message}`);
@@ -75,64 +125,106 @@ function runRepoLocal(runtime) {
75
125
  process.exit(result.status ?? 1);
76
126
  }
77
127
 
78
- if (!existsSync(BINARY_PATH)) {
79
- const repoLocal = findRepoLocalRuntime(process.cwd());
80
- if (repoLocal) {
81
- runRepoLocal(repoLocal);
128
+ // One-shot, on-demand fetch, used only when the binary isn't cached yet and
129
+ // no repo-local runtime was found. Bounded well below postinstall's own
130
+ // window since a live invocation is blocked on this, not a background
131
+ // install step.
132
+ const ON_DEMAND_DEADLINE_MS = 30_000;
133
+
134
+ function launchDirectory() {
135
+ const args = ARGS;
136
+ let target;
137
+ for (let i = 0; i < args.length && args[i] !== '--'; i++) {
138
+ if (args[i].startsWith('--cwd=')) target = args[i].slice(6);
139
+ else if (args[i] === '--cwd') {
140
+ target = args[++i];
141
+ if (!target || target.startsWith('--')) die('--cwd requires a repository directory.', 2);
142
+ }
82
143
  }
144
+ if (target === undefined && (args[0] === 'mcp' || (args.length === 0 && !process.stdin.isTTY))) target = process.env.CODELEDGER_REPO_ROOT;
145
+ if (target !== undefined && !target.trim()) die('Repository target cannot be empty.', 2);
146
+ try {
147
+ const root = realpathSync(resolve(target ?? process.cwd()));
148
+ if (!statSync(root).isDirectory()) throw new Error('Not a directory');
149
+ return root;
150
+ } catch { die('Repository target must be an existing directory. Check --cwd or CODELEDGER_REPO_ROOT.', 2); }
151
+ }
83
152
 
84
- const { arch } = require('node:os');
85
- const platformKey = `${platform()}-${arch()}`;
86
- const installerPath = join(__dirname, '..', 'scripts', 'postinstall.js');
87
-
88
- if (platformKey === 'darwin-x64') {
89
- die(
90
- [
91
- `ContextECF CodeLedger hardened binary for v${version} not found at ${BINARY_PATH}.`,
92
- '',
93
- 'You are on an Intel Mac (darwin-x64). Pre-built binaries are',
94
- 'available for Apple Silicon (darwin-arm64) only.',
95
- '',
96
- 'Options:',
97
- ' 1. Use Rosetta 2 (recommended):',
98
- ' arch -arm64 npm install -g contextecf-codeledger@latest',
99
- ' 2. If this repo was already initialized, use the vendored CLI:',
100
- ' npm install -g contextecf-codeledger@latest',
101
- ' ./.codeledger/bin/codeledger ready',
102
- ' node .codeledger/bin/codeledger-standalone.cjs ready',
103
- ' 3. Download a binary manually from:',
104
- ` https://github.com/codeledgerECF/codeledger/releases/tag/v${version}`,
105
- ].join('\n'),
106
- );
107
- } else {
108
- die(
109
- [
110
- `ContextECF CodeLedger hardened binary for v${version} not found at ${BINARY_PATH}.`,
111
- '',
112
- 'The postinstall step may have been skipped (e.g. --ignore-scripts).',
113
- 'This safe alias installs ContextECF CodeLedger. Avoid: npm install -g codeledger.',
114
- '',
115
- 'Recover with one of:',
116
- ` 1. Upgrade: npm install -g contextecf-codeledger@latest`,
117
- ` 2. Retry this install: npm rebuild contextecf-codeledger`,
118
- ` 3. node "${installerPath}"`,
119
- ` 4. Re-run the install without --ignore-scripts`,
120
- ` 5. In an initialized repo, use: ./.codeledger/bin/codeledger ready`,
121
- '',
122
- 'Or download the binary manually from:',
123
- ` https://github.com/codeledgerECF/codeledger/releases/tag/v${version}`,
124
- ].join('\n'),
153
+ async function main() {
154
+ const pinned = findPinnedRuntime(launchDirectory());
155
+ if (pinned) runRepoLocal(pinned);
156
+ if (existsSync(BINARY_PATH)) {
157
+ const problem = require('../scripts/postinstall.js').verifyCachedBinary(BINARY_PATH);
158
+ if (problem) {
159
+ process.stderr.write(`codeledger: cached binary at ${BINARY_PATH} failed verification (${problem}); discarding it and downloading a verified copy.\n`);
160
+ try { fs.unlinkSync(BINARY_PATH); } catch (error) { die(`could not remove the unverified cached binary: ${error.message}. Delete ${BINARY_PATH} and retry.`); }
161
+ }
162
+ }
163
+ if (!existsSync(BINARY_PATH)) {
164
+ const repoLocal = repairing() ? null : findRepoLocalRuntime(launchDirectory());
165
+ if (repoLocal) {
166
+ runRepoLocal(repoLocal);
167
+ }
168
+
169
+ const { arch } = require('node:os');
170
+ const platformKey = `${platform()}-${arch()}`;
171
+ const installerPath = join(__dirname, '..', 'scripts', 'postinstall.js');
172
+
173
+ const { PLATFORM_MAP, downloadAndVerifyBinary, unsupportedPlatformMessage, readInstallFailure, unpublishedAssetsMessage } = require('../scripts/postinstall.js');
174
+ if (!PLATFORM_MAP[platformKey]) {
175
+ // Don't waste time on a download attempt that can only ever fail —
176
+ // getBinaryPaths() would reject this platformKey immediately anyway.
177
+ // See postinstall.js's matching message for why this is "no
178
+ // committed timeline", not "try again later".
179
+ die(unsupportedPlatformMessage(platformKey));
180
+ }
181
+
182
+ const priorFailure = readInstallFailure();
183
+ if (priorFailure) {
184
+ process.stderr.write(`codeledger: the install step could not fetch the hardened binary (${priorFailure.at}): ${priorFailure.message}\n`);
185
+ }
186
+ process.stderr.write(
187
+ `codeledger: hardened binary for v${version} not cached locally — downloading now (one-time)...\n`,
125
188
  );
189
+ try {
190
+ await downloadAndVerifyBinary({ deadlineMs: ON_DEMAND_DEADLINE_MS });
191
+ } catch (err) {
192
+ const unpublished = unpublishedAssetsMessage(err);
193
+ die(
194
+ [
195
+ `hardened binary for v${version} not found at ${BINARY_PATH}, and the on-demand download just failed: ${err.message}`,
196
+ ...(unpublished ? ['', unpublished,
197
+ 'Retrying or reinstalling this version will not help until they are published.',
198
+ 'To use a published version instead, list them with: npm view @codeledger/cli versions'] : [
199
+ '',
200
+ 'The postinstall step may have been skipped (e.g. --ignore-scripts).']),
201
+ 'Confirm the official scoped package is installed: @codeledger/cli.',
202
+ 'Avoid the unrelated unscoped npm package: codeledger.',
203
+ '',
204
+ 'Recover with one of:',
205
+ ' 1. Retry: codeledger --version',
206
+ ` 2. Reinstall this version: npm install -g ${packageName}@${version}`,
207
+ ` 3. node "${installerPath}"`,
208
+ ' 4. Re-run the install without --ignore-scripts',
209
+ ' 5. In an initialized repo, use: ./.codeledger/bin/codeledger ready',
210
+ '',
211
+ `Release page for v${version} (it exists only once that release is published):`,
212
+ ` https://github.com/codeledgerECF/codeledger/releases/tag/v${version}`,
213
+ ].join('\n'),
214
+ );
215
+ }
126
216
  }
127
- }
128
217
 
129
- const result = spawnSync(BINARY_PATH, process.argv.slice(2), {
130
- stdio: 'inherit',
131
- env: process.env,
132
- });
218
+ const result = spawnSync(BINARY_PATH, ARGS, {
219
+ stdio: 'inherit',
220
+ env: process.env,
221
+ });
133
222
 
134
- if (result.error) {
135
- die(`failed to spawn binary: ${result.error.message}`);
223
+ if (result.error) {
224
+ die(`failed to spawn binary: ${result.error.message}`);
225
+ }
226
+
227
+ process.exit(result.status ?? 1);
136
228
  }
137
229
 
138
- process.exit(result.status ?? 1);
230
+ main().catch((error) => die(error.message));
package/package.json CHANGED
@@ -1,11 +1,12 @@
1
1
  {
2
2
  "name": "contextecf-codeledger",
3
- "version": "0.10.80",
3
+ "version": "0.10.82",
4
4
  "mcpName": "io.github.codeledgerECF/codeledger",
5
5
  "description": "ContextECF CodeLedger — deterministic context selection for AI coding agents. Safe unscoped alias for @codeledger/cli; avoid the unrelated codeledger package.",
6
6
  "type": "commonjs",
7
7
  "bin": {
8
- "codeledger": "./bin/codeledger.js"
8
+ "codeledger": "./bin/codeledger.js",
9
+ "codeledger-mcp": "./bin/codeledger.js"
9
10
  },
10
11
  "scripts": {
11
12
  "postinstall": "node scripts/postinstall.js"
@@ -1,20 +1,31 @@
1
1
  #!/usr/bin/env node
2
2
  /*
3
- * ContextECF CodeLedger postinstall — download + verify the hardened binary.
3
+ * CodeLedger postinstall — download + verify the hardened binary.
4
4
  *
5
- * Runs automatically on `npm install -g contextecf-codeledger`. Downloads the
5
+ * Runs automatically on `npm install -g @codeledger/cli`. Downloads the
6
6
  * platform-specific hardened binary from the GitHub Release for this
7
7
  * exact version, verifies its SHA256 against SHA256SUMS-v<version>.txt
8
8
  * (also downloaded from the release), and writes it to the cache dir.
9
9
  *
10
+ * This is best-effort and always exits 0: if the download can't complete
11
+ * within the propagation window (e.g. installing a version whose release
12
+ * assets haven't landed yet, or aren't going to), npm must NOT treat the
13
+ * install as failed — a failed postinstall can roll back an existing
14
+ * working install. bin/codeledger.js retries the same download on-demand,
15
+ * synchronously, the first time the binary is actually needed.
16
+ *
10
17
  * Environment variables (all optional):
11
18
  * CODELEDGER_SKIP_POSTINSTALL=1 — skip entirely (air-gapped installs)
19
+ * CODELEDGER_RUN_UPGRADE=1 — after the binary download succeeds,
20
+ * also run `codeledger upgrade`
12
21
  * CODELEDGER_BINARY_URL=<url> — override the release download base URL
13
22
  * (for corporate mirrors / internal CDNs).
14
23
  * Must serve the same filenames and a
15
24
  * SHA256SUMS-v<version>.txt sibling file.
16
25
  * CODELEDGER_CACHE_DIR=<path> — override the cache directory
17
26
  * (default: ~/.cache/codeledger/bin)
27
+ * CODELEDGER_INSTALL_DEADLINE_MS=<ms> — override the download propagation
28
+ * window (default: 150000 = 2.5 min)
18
29
  *
19
30
  * This file contains no engine logic. It only fetches, verifies, and writes
20
31
  * bytes. Safe to inspect, safe to mirror, safe to audit.
@@ -31,113 +42,157 @@ const {
31
42
  chmodSync,
32
43
  existsSync,
33
44
  unlinkSync,
45
+ readFileSync,
46
+ statSync,
34
47
  } = require('node:fs');
35
48
  const { join } = require('node:path');
36
49
  const { homedir, platform, arch } = require('node:os');
37
50
 
38
51
  const { version } = require('../package.json');
39
52
 
40
- // ─── config ──────────────────────────────────────────────────────────────────
53
+ // ─── platform / path resolution (pure — no I/O, no process.exit) ──────────
54
+
55
+ // Only these native asset names are wired into this installer.
56
+ const PLATFORM_MAP = {
57
+ 'darwin-arm64': 'darwin-arm64',
58
+ 'linux-x64': 'linux-x64',
59
+ };
60
+
61
+ function unsupportedPlatformMessage(platformKey) {
62
+ return [
63
+ `${platformKey} has no pre-built CodeLedger hardened binary.`,
64
+ `Supported native routes: ${Object.keys(PLATFORM_MAP).join(', ')}.`,
65
+ 'A successful npm install does not establish a working runtime or MCP connection.',
66
+ 'For an already initialized repository with a trusted vendored runtime:',
67
+ ' node .codeledger/bin/codeledger-standalone.cjs ready',
68
+ 'For a fresh repository, use a supported host or a supported linux-x64 environment.',
69
+ 'Skipping postinstall does not bootstrap a missing runtime; codeledger init cannot fix that loop.',
70
+ 'Intel Macs cannot run an Apple Silicon binary through Rosetta.',
71
+ 'Source-build prerequisites and limitations: https://github.com/codeledgerECF/codeledger/blob/main/docs/security/ENTERPRISE_ASSURANCE.md',
72
+ ].join('\n');
73
+ }
74
+
75
+ function getPlatformKey() {
76
+ return `${platform()}-${arch()}`;
77
+ }
41
78
 
42
- if (process.env.CODELEDGER_SKIP_POSTINSTALL === '1') {
43
- console.log(
44
- 'codeledger: CODELEDGER_SKIP_POSTINSTALL=1 set, skipping ContextECF CodeLedger hardened binary download.',
79
+ function getCacheDir() {
80
+ return (
81
+ process.env.CODELEDGER_CACHE_DIR || join(homedir(), '.cache', 'codeledger', 'bin')
45
82
  );
46
- process.exit(0);
47
83
  }
48
84
 
49
- // Skip when running inside this source workspace (dev mode). The presence of
50
- // pnpm-workspace.yaml two directories up is a reliable signal that this is not
51
- // a real end-user install, and the release binary for this version may not
52
- // exist yet.
53
- try {
54
- const path = require('node:path');
55
- const fs = require('node:fs');
56
- const workspaceRoot = path.resolve(__dirname, '..', '..', '..');
57
- if (fs.existsSync(path.join(workspaceRoot, 'pnpm-workspace.yaml'))) {
58
- console.log(
59
- 'codeledger: in-workspace dev install detected, skipping ContextECF CodeLedger hardened binary download.',
60
- );
61
- process.exit(0);
85
+ // Binaries are hosted on the public CodeLedger GitHub Releases page so
86
+ // `npm install -g @codeledger/cli` can fetch them without authentication.
87
+ function getBinaryPaths(platformKey) {
88
+ const platformSuffix = PLATFORM_MAP[platformKey];
89
+ if (!platformSuffix) return null;
90
+
91
+ const releaseBase =
92
+ process.env.CODELEDGER_BINARY_URL ||
93
+ `https://github.com/codeledgerECF/codeledger/releases/download/v${version}`;
94
+ const binaryRemoteName = `codeledger-hardened-${platformSuffix}-v${version}`;
95
+ const cacheDir = getCacheDir();
96
+
97
+ return {
98
+ binaryUrl: `${releaseBase}/${binaryRemoteName}`,
99
+ checksumsUrl: `${releaseBase}/SHA256SUMS-v${version}.txt`,
100
+ binaryRemoteName,
101
+ cacheDir,
102
+ binaryLocalPath: join(cacheDir, `codeledger-hardened-v${version}`),
103
+ };
104
+ }
105
+
106
+ // The verified SHA-256 is recorded beside the cached binary so every launch can
107
+ // reject a truncated, replaced, or tampered cache before executing it.
108
+ function receiptPath(binaryPath) {
109
+ return `${binaryPath}.sha256.json`;
110
+ }
111
+
112
+ function writeVerifiedReceipt(binaryPath, sha256) {
113
+ const stat = statSync(binaryPath);
114
+ writeFileSync(receiptPath(binaryPath), JSON.stringify({ schema: 1, version, sha256, size: stat.size }) + '\n');
115
+ }
116
+
117
+ /** Returns null when the cached binary matches its install receipt, else the reason it does not. */
118
+ function verifyCachedBinary(binaryPath) {
119
+ let receipt;
120
+ try {
121
+ receipt = JSON.parse(readFileSync(receiptPath(binaryPath), 'utf8'));
122
+ } catch {
123
+ return 'no install receipt';
62
124
  }
63
- } catch {
64
- // fall through to normal install
125
+ if (!receipt || receipt.schema !== 1 || receipt.version !== version || !/^[a-f0-9]{64}$/.test(String(receipt.sha256))) return 'invalid install receipt';
126
+ let stat;
127
+ try {
128
+ stat = statSync(binaryPath);
129
+ } catch {
130
+ return 'binary missing';
131
+ }
132
+ if (!stat.isFile() || stat.size === 0) return 'binary is empty or not a file';
133
+ if (stat.size !== receipt.size) return 'binary size changed since install';
134
+ // Hashed on every launch: size or timestamps can be preserved by a same-size replacement.
135
+ const actual = createHash('sha256').update(readFileSync(binaryPath)).digest('hex');
136
+ return actual === receipt.sha256 ? null : 'SHA256 mismatch with install receipt';
65
137
  }
66
138
 
67
- // darwin-x64 is temporarily absent from v0.10.6 — no working Intel
68
- // Mac runner on the release CI plan as of 2026-04-09. Intel Mac
69
- // users hit the "unsupported platform" soft-fail below, which tells
70
- // them to download manually from the GitHub Release. Re-add when a
71
- // working Intel Mac binary is available.
72
- const PLATFORM_MAP = {
73
- 'darwin-arm64': 'darwin-arm64',
74
- 'linux-x64': 'linux-x64',
75
- };
139
+ // npm hides postinstall output by default, so a soft failure is recorded where
140
+ // the first `codeledger` run can report it.
141
+ function installFailurePath() {
142
+ return join(getCacheDir(), `install-failure-v${version}.json`);
143
+ }
76
144
 
77
- const platformKey = `${platform()}-${arch()}`;
78
- const platformSuffix = PLATFORM_MAP[platformKey];
79
-
80
- if (!platformSuffix) {
81
- if (platformKey === 'darwin-x64') {
82
- console.log('');
83
- console.log('codeledger: ContextECF CodeLedger Intel Mac (darwin-x64) detected.');
84
- console.log('codeledger: Pre-built binaries are available for Apple Silicon (darwin-arm64) only.');
85
- console.log('');
86
- console.log('Options:');
87
- console.log(' Safe alias: contextecf-codeledger. Official package: @codeledger/cli. Avoid: codeledger.');
88
- console.log(' 1. Use Rosetta 2 (recommended):');
89
- console.log(' arch -arm64 npm install -g contextecf-codeledger@latest');
90
- console.log(' 2. If this repo was already initialized, use the vendored CLI:');
91
- console.log(' ./.codeledger/bin/codeledger ready');
92
- console.log(' node .codeledger/bin/codeledger-standalone.cjs ready');
93
- console.log(' 3. For a new repo without a vendored CLI, use a supported platform or Docker.');
94
- console.log(' 4. Download a binary manually from:');
95
- console.log(` https://github.com/codeledgerECF/codeledger/releases/tag/v${version}`);
96
- console.log('');
97
- } else {
98
- console.error(
99
- `codeledger: unsupported platform ${platformKey}. Supported: ${Object.keys(
100
- PLATFORM_MAP,
101
- ).join(', ')}`,
102
- );
103
- console.error(
104
- `codeledger: v${version} does not provide a hardened binary for ${platformKey}.`,
105
- );
106
- console.error(
107
- 'codeledger: this installer is for ContextECF CodeLedger; you can still use the published source package or a supported release asset.',
108
- );
145
+ function recordInstallFailure(err) {
146
+ try {
147
+ mkdirSync(getCacheDir(), { recursive: true });
148
+ writeFileSync(installFailurePath(), JSON.stringify({
149
+ schema: 1, version, at: new Date().toISOString(),
150
+ status: typeof err.statusCode === 'number' ? err.statusCode : null,
151
+ message: String(err.message).split('\n')[0].slice(0, 300),
152
+ }) + '\n');
153
+ } catch { /* Reporting is best effort; the install stays a soft failure. */ }
154
+ }
155
+
156
+ function readInstallFailure() {
157
+ try {
158
+ const record = JSON.parse(readFileSync(installFailurePath(), 'utf8'));
159
+ return record && record.schema === 1 && record.version === version ? record : null;
160
+ } catch {
161
+ return null;
109
162
  }
110
- // Fail softly: don't block `npm install` for unsupported platforms. The
111
- // bin shim will tell the user what happened when they first run `codeledger`.
112
- process.exit(0);
113
163
  }
114
164
 
115
- // Binaries are hosted on the public CodeLedger GitHub Releases page so
116
- // `npm install -g @codeledger/cli` can fetch them without authentication.
117
- const RELEASE_BASE =
118
- process.env.CODELEDGER_BINARY_URL ||
119
- `https://github.com/codeledgerECF/codeledger/releases/download/v${version}`;
165
+ function clearInstallFailure() {
166
+ try { unlinkSync(installFailurePath()); } catch { /* Nothing recorded. */ }
167
+ }
120
168
 
121
- const BINARY_REMOTE_NAME = `codeledger-hardened-${platformSuffix}-v${version}`;
122
- const BINARY_URL = `${RELEASE_BASE}/${BINARY_REMOTE_NAME}`;
123
- const CHECKSUMS_URL = `${RELEASE_BASE}/SHA256SUMS-v${version}.txt`;
169
+ /** A 404 after the propagation window means this version's release assets are not published. */
170
+ function unpublishedAssetsMessage(err) {
171
+ return err && err.statusCode === 404
172
+ ? `The v${version} release assets were not found (HTTP 404): this version's hardened binaries are not published yet, or were withdrawn.`
173
+ : null;
174
+ }
124
175
 
125
- const CACHE_DIR =
126
- process.env.CODELEDGER_CACHE_DIR ||
127
- join(homedir(), '.cache', 'codeledger', 'bin');
128
- const BINARY_LOCAL_NAME = `codeledger-hardened-v${version}`;
129
- const BINARY_LOCAL_PATH = join(CACHE_DIR, BINARY_LOCAL_NAME);
176
+ function isBinaryCached() {
177
+ const paths = getBinaryPaths(getPlatformKey());
178
+ return Boolean(paths && existsSync(paths.binaryLocalPath) && verifyCachedBinary(paths.binaryLocalPath) === null);
179
+ }
130
180
 
131
181
  // ─── http ────────────────────────────────────────────────────────────────────
132
182
 
133
- function fetchBufferOnce(url, redirectDepth = 0) {
183
+ const FETCH_TIMEOUT_MS = 60_000;
184
+
185
+ function fetchBufferOnce(url, redirectDepth = 0, expiresAt = Date.now() + FETCH_TIMEOUT_MS) {
134
186
  if (redirectDepth > 5) {
135
187
  return Promise.reject(new Error(`too many redirects fetching ${url}`));
136
188
  }
137
189
  return new Promise((resolve, reject) => {
190
+ const remaining = Math.min(FETCH_TIMEOUT_MS, expiresAt - Date.now());
191
+ if (remaining <= 0) { reject(new Error('Binary download deadline exceeded.')); return; }
138
192
  const req = https.get(
139
193
  url,
140
194
  {
195
+ timeout: FETCH_TIMEOUT_MS,
141
196
  headers: {
142
197
  'User-Agent': `codeledger-installer/${version}`,
143
198
  Accept: 'application/octet-stream',
@@ -153,7 +208,7 @@ function fetchBufferOnce(url, redirectDepth = 0) {
153
208
  ) {
154
209
  res.resume();
155
210
  const next = new URL(res.headers.location, url).toString();
156
- fetchBufferOnce(next, redirectDepth + 1).then(resolve, reject);
211
+ fetchBufferOnce(next, redirectDepth + 1, expiresAt).then(resolve, reject);
157
212
  return;
158
213
  }
159
214
  if (res.statusCode !== 200) {
@@ -169,34 +224,51 @@ function fetchBufferOnce(url, redirectDepth = 0) {
169
224
  res.on('error', reject);
170
225
  },
171
226
  );
227
+ const timer = setTimeout(() => {
228
+ const error = new Error('Binary download deadline exceeded.');
229
+ req.destroy(error);
230
+ reject(error);
231
+ }, remaining);
172
232
  req.on('error', reject);
173
- req.setTimeout(60_000, () => {
174
- req.destroy(new Error(`timeout fetching ${url}`));
175
- });
233
+ req.once('close', () => clearTimeout(timer));
176
234
  });
177
235
  }
178
236
 
237
+ // Default propagation window for the postinstall-time attempt. Deliberately
238
+ // a wall-clock deadline, not a fixed attempt count: the question is "how
239
+ // long is it plausible for a just-published release's assets to still be
240
+ // propagating," not "how many times did we try." A version whose GitHub
241
+ // Release will never exist (npm published ahead of a working release — the
242
+ // actual incident this retry policy is designed around) now fails within
243
+ // this window instead of retrying for ~8 minutes that were never going to
244
+ // succeed.
245
+ const DEFAULT_DEADLINE_MS = Number(process.env.CODELEDGER_INSTALL_DEADLINE_MS) || 150_000; // 2.5 minutes
246
+
179
247
  // Retry wrapper that tolerates the short propagation window where a freshly
180
248
  // published package may be visible before all release assets are available.
181
- // A 404 is retried with exponential backoff; non-404 errors fail fast.
182
- async function fetchBuffer(url) {
183
- const backoffs = [10, 20, 40, 60, 60, 60, 60, 60, 60, 60]; // seconds
249
+ // A 404 is retried with exponential backoff bounded by deadlineMs;
250
+ // non-404 errors fail fast.
251
+ async function fetchBuffer(url, deadlineMs = DEFAULT_DEADLINE_MS) {
252
+ const start = Date.now();
253
+ let attempt = 0;
184
254
  let lastErr;
185
- for (let i = 0; i <= backoffs.length; i++) {
255
+ for (;;) {
186
256
  try {
187
- return await fetchBufferOnce(url);
257
+ return await fetchBufferOnce(url, 0, start + deadlineMs);
188
258
  } catch (err) {
189
259
  lastErr = err;
190
- // Only retry on 404 — everything else is a hard failure
260
+ // Only retry on 404 — everything else is a hard failure.
191
261
  if (err.statusCode !== 404) throw err;
192
- if (i === backoffs.length) break;
193
- const wait = backoffs[i];
194
- console.log(
195
- `codeledger: Binary pending — new release detected, retrying in ${wait}s ` +
196
- `(attempt ${i + 1}/${backoffs.length + 1}). ` +
197
- `This is normal for the first ~10 minutes after a release.`,
262
+ const elapsed = Date.now() - start;
263
+ const wait = Math.min(20_000, 5_000 * 2 ** attempt);
264
+ if (elapsed + wait >= deadlineMs) break;
265
+ attempt += 1;
266
+ const remainingSec = Math.round((deadlineMs - elapsed) / 1000);
267
+ console.error(
268
+ `codeledger: Binary pending — retrying in ${Math.round(wait / 1000)}s ` +
269
+ `(${remainingSec}s left in propagation window).`,
198
270
  );
199
- await new Promise((r) => setTimeout(r, wait * 1000));
271
+ await new Promise((r) => setTimeout(r, wait));
200
272
  }
201
273
  }
202
274
  throw lastErr;
@@ -221,72 +293,171 @@ function findExpectedSha(checksumsText, filename) {
221
293
  return null;
222
294
  }
223
295
 
224
- // ─── main ────────────────────────────────────────────────────────────────────
296
+ // ─── download + verify (the reusable, side-effect-scoped core) ────────────
297
+
298
+ // Downloads, verifies, and caches the hardened binary for the current
299
+ // platform + this package's version. Throws on failure — does not exit the
300
+ // process. Callers decide what "failure" means for them: postinstall.js
301
+ // soft-fails (exit 0, retry later); bin/codeledger.js's on-demand path
302
+ // falls through to a hard failure message since the user is blocked right
303
+ // now.
304
+ async function downloadAndVerifyBinary({ deadlineMs = DEFAULT_DEADLINE_MS } = {}) {
305
+ const platformKey = getPlatformKey();
306
+ const paths = getBinaryPaths(platformKey);
307
+ if (!paths) {
308
+ throw new Error(`unsupported platform ${platformKey}`);
309
+ }
225
310
 
226
- async function main() {
227
- console.log(`codeledger: installing v${version} hardened binary for ${platformKey}`);
228
- console.log(` source: ${BINARY_URL}`);
311
+ console.error(`codeledger: installing v${version} hardened binary for ${platformKey}`);
312
+ console.error(` source: ${paths.binaryUrl}`);
229
313
 
230
314
  const [checksumsBuf, binary] = await Promise.all([
231
- fetchBuffer(CHECKSUMS_URL),
232
- fetchBuffer(BINARY_URL),
315
+ fetchBuffer(paths.checksumsUrl, deadlineMs),
316
+ fetchBuffer(paths.binaryUrl, deadlineMs),
233
317
  ]);
234
318
  const checksums = checksumsBuf.toString('utf8');
235
319
 
236
- const expectedSha = findExpectedSha(checksums, BINARY_REMOTE_NAME);
320
+ const expectedSha = findExpectedSha(checksums, paths.binaryRemoteName);
237
321
  if (!expectedSha) {
238
322
  throw new Error(
239
- `${BINARY_REMOTE_NAME} not listed in SHA256SUMS-v${version}.txt — cannot verify binary integrity`,
323
+ `${paths.binaryRemoteName} not listed in SHA256SUMS-v${version}.txt — cannot verify binary integrity`,
240
324
  );
241
325
  }
242
326
 
243
327
  const actualSha = createHash('sha256').update(binary).digest('hex');
244
328
  if (actualSha !== expectedSha) {
245
329
  throw new Error(
246
- `SHA256 mismatch for ${BINARY_REMOTE_NAME}:\n expected ${expectedSha}\n got ${actualSha}`,
330
+ `SHA256 mismatch for ${paths.binaryRemoteName}:\n expected ${expectedSha}\n got ${actualSha}`,
247
331
  );
248
332
  }
249
333
 
250
- mkdirSync(CACHE_DIR, { recursive: true });
334
+ mkdirSync(paths.cacheDir, { recursive: true });
251
335
 
252
336
  // Atomic-ish write: write to tmp, rename
253
- const tmpPath = `${BINARY_LOCAL_PATH}.tmp`;
337
+ const tmpPath = `${paths.binaryLocalPath}.tmp`;
254
338
  if (existsSync(tmpPath)) unlinkSync(tmpPath);
255
339
  writeFileSync(tmpPath, binary);
256
340
  chmodSync(tmpPath, 0o755);
257
- if (existsSync(BINARY_LOCAL_PATH)) unlinkSync(BINARY_LOCAL_PATH);
258
- // rename via fs for portability
259
- require('node:fs').renameSync(tmpPath, BINARY_LOCAL_PATH);
260
-
261
- console.log(`codeledger: installed ${BINARY_LOCAL_PATH}`);
262
- console.log(` sha256: ${actualSha}`);
263
- console.log('');
264
- console.log('codeledger: next step inside your repo:');
265
- console.log(' codeledger ready');
266
- console.log('codeledger: ready initializes, scans, and prepares ambient task context.');
341
+ if (existsSync(paths.binaryLocalPath)) unlinkSync(paths.binaryLocalPath);
342
+ require('node:fs').renameSync(tmpPath, paths.binaryLocalPath);
343
+ writeVerifiedReceipt(paths.binaryLocalPath, actualSha);
344
+ clearInstallFailure();
345
+
346
+ console.error(`codeledger: installed ${paths.binaryLocalPath}`);
347
+ console.error(` sha256: ${actualSha}`);
348
+
349
+ return paths.binaryLocalPath;
267
350
  }
268
351
 
269
- // Only run main() when invoked directly (npm postinstall, node scripts/postinstall.js).
270
- // When require()'d from tests, just export the helper functions.
271
- if (require.main === module) {
272
- main().catch((err) => {
273
- console.error(`codeledger: postinstall failed — ${err.message}`);
274
- console.error('');
275
- console.error('Recovery options:');
276
- console.error(' 1. Upgrade: npm install -g contextecf-codeledger@latest');
277
- console.error(' 2. Retry this install: npm rebuild contextecf-codeledger');
278
- console.error(' 3. Air-gapped install:');
279
- console.error(' CODELEDGER_SKIP_POSTINSTALL=1 npm install -g contextecf-codeledger@latest');
280
- console.error(' then manually place the binary at:');
281
- console.error(` ${BINARY_LOCAL_PATH}`);
282
- console.error(' 4. Use a mirror:');
283
- console.error(' CODELEDGER_BINARY_URL=https://your-mirror/path npm install -g contextecf-codeledger@latest');
284
- console.error('');
352
+ // Optional post-download step: run `codeledger upgrade` in the most likely
353
+ // target repo so existing repos pick up refreshed ambient hooks/runtime
354
+ // without a second manual command. Opt-in only — never runs by default.
355
+ function maybeRunUpgrade() {
356
+ if (process.env.CODELEDGER_RUN_UPGRADE === '1') {
357
+ const { spawnSync } = require('node:child_process');
358
+ const path = require('node:path');
359
+ const fs = require('node:fs');
360
+ const codeledgerBin = path.join(__dirname, '..', 'bin', 'codeledger.js');
361
+ const initCwd = process.env['INIT_CWD'];
362
+ const homeCwd = process.env['HOME'] ?? process.env['USERPROFILE'] ?? process.cwd();
363
+ const upgradeCwd =
364
+ initCwd && fs.existsSync(path.join(initCwd, '.codeledger')) ? initCwd : homeCwd;
365
+ console.error('codeledger: CODELEDGER_RUN_UPGRADE=1 set, running upgrade...');
366
+ const upgradeResult = spawnSync(process.execPath, [codeledgerBin, 'upgrade'], {
367
+ stdio: 'inherit',
368
+ cwd: upgradeCwd,
369
+ env: { ...process.env, CODELEDGER_SKIP_AMBIENT_ACTIVATION: '1' },
370
+ });
371
+ if ((upgradeResult.status ?? 0) !== 0) {
372
+ console.error('');
373
+ console.error('codeledger: upgrade step encountered an issue. Run manually:');
374
+ console.error(' codeledger upgrade');
375
+ }
376
+ }
377
+ }
378
+
379
+ // ─── postinstall entry point ───────────────────────────────────────────────
380
+ // All side-effecting decisions (env skip, dev-workspace skip, platform
381
+ // gating, process.exit) live here, gated behind `require.main === module`
382
+ // below — so requiring this file as a library (from bin/codeledger.js, or
383
+ // from tests) never has side effects.
384
+
385
+ async function runPostinstall() {
386
+ if (process.env.CODELEDGER_SKIP_POSTINSTALL === '1') {
285
387
  console.error(
286
- `Manual download: https://github.com/codeledgerECF/codeledger/releases/tag/v${version}`,
388
+ 'codeledger: CODELEDGER_SKIP_POSTINSTALL=1 set, skipping ContextECF CodeLedger hardened binary download.',
287
389
  );
288
- process.exit(1);
289
- });
390
+ return;
391
+ }
392
+
393
+ // Skip when running inside this source workspace (dev mode). The presence
394
+ // of pnpm-workspace.yaml two directories up is a reliable signal that
395
+ // this is not a real end-user install, and the release binary for this
396
+ // version may not exist yet.
397
+ try {
398
+ const path = require('node:path');
399
+ const fs = require('node:fs');
400
+ const workspaceRoot = path.resolve(__dirname, '..', '..', '..');
401
+ if (fs.existsSync(path.join(workspaceRoot, 'pnpm-workspace.yaml'))) {
402
+ console.error(
403
+ 'codeledger: in-workspace dev install detected, skipping ContextECF CodeLedger hardened binary download.',
404
+ );
405
+ return;
406
+ }
407
+ } catch {
408
+ // fall through to normal install
409
+ }
410
+
411
+ const platformKey = getPlatformKey();
412
+
413
+ if (!PLATFORM_MAP[platformKey]) {
414
+ // Soft install failure does not create a fresh runtime on this platform.
415
+ console.error(`codeledger: ${unsupportedPlatformMessage(platformKey)}`);
416
+ return;
417
+ }
418
+
419
+ try {
420
+ await downloadAndVerifyBinary();
421
+ console.error('');
422
+ console.error('codeledger: install complete.');
423
+ console.error(' Next: cd your-repo && codeledger ready');
424
+ console.error(' Existing repos can refresh ambient hooks/runtime with: codeledger upgrade');
425
+ console.error('codeledger: ready initializes, scans, and prepares ambient task context.');
426
+ maybeRunUpgrade();
427
+ } catch (err) {
428
+ // Soft-fail, deliberately: exit 0 either way. A failed postinstall
429
+ // makes npm treat the whole install as failed, which can roll back an
430
+ // existing working install (the exact incident this is designed
431
+ // around). bin/codeledger.js retries this same download on-demand,
432
+ // synchronously, the first time it's actually needed.
433
+ recordInstallFailure(err);
434
+ console.error(`codeledger: could not pre-fetch the hardened binary — ${err.message}`);
435
+ const unpublished = unpublishedAssetsMessage(err);
436
+ if (unpublished) console.error(`codeledger: ${unpublished}`);
437
+ console.error('codeledger: not fatal — it will download automatically on first "codeledger" invocation.');
438
+ console.error('codeledger: or, in an initialized repo, use: ./.codeledger/bin/codeledger ready');
439
+ }
290
440
  }
291
441
 
292
- module.exports = { findExpectedSha, PLATFORM_MAP };
442
+ // Only run the postinstall entry point when invoked directly (npm
443
+ // postinstall, node scripts/postinstall.js). When required — from
444
+ // bin/codeledger.js's on-demand download path, or from tests — this file
445
+ // has no side effects; only the exported pure/reusable functions are used.
446
+ if (require.main === module) {
447
+ runPostinstall().then(() => process.exit(0));
448
+ }
449
+
450
+ module.exports = {
451
+ findExpectedSha,
452
+ PLATFORM_MAP,
453
+ downloadAndVerifyBinary,
454
+ getPlatformKey,
455
+ getBinaryPaths,
456
+ isBinaryCached,
457
+ unsupportedPlatformMessage,
458
+ verifyCachedBinary,
459
+ writeVerifiedReceipt,
460
+ readInstallFailure,
461
+ recordInstallFailure,
462
+ unpublishedAssetsMessage,
463
+ };
@@ -0,0 +1,125 @@
1
+ /* Generated by scripts/generate-runtime-resolver.mjs. Public installation contract only. */
2
+ 'use strict';
3
+ module.exports = (() => {
4
+ /** Strict SemVer 2.0, shared by pin validation and explicit installation. */
5
+ function parseRuntimeVersion(value) {
6
+ const match = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/.exec(value);
7
+ if (!match || match[0] !== value) throw new Error(`Invalid runtime version: ${value}. Use an exact semantic version.`);
8
+ const core = match.slice(1, 4).map(Number);
9
+ const prerelease = match[4]?.split('.') ?? [];
10
+ if (core.some((n) => !Number.isSafeInteger(n)) || prerelease.some((p) => /^\d+$/.test(p) && (p.length > 1 && p.startsWith('0')))) {
11
+ throw new Error(`Invalid runtime version: ${value}`);
12
+ }
13
+ return { core, prerelease };
14
+ }
15
+
16
+ function compareRuntimeVersions(left, right) {
17
+ const a = parseRuntimeVersion(left);
18
+ const b = parseRuntimeVersion(right);
19
+ for (let i = 0; i < 3; i++) {
20
+ if (a.core[i] !== b.core[i]) return a.core[i] < b.core[i] ? -1 : 1;
21
+ }
22
+ if (!a.prerelease.length || !b.prerelease.length) {
23
+ return a.prerelease.length === b.prerelease.length ? 0 : a.prerelease.length ? -1 : 1;
24
+ }
25
+ for (let i = 0; i < Math.max(a.prerelease.length, b.prerelease.length); i++) {
26
+ const x = a.prerelease[i];
27
+ const y = b.prerelease[i];
28
+ if (x === y) continue;
29
+ if (x === undefined || y === undefined) return x === undefined ? -1 : 1;
30
+ const xn = /^\d+$/.test(x);
31
+ const yn = /^\d+$/.test(y);
32
+ if (xn && yn) return x.length !== y.length ? (x.length < y.length ? -1 : 1) : x < y ? -1 : 1;
33
+ if (xn !== yn) return xn ? -1 : 1;
34
+ return x < y ? -1 : 1;
35
+ }
36
+ return 0;
37
+ }
38
+
39
+ function runtimeGenerationSha256(bytes, notices, crypto) {
40
+ return crypto.createHash('sha256').update(String(bytes.length) + ':').update(bytes).update(notices === null ? '-1:' : String(notices.length) + ':').update(notices || Buffer.alloc(0)).digest('hex');
41
+ }
42
+
43
+ function resolveRuntimePin(
44
+ codeledgerDir,
45
+ io,
46
+ compare,
47
+ ) {
48
+ const manifestPath = io.path.join(codeledgerDir, 'runtime.json');
49
+ if (!io.fs.existsSync(manifestPath)) return null;
50
+ if (io.fs.lstatSync(codeledgerDir).isSymbolicLink() || io.fs.lstatSync(manifestPath).isSymbolicLink()) throw new Error('Runtime state must not be symlinked. Run codeledger vendor from the owning repository.');
51
+ if (io.fs.statSync(manifestPath).size > 65536) throw new Error('runtime.json exceeds its 64 KiB contract bound. Repair with codeledger vendor.');
52
+ let raw;
53
+ try { raw = JSON.parse(io.fs.readFileSync(manifestPath, 'utf8')); } catch (error) { throw new Error('.codeledger/runtime.json is not valid JSON (' + error.message + '). Restore it from version control, or repair with codeledger vendor.'); }
54
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw new Error('Invalid runtime.json: expected an object. Repair with codeledger vendor.');
55
+ const m = raw;
56
+ if (m.schema_version !== 'codeledger/runtime/v1' || typeof m.installation_id !== 'string' || typeof m.version !== 'string' || typeof m.min_runtime !== 'string'
57
+ || typeof m.sha256 !== 'string' || !/^[a-f0-9]{64}$/.test(m.sha256)
58
+ || (m.distribution !== 'full' && m.distribution !== 'open') || !['fail-open-v1', 'legacy'].includes(m.hook_contract)
59
+ || !m.source || typeof m.source.kind !== 'string' || typeof m.source.location !== 'string'
60
+ || !m.build || m.build.content_sha256 !== m.sha256 || (m.build.revision !== null && typeof m.build.revision !== 'string')
61
+ || (m.build.source_dirty !== undefined && m.build.source_dirty !== null && typeof m.build.source_dirty !== 'boolean')
62
+ || (m.build.generation_sha256 === undefined && m.build.legal_sha256 !== undefined)
63
+ || (m.build.generation_sha256 !== undefined && (typeof m.build.generation_sha256 !== 'string' || !/^[a-f0-9]{64}$/.test(m.build.generation_sha256) || (m.build.legal_sha256 !== null && !/^[a-f0-9]{64}$/.test(m.build.legal_sha256))))
64
+ || typeof m.entrypoint !== 'string' || m.entrypoint !== `bin/runtime/${m.version}-${m.build.generation_sha256 || m.sha256}/codeledger-standalone.cjs`) {
65
+ throw new Error('Invalid runtime.json contract. Repair with codeledger vendor.');
66
+ }
67
+ for (const reference of [m.previous, m.migration_fallback]) {
68
+ if (reference === undefined) continue;
69
+ if (!reference || typeof reference !== 'object' || !/^[a-f0-9]{64}$/.test(reference.sha256) || typeof reference.entrypoint !== 'string') throw new Error('Invalid previous runtime reference. Repair with codeledger vendor.');
70
+ if (reference.version !== null) compare(reference.version, reference.version);
71
+ const generation = reference.generation_sha256 || reference.sha256;
72
+ if (!/^[a-f0-9]{64}$/.test(generation) || (reference.entrypoint !== `bin/runtime/legacy-${reference.sha256}/codeledger-standalone.cjs` && (reference.version === null || reference.entrypoint !== `bin/runtime/${reference.version}-${generation}/codeledger-standalone.cjs`))) throw new Error('Invalid previous runtime path. Repair with codeledger vendor.');
73
+ }
74
+ if (compare(m.version, m.min_runtime) < 0) throw new Error('Pinned runtime is older than its configuration contract. Run codeledger upgrade.');
75
+ if (compare(m.version, '0.10.82-0') >= 0 && m.build.generation_sha256 === undefined) throw new Error('Runtime generation identity is missing. Repair with codeledger vendor.');
76
+ const root = io.fs.realpathSync(codeledgerDir);
77
+ if (!io.fs.existsSync(io.path.resolve(root, m.entrypoint))) throw new Error('Pinned runtime is missing: .codeledger/' + m.entrypoint + '. Restore it from version control (git checkout -- .codeledger/bin), or repair with codeledger vendor.');
78
+ const runtimePath = io.fs.realpathSync(io.path.resolve(root, m.entrypoint));
79
+ const rel = io.path.relative(root, runtimePath);
80
+ if (rel.startsWith('..') || io.path.isAbsolute(rel) || runtimePath !== io.path.resolve(root, m.entrypoint) || !io.fs.statSync(runtimePath).isFile()) {
81
+ throw new Error('Pinned runtime escapes the repository. Repair with codeledger vendor.');
82
+ }
83
+ const bytes = io.fs.readFileSync(runtimePath);
84
+ if (io.crypto.createHash('sha256').update(bytes).digest('hex') !== m.sha256) {
85
+ throw new Error('Pinned runtime checksum mismatch. Repair with codeledger vendor; do not execute the modified artifact.');
86
+ }
87
+ if (m.build.generation_sha256 !== undefined) {
88
+ let notices = null;
89
+ if (m.build.legal_sha256 !== null) {
90
+ const legal = runtimePath + '.LEGAL.txt';
91
+ if (!io.fs.existsSync(legal)) throw new Error('Pinned notices are missing: ' + legal + '. Restore them from version control, or repair with codeledger vendor.');
92
+ if (io.fs.lstatSync(legal).isSymbolicLink()) throw new Error('Pinned notices must not be symlinked. Repair with codeledger vendor.');
93
+ notices = io.fs.readFileSync(legal);
94
+ if (io.crypto.createHash('sha256').update(notices).digest('hex') !== m.build.legal_sha256) throw new Error('Pinned notices checksum mismatch. Repair with codeledger vendor.');
95
+ }
96
+ if (runtimeGenerationSha256(bytes, notices, io.crypto) !== m.build.generation_sha256) throw new Error('Pinned generation checksum mismatch. Repair with codeledger vendor.');
97
+ }
98
+ const header = bytes.subarray(0, 65536).toString('utf8');
99
+ const version = /^\/\* CODELEDGER_STANDALONE_VERSION:([^\s*]+) \*\//m.exec(header)?.[1];
100
+ const flavor = header.includes('/* CodeLedger Open CLI') ? 'open' : header.includes('/* CodeLedger Standalone CLI') ? 'full' : null;
101
+ if (version !== m.version || flavor !== m.distribution) throw new Error('Pinned runtime version or distribution does not match runtime.json.');
102
+ return { manifest: m, path: runtimePath };
103
+ }
104
+
105
+
106
+ function runtimeTrustKey(root, manifest, io) {
107
+ return io.crypto.createHash('sha256').update(JSON.stringify([io.fs.realpathSync(root), manifest])).digest('hex');
108
+ }
109
+ function isRuntimePinTrusted(root, manifest, io, trustDir) {
110
+ if (!trustDir || trustDir === 'disabled') return false;
111
+ try {
112
+ const directory = io.fs.realpathSync(trustDir);
113
+ const repo = io.fs.realpathSync(root);
114
+ const relative = io.path.relative(repo, directory);
115
+ if (!relative || (!relative.startsWith('..' + io.path.sep) && relative !== '..' && !io.path.isAbsolute(relative))) return false;
116
+ const key = runtimeTrustKey(root, manifest, io);
117
+ const file = io.path.join(directory, key + '.json');
118
+ const stat = io.fs.lstatSync(file);
119
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 4096) return false;
120
+ const receipt = JSON.parse(io.fs.readFileSync(file, 'utf8'));
121
+ return receipt.schema === 1 && receipt.key === key;
122
+ } catch { return false; }
123
+ }
124
+ return {parseRuntimeVersion, compareRuntimeVersions, resolveRuntimePin, runtimeGenerationSha256, runtimeTrustKey, isRuntimePinTrusted};
125
+ })();