iterate-skill-installer 3.4.2 → 3.5.0

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.
Files changed (2) hide show
  1. package/lib/installer.js +335 -17
  2. package/package.json +1 -1
package/lib/installer.js CHANGED
@@ -36,12 +36,25 @@ const readline = require('node:readline');
36
36
 
37
37
  const GITHUB_OWNER = 'jingzhao-l';
38
38
  const GITHUB_REPO = 'iterate-skill';
39
- const RELEASE_API_URL = `https://api.github.com/repos/${GITHUB_OWNER}/${GITHUB_REPO}/releases/latest`;
39
+ // NOTE: deliberately the *release list*, not `/releases/latest`.
40
+ // `/releases/latest` resolves to "newest non-prerelease by published_at" for the
41
+ // whole repository, and this repository also publishes releases for the
42
+ // harness/plugin sub-projects, which carry no iterate-skill.tar.gz. When such a
43
+ // release is the most recently published one, `/releases/latest` returns it and
44
+ // every install aborts with "Release is missing the iterate-skill.tar.gz asset."
45
+ // Listing releases and picking the newest one that actually carries both assets
46
+ // is immune to what any other release in the repo was published last.
47
+ const RELEASES_API_URL =
48
+ `https://api.github.com/repos/${GITHUB_OWNER}/${GITHUB_REPO}/releases?per_page=100`;
40
49
 
41
50
  // Release asset filenames — named constants so the magic strings are not
42
51
  // scattered through the download / verify pipeline.
43
52
  const TARBALL_ASSET_NAME = 'iterate-skill.tar.gz';
44
53
  const CHECKSUMS_ASSET_NAME = 'SHA256SUMS.txt';
54
+ // Detached ASCII-armored GPG signature over the tarball, published alongside
55
+ // it in the release. When a local `gpg` binary is available the installer
56
+ // verifies authenticity (not just integrity) against the embedded signing key.
57
+ const TARBALL_SIGNATURE_ASSET_NAME = 'iterate-skill.tar.gz.asc';
45
58
 
46
59
  // Hard cap on a single curl download: a misbehaving CDN or a corrupt release
47
60
  // must never hang the installer forever.
@@ -73,6 +86,69 @@ let CURL_SUPPORTS_FAIL_WITH_BODY = null; // lazily probed once
73
86
  // and the published npm version always stay in sync.
74
87
  const VERSION = require('../package.json').version;
75
88
 
89
+ // ASCII-armored GPG public key of the iterate-skill signing identity
90
+ // (uid: jingzhao-l (sign-github) <ET_lin@outlook.com>, key
91
+ // 0929EA31DF4F7429F63FC53189D88B1D043A1298). The release tarball `.asc`
92
+ // signature is verified against THIS key. It must stay in sync with the
93
+ // GPG_PRIVATE_KEY repo secret used by `.github/workflows/release.yml` to sign
94
+ // the release artifacts — if the release is re-keyed, this constant must be
95
+ // updated in the same release.
96
+ const ITERATE_SIGNING_PUBLIC_KEY = [
97
+ '-----BEGIN PGP PUBLIC KEY BLOCK-----',
98
+ '',
99
+ 'mQINBGq45VQBEACdjAoLYyfgPpHjvscmGqxlSsBkcBvSAoGHdCI0p2Rn5cBDaPie',
100
+ 'oPU17VmUiK4FBZf8FcaX0L+EeMRO4Bcj5NgoFaSgQPK0YarvoPssClNiWf71hDlg',
101
+ 'QmC5IlwM4WuVUeKi3+YoPmRSf0sYHzSYM7vEIoCFzEilYi4iEK/NMihNSktlUsQx',
102
+ 'jhIaXtnVJi+7GkO+dhckKmIHhcR76dUfIAsS/R0RzzH4ZXfuKi+B94mfCntURpM4',
103
+ 'G+NrxZx7Xv5UDpv9XsrmiWKzNpT+Th9GbNQREjrT1mmKbMEOmD/PWTqxNycJfgdW',
104
+ 'hWA++1oassOib3jd44+z5f7FKpp//C+SK8V7vuxNI0jRM/VYrbyrfON30hHwtbFM',
105
+ '0J/quDsAUzlOBNzNVPAyvGsOuSEULFtjaJ2q+JYjF+ZKDPy+lyY8V9sbYrqCCUb9',
106
+ 'v3wPn4tDvat30Q0A0rheMZPTMO7tRNSzFOd25H0w3ZF3F5Np8D/aX9VHMkA12xTm',
107
+ 'l7gpb1OIEJ1vdQl0twiF6SDz5jsteHfdUXha6CtM7tv7IZ4MIZi/qPkvv4zOKmsE',
108
+ 'utLUs1alD671Eez2sQow8NO5IXfd7bX2d34kU5JiM1tF9qhBxOZlpzd/Vm8FZS4V',
109
+ 'URF6Y7myW4ildqBeMzLt0to8WjHnHoV4v9rh5581qWEqvNIJ4/R3lKRhxwARAQAB',
110
+ 'tC1qaW5nemhhby1sIChzaWduLWdpdGh1YikgPEVUX2xpbkBvdXRsb29rLmNvbT6J',
111
+ 'Am0EEwEIAFcWIQQJKeox3090KfY/xTGJ2IsdBDoSmAUCarjlVBsUgAAAAAAEAA5t',
112
+ 'YW51MiwyLjUrMS4xMiwwLDMCGwMFCwkIBwICIgIGFQoJCAsCBBYCAwECHgcCF4AA',
113
+ 'CgkQidiLHQQ6EpharQ//VhcNiug3cHsgvb/tTqWp1CQV8heSfqoKrW51RPhcGAHW',
114
+ 'VMHpbPRO0wBKKE5mybyGAWhGDhh5mZt1MxnBN3lC7RsWBLEaXyJAqW4UPjR5LN8Q',
115
+ 'scapkCzFwrF5lisELdqKqkd/ACKR8h6U/fBf0eKE+TMDSrXZ/LkRcFRJErfsC7rx',
116
+ 'hy1WQnzQBT2+86HmfW9rrw5RSyCp8MZ0TJhYr0ZdgB4zvLwvVYQCnlRaskkLjGrh',
117
+ '6vUHAjDCUwoDFEpecadCJg34cOEAMRjnTt6Q0t8SnVHDH9PLq1MwGON2VzuSp5rY',
118
+ 'rthT3+VRzbzGpBu4wl4/GJiWJMfGTusEu8Ver6MTwHx9pFBDtH3cawIB5BT1RrXk',
119
+ 'cYdhtjzJ61RXIrSCeD8yoxEgDOj73Ll6oQ4+fJ+EpOc+SvP9FREeQ4k/uc8MpwtV',
120
+ 'YyD/6EPJu1lLMAzgd2Xm2ljokTRhm/Blft9Y0OEEWzsoDGv+jr3Jb3Dgw62OuL6B',
121
+ 'pLiZ5XNCYBHYhQhtleGnSpJtD9ooi1UUTbVZftunzYGKafMCgc9nnzPIGVtlzX+d',
122
+ 'K10CtPOX7ylS+lKukaIOSStGGSl3I2Fd66yb3ujIH6n/KAKLfMmmy48pxB3+t6WW',
123
+ 'StdD7QEASWIyW2wTrq7RyDwmzWMSFtgPzCOWFmcQfFykkvvQEqxMeyCrcPY6ZIy5',
124
+ 'Ag0EarjlVAEQAOjPGVDb8zGIc7XQelHhjyd8yLCVpNBWwYLmaSLfI+EQsfVVDJqT',
125
+ 'VAAeO82woHELPun06lbJRW59eH8BkVgzGhNkb5vKhrdvmZydYElC1NuRB9ag6/k/',
126
+ '0IaLwedKZscy1k3oG2LqsayzUO3L2d8BxO8zdLEmIl7FqtTdsYwj6DDRgZdA4Aj0',
127
+ 'VoUXOgWaR+7qA9GHnnucrE5n0zhrTd7F3mtZErWr6Edo/V9EHQ1PszsQTVH2artr',
128
+ 'lYJWjSsAv/ajEvAjaZ1mJoDvz/UzUk7hCCPeNcpy41SpDb3uey38qqxLYOGVgEeD',
129
+ '7JTrhC51VNHj2CCxSgyrlvED+resJtgWnE65Sa8g9cGAVpXlOBRQrPZGMHS+BcAg',
130
+ '9lQExMGrWt76ZPgT5Gygzj09oGx1q6/IyATphit4TblFGl2z1JDnlUQqfz/aCmZN',
131
+ 'Vg+fKBPQ93dLLGPKpmY022X3abJ197VaQ0WWB2cA8pcrcfJ8GY7Lm8xjm6nY/cER',
132
+ 'RQl72dE1NJsPGrp3Ad/s7fAJuEdR8UmMUPLDQRpiNRTcK6RC2AaRD7wpdyG/1csF',
133
+ 'P30IIY97aVSjD5nnkrHxNQKZ17yPef+bFIoJ7OS+WhLZKcr7P0DfgJoTbKc6GK6M',
134
+ 'ScQm5N7lRJ9Mfzu4R6576hDrgb3fmgflmpKOIVrZ6GkTGDvQ/FE16QdrABEBAAGJ',
135
+ 'AlIEGAEIADwWIQQJKeox3090KfY/xTGJ2IsdBDoSmAUCarjlVBsUgAAAAAAEAA5t',
136
+ 'YW51MiwyLjUrMS4xMiwwLDMCGwwACgkQidiLHQQ6Epi4aw//Qu00vxGtvRb+VQl9',
137
+ 'lMZLwIP2AgB0lAgKAqYeK6jZh/15GAKJqRh0u2jdgqXj2Sfm79X7Qwn7wAuFUAmx',
138
+ 'D1eegOtdAnEP6O8DUtZWWmy2TSRIqjfTcGXlZ12WHiOwwdG5VOUZERWi/rPj0zTs',
139
+ '5V1H4qyPOqgrFx9nNvavzo3zeJVpwYuuFkT2Ne0cZLXGglCQ6MJtuK0Qwk6iYsvy',
140
+ 'p3eZ1YCKmAi1v0UFojtFHqJEAsc3PnZb+48veE9b2whrL9DIIkNrrFlfFC1cjm7T',
141
+ 'wuRQuH6aYyrRoZiLxgwkW5xmc1Biitw7bMIX6eqYVn8hb1lQUKhTL8aZ2xQa6IsJ',
142
+ 'RHIJFYeEIEUtIl/GKQ3MeHQlJrXsfnZ1e8MHgwgMw3o4Nq4xww3Ch0pddYhBskmV',
143
+ '/QUaOHVqmuur9dnRvx9L+FGbzHEjvYDr0MkSe30hUhyBIg1uOLd2elATB/wg33Ow',
144
+ 'vcdqgewqpYdSg6g6KZYl6NhmWWNEMgX8KITUXFoTiV5CrSsrptBPJWsyIq+CuseL',
145
+ 'CKFdMHrkzbjFLGfdiPqykwttwHBAEk01aWArDP65gXRXmxGzDHVkA7Px1hdo/kMo',
146
+ 'Ouw6bEGpHtx7UJJMSMA9ywbTrOyaG4xzVDa7ixUslFtgxts1R/eLoC4I11grxE50',
147
+ 'YVXa4IEQs7aBxKO+n+T2AvUYiKQ=',
148
+ '=DYXk',
149
+ '-----END PGP PUBLIC KEY BLOCK-----',
150
+ ].join('\n');
151
+
76
152
  const ITERATE_BANNER = [
77
153
  '██╗████████╗███████╗██████╗ █████╗ ████████╗███████╗',
78
154
  '██║╚══██╔══╝██╔════╝██╔══██╗██╔══██╗╚══██╔══╝██╔════╝',
@@ -262,6 +338,40 @@ async function downloadFile(url, destPath, token, { progress = false, maxBytes =
262
338
  } else {
263
339
  await runCommand('curl', args);
264
340
  }
341
+ // `--max-filesize` is best-effort by curl's own documentation: it is only
342
+ // enforced when the server advertises a Content-Length, so a response sent
343
+ // with chunked transfer encoding streams to disk unbounded. Re-stat after the
344
+ // fact so the cap holds regardless of how the body was framed, and so a
345
+ // truncated-then-huge body cannot be hashed or extracted.
346
+ enforceDownloadedSize(destPath, maxBytes);
347
+ }
348
+
349
+ /**
350
+ * Fail closed when a completed download exceeds ``maxBytes``.
351
+ *
352
+ * The oversized file is removed so a later retry cannot silently reuse it.
353
+ * @param {string} destPath
354
+ * @param {number} maxBytes
355
+ */
356
+ function enforceDownloadedSize(destPath, maxBytes) {
357
+ let size;
358
+ try {
359
+ size = fs.statSync(destPath).size;
360
+ } catch (err) {
361
+ // curl already reported a missing output file as a failure; nothing to
362
+ // check here, and re-reporting would mask the real error.
363
+ return;
364
+ }
365
+ if (size > maxBytes) {
366
+ try {
367
+ fs.unlinkSync(destPath);
368
+ } catch {
369
+ // Best effort: the size violation below is what the user must act on.
370
+ }
371
+ throw new InstallerError(
372
+ `Downloaded file is ${size} bytes, above the ${maxBytes} byte safety cap — refusing to proceed.`,
373
+ );
374
+ }
265
375
  }
266
376
 
267
377
  function sha256File(filePath) {
@@ -274,6 +384,52 @@ function sha256File(filePath) {
274
384
  });
275
385
  }
276
386
 
387
+ // Best-effort GPG authenticity verification of a downloaded artifact against
388
+ // the embedded signing key. Returns one of:
389
+ // 'verified' — `gpg` present and the detached signature is valid.
390
+ // 'invalid' — `gpg` present, but the signature did not verify (key
391
+ // mismatch or tampered signature). Reported loudly, never
392
+ // silently ignored.
393
+ // 'unavailable' — no `gpg` binary, or the public key could not be imported.
394
+ // The caller treats this as non-blocking because SHA256
395
+ // already guarantees integrity; GPG adds authenticity as a
396
+ // defense-in-depth layer, not the only gate.
397
+ // Runs inside an isolated temporary GNUPGHOME so the end user's own keyring and
398
+ // gpg-agent are never touched or modified.
399
+ function verifyGpgDetachedSignature(artifactPath, signaturePath, publicKeyArmor, tmpDir) {
400
+ const gpgHome = path.join(tmpDir, 'gnupg');
401
+ fs.mkdirSync(gpgHome, { recursive: true, mode: 0o700 });
402
+ const keyPath = path.join(tmpDir, 'iterate-skill-pubkey.asc');
403
+ fs.writeFileSync(keyPath, publicKeyArmor, 'utf8');
404
+
405
+ const importResult = spawnSync(
406
+ 'gpg',
407
+ ['--homedir', gpgHome, '--batch', '--yes', '--quiet', '--no-autostart', '--import', keyPath],
408
+ { encoding: 'utf8' }
409
+ );
410
+ if (importResult.error) {
411
+ const detail = importResult.error.code === 'ENOENT' ? 'gpg binary not found' : importResult.error.message;
412
+ return { status: 'unavailable', detail };
413
+ }
414
+ if (importResult.status !== 0) {
415
+ return { status: 'invalid', detail: (importResult.stderr || '').trim() || 'failed to import the signing key' };
416
+ }
417
+
418
+ const verifyResult = spawnSync(
419
+ 'gpg',
420
+ ['--homedir', gpgHome, '--batch', '--quiet', '--no-autostart', '--verify', signaturePath, artifactPath],
421
+ { encoding: 'utf8' }
422
+ );
423
+ if (verifyResult.error) {
424
+ return { status: 'unavailable', detail: `gpg launch failed: ${verifyResult.error.message}` };
425
+ }
426
+ // gpg exit 0 = good signature; 1 = bad signature; 2 = fatal (bad key usage).
427
+ if (verifyResult.status === 0) {
428
+ return { status: 'verified', detail: (verifyResult.stderr || '').trim() };
429
+ }
430
+ return { status: 'invalid', detail: (verifyResult.stderr || '').trim() || `gpg exited ${verifyResult.status}` };
431
+ }
432
+
277
433
  function parseChecksums(text) {
278
434
  const map = new Map();
279
435
  for (const line of text.split(/\r?\n/)) {
@@ -336,40 +492,90 @@ class TailBuffer {
336
492
  // take minutes on slow networks, but a wedged child must never hang the
337
493
  // installer forever.
338
494
  const DEFAULT_COMMAND_TIMEOUT_MS = 10 * 60 * 1000;
495
+ // Grace period between the SIGKILL and giving up on 'close'. A grandchild that
496
+ // inherited our stdio pipe keeps that pipe open, so Node withholds 'close'
497
+ // even though the leader is already dead — without this the promise would
498
+ // never settle and the installer would hang despite the timeout having fired.
499
+ const POST_KILL_SETTLE_MS = 5 * 1000;
339
500
  // Hard cap on retained stdout/stderr per command (memory bound).
340
501
  const MAX_CAPTURED_OUTPUT_CHARS = 1024 * 1024;
341
502
 
503
+ // True only on POSIX: Windows has no process groups keyed by negative pid and
504
+ // `detached` there means "new console window", not "new process group".
505
+ const IS_POSIX = process.platform !== 'win32';
506
+
507
+ function killProcessTree(child) {
508
+ // The child was spawned detached (POSIX), so it is its own process-group
509
+ // leader and its negative pid addresses the WHOLE group. A bare
510
+ // `child.kill()` only signals the immediate process, so a `pip` that had
511
+ // already spawned a compiler/download helper outlived the kill and kept
512
+ // running (and holding our stdio pipe) after the installer "timed out".
513
+ if (IS_POSIX && child.pid) {
514
+ try {
515
+ process.kill(-child.pid, 'SIGKILL');
516
+ return;
517
+ } catch (err) {
518
+ // ESRCH: the group is already gone. EPERM: fall through to the leader.
519
+ if (err && err.code === 'ESRCH') return;
520
+ }
521
+ }
522
+ try {
523
+ child.kill('SIGKILL');
524
+ } catch {
525
+ // Already exited — nothing left to signal.
526
+ }
527
+ }
528
+
342
529
  function runCommand(bin, args, options = {}) {
343
530
  const { timeout = DEFAULT_COMMAND_TIMEOUT_MS, ...spawnOptions } = options;
344
531
  return new Promise((resolve, reject) => {
345
- const child = spawn(bin, args, { stdio: 'pipe', ...spawnOptions });
532
+ // `detached` on POSIX puts the child in its own process group so the
533
+ // timeout can kill the entire tree, not just the leader. It does not
534
+ // change how we await the child (we never unref it).
535
+ const child = spawn(bin, args, { stdio: 'pipe', detached: IS_POSIX, ...spawnOptions });
346
536
  const stdoutTail = new TailBuffer(MAX_CAPTURED_OUTPUT_CHARS);
347
537
  const stderrTail = new TailBuffer(MAX_CAPTURED_OUTPUT_CHARS);
348
538
  child.stdout?.on('data', (data) => stdoutTail.push(data.toString()));
349
539
  child.stderr?.on('data', (data) => stderrTail.push(data.toString()));
350
540
  let killed = false;
541
+ let settled = false;
542
+ let killFallback = null;
543
+ const settle = (fn, value) => {
544
+ if (settled) return;
545
+ settled = true;
546
+ fn(value);
547
+ };
351
548
  const timer =
352
549
  timeout > 0
353
550
  ? setTimeout(() => {
354
551
  killed = true;
355
- child.kill('SIGKILL');
552
+ killProcessTree(child);
553
+ // The leader may be dead while a grandchild still holds our stdio
554
+ // pipe open, in which case 'close' never arrives. Guarantee the
555
+ // promise settles either way.
556
+ killFallback = setTimeout(() => {
557
+ settle(reject, new InstallerError(`${bin} timed out after ${timeout} ms`));
558
+ }, POST_KILL_SETTLE_MS);
356
559
  }, timeout)
357
560
  : null;
358
- child.on('close', (code) => {
561
+ const finish = (code) => {
359
562
  if (timer) clearTimeout(timer);
563
+ if (killFallback) clearTimeout(killFallback);
360
564
  if (killed) {
361
- reject(new InstallerError(`${bin} timed out after ${timeout} ms`));
565
+ settle(reject, new InstallerError(`${bin} timed out after ${timeout} ms`));
362
566
  return;
363
567
  }
364
568
  if (code === 0) {
365
- resolve(stdoutTail.toString());
569
+ settle(resolve, stdoutTail.toString());
366
570
  } else {
367
- reject(new InstallerError(`${bin} exited with ${code}: ${stderrTail.toString() || stdoutTail.toString()}`));
571
+ settle(reject, new InstallerError(`${bin} exited with ${code}: ${stderrTail.toString() || stdoutTail.toString()}`));
368
572
  }
369
- });
573
+ };
574
+ child.on('close', finish);
370
575
  child.on('error', (err) => {
371
576
  if (timer) clearTimeout(timer);
372
- reject(err);
577
+ if (killFallback) clearTimeout(killFallback);
578
+ settle(reject, err);
373
579
  });
374
580
  });
375
581
  }
@@ -427,6 +633,20 @@ async function extractTarball(tarballPath, destDir) {
427
633
  throw new InstallerError('Refusing to extract tarball: archive contains no entries.');
428
634
  }
429
635
 
636
+ // 1a. Reject absolute-path entries outright before any other accounting.
637
+ // A member named ``/etc/cron.d/x`` or ``C:\\...`` would resolve through the
638
+ // top-level-strip logic into destDir, but accepting its name as a "safe"
639
+ // component invites ambiguity between tar implementations about what the
640
+ // first component really is — never claim a release archive can contain
641
+ // absolute paths, so fail closed instead.
642
+ for (const entry of entries) {
643
+ if (entry.startsWith('/') || /^[A-Za-z]:[\\/]/.test(entry)) {
644
+ throw new InstallerError(
645
+ `Refusing to extract tarball: entry "${entry}" uses an absolute path.`,
646
+ );
647
+ }
648
+ }
649
+
430
650
  // 1b. Reject any symbolic or hard link entry before touching the tree.
431
651
  // The verbose listing's first character is the entry type for both GNU tar
432
652
  // and bsdtar (macOS): '-' regular, 'd' directory, 'l' symbolic link, 'h'
@@ -473,13 +693,37 @@ async function extractTarball(tarballPath, destDir) {
473
693
  }
474
694
 
475
695
  function runPythonInstall(pythonBin, installScript, args, options = {}) {
696
+ // A wedged pip/skill install must never hang the whole installer forever.
697
+ // On timeout we SIGKILL the child's *process group*: spawn in its own
698
+ // session (detached) so grandchildren holding the pipes are reaped too,
699
+ // mirroring the Python updater's killpg hardening.
700
+ const { timeout = DEFAULT_COMMAND_TIMEOUT_MS, ...spawnOptions } = options;
701
+ const detached = process.platform !== 'win32';
476
702
  return new Promise((resolve, reject) => {
477
703
  const child = spawn(pythonBin, [installScript, 'install', ...args], {
478
704
  stdio: 'inherit',
479
705
  env: { ...process.env, FORCE_COLOR: '1' },
480
- ...options,
706
+ detached,
707
+ ...spawnOptions,
481
708
  });
709
+ const timer =
710
+ timeout > 0
711
+ ? setTimeout(() => {
712
+ try {
713
+ if (child.pid === undefined) {
714
+ child.kill('SIGKILL');
715
+ } else if (detached) {
716
+ process.kill(-child.pid, 'SIGKILL'); // negative pid = whole group
717
+ } else {
718
+ child.kill('SIGKILL');
719
+ }
720
+ } catch {
721
+ // Process already gone — nothing to reap.
722
+ }
723
+ }, timeout)
724
+ : null;
482
725
  child.on('close', (code) => {
726
+ if (timer) clearTimeout(timer);
483
727
  // Resolve with the exit code (rather than rejecting) so the caller can
484
728
  // distinguish "real failure" from "user cancelled the selection" and
485
729
  // avoid reporting a false success. A signal-killed child reports a null
@@ -487,7 +731,10 @@ function runPythonInstall(pythonBin, installScript, args, options = {}) {
487
731
  // non-zero failure.
488
732
  resolve(typeof code === 'number' ? code : 1);
489
733
  });
490
- child.on('error', reject);
734
+ child.on('error', (err) => {
735
+ if (timer) clearTimeout(timer);
736
+ reject(err);
737
+ });
491
738
  });
492
739
  }
493
740
 
@@ -785,15 +1032,39 @@ async function main(options = {}) {
785
1032
  ]);
786
1033
 
787
1034
  info('Fetching latest release from GitHub...');
788
- let release;
1035
+ let releases;
789
1036
  try {
790
- release = await fetchJson(RELEASE_API_URL, token);
1037
+ releases = await fetchJson(RELEASES_API_URL, token);
791
1038
  } catch (err) {
792
1039
  error(`Could not fetch release info: ${err.message}`);
793
1040
  hint('You can set GITHUB_TOKEN for higher API rate limits.');
794
1041
  return 1;
795
1042
  }
796
1043
 
1044
+ // The list endpoint returns releases newest-published first. Select the first
1045
+ // that is a stable release carrying BOTH assets we need, so a release from
1046
+ // another sub-project (or one whose assets are still uploading) is skipped
1047
+ // rather than mistaken for the latest skill release.
1048
+ const release = Array.isArray(releases)
1049
+ ? releases.find(
1050
+ (r) =>
1051
+ r &&
1052
+ !r.draft &&
1053
+ !r.prerelease &&
1054
+ Array.isArray(r.assets) &&
1055
+ r.assets.some((a) => a && a.name === TARBALL_ASSET_NAME) &&
1056
+ r.assets.some((a) => a && a.name === CHECKSUMS_ASSET_NAME)
1057
+ )
1058
+ : releases;
1059
+
1060
+ if (!release) {
1061
+ error(
1062
+ `No published release in the last 100 carries a ${TARBALL_ASSET_NAME} + ${CHECKSUMS_ASSET_NAME} asset pair.`
1063
+ );
1064
+ hint('This is usually a GitHub API rate limit; retry with GITHUB_TOKEN set.');
1065
+ return 1;
1066
+ }
1067
+
797
1068
  const tag = release.tag_name;
798
1069
  if (!tag) {
799
1070
  error('Latest release has no tag.');
@@ -803,6 +1074,9 @@ async function main(options = {}) {
803
1074
 
804
1075
  const tarballAsset = release.assets?.find((a) => a.name === TARBALL_ASSET_NAME);
805
1076
  const checksumAsset = release.assets?.find((a) => a.name === CHECKSUMS_ASSET_NAME);
1077
+ // Optional `.asc` signature; its absence only downgrades authenticity to
1078
+ // integrity (see the best-effort verify below), never fails the install.
1079
+ const signatureAsset = release.assets?.find((a) => a.name === TARBALL_SIGNATURE_ASSET_NAME);
806
1080
 
807
1081
  if (!tarballAsset) {
808
1082
  error(`Release is missing the ${TARBALL_ASSET_NAME} asset.`);
@@ -819,12 +1093,23 @@ async function main(options = {}) {
819
1093
 
820
1094
  try {
821
1095
  info(`Downloading release tarball (${TARBALL_ASSET_NAME})...`);
822
- await downloadFile(tarballAsset.browser_download_url, tarballPath, token, { progress: true });
1096
+ try {
1097
+ await downloadFile(tarballAsset.browser_download_url, tarballPath, token, { progress: true });
1098
+ } catch (err) {
1099
+ error(`Could not download release tarball: ${err.message}`);
1100
+ hint(`A 403/404 usually means the GitHub rate limit or an expired asset URL; retry with GITHUB_TOKEN set.`);
1101
+ return 1;
1102
+ }
823
1103
 
824
1104
  info('Downloading checksums...');
825
- await downloadFile(checksumAsset.browser_download_url, checksumPath, token, {
826
- maxBytes: CHECKSUMS_MAX_BYTES,
827
- });
1105
+ try {
1106
+ await downloadFile(checksumAsset.browser_download_url, checksumPath, token, {
1107
+ maxBytes: CHECKSUMS_MAX_BYTES,
1108
+ });
1109
+ } catch (err) {
1110
+ error(`Could not download ${CHECKSUMS_ASSET_NAME}: ${err.message}`);
1111
+ return 1;
1112
+ }
828
1113
 
829
1114
  info('Verifying checksum...');
830
1115
  const checksums = parseChecksums(fs.readFileSync(checksumPath, 'utf8'));
@@ -854,6 +1139,38 @@ async function main(options = {}) {
854
1139
  `\x1b[32m✓\x1b[0m SHA256 checksum verified`,
855
1140
  ]);
856
1141
 
1142
+ // GPG authenticity check (best-effort, defense-in-depth). The release also
1143
+ // publishes a detached ASCII-armored signature over the tarball. When a
1144
+ // `gpg` binary is present we verify it against the embedded signing key: a
1145
+ // *valid* signature upgrades the release from "integrity-guaranteed" to
1146
+ // "authenticity-guaranteed". If gpg is unavailable we degrade to a warning
1147
+ // (SHA256 already guards integrity); if gpg IS present but the signature
1148
+ // fails, we still install but report it loudly — a failed signature here is
1149
+ // almost always a key-rotation lag, never a hidden fault.
1150
+ if (signatureAsset) {
1151
+ const signaturePath = path.join(tmpDir, TARBALL_SIGNATURE_ASSET_NAME);
1152
+ try {
1153
+ await downloadFile(signatureAsset.browser_download_url, signaturePath, token, {
1154
+ maxBytes: CHECKSUMS_MAX_BYTES,
1155
+ });
1156
+ const sig = verifyGpgDetachedSignature(tarballPath, signaturePath, ITERATE_SIGNING_PUBLIC_KEY, tmpDir);
1157
+ if (sig.status === 'verified') {
1158
+ success('GPG signature verified.');
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
+ }
1169
+ } catch (err) {
1170
+ warning(`Could not download/verify the GPG signature (${err.message}); SHA256 integrity still enforced.`);
1171
+ }
1172
+ }
1173
+
857
1174
  info('Extracting release...');
858
1175
  const sourceDir = path.join(tmpDir, 'source');
859
1176
  fs.mkdirSync(sourceDir, { recursive: true });
@@ -933,6 +1250,7 @@ module.exports = {
933
1250
  resolveInstallMode,
934
1251
  askYesNo,
935
1252
  parseChecksums,
1253
+ enforceDownloadedSize,
936
1254
  extractTarball,
937
1255
  rejectLinkEntries,
938
1256
  isGithubApiUrl,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "iterate-skill-installer",
3
- "version": "3.4.2",
3
+ "version": "3.5.0",
4
4
  "description": "One-command installer for iterate-skill across AI coding assistants",
5
5
  "main": "lib/installer.js",
6
6
  "files": [