iterate-skill-installer 3.4.4 → 3.5.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/lib/installer.js +333 -42
- package/package.json +2 -2
package/lib/installer.js
CHANGED
|
@@ -67,6 +67,23 @@ const JSON_MAX_BYTES = 10 * 1024 * 1024; // 10 MiB
|
|
|
67
67
|
const CHECKSUMS_MAX_BYTES = 1 * 1024 * 1024; // 1 MiB
|
|
68
68
|
const TARBALL_MAX_BYTES = 100 * 1024 * 1024; // 100 MiB
|
|
69
69
|
|
|
70
|
+
// Hard cap on tarball LISTING output (``tar -tzf`` / ``tar -tvzf``). The
|
|
71
|
+
// listing must be seen IN FULL: the link-rejection, single-top-level-directory
|
|
72
|
+
// and path-escape guards in extractTarball are meaningless on a truncated view
|
|
73
|
+
// (a 1 MiB TailBuffer used to drop the OLDEST entries, exactly where a tampered
|
|
74
|
+
// archive hides its symlink). 16 MiB is far beyond the real release (a few
|
|
75
|
+
// hundred names) yet still bounded; exceeding it is fail-closed, never a
|
|
76
|
+
// silent truncation.
|
|
77
|
+
const MAX_LISTING_CHARS = 16 * 1024 * 1024;
|
|
78
|
+
|
|
79
|
+
// Post-extraction size budget, mirroring scripts/install.py (which enforces it
|
|
80
|
+
// during extraction). JS extraction shells out to ``tar -xzf``, which has no
|
|
81
|
+
// such bound, so a highly compressed "decompression bomb" could expand to
|
|
82
|
+
// many GiB on disk before anything noticed. The extracted tree is re-measured
|
|
83
|
+
// and removed when it exceeds these ceilings.
|
|
84
|
+
const MAX_EXTRACTED_BYTES = 500 * 1024 * 1024; // 500 MiB total
|
|
85
|
+
const MAX_EXTRACTED_MEMBER_BYTES = 256 * 1024 * 1024; // 256 MiB per file
|
|
86
|
+
|
|
70
87
|
// A strict upper bound for an interactive yes/no prompt. On EOF (piped stdin
|
|
71
88
|
// that ends) or a TTY that stops responding, the prompt must fall back to the
|
|
72
89
|
// default rather than hang the installer forever.
|
|
@@ -284,6 +301,32 @@ function normalizeToken(raw) {
|
|
|
284
301
|
return trimmed === '' ? null : trimmed;
|
|
285
302
|
}
|
|
286
303
|
|
|
304
|
+
// GitHub PAT prefixes accepted by scripts/install.py (kept in sync so the two
|
|
305
|
+
// installers agree about what a "malformed token" is). A typo'd token used to
|
|
306
|
+
// be sent verbatim and surfaced only as a confusing 401/403 later.
|
|
307
|
+
const GITHUB_TOKEN_PREFIXES = ['ghp_', 'gho_', 'ghu_', 'ghs_', 'ghr_', 'github_pat_'];
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Return an error message when ``token`` cannot be a GitHub PAT, else null.
|
|
311
|
+
* Mirrors install.py's ``_validate_github_token`` (length + prefix whitelist).
|
|
312
|
+
*/
|
|
313
|
+
function validateTokenShape(token) {
|
|
314
|
+
if (!token) return null;
|
|
315
|
+
if (token.length < 20 || token.length > 256) {
|
|
316
|
+
return (
|
|
317
|
+
'GitHub token looks malformed: expected a PAT (starts with ghp_/gho_/github_pat_ etc.) ' +
|
|
318
|
+
'of ~40-256 chars, got a very different length.'
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
if (!GITHUB_TOKEN_PREFIXES.some((prefix) => token.startsWith(prefix))) {
|
|
322
|
+
return (
|
|
323
|
+
'GitHub token does not look valid: expected a Personal Access Token starting with ' +
|
|
324
|
+
'one of (ghp_, gho_, ghu_, ghs_, ghr_, github_pat_). Double-check the --token value.'
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
return null;
|
|
328
|
+
}
|
|
329
|
+
|
|
287
330
|
/**
|
|
288
331
|
* Compose the curl flags that attach the caller's PAT — and only those. The
|
|
289
332
|
* token is never attached to a non-API host; and marking it up differently
|
|
@@ -307,7 +350,14 @@ async function fetchJson(url, token) {
|
|
|
307
350
|
const failFlag = (await supportsCurlFailWithBody()) ? '--fail-with-body' : '--fail';
|
|
308
351
|
const args = ['-sSL', failFlag, '--max-time', CURL_MAX_TIME_SECONDS, '--max-filesize', String(JSON_MAX_BYTES), '-H', 'Accept: application/vnd.github+json', '-H', 'X-GitHub-Api-Version: 2022-11-28', '-H', 'User-Agent: iterate-skill-installer', ...buildAuthFlags(url, token)];
|
|
309
352
|
args.push(url);
|
|
310
|
-
|
|
353
|
+
// stdoutMustBeComplete with an explicit cap: the default 1 MiB TailBuffer
|
|
354
|
+
// would truncate a large /releases payload and JSON.parse then failed with a
|
|
355
|
+
// baffling "Unexpected end of JSON input". The cap now matches the
|
|
356
|
+
// --max-filesize ceiling and an oversized body reports itself as such.
|
|
357
|
+
const stdout = await runCommand('curl', args, {
|
|
358
|
+
stdoutCap: JSON_MAX_BYTES,
|
|
359
|
+
stdoutMustBeComplete: true,
|
|
360
|
+
});
|
|
311
361
|
try {
|
|
312
362
|
return JSON.parse(stdout);
|
|
313
363
|
} catch (err) {
|
|
@@ -487,56 +537,161 @@ class TailBuffer {
|
|
|
487
537
|
}
|
|
488
538
|
}
|
|
489
539
|
|
|
540
|
+
/**
|
|
541
|
+
* Fail-closed output buffer for output that must be observed COMPLETELY
|
|
542
|
+
* (tarball listings, API JSON). Unlike TailBuffer — which keeps the newest
|
|
543
|
+
* text and is right for diagnostics — this one records that the cap was
|
|
544
|
+
* exceeded instead of discarding the oldest content, because a partially seen
|
|
545
|
+
* SECURITY listing is worse than no listing at all.
|
|
546
|
+
*/
|
|
547
|
+
class FullBuffer {
|
|
548
|
+
constructor(cap) {
|
|
549
|
+
this.cap = cap;
|
|
550
|
+
this.text = '';
|
|
551
|
+
this.overflowed = false;
|
|
552
|
+
}
|
|
553
|
+
push(chunk) {
|
|
554
|
+
if (!chunk || this.overflowed) return;
|
|
555
|
+
if (this.text.length + chunk.length > this.cap) {
|
|
556
|
+
this.overflowed = true;
|
|
557
|
+
this.text = (this.text + chunk).slice(0, this.cap);
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
this.text += chunk;
|
|
561
|
+
}
|
|
562
|
+
toString() {
|
|
563
|
+
return this.text;
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
490
567
|
// Default ceiling for a single child command: 10 minutes then SIGKILL. Long
|
|
491
568
|
// operations in this file (pip/pipx installs, venv creation) can legitimately
|
|
492
569
|
// take minutes on slow networks, but a wedged child must never hang the
|
|
493
570
|
// installer forever.
|
|
494
571
|
const DEFAULT_COMMAND_TIMEOUT_MS = 10 * 60 * 1000;
|
|
572
|
+
// Grace period between the SIGKILL and giving up on 'close'. A grandchild that
|
|
573
|
+
// inherited our stdio pipe keeps that pipe open, so Node withholds 'close'
|
|
574
|
+
// even though the leader is already dead — without this the promise would
|
|
575
|
+
// never settle and the installer would hang despite the timeout having fired.
|
|
576
|
+
const POST_KILL_SETTLE_MS = 5 * 1000;
|
|
495
577
|
// Hard cap on retained stdout/stderr per command (memory bound).
|
|
496
578
|
const MAX_CAPTURED_OUTPUT_CHARS = 1024 * 1024;
|
|
497
579
|
|
|
580
|
+
// True only on POSIX: Windows has no process groups keyed by negative pid and
|
|
581
|
+
// `detached` there means "new console window", not "new process group".
|
|
582
|
+
const IS_POSIX = process.platform !== 'win32';
|
|
583
|
+
|
|
584
|
+
function killProcessTree(child) {
|
|
585
|
+
// The child was spawned detached (POSIX), so it is its own process-group
|
|
586
|
+
// leader and its negative pid addresses the WHOLE group. A bare
|
|
587
|
+
// `child.kill()` only signals the immediate process, so a `pip` that had
|
|
588
|
+
// already spawned a compiler/download helper outlived the kill and kept
|
|
589
|
+
// running (and holding our stdio pipe) after the installer "timed out".
|
|
590
|
+
if (IS_POSIX && child.pid) {
|
|
591
|
+
try {
|
|
592
|
+
process.kill(-child.pid, 'SIGKILL');
|
|
593
|
+
return;
|
|
594
|
+
} catch (err) {
|
|
595
|
+
// ESRCH: the group is already gone. EPERM: fall through to the leader.
|
|
596
|
+
if (err && err.code === 'ESRCH') return;
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
try {
|
|
600
|
+
child.kill('SIGKILL');
|
|
601
|
+
} catch {
|
|
602
|
+
// Already exited — nothing left to signal.
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
|
|
498
606
|
function runCommand(bin, args, options = {}) {
|
|
499
|
-
const {
|
|
607
|
+
const {
|
|
608
|
+
timeout = DEFAULT_COMMAND_TIMEOUT_MS,
|
|
609
|
+
// Cap on retained stdout. Diagnostics want a TAIL (newest text); security
|
|
610
|
+
// listings / parseable payloads want the WHOLE thing or nothing, which is
|
|
611
|
+
// what ``stdoutMustBeComplete`` switches on.
|
|
612
|
+
stdoutCap = MAX_CAPTURED_OUTPUT_CHARS,
|
|
613
|
+
stdoutMustBeComplete = false,
|
|
614
|
+
...spawnOptions
|
|
615
|
+
} = options;
|
|
500
616
|
return new Promise((resolve, reject) => {
|
|
501
|
-
|
|
502
|
-
|
|
617
|
+
// `detached` on POSIX puts the child in its own process group so the
|
|
618
|
+
// timeout can kill the entire tree, not just the leader. It does not
|
|
619
|
+
// change how we await the child (we never unref it).
|
|
620
|
+
const child = spawn(bin, args, { stdio: 'pipe', detached: IS_POSIX, ...spawnOptions });
|
|
621
|
+
const stdoutTail = stdoutMustBeComplete
|
|
622
|
+
? new FullBuffer(stdoutCap)
|
|
623
|
+
: new TailBuffer(stdoutCap);
|
|
503
624
|
const stderrTail = new TailBuffer(MAX_CAPTURED_OUTPUT_CHARS);
|
|
504
625
|
child.stdout?.on('data', (data) => stdoutTail.push(data.toString()));
|
|
505
626
|
child.stderr?.on('data', (data) => stderrTail.push(data.toString()));
|
|
506
627
|
let killed = false;
|
|
628
|
+
let settled = false;
|
|
629
|
+
let killFallback = null;
|
|
630
|
+
const settle = (fn, value) => {
|
|
631
|
+
if (settled) return;
|
|
632
|
+
settled = true;
|
|
633
|
+
fn(value);
|
|
634
|
+
};
|
|
507
635
|
const timer =
|
|
508
636
|
timeout > 0
|
|
509
637
|
? setTimeout(() => {
|
|
510
638
|
killed = true;
|
|
511
|
-
child
|
|
639
|
+
killProcessTree(child);
|
|
640
|
+
// The leader may be dead while a grandchild still holds our stdio
|
|
641
|
+
// pipe open, in which case 'close' never arrives. Guarantee the
|
|
642
|
+
// promise settles either way.
|
|
643
|
+
killFallback = setTimeout(() => {
|
|
644
|
+
settle(reject, new InstallerError(`${bin} timed out after ${timeout} ms`));
|
|
645
|
+
}, POST_KILL_SETTLE_MS);
|
|
512
646
|
}, timeout)
|
|
513
647
|
: null;
|
|
514
|
-
|
|
648
|
+
const finish = (code) => {
|
|
515
649
|
if (timer) clearTimeout(timer);
|
|
650
|
+
if (killFallback) clearTimeout(killFallback);
|
|
516
651
|
if (killed) {
|
|
517
|
-
reject
|
|
652
|
+
settle(reject, new InstallerError(`${bin} timed out after ${timeout} ms`));
|
|
518
653
|
return;
|
|
519
654
|
}
|
|
520
655
|
if (code === 0) {
|
|
521
|
-
|
|
656
|
+
if (stdoutMustBeComplete && stdoutTail.overflowed) {
|
|
657
|
+
settle(
|
|
658
|
+
reject,
|
|
659
|
+
new InstallerError(
|
|
660
|
+
`${bin} produced more than ${stdoutCap} characters of output; refusing to act on a truncated result.`,
|
|
661
|
+
),
|
|
662
|
+
);
|
|
663
|
+
return;
|
|
664
|
+
}
|
|
665
|
+
settle(resolve, stdoutTail.toString());
|
|
522
666
|
} else {
|
|
523
|
-
reject
|
|
667
|
+
settle(reject, new InstallerError(`${bin} exited with ${code}: ${stderrTail.toString() || stdoutTail.toString()}`));
|
|
524
668
|
}
|
|
525
|
-
}
|
|
669
|
+
};
|
|
670
|
+
child.on('close', finish);
|
|
526
671
|
child.on('error', (err) => {
|
|
527
672
|
if (timer) clearTimeout(timer);
|
|
528
|
-
|
|
673
|
+
if (killFallback) clearTimeout(killFallback);
|
|
674
|
+
settle(reject, err);
|
|
529
675
|
});
|
|
530
676
|
});
|
|
531
677
|
}
|
|
532
678
|
|
|
533
|
-
async function rejectLinkEntries(tarballPath) {
|
|
679
|
+
async function rejectLinkEntries(tarballPath, { listingCap = MAX_LISTING_CHARS } = {}) {
|
|
534
680
|
// Inspect the verbose listing and reject any symbolic ("l") or hard ("h")
|
|
535
681
|
// link entry. We deliberately reject *all* links rather than validating
|
|
536
682
|
// their targets: the release tarball contains none, so any link is a sign
|
|
537
683
|
// of a tampered archive and refusing it outright is the safest response to
|
|
538
684
|
// a symlink-escape attempt (see extractTarball above).
|
|
539
|
-
|
|
685
|
+
//
|
|
686
|
+
// stdoutMustBeComplete: this listing is a SECURITY input. The old 1 MiB
|
|
687
|
+
// tail buffer silently dropped the OLDEST entries, so an archive with enough
|
|
688
|
+
// filler members to push its listing past the cap hid a symlink/hardlink
|
|
689
|
+
// entry from this scan entirely (while `tar -xzf` still extracted it).
|
|
690
|
+
// A listing over the cap now fails closed instead.
|
|
691
|
+
const verbose = await runCommand('tar', ['-tvzf', tarballPath], {
|
|
692
|
+
stdoutCap: listingCap,
|
|
693
|
+
stdoutMustBeComplete: true,
|
|
694
|
+
});
|
|
540
695
|
for (const line of verbose.split(/\r?\n/)) {
|
|
541
696
|
if (!line) {
|
|
542
697
|
continue;
|
|
@@ -555,7 +710,7 @@ async function rejectLinkEntries(tarballPath) {
|
|
|
555
710
|
}
|
|
556
711
|
}
|
|
557
712
|
|
|
558
|
-
async function extractTarball(tarballPath, destDir) {
|
|
713
|
+
async function extractTarball(tarballPath, destDir, { listingCap = MAX_LISTING_CHARS } = {}) {
|
|
559
714
|
// Use the system tar command (available on macOS, Linux, and Windows 10+),
|
|
560
715
|
// but defend against path-traversal tarballs: before extracting we list the
|
|
561
716
|
// archive contents and refuse to extract any entry whose resolved path
|
|
@@ -576,8 +731,13 @@ async function extractTarball(tarballPath, destDir) {
|
|
|
576
731
|
const absoluteDest = path.resolve(destDir);
|
|
577
732
|
fs.mkdirSync(absoluteDest, { recursive: true });
|
|
578
733
|
|
|
579
|
-
// 1. List archive entries (no extraction yet).
|
|
580
|
-
|
|
734
|
+
// 1. List archive entries (no extraction yet). Must be complete — see
|
|
735
|
+
// rejectLinkEntries: every guard below reads THIS listing, and a tail
|
|
736
|
+
// buffer would let a big archive hide entries from all of them.
|
|
737
|
+
const listing = await runCommand('tar', ['-tzf', tarballPath], {
|
|
738
|
+
stdoutCap: listingCap,
|
|
739
|
+
stdoutMustBeComplete: true,
|
|
740
|
+
});
|
|
581
741
|
const entries = listing.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
|
|
582
742
|
if (entries.length === 0) {
|
|
583
743
|
throw new InstallerError('Refusing to extract tarball: archive contains no entries.');
|
|
@@ -602,7 +762,7 @@ async function extractTarball(tarballPath, destDir) {
|
|
|
602
762
|
// and bsdtar (macOS): '-' regular, 'd' directory, 'l' symbolic link, 'h'
|
|
603
763
|
// hard link. We only need to detect links, so a cheap first-char scan is
|
|
604
764
|
// sufficient and avoids relying on the rest of the format.
|
|
605
|
-
await rejectLinkEntries(tarballPath);
|
|
765
|
+
await rejectLinkEntries(tarballPath, { listingCap });
|
|
606
766
|
|
|
607
767
|
// 2. Enforce the single-top-level-directory contract required by
|
|
608
768
|
// --strip-components=1.
|
|
@@ -640,6 +800,77 @@ async function extractTarball(tarballPath, destDir) {
|
|
|
640
800
|
|
|
641
801
|
// 4. Now extract safely — the content has been validated above.
|
|
642
802
|
await runCommand('tar', ['-xzf', tarballPath, '-C', absoluteDest, '--strip-components=1']);
|
|
803
|
+
|
|
804
|
+
// 5. Decompression-bomb guard (mirrors scripts/install.py). ``tar`` itself
|
|
805
|
+
// has no size bound, and ``--max-filesize`` on the download only limits
|
|
806
|
+
// the COMPRESSED size, so a small, highly compressible archive could have
|
|
807
|
+
// expanded to many GiB already. Re-measure the extracted tree and fail
|
|
808
|
+
// closed (removing it) when it exceeds the budget.
|
|
809
|
+
enforceExtractedSize(absoluteDest);
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
/**
|
|
813
|
+
* Fail closed when an extracted tree exceeds the size budget.
|
|
814
|
+
*
|
|
815
|
+
* Separate, injectable-budget helper so the bomb guard is unit-testable
|
|
816
|
+
* without writing 500 MiB to disk. Removes ``root`` on violation.
|
|
817
|
+
*/
|
|
818
|
+
function enforceExtractedSize(
|
|
819
|
+
root,
|
|
820
|
+
{ maxTotal = MAX_EXTRACTED_BYTES, maxMember = MAX_EXTRACTED_MEMBER_BYTES } = {},
|
|
821
|
+
) {
|
|
822
|
+
const extracted = measureTree(root);
|
|
823
|
+
if (extracted.total > maxTotal || extracted.largest > maxMember) {
|
|
824
|
+
try {
|
|
825
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
826
|
+
} catch {
|
|
827
|
+
// Best effort: the size violation below is the actionable message.
|
|
828
|
+
}
|
|
829
|
+
throw new InstallerError(
|
|
830
|
+
`Refusing to keep extracted tarball: expanded size ${extracted.total} bytes ` +
|
|
831
|
+
`(largest member ${extracted.largest} bytes) exceeds the safety budget ` +
|
|
832
|
+
`(${maxTotal} total / ${maxMember} per file).`,
|
|
833
|
+
);
|
|
834
|
+
}
|
|
835
|
+
return extracted;
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
/**
|
|
839
|
+
* Total and largest-file sizes of a directory tree.
|
|
840
|
+
*
|
|
841
|
+
* Symlinks are skipped (``lstat``): following them could walk outside the
|
|
842
|
+
* extraction directory, and the release ships none (any link was already
|
|
843
|
+
* rejected before extraction).
|
|
844
|
+
*/
|
|
845
|
+
function measureTree(root) {
|
|
846
|
+
let total = 0;
|
|
847
|
+
let largest = 0;
|
|
848
|
+
const stack = [root];
|
|
849
|
+
while (stack.length > 0) {
|
|
850
|
+
const current = stack.pop();
|
|
851
|
+
let entries;
|
|
852
|
+
try {
|
|
853
|
+
entries = fs.readdirSync(current, { withFileTypes: true });
|
|
854
|
+
} catch {
|
|
855
|
+
continue; // Unreadable entry: nothing to account for.
|
|
856
|
+
}
|
|
857
|
+
for (const entry of entries) {
|
|
858
|
+
const full = path.join(current, entry.name);
|
|
859
|
+
if (entry.isSymbolicLink()) continue;
|
|
860
|
+
if (entry.isDirectory()) {
|
|
861
|
+
stack.push(full);
|
|
862
|
+
continue;
|
|
863
|
+
}
|
|
864
|
+
try {
|
|
865
|
+
const size = fs.lstatSync(full).size;
|
|
866
|
+
total += size;
|
|
867
|
+
if (size > largest) largest = size;
|
|
868
|
+
} catch {
|
|
869
|
+
// Raced/removed entry: skip.
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
return { total, largest };
|
|
643
874
|
}
|
|
644
875
|
|
|
645
876
|
function runPythonInstall(pythonBin, installScript, args, options = {}) {
|
|
@@ -742,7 +973,11 @@ async function installCli(pythonBin, sourceDir) {
|
|
|
742
973
|
}
|
|
743
974
|
} catch (err) {
|
|
744
975
|
warning(`Could not install iterate CLI: ${err.message}`);
|
|
745
|
-
|
|
976
|
+
// Not on PyPI: the only public install paths are the repo (or its
|
|
977
|
+
// release source dir) — the old "pipx install <repo>" placeholder was not
|
|
978
|
+
// copy-pasteable at all.
|
|
979
|
+
hint('Install it later with: pipx install "git+https://github.com/jingzhao-l/iterate-skill.git"');
|
|
980
|
+
hint(' (or, from a downloaded release/repo root: pipx install . / pip install .)');
|
|
746
981
|
return;
|
|
747
982
|
}
|
|
748
983
|
|
|
@@ -804,8 +1039,14 @@ async function resolveInstallMode(options, ask = askYesNo) {
|
|
|
804
1039
|
const cwd = process.cwd();
|
|
805
1040
|
const home = os.homedir();
|
|
806
1041
|
if (cwd !== home) {
|
|
1042
|
+
// defaultNo = true (kept as the behavior AND made visible in the prompt):
|
|
1043
|
+
// the question used to render "[Y/n]" while Enter, EOF and the timeout all
|
|
1044
|
+
// resolved to No — a user pressing Enter to accept was silently sent to a
|
|
1045
|
+
// global install instead. Now the prompt shows "[y/N]" and matches.
|
|
807
1046
|
const answer = await ask(
|
|
808
|
-
|
|
1047
|
+
`Install into this project? / 当前目录 ${cwd} 看起来是项目目录,是否安装到此项目?` +
|
|
1048
|
+
' (n = install globally to your home directory / 全局安装)',
|
|
1049
|
+
true,
|
|
809
1050
|
);
|
|
810
1051
|
if (answer) {
|
|
811
1052
|
options.target = cwd;
|
|
@@ -968,6 +1209,14 @@ async function main(options = {}) {
|
|
|
968
1209
|
noCli = false,
|
|
969
1210
|
} = options;
|
|
970
1211
|
|
|
1212
|
+
// Fail fast on a malformed PAT (same rule as scripts/install.py): sending a
|
|
1213
|
+
// typo'd token produced a confusing 401/403 only AFTER the API round-trip.
|
|
1214
|
+
const tokenError = validateTokenShape(token);
|
|
1215
|
+
if (tokenError) {
|
|
1216
|
+
error(tokenError);
|
|
1217
|
+
return 1;
|
|
1218
|
+
}
|
|
1219
|
+
|
|
971
1220
|
step('Checking environment');
|
|
972
1221
|
const pythonBin = await findPython();
|
|
973
1222
|
if (!pythonBin) {
|
|
@@ -995,17 +1244,37 @@ async function main(options = {}) {
|
|
|
995
1244
|
// that is a stable release carrying BOTH assets we need, so a release from
|
|
996
1245
|
// another sub-project (or one whose assets are still uploading) is skipped
|
|
997
1246
|
// rather than mistaken for the latest skill release.
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1247
|
+
if (!Array.isArray(releases)) {
|
|
1248
|
+
// A non-array payload (an error object, a single release object) used to
|
|
1249
|
+
// be used AS the release and then failed with the misleading "Latest
|
|
1250
|
+
// release has no tag." — say what actually went wrong.
|
|
1251
|
+
error('GitHub API returned an unexpected payload (expected an array of releases).');
|
|
1252
|
+
hint('Retry, or set GITHUB_TOKEN if this is a rate-limit artefact.');
|
|
1253
|
+
return 1;
|
|
1254
|
+
}
|
|
1255
|
+
// Announce releases that carry the tarball but not the checksum pair: they
|
|
1256
|
+
// are skipped, which silently installs an OLDER release when the newest one
|
|
1257
|
+
// is mid-upload. Saying so out loud keeps "installed an old version" visible.
|
|
1258
|
+
for (const candidate of releases) {
|
|
1259
|
+
if (!candidate || candidate.draft || candidate.prerelease) continue;
|
|
1260
|
+
const assets = Array.isArray(candidate.assets) ? candidate.assets : [];
|
|
1261
|
+
const hasTarball = assets.some((a) => a && a.name === TARBALL_ASSET_NAME);
|
|
1262
|
+
const hasChecksums = assets.some((a) => a && a.name === CHECKSUMS_ASSET_NAME);
|
|
1263
|
+
if (hasTarball && !hasChecksums) {
|
|
1264
|
+
warning(
|
|
1265
|
+
`Skipping release ${candidate.tag_name}: has ${TARBALL_ASSET_NAME} but no ${CHECKSUMS_ASSET_NAME}.`,
|
|
1266
|
+
);
|
|
1267
|
+
}
|
|
1268
|
+
}
|
|
1269
|
+
const release = releases.find(
|
|
1270
|
+
(r) =>
|
|
1271
|
+
r &&
|
|
1272
|
+
!r.draft &&
|
|
1273
|
+
!r.prerelease &&
|
|
1274
|
+
Array.isArray(r.assets) &&
|
|
1275
|
+
r.assets.some((a) => a && a.name === TARBALL_ASSET_NAME) &&
|
|
1276
|
+
r.assets.some((a) => a && a.name === CHECKSUMS_ASSET_NAME),
|
|
1277
|
+
);
|
|
1009
1278
|
|
|
1010
1279
|
if (!release) {
|
|
1011
1280
|
error(
|
|
@@ -1089,14 +1358,19 @@ async function main(options = {}) {
|
|
|
1089
1358
|
`\x1b[32m✓\x1b[0m SHA256 checksum verified`,
|
|
1090
1359
|
]);
|
|
1091
1360
|
|
|
1092
|
-
// GPG authenticity check (
|
|
1093
|
-
// publishes a detached ASCII-armored signature over the
|
|
1094
|
-
// `gpg` binary is present we verify it against the embedded
|
|
1095
|
-
// *valid* signature upgrades the release from
|
|
1096
|
-
// "authenticity-guaranteed". If gpg is
|
|
1097
|
-
//
|
|
1098
|
-
//
|
|
1099
|
-
//
|
|
1361
|
+
// GPG authenticity check (defense-in-depth, fail-closed when it runs). The
|
|
1362
|
+
// release also publishes a detached ASCII-armored signature over the
|
|
1363
|
+
// tarball. When a `gpg` binary is present we verify it against the embedded
|
|
1364
|
+
// signing key: a *valid* signature upgrades the release from
|
|
1365
|
+
// "integrity-guaranteed" to "authenticity-guaranteed". If gpg is
|
|
1366
|
+
// unavailable we degrade to a warning (SHA256 already guards integrity —
|
|
1367
|
+
// that check still stands on its own).
|
|
1368
|
+
//
|
|
1369
|
+
// If gpg IS present and the signature is present but does NOT verify, the
|
|
1370
|
+
// install now FAILS. SHA256SUMS travels in the same release as the tarball,
|
|
1371
|
+
// so an attacker who can replace one can replace both; the pinned public
|
|
1372
|
+
// key is the single anchor they cannot forge. Reporting "signature
|
|
1373
|
+
// invalid" and installing anyway made that anchor decorative.
|
|
1100
1374
|
if (signatureAsset) {
|
|
1101
1375
|
const signaturePath = path.join(tmpDir, TARBALL_SIGNATURE_ASSET_NAME);
|
|
1102
1376
|
try {
|
|
@@ -1107,10 +1381,13 @@ async function main(options = {}) {
|
|
|
1107
1381
|
if (sig.status === 'verified') {
|
|
1108
1382
|
success('GPG signature verified.');
|
|
1109
1383
|
} else if (sig.status === 'invalid') {
|
|
1110
|
-
|
|
1111
|
-
`GPG signature
|
|
1112
|
-
'
|
|
1384
|
+
error(
|
|
1385
|
+
`GPG signature verification FAILED (${sig.detail}) — refusing to install.\n` +
|
|
1386
|
+
' The tarball was signed with a key that does not match the pinned ' +
|
|
1387
|
+
'iterate-skill signing key: this is either tampering or a key rotation ' +
|
|
1388
|
+
'that this installer version does not know about. Report it before retrying.',
|
|
1113
1389
|
);
|
|
1390
|
+
return 1;
|
|
1114
1391
|
} else {
|
|
1115
1392
|
warning(
|
|
1116
1393
|
`gpg not available (${sig.detail}); skipping signature check — SHA256 integrity still enforced.`
|
|
@@ -1119,6 +1396,13 @@ async function main(options = {}) {
|
|
|
1119
1396
|
} catch (err) {
|
|
1120
1397
|
warning(`Could not download/verify the GPG signature (${err.message}); SHA256 integrity still enforced.`);
|
|
1121
1398
|
}
|
|
1399
|
+
} else {
|
|
1400
|
+
// Say it out loud: a silent skip meant an operator could believe the
|
|
1401
|
+
// signature layer ran on every install. SHA256 still guards integrity,
|
|
1402
|
+
// but authenticity was NOT verified for this release.
|
|
1403
|
+
warning(
|
|
1404
|
+
'Release publishes no GPG signature asset; authenticity check skipped (SHA256 integrity still enforced).',
|
|
1405
|
+
);
|
|
1122
1406
|
}
|
|
1123
1407
|
|
|
1124
1408
|
info('Extracting release...');
|
|
@@ -1171,7 +1455,7 @@ async function main(options = {}) {
|
|
|
1171
1455
|
// Skill-only install: explain how to add the CLI later instead of
|
|
1172
1456
|
// silently installing it globally.
|
|
1173
1457
|
hint('Skipped installing the iterate CLI (--no-cli).');
|
|
1174
|
-
hint('Install it later for `iterate onboard` with: pipx install
|
|
1458
|
+
hint('Install it later for `iterate onboard` with: pipx install "git+https://github.com/jingzhao-l/iterate-skill.git"');
|
|
1175
1459
|
} else {
|
|
1176
1460
|
// Install the iterate CLI so `iterate onboard` works right after
|
|
1177
1461
|
// install. Made explicit up front so skill-only users are not surprised
|
|
@@ -1205,6 +1489,7 @@ module.exports = {
|
|
|
1205
1489
|
rejectLinkEntries,
|
|
1206
1490
|
isGithubApiUrl,
|
|
1207
1491
|
normalizeToken,
|
|
1492
|
+
validateTokenShape,
|
|
1208
1493
|
buildAuthFlags,
|
|
1209
1494
|
supportsCurlFailWithBody,
|
|
1210
1495
|
// Executed under test for the timeout/null-exit-code hardening; retaining
|
|
@@ -1212,5 +1497,11 @@ module.exports = {
|
|
|
1212
1497
|
runCommand,
|
|
1213
1498
|
runPythonInstall,
|
|
1214
1499
|
TailBuffer,
|
|
1500
|
+
FullBuffer,
|
|
1501
|
+
measureTree,
|
|
1502
|
+
enforceExtractedSize,
|
|
1503
|
+
MAX_LISTING_CHARS,
|
|
1504
|
+
MAX_EXTRACTED_BYTES,
|
|
1505
|
+
MAX_EXTRACTED_MEMBER_BYTES,
|
|
1215
1506
|
DEFAULT_COMMAND_TIMEOUT_MS,
|
|
1216
1507
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "iterate-skill-installer",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.5.1",
|
|
4
4
|
"description": "One-command installer for iterate-skill across AI coding assistants",
|
|
5
5
|
"main": "lib/installer.js",
|
|
6
6
|
"files": [
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
"iterate-skill-installer": "bin/cli.js"
|
|
15
15
|
},
|
|
16
16
|
"scripts": {
|
|
17
|
-
"test": "node --check bin/cli.js && node --check lib/installer.js && node test/mode.test.js"
|
|
17
|
+
"test": "node --check bin/cli.js && node --check lib/installer.js && node test/mode.test.js && node test/tarball.test.js"
|
|
18
18
|
},
|
|
19
19
|
"keywords": [
|
|
20
20
|
"iterate-skill",
|