iterate-skill-installer 3.5.0 → 3.5.2
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 +9 -2
- package/README.zh-CN.md +7 -2
- package/lib/installer.js +365 -47
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -39,8 +39,15 @@ npx iterate-skill-installer --version
|
|
|
39
39
|
|
|
40
40
|
1. Checks for Python 3 on your system.
|
|
41
41
|
2. Fetches the latest iterate-skill release from GitHub.
|
|
42
|
-
3. Downloads the release tarball
|
|
43
|
-
|
|
42
|
+
3. Downloads the release tarball, `SHA256SUMS.txt` and its detached GPG
|
|
43
|
+
signature (`iterate-skill.tar.gz.asc`).
|
|
44
|
+
4. Verifies BOTH the tarball checksum (integrity) and the signature against the
|
|
45
|
+
pinned iterate signing key (authenticity). **Signing is mandatory**: a
|
|
46
|
+
release without a valid signature is refused, because `SHA256SUMS.txt`
|
|
47
|
+
travels in the same release as the tarball and therefore cannot be the only
|
|
48
|
+
anchor. Install GnuPG (`gpg`) if you do not have it; hosts that cannot run
|
|
49
|
+
gpg may opt out explicitly with `ITERATE_ALLOW_UNSIGNED=1` (a bad signature
|
|
50
|
+
is never bypassed).
|
|
44
51
|
5. Extracts the release into a temporary directory.
|
|
45
52
|
6. Runs the bundled Python install script (`scripts/install.py`) which:
|
|
46
53
|
- Detects installed AI coding assistants on your machine.
|
package/README.zh-CN.md
CHANGED
|
@@ -39,8 +39,13 @@ npx iterate-skill-installer --version
|
|
|
39
39
|
|
|
40
40
|
1. 检查你系统上的 Python 3。
|
|
41
41
|
2. 从 GitHub 获取最新的 iterate-skill 发布版本。
|
|
42
|
-
3. 下载发布 tarball
|
|
43
|
-
|
|
42
|
+
3. 下载发布 tarball、`SHA256SUMS.txt` 及其分离式 GPG 签名
|
|
43
|
+
(`iterate-skill.tar.gz.asc`)。
|
|
44
|
+
4. 同时校验 tarball 校验和(完整性)与签名(真实性,对照内嵌的 iterate 签名公钥)。
|
|
45
|
+
**签名为强制**:没有有效签名的发布会直接拒绝安装——因为 `SHA256SUMS.txt` 与
|
|
46
|
+
tarball 出自同一个 release,不能作为唯一信任锚点。没有 `gpg` 请先安装 GnuPG;
|
|
47
|
+
确实无法运行 gpg 的环境可显式设置 `ITERATE_ALLOW_UNSIGNED=1` 放行(但**坏签名
|
|
48
|
+
永不放行**)。
|
|
44
49
|
5. 解压发布内容到临时目录。
|
|
45
50
|
6. 运行自带的 Python 安装脚本(`scripts/install.py`),它会:
|
|
46
51
|
- 检测你机器上已安装的 AI 编程助手。
|
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) {
|
|
@@ -374,6 +424,71 @@ function enforceDownloadedSize(destPath, maxBytes) {
|
|
|
374
424
|
}
|
|
375
425
|
}
|
|
376
426
|
|
|
427
|
+
// True when the operator explicitly opted out of signature enforcement. Kept
|
|
428
|
+
// as an ENVIRONMENT variable (not a flag) so it cannot be enabled from a
|
|
429
|
+
// project config and shows up in the invoking environment for audit.
|
|
430
|
+
const ALLOW_UNSIGNED_ENV = 'ITERATE_ALLOW_UNSIGNED';
|
|
431
|
+
|
|
432
|
+
function allowUnsigned() {
|
|
433
|
+
return String(process.env[ALLOW_UNSIGNED_ENV] || '').trim() !== '';
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Apply the MANDATORY release-signature policy (mirrors
|
|
438
|
+
* ``iterate_cli/signing.py`` and ``scripts/install.py``).
|
|
439
|
+
*
|
|
440
|
+
* Returns an error MESSAGE when the install must abort, or null when it may
|
|
441
|
+
* proceed. The ``ITERATE_ALLOW_UNSIGNED`` override downgrades "cannot check"
|
|
442
|
+
* (no ``.asc`` asset / no ``gpg`` binary) to a warning, but it NEVER bypasses a
|
|
443
|
+
* BAD signature — that is exactly the tampering/key-substitution case.
|
|
444
|
+
*
|
|
445
|
+
* @param {object} state
|
|
446
|
+
* @param {boolean} state.hasSignatureAsset release published an ``.asc``
|
|
447
|
+
* @param {string} [state.status] 'verified' | 'invalid' | 'unavailable'
|
|
448
|
+
* @param {string} [state.detail] verifier detail for messages
|
|
449
|
+
* @param {string} [state.downloadError] failure to FETCH the ``.asc``
|
|
450
|
+
* @param {boolean} [state.allowUnsigned] override (defaults to the env var)
|
|
451
|
+
*/
|
|
452
|
+
function signaturePolicyError({
|
|
453
|
+
hasSignatureAsset,
|
|
454
|
+
status = null,
|
|
455
|
+
detail = '',
|
|
456
|
+
downloadError = null,
|
|
457
|
+
allowUnsigned: override = allowUnsigned(),
|
|
458
|
+
} = {}) {
|
|
459
|
+
if (downloadError) {
|
|
460
|
+
// The release DID publish a signature but we could not fetch it: a
|
|
461
|
+
// transport problem, not a licence to skip authenticity.
|
|
462
|
+
return (
|
|
463
|
+
`Could not download the release signature (${downloadError}). Refusing to install ` +
|
|
464
|
+
`without authenticity verification; retry once the network recovers. Set ` +
|
|
465
|
+
`${ALLOW_UNSIGNED_ENV}=1 only if you accept an unauthenticated install.`
|
|
466
|
+
);
|
|
467
|
+
}
|
|
468
|
+
if (!hasSignatureAsset) {
|
|
469
|
+
if (override) return null;
|
|
470
|
+
return (
|
|
471
|
+
'Release is UNSIGNED: it publishes no .asc signature, so authenticity cannot be ' +
|
|
472
|
+
`verified (SHA256SUMS travels in the same release and is not an independent ` +
|
|
473
|
+
`anchor). Set ${ALLOW_UNSIGNED_ENV}=1 only if you accept an unauthenticated install.`
|
|
474
|
+
);
|
|
475
|
+
}
|
|
476
|
+
if (status === 'verified') return null;
|
|
477
|
+
if (status === 'invalid') {
|
|
478
|
+
return (
|
|
479
|
+
`GPG signature verification FAILED (${detail}). Refusing to install: the tarball ` +
|
|
480
|
+
`is not signed by the pinned iterate signing key. This is either tampering or a ` +
|
|
481
|
+
`key rotation this installer version does not know about; ${ALLOW_UNSIGNED_ENV} ` +
|
|
482
|
+
'does NOT bypass a bad signature.'
|
|
483
|
+
);
|
|
484
|
+
}
|
|
485
|
+
if (override) return null;
|
|
486
|
+
return (
|
|
487
|
+
`Cannot verify the release signature (${detail || 'gpg unavailable'}). Install GnuPG ` +
|
|
488
|
+
`(gpg) and retry, or set ${ALLOW_UNSIGNED_ENV}=1 to accept an unauthenticated install.`
|
|
489
|
+
);
|
|
490
|
+
}
|
|
491
|
+
|
|
377
492
|
function sha256File(filePath) {
|
|
378
493
|
return new Promise((resolve, reject) => {
|
|
379
494
|
const hash = crypto.createHash('sha256');
|
|
@@ -397,7 +512,11 @@ function sha256File(filePath) {
|
|
|
397
512
|
// Runs inside an isolated temporary GNUPGHOME so the end user's own keyring and
|
|
398
513
|
// gpg-agent are never touched or modified.
|
|
399
514
|
function verifyGpgDetachedSignature(artifactPath, signaturePath, publicKeyArmor, tmpDir) {
|
|
400
|
-
|
|
515
|
+
// NOTE: gpg derives its agent socket from the homedir
|
|
516
|
+
// (<homedir>/S.gpg-agent) and unix sockets cap out around 104 characters, so
|
|
517
|
+
// keep this path SHORT — a deep tmpdir made gpg fail with "File name too
|
|
518
|
+
// long: can't connect to the gpg-agent".
|
|
519
|
+
const gpgHome = path.join(tmpDir, 'g');
|
|
401
520
|
fs.mkdirSync(gpgHome, { recursive: true, mode: 0o700 });
|
|
402
521
|
const keyPath = path.join(tmpDir, 'iterate-skill-pubkey.asc');
|
|
403
522
|
fs.writeFileSync(keyPath, publicKeyArmor, 'utf8');
|
|
@@ -487,6 +606,33 @@ class TailBuffer {
|
|
|
487
606
|
}
|
|
488
607
|
}
|
|
489
608
|
|
|
609
|
+
/**
|
|
610
|
+
* Fail-closed output buffer for output that must be observed COMPLETELY
|
|
611
|
+
* (tarball listings, API JSON). Unlike TailBuffer — which keeps the newest
|
|
612
|
+
* text and is right for diagnostics — this one records that the cap was
|
|
613
|
+
* exceeded instead of discarding the oldest content, because a partially seen
|
|
614
|
+
* SECURITY listing is worse than no listing at all.
|
|
615
|
+
*/
|
|
616
|
+
class FullBuffer {
|
|
617
|
+
constructor(cap) {
|
|
618
|
+
this.cap = cap;
|
|
619
|
+
this.text = '';
|
|
620
|
+
this.overflowed = false;
|
|
621
|
+
}
|
|
622
|
+
push(chunk) {
|
|
623
|
+
if (!chunk || this.overflowed) return;
|
|
624
|
+
if (this.text.length + chunk.length > this.cap) {
|
|
625
|
+
this.overflowed = true;
|
|
626
|
+
this.text = (this.text + chunk).slice(0, this.cap);
|
|
627
|
+
return;
|
|
628
|
+
}
|
|
629
|
+
this.text += chunk;
|
|
630
|
+
}
|
|
631
|
+
toString() {
|
|
632
|
+
return this.text;
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
|
|
490
636
|
// Default ceiling for a single child command: 10 minutes then SIGKILL. Long
|
|
491
637
|
// operations in this file (pip/pipx installs, venv creation) can legitimately
|
|
492
638
|
// take minutes on slow networks, but a wedged child must never hang the
|
|
@@ -527,13 +673,23 @@ function killProcessTree(child) {
|
|
|
527
673
|
}
|
|
528
674
|
|
|
529
675
|
function runCommand(bin, args, options = {}) {
|
|
530
|
-
const {
|
|
676
|
+
const {
|
|
677
|
+
timeout = DEFAULT_COMMAND_TIMEOUT_MS,
|
|
678
|
+
// Cap on retained stdout. Diagnostics want a TAIL (newest text); security
|
|
679
|
+
// listings / parseable payloads want the WHOLE thing or nothing, which is
|
|
680
|
+
// what ``stdoutMustBeComplete`` switches on.
|
|
681
|
+
stdoutCap = MAX_CAPTURED_OUTPUT_CHARS,
|
|
682
|
+
stdoutMustBeComplete = false,
|
|
683
|
+
...spawnOptions
|
|
684
|
+
} = options;
|
|
531
685
|
return new Promise((resolve, reject) => {
|
|
532
686
|
// `detached` on POSIX puts the child in its own process group so the
|
|
533
687
|
// timeout can kill the entire tree, not just the leader. It does not
|
|
534
688
|
// change how we await the child (we never unref it).
|
|
535
689
|
const child = spawn(bin, args, { stdio: 'pipe', detached: IS_POSIX, ...spawnOptions });
|
|
536
|
-
const stdoutTail =
|
|
690
|
+
const stdoutTail = stdoutMustBeComplete
|
|
691
|
+
? new FullBuffer(stdoutCap)
|
|
692
|
+
: new TailBuffer(stdoutCap);
|
|
537
693
|
const stderrTail = new TailBuffer(MAX_CAPTURED_OUTPUT_CHARS);
|
|
538
694
|
child.stdout?.on('data', (data) => stdoutTail.push(data.toString()));
|
|
539
695
|
child.stderr?.on('data', (data) => stderrTail.push(data.toString()));
|
|
@@ -566,6 +722,15 @@ function runCommand(bin, args, options = {}) {
|
|
|
566
722
|
return;
|
|
567
723
|
}
|
|
568
724
|
if (code === 0) {
|
|
725
|
+
if (stdoutMustBeComplete && stdoutTail.overflowed) {
|
|
726
|
+
settle(
|
|
727
|
+
reject,
|
|
728
|
+
new InstallerError(
|
|
729
|
+
`${bin} produced more than ${stdoutCap} characters of output; refusing to act on a truncated result.`,
|
|
730
|
+
),
|
|
731
|
+
);
|
|
732
|
+
return;
|
|
733
|
+
}
|
|
569
734
|
settle(resolve, stdoutTail.toString());
|
|
570
735
|
} else {
|
|
571
736
|
settle(reject, new InstallerError(`${bin} exited with ${code}: ${stderrTail.toString() || stdoutTail.toString()}`));
|
|
@@ -580,13 +745,22 @@ function runCommand(bin, args, options = {}) {
|
|
|
580
745
|
});
|
|
581
746
|
}
|
|
582
747
|
|
|
583
|
-
async function rejectLinkEntries(tarballPath) {
|
|
748
|
+
async function rejectLinkEntries(tarballPath, { listingCap = MAX_LISTING_CHARS } = {}) {
|
|
584
749
|
// Inspect the verbose listing and reject any symbolic ("l") or hard ("h")
|
|
585
750
|
// link entry. We deliberately reject *all* links rather than validating
|
|
586
751
|
// their targets: the release tarball contains none, so any link is a sign
|
|
587
752
|
// of a tampered archive and refusing it outright is the safest response to
|
|
588
753
|
// a symlink-escape attempt (see extractTarball above).
|
|
589
|
-
|
|
754
|
+
//
|
|
755
|
+
// stdoutMustBeComplete: this listing is a SECURITY input. The old 1 MiB
|
|
756
|
+
// tail buffer silently dropped the OLDEST entries, so an archive with enough
|
|
757
|
+
// filler members to push its listing past the cap hid a symlink/hardlink
|
|
758
|
+
// entry from this scan entirely (while `tar -xzf` still extracted it).
|
|
759
|
+
// A listing over the cap now fails closed instead.
|
|
760
|
+
const verbose = await runCommand('tar', ['-tvzf', tarballPath], {
|
|
761
|
+
stdoutCap: listingCap,
|
|
762
|
+
stdoutMustBeComplete: true,
|
|
763
|
+
});
|
|
590
764
|
for (const line of verbose.split(/\r?\n/)) {
|
|
591
765
|
if (!line) {
|
|
592
766
|
continue;
|
|
@@ -605,7 +779,7 @@ async function rejectLinkEntries(tarballPath) {
|
|
|
605
779
|
}
|
|
606
780
|
}
|
|
607
781
|
|
|
608
|
-
async function extractTarball(tarballPath, destDir) {
|
|
782
|
+
async function extractTarball(tarballPath, destDir, { listingCap = MAX_LISTING_CHARS } = {}) {
|
|
609
783
|
// Use the system tar command (available on macOS, Linux, and Windows 10+),
|
|
610
784
|
// but defend against path-traversal tarballs: before extracting we list the
|
|
611
785
|
// archive contents and refuse to extract any entry whose resolved path
|
|
@@ -626,8 +800,13 @@ async function extractTarball(tarballPath, destDir) {
|
|
|
626
800
|
const absoluteDest = path.resolve(destDir);
|
|
627
801
|
fs.mkdirSync(absoluteDest, { recursive: true });
|
|
628
802
|
|
|
629
|
-
// 1. List archive entries (no extraction yet).
|
|
630
|
-
|
|
803
|
+
// 1. List archive entries (no extraction yet). Must be complete — see
|
|
804
|
+
// rejectLinkEntries: every guard below reads THIS listing, and a tail
|
|
805
|
+
// buffer would let a big archive hide entries from all of them.
|
|
806
|
+
const listing = await runCommand('tar', ['-tzf', tarballPath], {
|
|
807
|
+
stdoutCap: listingCap,
|
|
808
|
+
stdoutMustBeComplete: true,
|
|
809
|
+
});
|
|
631
810
|
const entries = listing.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
|
|
632
811
|
if (entries.length === 0) {
|
|
633
812
|
throw new InstallerError('Refusing to extract tarball: archive contains no entries.');
|
|
@@ -652,7 +831,7 @@ async function extractTarball(tarballPath, destDir) {
|
|
|
652
831
|
// and bsdtar (macOS): '-' regular, 'd' directory, 'l' symbolic link, 'h'
|
|
653
832
|
// hard link. We only need to detect links, so a cheap first-char scan is
|
|
654
833
|
// sufficient and avoids relying on the rest of the format.
|
|
655
|
-
await rejectLinkEntries(tarballPath);
|
|
834
|
+
await rejectLinkEntries(tarballPath, { listingCap });
|
|
656
835
|
|
|
657
836
|
// 2. Enforce the single-top-level-directory contract required by
|
|
658
837
|
// --strip-components=1.
|
|
@@ -690,6 +869,77 @@ async function extractTarball(tarballPath, destDir) {
|
|
|
690
869
|
|
|
691
870
|
// 4. Now extract safely — the content has been validated above.
|
|
692
871
|
await runCommand('tar', ['-xzf', tarballPath, '-C', absoluteDest, '--strip-components=1']);
|
|
872
|
+
|
|
873
|
+
// 5. Decompression-bomb guard (mirrors scripts/install.py). ``tar`` itself
|
|
874
|
+
// has no size bound, and ``--max-filesize`` on the download only limits
|
|
875
|
+
// the COMPRESSED size, so a small, highly compressible archive could have
|
|
876
|
+
// expanded to many GiB already. Re-measure the extracted tree and fail
|
|
877
|
+
// closed (removing it) when it exceeds the budget.
|
|
878
|
+
enforceExtractedSize(absoluteDest);
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
/**
|
|
882
|
+
* Fail closed when an extracted tree exceeds the size budget.
|
|
883
|
+
*
|
|
884
|
+
* Separate, injectable-budget helper so the bomb guard is unit-testable
|
|
885
|
+
* without writing 500 MiB to disk. Removes ``root`` on violation.
|
|
886
|
+
*/
|
|
887
|
+
function enforceExtractedSize(
|
|
888
|
+
root,
|
|
889
|
+
{ maxTotal = MAX_EXTRACTED_BYTES, maxMember = MAX_EXTRACTED_MEMBER_BYTES } = {},
|
|
890
|
+
) {
|
|
891
|
+
const extracted = measureTree(root);
|
|
892
|
+
if (extracted.total > maxTotal || extracted.largest > maxMember) {
|
|
893
|
+
try {
|
|
894
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
895
|
+
} catch {
|
|
896
|
+
// Best effort: the size violation below is the actionable message.
|
|
897
|
+
}
|
|
898
|
+
throw new InstallerError(
|
|
899
|
+
`Refusing to keep extracted tarball: expanded size ${extracted.total} bytes ` +
|
|
900
|
+
`(largest member ${extracted.largest} bytes) exceeds the safety budget ` +
|
|
901
|
+
`(${maxTotal} total / ${maxMember} per file).`,
|
|
902
|
+
);
|
|
903
|
+
}
|
|
904
|
+
return extracted;
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
/**
|
|
908
|
+
* Total and largest-file sizes of a directory tree.
|
|
909
|
+
*
|
|
910
|
+
* Symlinks are skipped (``lstat``): following them could walk outside the
|
|
911
|
+
* extraction directory, and the release ships none (any link was already
|
|
912
|
+
* rejected before extraction).
|
|
913
|
+
*/
|
|
914
|
+
function measureTree(root) {
|
|
915
|
+
let total = 0;
|
|
916
|
+
let largest = 0;
|
|
917
|
+
const stack = [root];
|
|
918
|
+
while (stack.length > 0) {
|
|
919
|
+
const current = stack.pop();
|
|
920
|
+
let entries;
|
|
921
|
+
try {
|
|
922
|
+
entries = fs.readdirSync(current, { withFileTypes: true });
|
|
923
|
+
} catch {
|
|
924
|
+
continue; // Unreadable entry: nothing to account for.
|
|
925
|
+
}
|
|
926
|
+
for (const entry of entries) {
|
|
927
|
+
const full = path.join(current, entry.name);
|
|
928
|
+
if (entry.isSymbolicLink()) continue;
|
|
929
|
+
if (entry.isDirectory()) {
|
|
930
|
+
stack.push(full);
|
|
931
|
+
continue;
|
|
932
|
+
}
|
|
933
|
+
try {
|
|
934
|
+
const size = fs.lstatSync(full).size;
|
|
935
|
+
total += size;
|
|
936
|
+
if (size > largest) largest = size;
|
|
937
|
+
} catch {
|
|
938
|
+
// Raced/removed entry: skip.
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
return { total, largest };
|
|
693
943
|
}
|
|
694
944
|
|
|
695
945
|
function runPythonInstall(pythonBin, installScript, args, options = {}) {
|
|
@@ -792,7 +1042,11 @@ async function installCli(pythonBin, sourceDir) {
|
|
|
792
1042
|
}
|
|
793
1043
|
} catch (err) {
|
|
794
1044
|
warning(`Could not install iterate CLI: ${err.message}`);
|
|
795
|
-
|
|
1045
|
+
// Not on PyPI: the only public install paths are the repo (or its
|
|
1046
|
+
// release source dir) — the old "pipx install <repo>" placeholder was not
|
|
1047
|
+
// copy-pasteable at all.
|
|
1048
|
+
hint('Install it later with: pipx install "git+https://github.com/jingzhao-l/iterate-skill.git"');
|
|
1049
|
+
hint(' (or, from a downloaded release/repo root: pipx install . / pip install .)');
|
|
796
1050
|
return;
|
|
797
1051
|
}
|
|
798
1052
|
|
|
@@ -854,8 +1108,14 @@ async function resolveInstallMode(options, ask = askYesNo) {
|
|
|
854
1108
|
const cwd = process.cwd();
|
|
855
1109
|
const home = os.homedir();
|
|
856
1110
|
if (cwd !== home) {
|
|
1111
|
+
// defaultNo = true (kept as the behavior AND made visible in the prompt):
|
|
1112
|
+
// the question used to render "[Y/n]" while Enter, EOF and the timeout all
|
|
1113
|
+
// resolved to No — a user pressing Enter to accept was silently sent to a
|
|
1114
|
+
// global install instead. Now the prompt shows "[y/N]" and matches.
|
|
857
1115
|
const answer = await ask(
|
|
858
|
-
|
|
1116
|
+
`Install into this project? / 当前目录 ${cwd} 看起来是项目目录,是否安装到此项目?` +
|
|
1117
|
+
' (n = install globally to your home directory / 全局安装)',
|
|
1118
|
+
true,
|
|
859
1119
|
);
|
|
860
1120
|
if (answer) {
|
|
861
1121
|
options.target = cwd;
|
|
@@ -1018,6 +1278,14 @@ async function main(options = {}) {
|
|
|
1018
1278
|
noCli = false,
|
|
1019
1279
|
} = options;
|
|
1020
1280
|
|
|
1281
|
+
// Fail fast on a malformed PAT (same rule as scripts/install.py): sending a
|
|
1282
|
+
// typo'd token produced a confusing 401/403 only AFTER the API round-trip.
|
|
1283
|
+
const tokenError = validateTokenShape(token);
|
|
1284
|
+
if (tokenError) {
|
|
1285
|
+
error(tokenError);
|
|
1286
|
+
return 1;
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1021
1289
|
step('Checking environment');
|
|
1022
1290
|
const pythonBin = await findPython();
|
|
1023
1291
|
if (!pythonBin) {
|
|
@@ -1045,21 +1313,50 @@ async function main(options = {}) {
|
|
|
1045
1313
|
// that is a stable release carrying BOTH assets we need, so a release from
|
|
1046
1314
|
// another sub-project (or one whose assets are still uploading) is skipped
|
|
1047
1315
|
// rather than mistaken for the latest skill release.
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1316
|
+
if (!Array.isArray(releases)) {
|
|
1317
|
+
// A non-array payload (an error object, a single release object) used to
|
|
1318
|
+
// be used AS the release and then failed with the misleading "Latest
|
|
1319
|
+
// release has no tag." — say what actually went wrong.
|
|
1320
|
+
error('GitHub API returned an unexpected payload (expected an array of releases).');
|
|
1321
|
+
hint('Retry, or set GITHUB_TOKEN if this is a rate-limit artefact.');
|
|
1322
|
+
return 1;
|
|
1323
|
+
}
|
|
1324
|
+
// Announce releases that carry the tarball but not the checksum/signature
|
|
1325
|
+
// pair: they are skipped, which silently installs an OLDER release when the
|
|
1326
|
+
// newest one is mid-upload. Saying so out loud keeps "installed an old
|
|
1327
|
+
// version" visible.
|
|
1328
|
+
for (const candidate of releases) {
|
|
1329
|
+
if (!candidate || candidate.draft || candidate.prerelease) continue;
|
|
1330
|
+
const assets = Array.isArray(candidate.assets) ? candidate.assets : [];
|
|
1331
|
+
const hasTarball = assets.some((a) => a && a.name === TARBALL_ASSET_NAME);
|
|
1332
|
+
const missing = [];
|
|
1333
|
+
if (!assets.some((a) => a && a.name === CHECKSUMS_ASSET_NAME)) missing.push(CHECKSUMS_ASSET_NAME);
|
|
1334
|
+
if (!assets.some((a) => a && a.name === TARBALL_SIGNATURE_ASSET_NAME)) missing.push(TARBALL_SIGNATURE_ASSET_NAME);
|
|
1335
|
+
if (hasTarball && missing.length > 0) {
|
|
1336
|
+
warning(
|
|
1337
|
+
`Skipping release ${candidate.tag_name}: has ${TARBALL_ASSET_NAME} but no ${missing.join(' / ')}.`,
|
|
1338
|
+
);
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
1341
|
+
// A usable release must carry the tarball, its checksums AND its detached
|
|
1342
|
+
// signature: the signature is mandatory at install time (see
|
|
1343
|
+
// signaturePolicyError), so selecting a release without one would only turn
|
|
1344
|
+
// "found the latest" into "failed to install".
|
|
1345
|
+
const release = releases.find(
|
|
1346
|
+
(r) =>
|
|
1347
|
+
r &&
|
|
1348
|
+
!r.draft &&
|
|
1349
|
+
!r.prerelease &&
|
|
1350
|
+
Array.isArray(r.assets) &&
|
|
1351
|
+
r.assets.some((a) => a && a.name === TARBALL_ASSET_NAME) &&
|
|
1352
|
+
r.assets.some((a) => a && a.name === CHECKSUMS_ASSET_NAME) &&
|
|
1353
|
+
r.assets.some((a) => a && a.name === TARBALL_SIGNATURE_ASSET_NAME),
|
|
1354
|
+
);
|
|
1059
1355
|
|
|
1060
1356
|
if (!release) {
|
|
1061
1357
|
error(
|
|
1062
|
-
`No published release in the last 100 carries a ${TARBALL_ASSET_NAME} +
|
|
1358
|
+
`No published release in the last 100 carries a ${TARBALL_ASSET_NAME} + ` +
|
|
1359
|
+
`${CHECKSUMS_ASSET_NAME} + ${TARBALL_SIGNATURE_ASSET_NAME} asset triple.`
|
|
1063
1360
|
);
|
|
1064
1361
|
hint('This is usually a GitHub API rate limit; retry with GITHUB_TOKEN set.');
|
|
1065
1362
|
return 1;
|
|
@@ -1087,7 +1384,7 @@ async function main(options = {}) {
|
|
|
1087
1384
|
return 1;
|
|
1088
1385
|
}
|
|
1089
1386
|
|
|
1090
|
-
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), '
|
|
1387
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ig-install-'));
|
|
1091
1388
|
const tarballPath = path.join(tmpDir, TARBALL_ASSET_NAME);
|
|
1092
1389
|
const checksumPath = path.join(tmpDir, CHECKSUMS_ASSET_NAME);
|
|
1093
1390
|
|
|
@@ -1139,14 +1436,17 @@ async function main(options = {}) {
|
|
|
1139
1436
|
`\x1b[32m✓\x1b[0m SHA256 checksum verified`,
|
|
1140
1437
|
]);
|
|
1141
1438
|
|
|
1142
|
-
// GPG authenticity check (
|
|
1143
|
-
//
|
|
1144
|
-
//
|
|
1145
|
-
//
|
|
1146
|
-
//
|
|
1147
|
-
//
|
|
1148
|
-
//
|
|
1149
|
-
//
|
|
1439
|
+
// GPG authenticity check — MANDATORY (see signaturePolicyError).
|
|
1440
|
+
//
|
|
1441
|
+
// SHA256SUMS.txt travels in the SAME release as the tarball, so an attacker
|
|
1442
|
+
// who can replace one can replace both; the pinned public key below is the
|
|
1443
|
+
// only anchor they cannot forge. A missing `.asc`, a host without `gpg`, an
|
|
1444
|
+
// unfetchable signature, or a signature that does not verify all ABORT the
|
|
1445
|
+
// install. The single documented escape hatch is ``ITERATE_ALLOW_UNSIGNED``
|
|
1446
|
+
// (hosts without GnuPG) and it never bypasses a BAD signature.
|
|
1447
|
+
let signatureStatus = null;
|
|
1448
|
+
let signatureDetail = '';
|
|
1449
|
+
let signatureDownloadError = null;
|
|
1150
1450
|
if (signatureAsset) {
|
|
1151
1451
|
const signaturePath = path.join(tmpDir, TARBALL_SIGNATURE_ASSET_NAME);
|
|
1152
1452
|
try {
|
|
@@ -1154,22 +1454,30 @@ async function main(options = {}) {
|
|
|
1154
1454
|
maxBytes: CHECKSUMS_MAX_BYTES,
|
|
1155
1455
|
});
|
|
1156
1456
|
const sig = verifyGpgDetachedSignature(tarballPath, signaturePath, ITERATE_SIGNING_PUBLIC_KEY, tmpDir);
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
} else if (sig.status === 'invalid') {
|
|
1160
|
-
warning(
|
|
1161
|
-
`GPG signature could NOT be verified (${sig.detail}).\n` +
|
|
1162
|
-
' SHA256 integrity still passed; re-run after the release key is rotated if this persists.'
|
|
1163
|
-
);
|
|
1164
|
-
} else {
|
|
1165
|
-
warning(
|
|
1166
|
-
`gpg not available (${sig.detail}); skipping signature check — SHA256 integrity still enforced.`
|
|
1167
|
-
);
|
|
1168
|
-
}
|
|
1457
|
+
signatureStatus = sig.status;
|
|
1458
|
+
signatureDetail = sig.detail;
|
|
1169
1459
|
} catch (err) {
|
|
1170
|
-
|
|
1460
|
+
signatureDownloadError = err.message;
|
|
1171
1461
|
}
|
|
1172
1462
|
}
|
|
1463
|
+
const signatureError = signaturePolicyError({
|
|
1464
|
+
hasSignatureAsset: Boolean(signatureAsset),
|
|
1465
|
+
status: signatureStatus,
|
|
1466
|
+
detail: signatureDetail,
|
|
1467
|
+
downloadError: signatureDownloadError,
|
|
1468
|
+
});
|
|
1469
|
+
if (signatureError) {
|
|
1470
|
+
error(signatureError);
|
|
1471
|
+
return 1;
|
|
1472
|
+
}
|
|
1473
|
+
if (signatureStatus === 'verified') {
|
|
1474
|
+
success('GPG signature verified.');
|
|
1475
|
+
} else if (allowUnsigned()) {
|
|
1476
|
+
warning(
|
|
1477
|
+
`Signature NOT verified (${signatureDetail || 'no signature'}) but accepted because ` +
|
|
1478
|
+
`${ALLOW_UNSIGNED_ENV} is set — SHA256 integrity only.`,
|
|
1479
|
+
);
|
|
1480
|
+
}
|
|
1173
1481
|
|
|
1174
1482
|
info('Extracting release...');
|
|
1175
1483
|
const sourceDir = path.join(tmpDir, 'source');
|
|
@@ -1221,7 +1529,7 @@ async function main(options = {}) {
|
|
|
1221
1529
|
// Skill-only install: explain how to add the CLI later instead of
|
|
1222
1530
|
// silently installing it globally.
|
|
1223
1531
|
hint('Skipped installing the iterate CLI (--no-cli).');
|
|
1224
|
-
hint('Install it later for `iterate onboard` with: pipx install
|
|
1532
|
+
hint('Install it later for `iterate onboard` with: pipx install "git+https://github.com/jingzhao-l/iterate-skill.git"');
|
|
1225
1533
|
} else {
|
|
1226
1534
|
// Install the iterate CLI so `iterate onboard` works right after
|
|
1227
1535
|
// install. Made explicit up front so skill-only users are not surprised
|
|
@@ -1255,6 +1563,10 @@ module.exports = {
|
|
|
1255
1563
|
rejectLinkEntries,
|
|
1256
1564
|
isGithubApiUrl,
|
|
1257
1565
|
normalizeToken,
|
|
1566
|
+
validateTokenShape,
|
|
1567
|
+
allowUnsigned,
|
|
1568
|
+
signaturePolicyError,
|
|
1569
|
+
ALLOW_UNSIGNED_ENV,
|
|
1258
1570
|
buildAuthFlags,
|
|
1259
1571
|
supportsCurlFailWithBody,
|
|
1260
1572
|
// Executed under test for the timeout/null-exit-code hardening; retaining
|
|
@@ -1262,5 +1574,11 @@ module.exports = {
|
|
|
1262
1574
|
runCommand,
|
|
1263
1575
|
runPythonInstall,
|
|
1264
1576
|
TailBuffer,
|
|
1577
|
+
FullBuffer,
|
|
1578
|
+
measureTree,
|
|
1579
|
+
enforceExtractedSize,
|
|
1580
|
+
MAX_LISTING_CHARS,
|
|
1581
|
+
MAX_EXTRACTED_BYTES,
|
|
1582
|
+
MAX_EXTRACTED_MEMBER_BYTES,
|
|
1265
1583
|
DEFAULT_COMMAND_TIMEOUT_MS,
|
|
1266
1584
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "iterate-skill-installer",
|
|
3
|
-
"version": "3.5.
|
|
3
|
+
"version": "3.5.2",
|
|
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",
|