@sayknow-cli/coding-agent 0.5.2 → 0.5.8
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/CHANGELOG.md +66 -16
- package/dist/types/cli/update-cli.d.ts +8 -1
- package/dist/types/config/atomic-yaml-patch.d.ts +20 -1
- package/dist/types/config/keybindings.d.ts +10 -0
- package/dist/types/config/model-registry.d.ts +1 -1
- package/dist/types/config/models-config-schema.d.ts +4 -0
- package/dist/types/config/settings-schema.d.ts +73 -0
- package/dist/types/dap/client.d.ts +1 -0
- package/dist/types/lsp/client.d.ts +5 -0
- package/dist/types/modes/components/welcome.d.ts +1 -0
- package/dist/types/modes/controllers/selector-controller.d.ts +3 -1
- package/dist/types/modes/interactive-mode.d.ts +13 -1
- package/dist/types/modes/types.d.ts +7 -1
- package/dist/types/runtime-mcp/transports/stdio.d.ts +2 -0
- package/dist/types/session/agent-session.d.ts +8 -0
- package/dist/types/session/internal/managed-session-scope.d.ts +3 -1
- package/dist/types/session/internal/managed-session-storage.d.ts +2 -0
- package/dist/types/session/session-manager.d.ts +9 -0
- package/dist/types/session/session-storage.d.ts +17 -0
- package/dist/types/session-import/claude.d.ts +26 -0
- package/dist/types/session-import/codex.d.ts +22 -0
- package/dist/types/session-import/command.d.ts +20 -0
- package/dist/types/session-import/detect.d.ts +22 -0
- package/dist/types/session-import/index.d.ts +7 -0
- package/dist/types/session-import/redact.d.ts +23 -0
- package/dist/types/session-import/service.d.ts +28 -0
- package/dist/types/session-import/types.d.ts +125 -0
- package/dist/types/skc-runtime/launch-worktree.d.ts +17 -1
- package/dist/types/slash-commands/builtin-registry.d.ts +4 -0
- package/dist/types/slash-commands/types.d.ts +5 -0
- package/dist/types/system-prompt.d.ts +5 -0
- package/dist/types/tools/ask.d.ts +5 -0
- package/dist/types/utils/mupdf-wasm.d.ts +1 -0
- package/package.json +13 -7
- package/scripts/compile-args.ts +8 -4
- package/scripts/generate-sdk-operation-inventory.ts +5 -6
- package/src/cli/config-cli.ts +23 -8
- package/src/cli/update-cli.ts +265 -21
- package/src/config/atomic-yaml-patch.ts +167 -30
- package/src/config/keybindings.ts +10 -0
- package/src/config/model-profiles.ts +84 -16
- package/src/config/model-registry.ts +60 -38
- package/src/config/models-config-schema.ts +1 -1
- package/src/config/settings-schema.ts +79 -0
- package/src/config/settings.ts +13 -2
- package/src/dap/client.ts +78 -30
- package/src/internal-urls/docs-index.generated.ts +5 -4
- package/src/lsp/client.ts +17 -29
- package/src/modes/action-registry.ts +2 -0
- package/src/modes/components/welcome.ts +5 -0
- package/src/modes/controllers/goal-mode-controller.ts +7 -2
- package/src/modes/controllers/input-controller.ts +71 -6
- package/src/modes/controllers/plan-mode-controller.ts +8 -1
- package/src/modes/controllers/runtime-mcp-command-controller.ts +14 -0
- package/src/modes/controllers/selector-controller.ts +47 -19
- package/src/modes/interactive-mode.ts +54 -4
- package/src/modes/types.ts +5 -1
- package/src/prompts/system/system-prompt.md +8 -0
- package/src/runtime-mcp/transports/stdio.ts +96 -33
- package/src/sdk/broker/lifecycle.ts +6 -5
- package/src/sdk/protocol/operation-inventory.generated.json +33 -0
- package/src/sdk/session.ts +27 -18
- package/src/session/agent-session.ts +155 -15
- package/src/session/internal/managed-session-scope.ts +41 -6
- package/src/session/internal/managed-session-storage.ts +14 -0
- package/src/session/session-manager.ts +113 -11
- package/src/session/session-storage.ts +70 -0
- package/src/session-import/claude.ts +382 -0
- package/src/session-import/codex.ts +458 -0
- package/src/session-import/command.ts +61 -0
- package/src/session-import/detect.ts +133 -0
- package/src/session-import/index.ts +27 -0
- package/src/session-import/redact.ts +125 -0
- package/src/session-import/service.ts +749 -0
- package/src/session-import/types.ts +163 -0
- package/src/skc-runtime/launch-worktree.ts +277 -4
- package/src/slash-commands/acp-builtins.ts +5 -1
- package/src/slash-commands/builtin-registry.ts +100 -1
- package/src/slash-commands/types.ts +5 -0
- package/src/system-prompt.ts +7 -0
- package/src/tools/ask.ts +5 -0
- package/src/tools/fetch.ts +7 -2
- package/src/utils/markit.ts +24 -2
- package/src/utils/mupdf-wasm.ts +26 -0
- package/src/export/html/template.css +0 -1060
- package/src/export/html/template.html +0 -47
- package/src/export/html/template.js +0 -2348
- package/vendor/insane-search/engine/tests/test_hardening.py +0 -57
- package/vendor/insane-search/engine/tests/test_smoke.py +0 -152
- package/vendor/insane-search/engine/tests/test_u1.py +0 -200
- package/vendor/insane-search/engine/tests/test_u4.py +0 -131
- package/vendor/insane-search/engine/tests/test_u5.py +0 -163
- package/vendor/insane-search/engine/tests/test_u7.py +0 -124
- package/vendor/insane-search/engine/tests/test_u8.py +0 -216
package/src/cli/update-cli.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* Handles `skc update` to check for and install updates.
|
|
5
5
|
* Uses bun if available, otherwise downloads binary from GitHub releases.
|
|
6
6
|
*/
|
|
7
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
7
8
|
import * as fs from "node:fs";
|
|
8
9
|
import * as path from "node:path";
|
|
9
10
|
import { pipeline } from "node:stream/promises";
|
|
@@ -17,6 +18,8 @@ const RELEASE_REPO = "jaybeyond/Sayknow_CLI";
|
|
|
17
18
|
const PACKAGE = "@sayknow-cli/coding-agent";
|
|
18
19
|
const NPM_WRAPPER_PACKAGE = "sayknow-cli";
|
|
19
20
|
const NPM_MANAGED_PACKAGES = [NPM_WRAPPER_PACKAGE, PACKAGE] as const;
|
|
21
|
+
const BINARY_MANIFEST_ASSET = "sayknow-release-binaries-v1.json";
|
|
22
|
+
const BINARY_SHA256_ASSET = "sayknow-release-binaries.sha256";
|
|
20
23
|
|
|
21
24
|
interface ReleaseInfo {
|
|
22
25
|
tag: string;
|
|
@@ -272,11 +275,21 @@ function getBinaryName(platform: NodeJS.Platform = process.platform, arch: strin
|
|
|
272
275
|
}
|
|
273
276
|
|
|
274
277
|
if (os === "windows") {
|
|
278
|
+
if (archName !== "x64") {
|
|
279
|
+
throw new Error(formatUnsupportedTargetMessage(`Unsupported architecture for Windows: ${arch}`));
|
|
280
|
+
}
|
|
275
281
|
return `${APP_NAME}-${os}-${archName}.exe`;
|
|
276
282
|
}
|
|
277
283
|
return `${APP_NAME}-${os}-${archName}`;
|
|
278
284
|
}
|
|
279
285
|
|
|
286
|
+
export function getBinaryNameForTest(
|
|
287
|
+
platform: NodeJS.Platform = process.platform,
|
|
288
|
+
arch: string = process.arch,
|
|
289
|
+
): string {
|
|
290
|
+
return getBinaryName(platform, arch);
|
|
291
|
+
}
|
|
292
|
+
|
|
280
293
|
/**
|
|
281
294
|
* Resolve the path that `skc` maps to in the user's PATH.
|
|
282
295
|
*/
|
|
@@ -367,10 +380,89 @@ function buildReleaseBinaryUrl(
|
|
|
367
380
|
arch: string = process.arch,
|
|
368
381
|
): string {
|
|
369
382
|
const binaryName = getBinaryName(platform, arch);
|
|
370
|
-
const tag = `v${version}`;
|
|
383
|
+
const tag = `sayknow-v${version}`;
|
|
371
384
|
return `https://github.com/${RELEASE_REPO}/releases/download/${tag}/${binaryName}`;
|
|
372
385
|
}
|
|
373
386
|
|
|
387
|
+
function buildReleaseAssetUrl(version: string, assetName: string): string {
|
|
388
|
+
return `https://github.com/${RELEASE_REPO}/releases/download/sayknow-v${version}/${assetName}`;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function parseExpectedDigestFromSums(text: string, assetName: string): string | undefined {
|
|
392
|
+
const matches: string[] = [];
|
|
393
|
+
for (const line of text.split(/\r?\n/)) {
|
|
394
|
+
const match = line.trim().match(/^([a-fA-F0-9]{64}) {2}[ *]?([^/\\]+)$/);
|
|
395
|
+
if (match?.[2] === assetName) matches.push(match[1]!.toLowerCase());
|
|
396
|
+
}
|
|
397
|
+
if (matches.length > 1) throw new Error(`Release checksum file lists ${assetName} more than once`);
|
|
398
|
+
return matches[0];
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function expectedDigestFromManifest(value: unknown, version: string, assetName: string): string {
|
|
402
|
+
if (!value || typeof value !== "object") throw new Error("Release binary manifest is not an object");
|
|
403
|
+
const manifest = value as Record<string, unknown>;
|
|
404
|
+
if (
|
|
405
|
+
manifest.schema !== "sayknow-release-binaries-v1" ||
|
|
406
|
+
manifest.schema_version !== 1 ||
|
|
407
|
+
manifest.release_version !== version ||
|
|
408
|
+
manifest.tag !== `sayknow-v${version}` ||
|
|
409
|
+
!Array.isArray(manifest.binaries)
|
|
410
|
+
) {
|
|
411
|
+
throw new Error("Release binary manifest metadata does not match the requested release");
|
|
412
|
+
}
|
|
413
|
+
const matches = manifest.binaries.filter(
|
|
414
|
+
entry => entry && typeof entry === "object" && (entry as Record<string, unknown>).name === assetName,
|
|
415
|
+
) as Array<Record<string, unknown>>;
|
|
416
|
+
if (matches.length !== 1 || typeof matches[0]?.sha256 !== "string" || !/^[a-f0-9]{64}$/.test(matches[0].sha256)) {
|
|
417
|
+
throw new Error(`Release binary manifest does not contain one valid SHA-256 for ${assetName}`);
|
|
418
|
+
}
|
|
419
|
+
return matches[0].sha256;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
export async function fetchExpectedBinaryDigest(
|
|
423
|
+
version: string,
|
|
424
|
+
assetName: string,
|
|
425
|
+
fetchImpl: (url: string) => Promise<Response> = fetch,
|
|
426
|
+
): Promise<string> {
|
|
427
|
+
const sumsResponse = await fetchImpl(buildReleaseAssetUrl(version, BINARY_SHA256_ASSET));
|
|
428
|
+
if (sumsResponse.ok) {
|
|
429
|
+
const digest = parseExpectedDigestFromSums(await sumsResponse.text(), assetName);
|
|
430
|
+
if (!digest) throw new Error(`Release checksum file does not list ${assetName}`);
|
|
431
|
+
return digest;
|
|
432
|
+
}
|
|
433
|
+
if (sumsResponse.status !== 404) {
|
|
434
|
+
throw new Error(`Release checksum file could not be fetched: HTTP ${sumsResponse.status}`);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
const manifestResponse = await fetchImpl(buildReleaseAssetUrl(version, BINARY_MANIFEST_ASSET));
|
|
438
|
+
if (!manifestResponse.ok) {
|
|
439
|
+
if (manifestResponse.status === 404) {
|
|
440
|
+
throw new Error(`Release ${version} has no Sayknow binary integrity manifest`);
|
|
441
|
+
}
|
|
442
|
+
throw new Error(`Release binary manifest could not be fetched: HTTP ${manifestResponse.status}`);
|
|
443
|
+
}
|
|
444
|
+
return expectedDigestFromManifest(await manifestResponse.json(), version, assetName);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
async function sha256File(filePath: string): Promise<string> {
|
|
448
|
+
const hash = createHash("sha256");
|
|
449
|
+
for await (const chunk of fs.createReadStream(filePath)) hash.update(chunk);
|
|
450
|
+
return hash.digest("hex");
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
export async function verifyReleaseBinaryIntegrity(
|
|
454
|
+
filePath: string,
|
|
455
|
+
version: string,
|
|
456
|
+
assetName: string,
|
|
457
|
+
fetchImpl: (url: string) => Promise<Response> = fetch,
|
|
458
|
+
): Promise<void> {
|
|
459
|
+
const expected = await fetchExpectedBinaryDigest(version, assetName, fetchImpl);
|
|
460
|
+
const actual = await sha256File(filePath);
|
|
461
|
+
if (actual !== expected) {
|
|
462
|
+
throw new Error(`SHA-256 mismatch for ${assetName}: expected ${expected}, got ${actual}`);
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
374
466
|
function formatBinaryDownloadFailureMessage(
|
|
375
467
|
binaryName: string,
|
|
376
468
|
url: string,
|
|
@@ -439,42 +531,186 @@ function formatBackupCleanupWarning(backupPath: string, err: unknown): string {
|
|
|
439
531
|
|
|
440
532
|
async function cleanupVerifiedBackup(backupPath: string): Promise<string | undefined> {
|
|
441
533
|
try {
|
|
442
|
-
await
|
|
534
|
+
await unlinkDurably(backupPath);
|
|
443
535
|
return undefined;
|
|
444
536
|
} catch (err) {
|
|
445
537
|
return formatBackupCleanupWarning(backupPath, err);
|
|
446
538
|
}
|
|
447
539
|
}
|
|
448
540
|
|
|
541
|
+
async function pathExists(filePath: string): Promise<boolean> {
|
|
542
|
+
try {
|
|
543
|
+
await fs.promises.lstat(filePath);
|
|
544
|
+
return true;
|
|
545
|
+
} catch (err) {
|
|
546
|
+
if (isEnoent(err)) return false;
|
|
547
|
+
throw err;
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
async function syncParentDirectory(filePath: string): Promise<void> {
|
|
552
|
+
// Node cannot portably fsync directory handles on Windows. NTFS journals the
|
|
553
|
+
// rename operations used below; POSIX platforms require an explicit parent sync.
|
|
554
|
+
if (process.platform === "win32") return;
|
|
555
|
+
const handle = await fs.promises.open(path.dirname(filePath), "r");
|
|
556
|
+
try {
|
|
557
|
+
await handle.sync();
|
|
558
|
+
} finally {
|
|
559
|
+
await handle.close();
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
async function unlinkDurably(filePath: string): Promise<void> {
|
|
564
|
+
await unlinkIfExists(filePath);
|
|
565
|
+
await syncParentDirectory(filePath);
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
async function renameDurably(sourcePath: string, targetPath: string): Promise<void> {
|
|
569
|
+
await fs.promises.rename(sourcePath, targetPath);
|
|
570
|
+
await syncParentDirectory(targetPath);
|
|
571
|
+
if (path.dirname(sourcePath) !== path.dirname(targetPath)) await syncParentDirectory(sourcePath);
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
export async function withBinaryUpdateLock<T>(targetPath: string, operation: () => Promise<T>): Promise<T> {
|
|
575
|
+
const lockPath = path.join(path.dirname(targetPath), ".skc-install.lock");
|
|
576
|
+
try {
|
|
577
|
+
await fs.promises.mkdir(lockPath);
|
|
578
|
+
} catch (err) {
|
|
579
|
+
if ((err as NodeJS.ErrnoException).code === "EEXIST") {
|
|
580
|
+
throw new Error(`Another SKC installer or update is already running (lock: ${lockPath}).`);
|
|
581
|
+
}
|
|
582
|
+
throw err;
|
|
583
|
+
}
|
|
584
|
+
try {
|
|
585
|
+
await fs.promises.writeFile(
|
|
586
|
+
path.join(lockPath, "owner.json"),
|
|
587
|
+
JSON.stringify({ pid: process.pid, startedAt: new Date().toISOString() }),
|
|
588
|
+
{ flag: "wx" },
|
|
589
|
+
);
|
|
590
|
+
return await operation();
|
|
591
|
+
} finally {
|
|
592
|
+
await fs.promises.rm(lockPath, { recursive: true, force: true });
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
type BinaryReplacementPhase = "replacing" | "committed";
|
|
597
|
+
|
|
598
|
+
interface BinaryReplacementState {
|
|
599
|
+
phase: BinaryReplacementPhase;
|
|
600
|
+
hadTarget: boolean;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
function binaryReplacementStatePath(backupPath: string): string {
|
|
604
|
+
return `${backupPath}.state.json`;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
async function readBinaryReplacementState(statePath: string): Promise<BinaryReplacementState | undefined> {
|
|
608
|
+
try {
|
|
609
|
+
const value = JSON.parse(await fs.promises.readFile(statePath, "utf8")) as Partial<BinaryReplacementState>;
|
|
610
|
+
if ((value.phase === "replacing" || value.phase === "committed") && typeof value.hadTarget === "boolean") {
|
|
611
|
+
return value as BinaryReplacementState;
|
|
612
|
+
}
|
|
613
|
+
return { phase: "replacing", hadTarget: true };
|
|
614
|
+
} catch (err) {
|
|
615
|
+
if (isEnoent(err)) return undefined;
|
|
616
|
+
return { phase: "replacing", hadTarget: true };
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
async function writeBinaryReplacementState(statePath: string, state: BinaryReplacementState): Promise<void> {
|
|
621
|
+
const handle = await fs.promises.open(statePath, "w", 0o600);
|
|
622
|
+
try {
|
|
623
|
+
await handle.writeFile(JSON.stringify(state));
|
|
624
|
+
await handle.sync();
|
|
625
|
+
} finally {
|
|
626
|
+
await handle.close();
|
|
627
|
+
}
|
|
628
|
+
await syncParentDirectory(statePath);
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
async function recoverInterruptedBinaryReplacement(targetPath: string, backupPath: string): Promise<void> {
|
|
632
|
+
const statePath = binaryReplacementStatePath(backupPath);
|
|
633
|
+
const state = await readBinaryReplacementState(statePath);
|
|
634
|
+
if (!(await pathExists(backupPath))) {
|
|
635
|
+
if (state?.phase === "replacing" && state.hadTarget === false) {
|
|
636
|
+
await unlinkDurably(targetPath);
|
|
637
|
+
}
|
|
638
|
+
await unlinkDurably(statePath);
|
|
639
|
+
return;
|
|
640
|
+
}
|
|
641
|
+
if (state?.phase === "committed" && (await pathExists(targetPath))) {
|
|
642
|
+
await unlinkDurably(backupPath);
|
|
643
|
+
await unlinkDurably(statePath);
|
|
644
|
+
return;
|
|
645
|
+
}
|
|
646
|
+
await unlinkDurably(targetPath);
|
|
647
|
+
await renameDurably(backupPath, targetPath);
|
|
648
|
+
await unlinkDurably(statePath);
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
async function cleanupCommittedReplacementState(statePath: string): Promise<string | undefined> {
|
|
652
|
+
try {
|
|
653
|
+
await unlinkDurably(statePath);
|
|
654
|
+
return undefined;
|
|
655
|
+
} catch (err) {
|
|
656
|
+
return `Installed update, but could not remove transaction state file ${statePath}: ${err}. The verified binary remains installed.`;
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
|
|
449
660
|
/**
|
|
450
661
|
* Atomically replace the installed binary and roll back if version verification fails.
|
|
451
662
|
*/
|
|
452
663
|
export async function replaceBinaryForUpdate(options: BinaryReplacementOptions): Promise<InstalledVersionVerification> {
|
|
664
|
+
const statePath = binaryReplacementStatePath(options.backupPath);
|
|
665
|
+
await recoverInterruptedBinaryReplacement(options.targetPath, options.backupPath);
|
|
666
|
+
await unlinkDurably(options.backupPath);
|
|
667
|
+
const hadTarget = await pathExists(options.targetPath);
|
|
453
668
|
let backupReady = false;
|
|
669
|
+
let publishedNew = false;
|
|
670
|
+
let verification: InstalledVersionVerification;
|
|
454
671
|
try {
|
|
455
|
-
await
|
|
456
|
-
|
|
457
|
-
|
|
672
|
+
await writeBinaryReplacementState(statePath, {
|
|
673
|
+
phase: "replacing",
|
|
674
|
+
hadTarget,
|
|
675
|
+
});
|
|
676
|
+
try {
|
|
677
|
+
await fs.promises.rename(options.targetPath, options.backupPath);
|
|
678
|
+
backupReady = true;
|
|
679
|
+
await syncParentDirectory(options.backupPath);
|
|
680
|
+
} catch (err) {
|
|
681
|
+
if (!isEnoent(err)) throw err;
|
|
682
|
+
}
|
|
458
683
|
await fs.promises.rename(options.tempPath, options.targetPath);
|
|
684
|
+
publishedNew = true;
|
|
685
|
+
await syncParentDirectory(options.targetPath);
|
|
459
686
|
|
|
460
|
-
|
|
687
|
+
verification = await options.verifyInstalledVersion(options.expectedVersion);
|
|
461
688
|
if (!verification.ok) {
|
|
462
689
|
throw new Error(
|
|
463
690
|
`${formatVerificationFailure(verification, options.expectedVersion)}; restored previous ${APP_NAME} binary`,
|
|
464
691
|
);
|
|
465
692
|
}
|
|
466
693
|
|
|
694
|
+
await writeBinaryReplacementState(statePath, {
|
|
695
|
+
phase: "committed",
|
|
696
|
+
hadTarget,
|
|
697
|
+
});
|
|
467
698
|
backupReady = false;
|
|
468
|
-
const cleanupWarning = await cleanupVerifiedBackup(options.backupPath);
|
|
469
|
-
return cleanupWarning ? { ...verification, cleanupWarning } : verification;
|
|
470
699
|
} catch (err) {
|
|
471
700
|
if (backupReady) {
|
|
472
|
-
await
|
|
473
|
-
await
|
|
701
|
+
await unlinkDurably(options.targetPath);
|
|
702
|
+
await renameDurably(options.backupPath, options.targetPath);
|
|
703
|
+
} else if (publishedNew) {
|
|
704
|
+
await unlinkDurably(options.targetPath);
|
|
474
705
|
}
|
|
475
|
-
await
|
|
706
|
+
await unlinkDurably(options.tempPath);
|
|
707
|
+
await unlinkDurably(statePath);
|
|
476
708
|
throw err;
|
|
477
709
|
}
|
|
710
|
+
|
|
711
|
+
let cleanupWarning = await cleanupVerifiedBackup(options.backupPath);
|
|
712
|
+
if (!cleanupWarning) cleanupWarning = await cleanupCommittedReplacementState(statePath);
|
|
713
|
+
return cleanupWarning ? { ...verification, cleanupWarning } : verification;
|
|
478
714
|
}
|
|
479
715
|
|
|
480
716
|
function formatPackageManagerInstallFailure(
|
|
@@ -594,17 +830,20 @@ async function downloadBinaryTo(url: string, tempPath: string, binaryName: strin
|
|
|
594
830
|
/** Injectable steps of the binary update flow (seams for testing ordering). */
|
|
595
831
|
export interface BinaryUpdateFlow {
|
|
596
832
|
download(url: string, tempPath: string): Promise<void>;
|
|
833
|
+
verifyIntegrity(filePath: string): Promise<void>;
|
|
597
834
|
fsync(filePath: string): Promise<void>;
|
|
598
835
|
replace(options: BinaryReplacementOptions): Promise<InstalledVersionVerification>;
|
|
599
836
|
verifyInstalledVersion(expectedVersion: string): Promise<InstalledVersionVerification>;
|
|
600
837
|
/** Best-effort cleanup of the temp file when the flow aborts before replace. */
|
|
601
838
|
removeTemp?(filePath: string): Promise<void>;
|
|
839
|
+
/** Stable suffix for focused tests; production uses a process-unique staging path. */
|
|
840
|
+
transactionId?: string;
|
|
602
841
|
/** Called once fsync has succeeded, right before replacement begins. */
|
|
603
842
|
beforeReplace?(): void;
|
|
604
843
|
}
|
|
605
844
|
|
|
606
845
|
/**
|
|
607
|
-
* Orchestrate download → fsync → replace →
|
|
846
|
+
* Orchestrate download → integrity verification → fsync → replace → version/smoke verification.
|
|
608
847
|
* contract: the downloaded temp binary MUST be flushed to stable storage
|
|
609
848
|
* before it is published (renamed into place) or exec'd for verification.
|
|
610
849
|
*
|
|
@@ -618,11 +857,13 @@ export async function runBinaryUpdateFlow(
|
|
|
618
857
|
expectedVersion: string,
|
|
619
858
|
flow: BinaryUpdateFlow,
|
|
620
859
|
): Promise<InstalledVersionVerification> {
|
|
621
|
-
const
|
|
860
|
+
const transactionId = flow.transactionId ?? `${process.pid}.${randomUUID()}`;
|
|
861
|
+
const tempPath = `${targetPath}.new.${transactionId}`;
|
|
622
862
|
const backupPath = `${targetPath}.bak`;
|
|
623
863
|
|
|
624
864
|
await flow.download(url, tempPath);
|
|
625
865
|
try {
|
|
866
|
+
await flow.verifyIntegrity(tempPath);
|
|
626
867
|
await flow.fsync(tempPath);
|
|
627
868
|
} catch (err) {
|
|
628
869
|
if (flow.removeTemp) await flow.removeTemp(tempPath);
|
|
@@ -647,14 +888,17 @@ async function updateViaBinaryAt(targetPath: string, expectedVersion: string): P
|
|
|
647
888
|
const url = buildReleaseBinaryUrl(expectedVersion);
|
|
648
889
|
console.log(chalk.dim(`Downloading ${binaryName}…`));
|
|
649
890
|
|
|
650
|
-
const verification = await
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
891
|
+
const verification = await withBinaryUpdateLock(targetPath, () =>
|
|
892
|
+
runBinaryUpdateFlow(targetPath, url, expectedVersion, {
|
|
893
|
+
download: (downloadUrl, tempPath) => downloadBinaryTo(downloadUrl, tempPath, binaryName),
|
|
894
|
+
verifyIntegrity: tempPath => verifyReleaseBinaryIntegrity(tempPath, expectedVersion, binaryName),
|
|
895
|
+
fsync: fsyncFile,
|
|
896
|
+
replace: replaceBinaryForUpdate,
|
|
897
|
+
verifyInstalledVersion: verifyInstalledRuntime,
|
|
898
|
+
removeTemp: unlinkIfExists,
|
|
899
|
+
beforeReplace: () => console.log(chalk.dim("Installing update...")),
|
|
900
|
+
}),
|
|
901
|
+
);
|
|
658
902
|
|
|
659
903
|
printVerifiedVersion(expectedVersion);
|
|
660
904
|
if (verification.cleanupWarning) console.warn(chalk.yellow(verification.cleanupWarning));
|