@volute_cvc/cvc 0.0.0-stage → 0.7.1

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/release.js ADDED
@@ -0,0 +1,981 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('crypto');
4
+ const fs = require('fs');
5
+ const fsp = fs.promises;
6
+ const https = require('https');
7
+ const os = require('os');
8
+ const path = require('path');
9
+ const { spawn, spawnSync } = require('child_process');
10
+ const { performance } = require('perf_hooks');
11
+ const { Transform } = require('stream');
12
+ const { pipeline } = require('stream/promises');
13
+
14
+ const DEFAULT_REPOSITORY = 'meirka8/volute';
15
+ const DEFAULT_BASE_URL = 'https://github.com';
16
+ const PACKAGE_VERSION = require('../package.json').version;
17
+ const MAX_REDIRECTS = 5;
18
+ const MAX_CHECKSUM_BYTES = 1024 * 1024;
19
+ const MAX_ARCHIVE_BYTES = 256 * 1024 * 1024;
20
+ const MAX_BINARY_BYTES = 128 * 1024 * 1024;
21
+ const BINARIES = ['cvc', 'cvc-mcp', 'cvc-lsp'];
22
+ // Publication order into the stable directory. `cvc` goes last so a concurrent
23
+ // version probe never sees the new `cvc` before its sibling binaries.
24
+ const PUBLISH_ORDER = ['cvc-lsp', 'cvc-mcp', 'cvc'];
25
+ // Same name the installers (install.sh / install.ps1) use, so launcher and
26
+ // installers exclude each other.
27
+ const INSTALL_LOCK_NAME = '.cvc-install.lock';
28
+ const STALE_LOCK_MS = 10 * 60 * 1000;
29
+ const LOCK_OWNER_TOOL = 'cvc-npm-launcher';
30
+ const MAX_LOCK_OWNER_BYTES = 4096;
31
+ const VERSION_PROBE_TIMEOUT_MS = 5000;
32
+ const MAX_PROBE_OUTPUT_BYTES = 64 * 1024;
33
+ const LOCK_WAIT_MS = 5000;
34
+ const LOCK_POLL_MS = 100;
35
+ const MAX_ACQUIRE_ATTEMPTS = 5;
36
+ const LOCK_GRACE_AGE_MS = 1000; // an empty lock younger than this may still be getting its record
37
+ const LOCK_GRACE_WAIT_MS = 500;
38
+ const BOOT_MARGIN_MS = 60 * 1000;
39
+ const LIVE_LOCK_MAX_AGE_MS = 60 * 1000; // a real publish takes seconds; an older record with a live-looking pid is pid reuse
40
+ const ORPHAN_STAGING_AGE_MS = 10 * 60 * 1000;
41
+
42
+ function validVersion(version) {
43
+ return /^\d+\.\d+\.\d+(?:-[0-9A-Za-z]+(?:[.-][0-9A-Za-z]+)*)?$/.test(version);
44
+ }
45
+
46
+ function binaryFileName(name, platform) {
47
+ return platform === 'win32' ? `${name}.exe` : name;
48
+ }
49
+
50
+ function compareNumeric(a, b) {
51
+ const x = a.replace(/^0+(?=\d)/, '');
52
+ const y = b.replace(/^0+(?=\d)/, '');
53
+ if (x.length !== y.length) return x.length < y.length ? -1 : 1;
54
+ return x === y ? 0 : (x < y ? -1 : 1);
55
+ }
56
+
57
+ function parseSemver(version) {
58
+ if (typeof version !== 'string' || !validVersion(version)) throw new TypeError(`Invalid version: ${version}`);
59
+ const dash = version.indexOf('-');
60
+ return {
61
+ core: (dash === -1 ? version : version.slice(0, dash)).split('.'),
62
+ prerelease: dash === -1 ? [] : version.slice(dash + 1).split('.'),
63
+ };
64
+ }
65
+
66
+ // SemVer 2.0.0 precedence: -1 when a < b, 0 when equal, 1 when a > b.
67
+ function compareSemver(a, b) {
68
+ const left = parseSemver(a);
69
+ const right = parseSemver(b);
70
+ for (let i = 0; i < 3; i++) {
71
+ const order = compareNumeric(left.core[i], right.core[i]);
72
+ if (order !== 0) return order;
73
+ }
74
+ if (left.prerelease.length === 0 || right.prerelease.length === 0) {
75
+ if (left.prerelease.length === right.prerelease.length) return 0;
76
+ return left.prerelease.length === 0 ? 1 : -1;
77
+ }
78
+ const shared = Math.min(left.prerelease.length, right.prerelease.length);
79
+ for (let i = 0; i < shared; i++) {
80
+ const p = left.prerelease[i];
81
+ const q = right.prerelease[i];
82
+ const pNumeric = /^\d+$/.test(p);
83
+ const qNumeric = /^\d+$/.test(q);
84
+ if (pNumeric && qNumeric) {
85
+ const order = compareNumeric(p, q);
86
+ if (order !== 0) return order;
87
+ } else if (pNumeric !== qNumeric) {
88
+ return pNumeric ? -1 : 1;
89
+ } else if (p !== q) {
90
+ return p < q ? -1 : 1;
91
+ }
92
+ }
93
+ if (left.prerelease.length === right.prerelease.length) return 0;
94
+ return left.prerelease.length < right.prerelease.length ? -1 : 1;
95
+ }
96
+
97
+ function releaseConfiguration(env = process.env) {
98
+ const repository = env.CVC_RELEASE_REPOSITORY || DEFAULT_REPOSITORY;
99
+ const baseUrl = (env.CVC_RELEASE_BASE_URL || DEFAULT_BASE_URL).replace(/\/+$/, '');
100
+ if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository) || repository.includes('..')) {
101
+ throw new Error('CVC_RELEASE_REPOSITORY must be a safe owner/repository name.');
102
+ }
103
+ const parsed = new URL(baseUrl);
104
+ if (parsed.protocol !== 'https:') throw new Error('CVC_RELEASE_BASE_URL must use HTTPS.');
105
+ if (parsed.username || parsed.password || parsed.search || parsed.hash) {
106
+ throw new Error('CVC_RELEASE_BASE_URL must not contain credentials, a query, or a fragment.');
107
+ }
108
+ if (!validVersion(PACKAGE_VERSION)) throw new Error('The npm package has an invalid release version.');
109
+ return { repository, baseUrl, version: PACKAGE_VERSION };
110
+ }
111
+
112
+ function getPlatformDetails(platform = os.platform(), arch = os.arch()) {
113
+ if (platform === 'linux' && arch === 'x64') return { platform, osTag: 'unknown-linux-gnu', archTag: 'x86_64' };
114
+ if (platform === 'darwin' && arch === 'x64') return { platform, osTag: 'apple-darwin', archTag: 'x86_64' };
115
+ if (platform === 'darwin' && arch === 'arm64') return { platform, osTag: 'apple-darwin', archTag: 'aarch64' };
116
+ if (platform === 'win32' && arch === 'x64') return { platform, osTag: 'pc-windows-msvc', archTag: 'x86_64' };
117
+ if (platform === 'linux' && arch === 'arm64') throw new Error('Unsupported architecture: Linux arm64 releases are not published.');
118
+ throw new Error(`Unsupported platform or architecture: ${platform}/${arch}`);
119
+ }
120
+
121
+ function releaseUrls(details, env = process.env) {
122
+ const { repository, baseUrl, version } = releaseConfiguration(env);
123
+ const ext = details.platform === 'win32' ? 'zip' : 'tar.gz';
124
+ const assetName = `cvc-${details.archTag}-${details.osTag}.${ext}`;
125
+ const releaseUrl = `${baseUrl}/${repository}/releases/download/v${version}`;
126
+ return { assetName, archiveUrl: `${releaseUrl}/${assetName}`, checksumUrl: `${releaseUrl}/SHA256SUMS.txt` };
127
+ }
128
+
129
+ function checksumForAsset(manifest, assetName) {
130
+ const escaped = assetName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
131
+ const match = new RegExp(`^([a-fA-F0-9]{64})\\s+\\*?${escaped}\\s*$`);
132
+ const values = manifest.split(/\r?\n/).map(line => line.match(match)).filter(Boolean).map(result => result[1].toLowerCase());
133
+ if (values.length !== 1) throw new Error(`SHA256SUMS does not contain one valid checksum for ${assetName}.`);
134
+ return values[0];
135
+ }
136
+
137
+ function redirectAllowed(initial, target) {
138
+ if (target.protocol !== 'https:' || target.username || target.password) return false;
139
+ if (target.host === initial.host) return true;
140
+ return initial.hostname === 'github.com' &&
141
+ (target.hostname === 'githubusercontent.com' || target.hostname.endsWith('.githubusercontent.com')) &&
142
+ target.port === '';
143
+ }
144
+
145
+ function request(url, redirects = 0, initialUrl) {
146
+ return new Promise((resolve, reject) => {
147
+ let parsed;
148
+ try { parsed = new URL(url); } catch (error) { reject(error); return; }
149
+ const initial = initialUrl || parsed;
150
+ if (parsed.protocol !== 'https:' || parsed.username || parsed.password) {
151
+ reject(new Error(`Refusing unsafe download URL: ${parsed.href}`)); return;
152
+ }
153
+ const req = https.get(parsed, { headers: { 'User-Agent': 'cvc-mcp-npm-launcher' } }, response => {
154
+ if ([301, 302, 303, 307, 308].includes(response.statusCode)) {
155
+ response.resume();
156
+ if (redirects >= MAX_REDIRECTS || !response.headers.location) {
157
+ reject(new Error(`Too many or invalid redirects while downloading ${initial.href}`)); return;
158
+ }
159
+ const target = new URL(response.headers.location, parsed);
160
+ if (!redirectAllowed(initial, target)) {
161
+ reject(new Error(`Refusing cross-origin or insecure redirect to ${target.origin}`)); return;
162
+ }
163
+ request(target.href, redirects + 1, initial).then(resolve, reject);
164
+ return;
165
+ }
166
+ if (response.statusCode !== 200) {
167
+ response.resume(); reject(new Error(`Download failed (${response.statusCode}) for ${initial.href}`)); return;
168
+ }
169
+ resolve(response);
170
+ });
171
+ req.setTimeout(30000, () => req.destroy(new Error(`Download timed out for ${initial.href}`)));
172
+ req.on('error', reject);
173
+ });
174
+ }
175
+
176
+ async function downloadFile(url, destination, maximumBytes = MAX_ARCHIVE_BYTES) {
177
+ const response = await request(url);
178
+ const advertised = Number(response.headers['content-length']);
179
+ if (Number.isFinite(advertised) && advertised > maximumBytes) {
180
+ response.destroy(); throw new Error('Release download is unexpectedly large.');
181
+ }
182
+ let length = 0;
183
+ const limiter = new Transform({
184
+ transform(chunk, _encoding, callback) {
185
+ length += chunk.length;
186
+ callback(length > maximumBytes ? new Error('Release download is unexpectedly large.') : null, chunk);
187
+ },
188
+ });
189
+ const file = fs.createWriteStream(destination, { flags: 'wx', mode: 0o600 });
190
+ await pipeline(response, limiter, file);
191
+ }
192
+
193
+ async function downloadText(url) {
194
+ const response = await request(url);
195
+ const chunks = [];
196
+ let length = 0;
197
+ return new Promise((resolve, reject) => {
198
+ response.on('data', chunk => {
199
+ length += chunk.length;
200
+ if (length > MAX_CHECKSUM_BYTES) { response.destroy(new Error('SHA256SUMS is unexpectedly large.')); return; }
201
+ chunks.push(chunk);
202
+ });
203
+ response.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
204
+ response.on('error', reject);
205
+ });
206
+ }
207
+
208
+ async function sha256(file) {
209
+ const hash = crypto.createHash('sha256');
210
+ await pipeline(fs.createReadStream(file), new Transform({ transform(chunk, _encoding, callback) { hash.update(chunk); callback(); } }));
211
+ return hash.digest('hex');
212
+ }
213
+
214
+ function trustedExecutable() {
215
+ if (process.platform === 'win32') return path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe');
216
+ return '/usr/bin/tar';
217
+ }
218
+
219
+ function extractMember(archive, destination, member, platform) {
220
+ const fd = fs.openSync(destination, 'wx', 0o700);
221
+ try {
222
+ let result;
223
+ if (platform === 'win32') {
224
+ const script = path.join(path.dirname(archive), 'extract-member.ps1');
225
+ if (!fs.existsSync(script)) {
226
+ fs.writeFileSync(script, [
227
+ 'param([string]$Archive,[string]$Member,[string]$Destination,[long]$Maximum)',
228
+ 'Add-Type -AssemblyName System.IO.Compression.FileSystem',
229
+ '$zip = [IO.Compression.ZipFile]::OpenRead($Archive)',
230
+ 'try {',
231
+ ' $entries = @($zip.Entries | Where-Object { $_.FullName -ceq $Member })',
232
+ ' if ($entries.Count -ne 1 -or $entries[0].Length -le 0 -or $entries[0].Length -gt $Maximum) { throw "Unsafe or missing archive member: $Member" }',
233
+ ' $input = $entries[0].Open(); $output = [IO.File]::Open($Destination, [IO.FileMode]::Truncate, [IO.FileAccess]::Write, [IO.FileShare]::None)',
234
+ ' try {',
235
+ ' $buffer = New-Object byte[] 65536; [long]$total = 0',
236
+ ' while (($read = $input.Read($buffer, 0, $buffer.Length)) -gt 0) { $total += $read; if ($total -gt $Maximum) { throw "Expanded member is too large: $Member" }; $output.Write($buffer, 0, $read) }',
237
+ ' $output.Flush($true)',
238
+ ' } finally { $output.Dispose(); $input.Dispose() }',
239
+ '} finally { $zip.Dispose() }',
240
+ ].join('\n'), { mode: 0o600 });
241
+ }
242
+ fs.closeSync(fd);
243
+ result = spawnSync(trustedExecutable(), ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', script, archive, member, destination, String(MAX_BINARY_BYTES)], { stdio: ['ignore', 'ignore', 'inherit'] });
244
+ } else {
245
+ // Limit the child output at the OS level as well as validating the final
246
+ // file, so a compressed member cannot fill the destination filesystem.
247
+ result = spawnSync('/bin/sh', ['-c', 'ulimit -f 262144; exec /usr/bin/tar -xOzf "$1" -- "$2"', 'cvc-extract', archive, member], { stdio: ['ignore', fd, 'inherit'] });
248
+ }
249
+ if (result.error || result.status !== 0) throw result.error || new Error(`Failed to extract ${member} (exit ${result.status}).`);
250
+ } finally {
251
+ try { fs.closeSync(fd); } catch (_) { /* PowerShell path closes it before spawning. */ }
252
+ }
253
+ const stat = fs.lstatSync(destination);
254
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size <= 0 || stat.size > MAX_BINARY_BYTES) {
255
+ throw new Error(`Release archive contains an unsafe binary: ${member}`);
256
+ }
257
+ }
258
+
259
+ async function safeRegularFile(file) {
260
+ const stat = await fsp.lstat(file);
261
+ return stat.isFile() && !stat.isSymbolicLink() && stat.size > 0 && stat.size <= MAX_BINARY_BYTES;
262
+ }
263
+
264
+ async function safeDirectory(directory) {
265
+ const stat = await fsp.lstat(directory);
266
+ return stat.isDirectory() && !stat.isSymbolicLink();
267
+ }
268
+
269
+ async function ensurePrivateDirectory(directory) {
270
+ await fsp.mkdir(directory, { recursive: true, mode: 0o700 });
271
+ const stat = await fsp.lstat(directory).catch(() => null);
272
+ // On Unix the directory must also belong to the current user: a directory
273
+ // someone else owns could be swapped for anything and could not be chmod'ed.
274
+ const owned = typeof process.getuid !== 'function' || (stat !== null && stat.uid === process.getuid());
275
+ if (stat === null || !stat.isDirectory() || stat.isSymbolicLink() || !owned) {
276
+ throw new Error(`Refusing unsafe cache directory: ${directory}`);
277
+ }
278
+ await fsp.chmod(directory, 0o700);
279
+ }
280
+
281
+ async function validCache(cacheDir, names) {
282
+ if (!await safeDirectory(cacheDir).catch(() => false)) return false;
283
+ for (const name of names) if (!await safeRegularFile(path.join(cacheDir, name)).catch(() => false)) return false;
284
+ return true;
285
+ }
286
+
287
+ async function installRelease(details, executable) {
288
+ const { version } = releaseConfiguration();
289
+ const names = BINARIES.map(name => binaryFileName(name, details.platform));
290
+ const binaryName = binaryFileName(executable, details.platform);
291
+ const cacheRoot = path.join(os.homedir(), '.cvc', 'mcp-cache');
292
+ const cacheDir = path.join(cacheRoot, `v${version}-${details.archTag}-${details.osTag}`);
293
+ const binaryPath = path.join(cacheDir, binaryName);
294
+ await ensurePrivateDirectory(path.join(os.homedir(), '.cvc'));
295
+ await ensurePrivateDirectory(cacheRoot);
296
+ if (await validCache(cacheDir, names)) return binaryPath;
297
+ if (await fsp.lstat(cacheDir).catch(() => null)) {
298
+ if (!await safeDirectory(cacheDir).catch(() => false)) throw new Error(`Refusing unsafe cache path: ${cacheDir}`);
299
+ await fsp.rm(cacheDir, { recursive: true, force: true });
300
+ }
301
+
302
+ const tempDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'cvc-mcp-'));
303
+ let publishDir;
304
+ try {
305
+ const urls = releaseUrls(details);
306
+ const archive = path.join(tempDir, urls.assetName);
307
+ console.error(`Downloading CVC release from ${urls.archiveUrl}...`);
308
+ const expected = checksumForAsset(await downloadText(urls.checksumUrl), urls.assetName);
309
+ await downloadFile(urls.archiveUrl, archive);
310
+ if (!crypto.timingSafeEqual(Buffer.from(await sha256(archive), 'hex'), Buffer.from(expected, 'hex'))) {
311
+ throw new Error(`Checksum verification failed for ${urls.assetName}.`);
312
+ }
313
+ const stage = path.join(tempDir, 'stage');
314
+ await fsp.mkdir(stage, { mode: 0o700 });
315
+ console.error('Extracting verified members...');
316
+ for (const name of names) extractMember(archive, path.join(stage, name), name, details.platform);
317
+
318
+ publishDir = await fsp.mkdtemp(path.join(cacheRoot, '.install-'));
319
+ for (const name of names) {
320
+ const target = path.join(publishDir, name);
321
+ await fsp.copyFile(path.join(stage, name), target, fs.constants.COPYFILE_EXCL);
322
+ if (details.platform !== 'win32') await fsp.chmod(target, 0o755);
323
+ }
324
+ try {
325
+ await fsp.rename(publishDir, cacheDir);
326
+ publishDir = undefined;
327
+ } catch (error) {
328
+ if (error.code !== 'EEXIST' && error.code !== 'ENOTEMPTY') throw error;
329
+ if (!await validCache(cacheDir, names)) throw new Error('A concurrent cache installation was incomplete or unsafe.');
330
+ }
331
+ console.error('Download and extraction complete.');
332
+ return binaryPath;
333
+ } finally {
334
+ if (publishDir) await fsp.rm(publishDir, { recursive: true, force: true }).catch(() => {});
335
+ await fsp.rm(tempDir, { recursive: true, force: true }).catch(() => {});
336
+ }
337
+ }
338
+
339
+ // ---------------------------------------------------------------------------
340
+ // Stable binary directory (~/.cvc/bin)
341
+ //
342
+ // installRelease() leaves verified binaries in a versioned private cache. Hook
343
+ // entries written by `cvc harness install` record the absolute path of the
344
+ // running `cvc`, so running out of the versioned cache would pin hooks to a
345
+ // stale version forever. Like install.sh / install.ps1, the launcher therefore
346
+ // publishes the binaries into ~/.cvc/bin and runs them from there. The npm
347
+ // package version is a floor, never a pin: a newer installed version is left
348
+ // alone and the launcher never downgrades.
349
+ //
350
+ // Everything here writes to stderr only: cvc-mcp speaks MCP JSON-RPC on stdout.
351
+ // ---------------------------------------------------------------------------
352
+
353
+ function publishContext(options = {}) {
354
+ const home = options.home ?? os.homedir();
355
+ const platform = options.platform ?? process.platform;
356
+ const packageVersion = options.packageVersion ?? PACKAGE_VERSION;
357
+ if (!validVersion(packageVersion)) throw new Error('the npm package has an invalid release version');
358
+ return {
359
+ home,
360
+ platform,
361
+ packageVersion,
362
+ stableDir: path.join(home, '.cvc', 'bin'),
363
+ names: BINARIES.map(name => binaryFileName(name, platform)),
364
+ // Version probes keyed by file identity, so the decision re-run under the
365
+ // lock does not pay a second timeout for an unchanged, unresponsive `cvc`.
366
+ probed: new Map(),
367
+ // Internal seams for tests: host identity overrides, lock-wait and probe timing,
368
+ // and the function that starts the version probe.
369
+ system: options.system ?? {},
370
+ lockWaitMs: options.lockWaitMs ?? LOCK_WAIT_MS,
371
+ lockPollMs: options.lockPollMs ?? LOCK_POLL_MS,
372
+ probeTimeoutMs: options.probeTimeoutMs ?? VERSION_PROBE_TIMEOUT_MS,
373
+ spawnSync: options.spawnSync ?? spawnSync,
374
+ };
375
+ }
376
+
377
+ const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
378
+
379
+ async function inspectPath(file) {
380
+ try {
381
+ const stat = await fsp.lstat(file);
382
+ let kind = 'other';
383
+ if (stat.isSymbolicLink()) kind = 'symlink';
384
+ else if (stat.isDirectory()) kind = 'directory';
385
+ else if (stat.isFile()) kind = 'file';
386
+ return { kind, size: stat.size, mode: stat.mode, mtimeMs: stat.mtimeMs, identity: `${stat.dev}:${stat.ino}:${stat.size}:${stat.mtimeMs}:${stat.ctimeMs}` };
387
+ } catch (error) {
388
+ if (error.code === 'ENOENT') return { kind: 'absent', size: 0, mode: 0, mtimeMs: 0, identity: 'absent' };
389
+ throw error;
390
+ }
391
+ }
392
+
393
+ // What `cvc --version` told us about an installed binary:
394
+ // known - it printed `cvc X.Y.Z` (optionally `+build` metadata and trailing text) to
395
+ // stdout and exited 0 or, for releases before v0.7.0 whose generic error path
396
+ // handled --version, to stderr with exit status 1 and nothing on stdout
397
+ // cannot-run - the file itself cannot be executed: a format the OS rejects (ENOEXEC,
398
+ // EFTYPE, or the shell's exit 126), or EACCES on a file with no execute
399
+ // bit. A broken install that is safe to repair.
400
+ // unknown - anything else: it timed out, failed, printed something unrecognised, or
401
+ // could not start for reasons that describe the ENVIRONMENT rather than
402
+ // the file (a missing loader, interpreter or shared library: ENOENT or
403
+ // exit 127; EACCES on an executable file: noexec mount, AppArmor,
404
+ // SELinux). The same file may well run on the host that installed it,
405
+ // so it is left exactly as it is.
406
+ // Every outcome except `known` carries a `reason` for the stderr line.
407
+ // Only `cvc` is ever probed: `cvc-mcp` blocks waiting on stdin.
408
+ const VERSION_LINE = /^cvc (\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)(?:\+[0-9A-Za-z.-]+)?(?:\s.*)?$/;
409
+
410
+ // First line of a probe stream, if it has the shape of the version line.
411
+ function versionFromFirstLine(stream) {
412
+ const match = typeof stream === 'string' ? VERSION_LINE.exec(stream.split(/\r?\n/, 1)[0]) : null;
413
+ return match && validVersion(match[1]) ? match[1] : null;
414
+ }
415
+
416
+ function probeInstalledVersion(cvcPath, { timeoutMs = VERSION_PROBE_TIMEOUT_MS, mode, platform = process.platform, spawn: spawnProbe = spawnSync } = {}) {
417
+ const result = spawnProbe(cvcPath, ['--version'], {
418
+ stdio: ['ignore', 'pipe', 'pipe'],
419
+ timeout: timeoutMs,
420
+ killSignal: 'SIGKILL', // a child that traps SIGTERM must not outlive the timeout
421
+ maxBuffer: MAX_PROBE_OUTPUT_BYTES,
422
+ windowsHide: true,
423
+ encoding: 'utf8',
424
+ });
425
+ if (result.error) {
426
+ const code = result.error.code;
427
+ if (code === 'ETIMEDOUT') return { kind: 'unknown', reason: 'timed out' };
428
+ if (code === 'ENOBUFS') return { kind: 'unknown', reason: 'unrecognised output' };
429
+ const reason = `could not be started: ${code || result.error.message}`;
430
+ if (code === 'ENOEXEC' || code === 'EFTYPE') return { kind: 'cannot-run', reason };
431
+ // EACCES describes the file only when it has no execute permission at all. On
432
+ // Windows there are no execute bits to consult, so it is never conclusive there.
433
+ if (code === 'EACCES' && platform !== 'win32' && mode !== undefined && (mode & 0o111) === 0) return { kind: 'cannot-run', reason };
434
+ return { kind: 'unknown', reason };
435
+ }
436
+ if (result.status === null) return { kind: 'unknown', reason: `terminated by ${result.signal || 'a signal'}` };
437
+ // 126 is the shell's "found but cannot execute" (a malformed binary retried through
438
+ // /bin/sh by the C library). 127, "not found", also covers a missing shared library
439
+ // and is therefore not conclusive about the file.
440
+ if (result.status === 126) return { kind: 'cannot-run', reason: 'exit 126' };
441
+ if (result.status === 1 && result.stdout === '') {
442
+ // Releases before v0.7.0 print the version through the generic error path: to
443
+ // stderr, exiting 1. Only that exact shape is accepted; any other failure is unknown.
444
+ const legacy = versionFromFirstLine(result.stderr);
445
+ if (legacy !== null) return { kind: 'known', version: legacy };
446
+ }
447
+ if (result.status !== 0) return { kind: 'unknown', reason: `exit ${result.status}` };
448
+ const version = versionFromFirstLine(result.stdout);
449
+ if (version !== null) return { kind: 'known', version };
450
+ return { kind: 'unknown', reason: 'unrecognised output' };
451
+ }
452
+
453
+ function probeOptions(ctx, mode) {
454
+ return { timeoutMs: ctx.probeTimeoutMs, mode, platform: ctx.platform, spawn: ctx.spawnSync };
455
+ }
456
+
457
+ // Throws unless `outcome` is a successful probe of exactly the package version.
458
+ function requireProbedVersion(ctx, label, cvcName, outcome) {
459
+ if (outcome.kind === 'known' && compareSemver(outcome.version, ctx.packageVersion) === 0) return;
460
+ const reports = outcome.kind === 'known' ? outcome.version : `no usable version (${outcome.reason})`;
461
+ throw new Error(`the ${label} ${cvcName} reports ${reports} instead of ${ctx.packageVersion}`);
462
+ }
463
+
464
+ // Decides whether the stable directory must be (re)published. `target` is the
465
+ // stable binary to run when nothing needs publishing, or null when the stable
466
+ // copy of the requested binary is unusable and the cache is needed instead.
467
+ // `blocked` means an installed `cvc` exists but would not say what version it
468
+ // is, so it must be left exactly as it is. Throws when the stable directory
469
+ // itself is unsafe to use.
470
+ async function decidePublication(ctx, binaryName) {
471
+ const directory = await inspectPath(ctx.stableDir);
472
+ if (directory.kind === 'absent') return { publish: true };
473
+ if (directory.kind !== 'directory') {
474
+ throw new Error(`${ctx.stableDir} is not a real directory (symlinks and files are refused)`);
475
+ }
476
+ const usable = {};
477
+ const entries = {};
478
+ for (const name of ctx.names) {
479
+ entries[name] = await inspectPath(path.join(ctx.stableDir, name));
480
+ usable[name] = entries[name].kind === 'file' && entries[name].size > 0;
481
+ }
482
+ const cvcName = binaryFileName('cvc', ctx.platform);
483
+ const cvcPath = path.join(ctx.stableDir, cvcName);
484
+ let installed = null;
485
+ if (usable[cvcName]) {
486
+ const identity = entries[cvcName].identity;
487
+ if (!ctx.probed.has(identity)) ctx.probed.set(identity, probeInstalledVersion(cvcPath, probeOptions(ctx, entries[cvcName].mode)));
488
+ const outcome = ctx.probed.get(identity);
489
+ if (outcome.kind === 'unknown') {
490
+ return { publish: false, target: null, blocked: `${cvcPath} did not report a usable version (${outcome.reason}), so it was left in place; delete it to have the launcher replace it` };
491
+ }
492
+ if (outcome.kind === 'known') installed = outcome.version;
493
+ }
494
+ if (installed !== null) {
495
+ const order = compareSemver(installed, ctx.packageVersion);
496
+ const stableBinary = path.join(ctx.stableDir, binaryName);
497
+ // Newer than this package: never touch it, never downgrade.
498
+ if (order > 0) return { publish: false, target: usable[binaryName] ? stableBinary : null };
499
+ // Same version and complete: leave it alone (it may be a local build).
500
+ if (order === 0 && ctx.names.every(name => usable[name])) return { publish: false, target: stableBinary };
501
+ }
502
+ return { publish: true };
503
+ }
504
+
505
+ async function ensureStableDirectory(ctx) {
506
+ await fsp.mkdir(ctx.stableDir, { recursive: true, mode: 0o700 });
507
+ if ((await inspectPath(ctx.stableDir)).kind !== 'directory') {
508
+ throw new Error(`${ctx.stableDir} is not a real directory (symlinks and files are refused)`);
509
+ }
510
+ }
511
+
512
+ // The lock is a directory on every platform: install.sh creates a directory and
513
+ // install.ps1 creates a file at the same path, and an existing entry of either
514
+ // kind makes the other's exclusive create fail, so launcher and installers
515
+ // still exclude each other. Inside it sits one `owner-<nonce>` file recording
516
+ // who holds it. The record lets a later launch recover from a launcher that was
517
+ // killed mid-publish (Node exits on SIGTERM without running `finally`), but only
518
+ // a lock this launcher created is ever recoverable: locks from the installers
519
+ // carry no record and are never broken.
520
+ //
521
+ // The nonce is in the file NAME so a lock can be broken with a compare-and-swap:
522
+ // a breaker claims the dead lock by renaming that exact `owner-<nonce>` file.
523
+ // That rename succeeds for exactly one racer and fails with ENOENT if the lock
524
+ // was released, broken, or replaced by a live one in the meantime, so a live
525
+ // lock can never be displaced. (Moving the whole lock directory aside cannot
526
+ // give this guarantee: a competitor's fresh lock could be moved and then could
527
+ // not be restored atomically.)
528
+ const LOCK_OWNER_ENTRY = /^owner-[0-9a-f]{16}$/;
529
+
530
+ class LockHeldError extends Error {
531
+ constructor(message, lockPath, live) {
532
+ super(message);
533
+ this.lockPath = lockPath;
534
+ this.live = live; // held by a launcher that is publishing right now
535
+ }
536
+ }
537
+
538
+ function readTrimmed(file) {
539
+ try { return fs.readFileSync(file, 'utf8').trim() || null; } catch (_) { return null; }
540
+ }
541
+
542
+ // What identifies this host, boot and PID namespace; any field can be overridden
543
+ // through ctx.system by tests. Fields that cannot be determined are null.
544
+ function currentSystem(ctx) {
545
+ let pidns = null;
546
+ if (process.platform === 'linux') {
547
+ try { pidns = fs.readlinkSync('/proc/self/ns/pid') || null; } catch (_) { /* unknown */ }
548
+ }
549
+ return {
550
+ host: os.hostname(),
551
+ boot: process.platform === 'linux' ? readTrimmed('/proc/sys/kernel/random/boot_id') : null,
552
+ pidns,
553
+ uptimeMs: os.uptime() * 1000,
554
+ now: Date.now(),
555
+ ...ctx.system,
556
+ };
557
+ }
558
+
559
+ function newLockOwner(system) {
560
+ const nonce = crypto.randomBytes(8).toString('hex');
561
+ const raw = JSON.stringify({
562
+ tool: LOCK_OWNER_TOOL,
563
+ pid: process.pid,
564
+ host: system.host,
565
+ created: new Date().toISOString(),
566
+ pidns: system.pidns ?? null,
567
+ boot: system.boot ?? null,
568
+ nonce,
569
+ });
570
+ return { raw, entry: `owner-${nonce}` };
571
+ }
572
+
573
+ // Bounded, symlink-refusing read of a lock's single owner record. Returns
574
+ // { file, raw, record } or null for anything else: a missing or empty lock (an
575
+ // installer's), extra entries, an oversized or malformed record.
576
+ async function readLockOwner(lockPath) {
577
+ try {
578
+ if ((await inspectPath(lockPath)).kind !== 'directory') return null;
579
+ const entries = await fsp.readdir(lockPath);
580
+ if (entries.length !== 1 || !LOCK_OWNER_ENTRY.test(entries[0])) return null;
581
+ const file = path.join(lockPath, entries[0]);
582
+ const handle = await fsp.open(file, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0));
583
+ try {
584
+ const stat = await handle.stat();
585
+ if (!stat.isFile() || stat.size > MAX_LOCK_OWNER_BYTES) return null;
586
+ const buffer = Buffer.alloc(MAX_LOCK_OWNER_BYTES + 1);
587
+ const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
588
+ if (bytesRead > MAX_LOCK_OWNER_BYTES) return null;
589
+ const raw = buffer.toString('utf8', 0, bytesRead);
590
+ const record = JSON.parse(raw);
591
+ return record !== null && typeof record === 'object' && !Array.isArray(record) ? { file, raw, record } : null;
592
+ } finally {
593
+ await handle.close();
594
+ }
595
+ } catch (_) {
596
+ return null;
597
+ }
598
+ }
599
+
600
+ const known = value => typeof value === 'string' && value !== '';
601
+
602
+ // Is the process that wrote this lock record gone?
603
+ // 'dead' - provably gone: the lock may be broken
604
+ // 'live' - still running here: another launcher is publishing right now
605
+ // 'foreign' - not a record we can judge (other tool, other host, a boot id or PID
606
+ // namespace known on only one side, a different PID namespace,
607
+ // malformed) or too old to be a publish in progress: never broken,
608
+ // never waited for
609
+ // Rules, in order. Boot id: both known and different means the machine restarted;
610
+ // known on only one side means a different kind of environment sharing the host name
611
+ // and home (Windows and WSL, say), which cannot be judged; unknown on both sides falls
612
+ // back to whether the record predates this boot. PID namespace: known on only one side
613
+ // or different on the two sides cannot be judged by signalling a pid. Otherwise the pid
614
+ // decides: ESRCH means gone, EPERM or success means alive, and an alive pid behind a
615
+ // record older than a real publish could take is pid reuse, not a live launcher.
616
+ function lockOwnerState(record, system) {
617
+ if (record.tool !== LOCK_OWNER_TOOL || record.host !== system.host) return 'foreign';
618
+ if (!Number.isSafeInteger(record.pid) || record.pid <= 0) return 'foreign';
619
+ const recordBoot = known(record.boot);
620
+ if (recordBoot !== known(system.boot)) return 'foreign';
621
+ if (recordBoot) {
622
+ if (record.boot !== system.boot) return 'dead';
623
+ } else if (typeof record.created === 'string') {
624
+ const created = Date.parse(record.created);
625
+ if (Number.isFinite(created) && created < system.now - system.uptimeMs - BOOT_MARGIN_MS) return 'dead';
626
+ }
627
+ const recordPidns = known(record.pidns);
628
+ if (recordPidns !== known(system.pidns)) return 'foreign';
629
+ if (recordPidns && record.pidns !== system.pidns) return 'foreign';
630
+ try {
631
+ process.kill(record.pid, 0);
632
+ } catch (error) {
633
+ if (error.code === 'ESRCH') return 'dead';
634
+ }
635
+ const created = typeof record.created === 'string' ? Date.parse(record.created) : NaN;
636
+ return Number.isFinite(created) && system.now - created <= LIVE_LOCK_MAX_AGE_MS ? 'live' : 'foreign';
637
+ }
638
+
639
+ // Classifies whatever currently occupies the lock path.
640
+ async function inspectHeldLock(ctx, lockPath) {
641
+ const system = currentSystem(ctx);
642
+ const graceUntil = performance.now() + LOCK_GRACE_WAIT_MS; // monotonic: immune to clock changes
643
+ for (;;) {
644
+ const entry = await inspectPath(lockPath);
645
+ if (entry.kind === 'absent') return { state: 'gone' };
646
+ const owner = await readLockOwner(lockPath);
647
+ if (owner !== null) return { state: lockOwnerState(owner.record, system), owner };
648
+ // No readable record. A lock directory that was only just created or just
649
+ // emptied is most likely a launcher between creating it and finishing its
650
+ // record (still empty, or the record file created but not yet written), or
651
+ // between removing the record and the directory. Give that a moment before
652
+ // treating it as an installer's lock, whose directory stays empty. The
653
+ // directory's age is read again after listing it: an unlink that emptied it
654
+ // moments ago refreshes its mtime, which the earlier stat could not show.
655
+ if (entry.kind === 'directory' && performance.now() < graceUntil) {
656
+ let names = null;
657
+ try {
658
+ names = await fsp.readdir(lockPath);
659
+ } catch (error) {
660
+ if (error.code === 'ENOENT') continue; // removed under us (possibly replaced): classify afresh
661
+ }
662
+ if (names !== null && (names.length === 0 || (names.length === 1 && LOCK_OWNER_ENTRY.test(names[0])))) {
663
+ const fresh = await inspectPath(lockPath);
664
+ if (fresh.kind === 'absent' || (fresh.kind === 'directory' && Date.now() - fresh.mtimeMs < LOCK_GRACE_AGE_MS)) {
665
+ await sleep(20);
666
+ continue;
667
+ }
668
+ }
669
+ }
670
+ return { state: 'foreign' };
671
+ }
672
+ }
673
+
674
+ // rmdir of the lock directory. On Windows an antivirus or indexer can briefly
675
+ // hold the just-deleted owner file, so ENOTEMPTY/EBUSY/EPERM are retried.
676
+ async function removeLockDirectory(ctx, lockPath) {
677
+ const attempts = ctx.platform === 'win32' ? 5 : 1;
678
+ for (let attempt = 1; ; attempt++) {
679
+ try {
680
+ await fsp.rmdir(lockPath);
681
+ return;
682
+ } catch (error) {
683
+ if (error.code === 'ENOENT') return;
684
+ if (attempt >= attempts || !['ENOTEMPTY', 'EBUSY', 'EPERM'].includes(error.code)) throw error;
685
+ await sleep(50);
686
+ }
687
+ }
688
+ }
689
+
690
+ // Removes a lock whose launcher is provably dead. Whether we win the claim or a
691
+ // rival does (or the lock changed under us), the caller just tries again.
692
+ async function breakStaleLock(ctx, lockPath, judged) {
693
+ // Claim: only one racer can rename the judged file, and only while it is
694
+ // still in the lock instance that was judged. The claimed name still matches
695
+ // the owner pattern and still holds the dead record, so if we are killed from
696
+ // here on, the next launch simply breaks the lock again.
697
+ const claimed = path.join(lockPath, `owner-${crypto.randomBytes(8).toString('hex')}`);
698
+ try {
699
+ await fsp.rename(judged.file, claimed);
700
+ } catch (error) {
701
+ if (error.code === 'ENOENT') return; // released, broken by another, or replaced
702
+ throw error;
703
+ }
704
+ // Exactly one claimant wins this unlink (a rival may have re-claimed the file).
705
+ try {
706
+ await fsp.unlink(claimed);
707
+ } catch (error) {
708
+ if (error.code === 'ENOENT') return;
709
+ throw error;
710
+ }
711
+ await removeLockDirectory(ctx, lockPath);
712
+ }
713
+
714
+ function removalCommand(platform, lockPath) {
715
+ if (platform === 'win32') return `Remove-Item -Recurse -Force '${lockPath.replace(/'/g, "''")}'`;
716
+ return `rm -r '${lockPath.replace(/'/g, "'\\''")}'`;
717
+ }
718
+
719
+ async function lockHeldMessage(ctx, lockPath) {
720
+ let message = `another CVC installation holds ${lockPath}`;
721
+ const held = await inspectPath(lockPath).catch(() => null);
722
+ if (held && held.kind !== 'absent' && Date.now() - held.mtimeMs > STALE_LOCK_MS) {
723
+ message += ` (older than 10 minutes, so it may be stale; if no installation is running, remove it with: ${removalCommand(ctx.platform, lockPath)})`;
724
+ }
725
+ return message;
726
+ }
727
+
728
+ // Returns { lockPath, ownerFile, raw } only when this process created the lock;
729
+ // a lock it did not create is never removed. A dead launcher's lock is broken
730
+ // (a bounded number of attempts). Throws LockHeldError when someone else holds it
731
+ // (`live` when that is another launcher publishing right now, which is worth
732
+ // waiting for).
733
+ async function acquireInstallLock(ctx, { mayWait = true } = {}) {
734
+ const lockPath = path.join(ctx.stableDir, INSTALL_LOCK_NAME);
735
+ for (let attempt = 1; ; attempt++) {
736
+ try {
737
+ await fsp.mkdir(lockPath, { mode: 0o700 });
738
+ break;
739
+ } catch (error) {
740
+ if (error.code !== 'EEXIST') throw error;
741
+ const held = await inspectHeldLock(ctx, lockPath);
742
+ // Its holder released it between our mkdir and our look: simply try again.
743
+ if (held.state === 'gone' && attempt < MAX_ACQUIRE_ATTEMPTS) continue;
744
+ // A dead holder's lock is broken; if a rival breaker is mid-cleanup we may
745
+ // see its claimed (still dead) record next time round. Bounded by the attempts.
746
+ if (held.state === 'dead' && attempt < MAX_ACQUIRE_ATTEMPTS) {
747
+ await breakStaleLock(ctx, lockPath, held.owner);
748
+ continue;
749
+ }
750
+ throw new LockHeldError(await lockHeldMessage(ctx, lockPath), lockPath, mayWait && held.state === 'live');
751
+ }
752
+ }
753
+ const { raw, entry } = newLockOwner(currentSystem(ctx));
754
+ const ownerFile = path.join(lockPath, entry);
755
+ try {
756
+ await fsp.writeFile(ownerFile, raw, { flag: 'wx', mode: 0o600 });
757
+ } catch (error) {
758
+ // We created the lock but could not label it; do not leave it behind.
759
+ await fsp.unlink(ownerFile).catch(() => {});
760
+ await fsp.rmdir(lockPath).catch(() => {});
761
+ throw error;
762
+ }
763
+ return { lockPath, ownerFile, raw };
764
+ }
765
+
766
+ // Waits (bounded) for another launcher's lock to go away. Returns true as soon
767
+ // as the lock is gone or its holder is no longer a live launcher, false when
768
+ // the budget runs out.
769
+ async function waitForLockRelease(ctx, lockPath) {
770
+ const deadline = performance.now() + ctx.lockWaitMs; // monotonic: immune to clock changes
771
+ while (performance.now() < deadline) {
772
+ await sleep(Math.min(ctx.lockPollMs, Math.max(0, deadline - performance.now())));
773
+ if ((await inspectPath(lockPath)).kind === 'absent') return true;
774
+ const owner = await readLockOwner(lockPath);
775
+ if (owner !== null && lockOwnerState(owner.record, currentSystem(ctx)) !== 'live') return true;
776
+ }
777
+ return (await inspectPath(lockPath)).kind === 'absent';
778
+ }
779
+
780
+ // Removes the lock only if it still carries this process's own record.
781
+ async function releaseInstallLock(ctx, lock) {
782
+ try {
783
+ const current = await readLockOwner(lock.lockPath);
784
+ if (current === null || current.file !== lock.ownerFile || current.raw !== lock.raw) return;
785
+ await fsp.unlink(lock.ownerFile);
786
+ await removeLockDirectory(ctx, lock.lockPath);
787
+ } catch (_) { /* best effort: a lingering lock is recovered or reported by the next launch */ }
788
+ }
789
+
790
+ // An existing target must be a regular file (or absent); symlinks, directories
791
+ // and special files are refused, as the installers do.
792
+ async function replaceableTarget(target) {
793
+ const entry = await inspectPath(target);
794
+ if (entry.kind !== 'absent' && entry.kind !== 'file') {
795
+ throw new Error(`refusing to replace ${target}: it is not a regular file`);
796
+ }
797
+ return entry.kind;
798
+ }
799
+
800
+ // Windows keeps a running .exe from being overwritten or deleted, but it can be
801
+ // renamed. Retired images are named `.cvc-old-<file>-<hex>`; delete leftovers
802
+ // from earlier publishes when they are no longer running.
803
+ async function removeRetiredImages(stableDir) {
804
+ const retired = /^\.cvc-old-(?:cvc|cvc-mcp|cvc-lsp)(?:\.exe)?-[0-9a-f]{16}$/;
805
+ let entries;
806
+ try { entries = await fsp.readdir(stableDir); } catch (_) { return; }
807
+ for (const entry of entries) {
808
+ if (!retired.test(entry)) continue;
809
+ try {
810
+ const file = path.join(stableDir, entry);
811
+ if ((await inspectPath(file)).kind === 'file') await fsp.unlink(file);
812
+ } catch (_) { /* EBUSY/EPERM/EACCES: still running; the next publish retries. */ }
813
+ }
814
+ }
815
+
816
+ // A publish that was killed leaves its `.cvc-install.<random>` staging
817
+ // directory behind (install.sh uses the same prefix). With the shared lock held
818
+ // no installer is mid-publish; the age gate adds margin. Only real directories
819
+ // with the exact mkdtemp shape are removed, and nothing sharing the lock's name.
820
+ async function reapOrphanedStaging(stableDir) {
821
+ const orphan = /^\.cvc-install\.[A-Za-z0-9]{6}$/; // exactly mkdtemp's six characters
822
+ let entries;
823
+ try { entries = await fsp.readdir(stableDir); } catch (_) { return; }
824
+ for (const entry of entries) {
825
+ if (entry.startsWith(INSTALL_LOCK_NAME) || !orphan.test(entry)) continue;
826
+ try {
827
+ const dir = path.join(stableDir, entry);
828
+ const stat = await inspectPath(dir);
829
+ if (stat.kind === 'directory' && Date.now() - stat.mtimeMs > ORPHAN_STAGING_AGE_MS) {
830
+ await fsp.rm(dir, { recursive: true, force: true });
831
+ }
832
+ } catch (_) { /* best effort */ }
833
+ }
834
+ }
835
+
836
+ async function moveIntoPlace(ctx, stageDir, name) {
837
+ const staged = path.join(stageDir, name);
838
+ const target = path.join(ctx.stableDir, name);
839
+ const existing = await replaceableTarget(target);
840
+ if (ctx.platform !== 'win32') {
841
+ await fsp.rename(staged, target); // atomic replace on the same filesystem
842
+ return;
843
+ }
844
+ let retired;
845
+ if (existing === 'file') {
846
+ retired = path.join(ctx.stableDir, `.cvc-old-${name}-${crypto.randomBytes(8).toString('hex')}`);
847
+ await fsp.rename(target, retired);
848
+ }
849
+ try {
850
+ await fsp.rename(staged, target);
851
+ } catch (error) {
852
+ if (retired) await fsp.rename(retired, target).catch(() => {});
853
+ throw error;
854
+ }
855
+ if (retired) await fsp.unlink(retired).catch(() => {});
856
+ }
857
+
858
+ async function publishBinaries(ctx, cacheDir) {
859
+ if (ctx.platform === 'win32') await removeRetiredImages(ctx.stableDir);
860
+ await reapOrphanedStaging(ctx.stableDir);
861
+ const sizes = new Map();
862
+ for (const name of ctx.names) {
863
+ const source = await inspectPath(path.join(cacheDir, name));
864
+ if (source.kind !== 'file' || source.size <= 0) throw new Error(`the cached ${name} is missing or unsafe`);
865
+ sizes.set(name, source.size);
866
+ await replaceableTarget(path.join(ctx.stableDir, name));
867
+ }
868
+ const stageDir = await fsp.mkdtemp(path.join(ctx.stableDir, '.cvc-install.'));
869
+ try {
870
+ for (const name of ctx.names) {
871
+ const staged = path.join(stageDir, name);
872
+ await fsp.copyFile(path.join(cacheDir, name), staged, fs.constants.COPYFILE_EXCL);
873
+ if (ctx.platform !== 'win32') await fsp.chmod(staged, 0o755);
874
+ if ((await fsp.lstat(staged)).size !== sizes.get(name)) throw new Error(`staged ${name} has an unexpected size`);
875
+ }
876
+ // Nothing is replaced unless the new cvc demonstrably runs here and is the right
877
+ // version: a cache copy that cannot run in this environment must never displace an
878
+ // installation that may work elsewhere, and would only fail the same way next launch.
879
+ const cvcName = binaryFileName('cvc', ctx.platform);
880
+ const stagedCvc = path.join(stageDir, cvcName);
881
+ requireProbedVersion(ctx, 'staged', cvcName, probeInstalledVersion(stagedCvc, probeOptions(ctx, (await fsp.lstat(stagedCvc)).mode)));
882
+ for (const base of PUBLISH_ORDER) await moveIntoPlace(ctx, stageDir, binaryFileName(base, ctx.platform));
883
+ } finally {
884
+ await fsp.rm(stageDir, { recursive: true, force: true }).catch(() => {});
885
+ }
886
+ }
887
+
888
+ async function resolveLaunchTarget(ctx, cachePath, executable) {
889
+ const binaryName = binaryFileName(executable, ctx.platform);
890
+ let decision = await decidePublication(ctx, binaryName);
891
+ if (decision.blocked) throw new Error(decision.blocked);
892
+ if (!decision.publish) return decision.target ?? cachePath;
893
+
894
+ await ensureStableDirectory(ctx);
895
+ let lock;
896
+ try {
897
+ lock = await acquireInstallLock(ctx);
898
+ } catch (error) {
899
+ if (!(error instanceof LockHeldError) || !error.live || !await waitForLockRelease(ctx, error.lockPath)) throw error;
900
+ // The other launcher finished (or died). It may well have done our work.
901
+ decision = await decidePublication(ctx, binaryName);
902
+ if (decision.blocked) throw new Error(decision.blocked);
903
+ if (!decision.publish) return decision.target ?? cachePath;
904
+ lock = await acquireInstallLock(ctx, { mayWait: false });
905
+ }
906
+ try {
907
+ // Another launcher may have published while this one waited for the lock.
908
+ decision = await decidePublication(ctx, binaryName);
909
+ if (decision.blocked) throw new Error(decision.blocked);
910
+ if (!decision.publish) return decision.target ?? cachePath;
911
+
912
+ await publishBinaries(ctx, path.dirname(cachePath));
913
+
914
+ const cvcName = binaryFileName('cvc', ctx.platform);
915
+ const publishedCvc = path.join(ctx.stableDir, cvcName);
916
+ requireProbedVersion(ctx, 'published', cvcName, probeInstalledVersion(publishedCvc, probeOptions(ctx, (await fsp.lstat(publishedCvc)).mode)));
917
+ console.error(`Installed CVC ${ctx.packageVersion} to ${ctx.stableDir}`);
918
+ return path.join(ctx.stableDir, binaryName);
919
+ } finally {
920
+ await releaseInstallLock(ctx, lock);
921
+ }
922
+ }
923
+
924
+ // Returns the path to execute: ~/.cvc/bin/<binary> when usable, else the cache
925
+ // path. Never throws and never blocks the launch. `shared` is the context built
926
+ // by the pre-download check, so both phases reuse the same version probes.
927
+ async function ensurePublished(cachePath, executable, options = {}, shared) {
928
+ let stableDir = '~/.cvc/bin';
929
+ try {
930
+ const ctx = shared ?? publishContext(options);
931
+ stableDir = ctx.stableDir;
932
+ return await resolveLaunchTarget(ctx, cachePath, executable);
933
+ } catch (error) {
934
+ console.error(`CVC: could not install into ${stableDir}: ${error.message}. Running the verified cached copy this time; the next launch tries again.`);
935
+ return cachePath;
936
+ }
937
+ }
938
+
939
+ function run(binary, args) {
940
+ const child = spawn(binary, args, { stdio: 'inherit' });
941
+ child.on('error', error => { console.error(`Failed to run ${binary}: ${error.message}`); process.exit(1); });
942
+ child.on('close', (code, signal) => process.exit(code === null ? (signal ? 1 : 0) : code));
943
+ }
944
+
945
+ // Decides what to execute. ~/.cvc must pass the same private-directory check
946
+ // every launch has always passed through installRelease (a real directory owned
947
+ // by this user, mode 0700) before anything below it is probed or run. The
948
+ // stable directory is then checked before anything is downloaded: when it
949
+ // already holds a same-or-newer complete release there is nothing to download
950
+ // (this also keeps an old pinned launcher working offline). The release is
951
+ // fetched into the cache only when the cache is actually needed.
952
+ async function launchTarget(executable, options = {}) {
953
+ if (!BINARIES.includes(executable)) throw new Error('Refusing unknown CVC executable.');
954
+ await ensurePrivateDirectory(path.join(options.home ?? os.homedir(), '.cvc'));
955
+ let ctx;
956
+ try {
957
+ ctx = publishContext(options);
958
+ const decision = await decidePublication(ctx, binaryFileName(executable, ctx.platform));
959
+ if (!decision.publish && decision.target) return decision.target;
960
+ } catch (_) { /* ensurePublished reports it below; the launch continues with the cache */ }
961
+ const install = options.installRelease ?? installRelease;
962
+ const cachePath = await install(options.details ?? getPlatformDetails(), executable);
963
+ return ensurePublished(cachePath, executable, options, ctx);
964
+ }
965
+
966
+ async function launch(executable) {
967
+ run(await launchTarget(executable), process.argv.slice(2));
968
+ }
969
+
970
+ module.exports = {
971
+ checksumForAsset,
972
+ compareSemver,
973
+ getPlatformDetails,
974
+ launch,
975
+ redirectAllowed,
976
+ releaseConfiguration,
977
+ releaseUrls,
978
+ _extractMemberForTest: extractMember,
979
+ _ensurePublishedForTest: ensurePublished,
980
+ _launchTargetForTest: launchTarget,
981
+ };