@volute_cvc/cvc-mcp 0.7.0 → 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/README.md +34 -3
- package/THIRD-PARTY-NOTICES.md +2073 -407
- package/bin/cvc-lsp.js +7 -0
- package/bin/release.js +715 -7
- package/package.json +4 -3
package/bin/release.js
CHANGED
|
@@ -7,6 +7,7 @@ const https = require('https');
|
|
|
7
7
|
const os = require('os');
|
|
8
8
|
const path = require('path');
|
|
9
9
|
const { spawn, spawnSync } = require('child_process');
|
|
10
|
+
const { performance } = require('perf_hooks');
|
|
10
11
|
const { Transform } = require('stream');
|
|
11
12
|
const { pipeline } = require('stream/promises');
|
|
12
13
|
|
|
@@ -18,11 +19,81 @@ const MAX_CHECKSUM_BYTES = 1024 * 1024;
|
|
|
18
19
|
const MAX_ARCHIVE_BYTES = 256 * 1024 * 1024;
|
|
19
20
|
const MAX_BINARY_BYTES = 128 * 1024 * 1024;
|
|
20
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;
|
|
21
41
|
|
|
22
42
|
function validVersion(version) {
|
|
23
43
|
return /^\d+\.\d+\.\d+(?:-[0-9A-Za-z]+(?:[.-][0-9A-Za-z]+)*)?$/.test(version);
|
|
24
44
|
}
|
|
25
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
|
+
|
|
26
97
|
function releaseConfiguration(env = process.env) {
|
|
27
98
|
const repository = env.CVC_RELEASE_REPOSITORY || DEFAULT_REPOSITORY;
|
|
28
99
|
const baseUrl = (env.CVC_RELEASE_BASE_URL || DEFAULT_BASE_URL).replace(/\/+$/, '');
|
|
@@ -169,7 +240,7 @@ function extractMember(archive, destination, member, platform) {
|
|
|
169
240
|
].join('\n'), { mode: 0o600 });
|
|
170
241
|
}
|
|
171
242
|
fs.closeSync(fd);
|
|
172
|
-
result = spawnSync(trustedExecutable(), ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', script, archive, member, destination, String(MAX_BINARY_BYTES)], { stdio: 'inherit' });
|
|
243
|
+
result = spawnSync(trustedExecutable(), ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', script, archive, member, destination, String(MAX_BINARY_BYTES)], { stdio: ['ignore', 'ignore', 'inherit'] });
|
|
173
244
|
} else {
|
|
174
245
|
// Limit the child output at the OS level as well as validating the final
|
|
175
246
|
// file, so a compressed member cannot fill the destination filesystem.
|
|
@@ -197,7 +268,13 @@ async function safeDirectory(directory) {
|
|
|
197
268
|
|
|
198
269
|
async function ensurePrivateDirectory(directory) {
|
|
199
270
|
await fsp.mkdir(directory, { recursive: true, mode: 0o700 });
|
|
200
|
-
|
|
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
|
+
}
|
|
201
278
|
await fsp.chmod(directory, 0o700);
|
|
202
279
|
}
|
|
203
280
|
|
|
@@ -209,8 +286,8 @@ async function validCache(cacheDir, names) {
|
|
|
209
286
|
|
|
210
287
|
async function installRelease(details, executable) {
|
|
211
288
|
const { version } = releaseConfiguration();
|
|
212
|
-
const names =
|
|
213
|
-
const binaryName = details.platform
|
|
289
|
+
const names = BINARIES.map(name => binaryFileName(name, details.platform));
|
|
290
|
+
const binaryName = binaryFileName(executable, details.platform);
|
|
214
291
|
const cacheRoot = path.join(os.homedir(), '.cvc', 'mcp-cache');
|
|
215
292
|
const cacheDir = path.join(cacheRoot, `v${version}-${details.archTag}-${details.osTag}`);
|
|
216
293
|
const binaryPath = path.join(cacheDir, binaryName);
|
|
@@ -259,15 +336,646 @@ async function installRelease(details, executable) {
|
|
|
259
336
|
}
|
|
260
337
|
}
|
|
261
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
|
+
|
|
262
939
|
function run(binary, args) {
|
|
263
940
|
const child = spawn(binary, args, { stdio: 'inherit' });
|
|
264
941
|
child.on('error', error => { console.error(`Failed to run ${binary}: ${error.message}`); process.exit(1); });
|
|
265
942
|
child.on('close', (code, signal) => process.exit(code === null ? (signal ? 1 : 0) : code));
|
|
266
943
|
}
|
|
267
944
|
|
|
268
|
-
|
|
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 = {}) {
|
|
269
953
|
if (!BINARIES.includes(executable)) throw new Error('Refusing unknown CVC executable.');
|
|
270
|
-
|
|
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));
|
|
271
968
|
}
|
|
272
969
|
|
|
273
|
-
module.exports = {
|
|
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
|
+
};
|