iterate-skill-installer 3.4.1 → 3.4.4

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/bin/cli.js CHANGED
@@ -56,7 +56,10 @@ async function run() {
56
56
 
57
57
  try {
58
58
  const code = await main(options);
59
- process.exit(code);
59
+ // Normalize: a null/non-numeric code (main should never produce one, but a
60
+ // signal-killed child upstream does) must surface as a failure, never as
61
+ // the success exit 0 that `process.exit(null)` would yield.
62
+ process.exit(typeof code === 'number' ? code : 1);
60
63
  } catch (err) {
61
64
  console.error(`\nUnexpected error: ${err.message}`);
62
65
  process.exit(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/)) {
@@ -308,21 +464,69 @@ function parseChecksums(text) {
308
464
  return map;
309
465
  }
310
466
 
467
+ /**
468
+ * Bounded in-memory output buffer: retains only the last ``cap`` characters.
469
+ * Used by ``runCommand`` so a chatty child (a verbose build listing, pip log
470
+ * flood, etc.) can never drive the installer's memory usage unbounded.
471
+ */
472
+ class TailBuffer {
473
+ constructor(cap) {
474
+ this.cap = cap;
475
+ this.text = '';
476
+ }
477
+ push(chunk) {
478
+ if (!chunk) return;
479
+ if (this.text.length + chunk.length > this.cap) {
480
+ this.text = (this.text + chunk).slice(-this.cap);
481
+ } else {
482
+ this.text += chunk;
483
+ }
484
+ }
485
+ toString() {
486
+ return this.text;
487
+ }
488
+ }
489
+
490
+ // Default ceiling for a single child command: 10 minutes then SIGKILL. Long
491
+ // operations in this file (pip/pipx installs, venv creation) can legitimately
492
+ // take minutes on slow networks, but a wedged child must never hang the
493
+ // installer forever.
494
+ const DEFAULT_COMMAND_TIMEOUT_MS = 10 * 60 * 1000;
495
+ // Hard cap on retained stdout/stderr per command (memory bound).
496
+ const MAX_CAPTURED_OUTPUT_CHARS = 1024 * 1024;
497
+
311
498
  function runCommand(bin, args, options = {}) {
499
+ const { timeout = DEFAULT_COMMAND_TIMEOUT_MS, ...spawnOptions } = options;
312
500
  return new Promise((resolve, reject) => {
313
- const child = spawn(bin, args, { stdio: 'pipe', ...options });
314
- let stdout = '';
315
- let stderr = '';
316
- child.stdout?.on('data', (data) => { stdout += data.toString(); });
317
- child.stderr?.on('data', (data) => { stderr += data.toString(); });
501
+ const child = spawn(bin, args, { stdio: 'pipe', ...spawnOptions });
502
+ const stdoutTail = new TailBuffer(MAX_CAPTURED_OUTPUT_CHARS);
503
+ const stderrTail = new TailBuffer(MAX_CAPTURED_OUTPUT_CHARS);
504
+ child.stdout?.on('data', (data) => stdoutTail.push(data.toString()));
505
+ child.stderr?.on('data', (data) => stderrTail.push(data.toString()));
506
+ let killed = false;
507
+ const timer =
508
+ timeout > 0
509
+ ? setTimeout(() => {
510
+ killed = true;
511
+ child.kill('SIGKILL');
512
+ }, timeout)
513
+ : null;
318
514
  child.on('close', (code) => {
515
+ if (timer) clearTimeout(timer);
516
+ if (killed) {
517
+ reject(new InstallerError(`${bin} timed out after ${timeout} ms`));
518
+ return;
519
+ }
319
520
  if (code === 0) {
320
- resolve(stdout);
521
+ resolve(stdoutTail.toString());
321
522
  } else {
322
- reject(new InstallerError(`${bin} exited with ${code}: ${stderr || stdout}`));
523
+ reject(new InstallerError(`${bin} exited with ${code}: ${stderrTail.toString() || stdoutTail.toString()}`));
323
524
  }
324
525
  });
325
- child.on('error', reject);
526
+ child.on('error', (err) => {
527
+ if (timer) clearTimeout(timer);
528
+ reject(err);
529
+ });
326
530
  });
327
531
  }
328
532
 
@@ -379,6 +583,20 @@ async function extractTarball(tarballPath, destDir) {
379
583
  throw new InstallerError('Refusing to extract tarball: archive contains no entries.');
380
584
  }
381
585
 
586
+ // 1a. Reject absolute-path entries outright before any other accounting.
587
+ // A member named ``/etc/cron.d/x`` or ``C:\\...`` would resolve through the
588
+ // top-level-strip logic into destDir, but accepting its name as a "safe"
589
+ // component invites ambiguity between tar implementations about what the
590
+ // first component really is — never claim a release archive can contain
591
+ // absolute paths, so fail closed instead.
592
+ for (const entry of entries) {
593
+ if (entry.startsWith('/') || /^[A-Za-z]:[\\/]/.test(entry)) {
594
+ throw new InstallerError(
595
+ `Refusing to extract tarball: entry "${entry}" uses an absolute path.`,
596
+ );
597
+ }
598
+ }
599
+
382
600
  // 1b. Reject any symbolic or hard link entry before touching the tree.
383
601
  // The verbose listing's first character is the entry type for both GNU tar
384
602
  // and bsdtar (macOS): '-' regular, 'd' directory, 'l' symbolic link, 'h'
@@ -425,19 +643,48 @@ async function extractTarball(tarballPath, destDir) {
425
643
  }
426
644
 
427
645
  function runPythonInstall(pythonBin, installScript, args, options = {}) {
646
+ // A wedged pip/skill install must never hang the whole installer forever.
647
+ // On timeout we SIGKILL the child's *process group*: spawn in its own
648
+ // session (detached) so grandchildren holding the pipes are reaped too,
649
+ // mirroring the Python updater's killpg hardening.
650
+ const { timeout = DEFAULT_COMMAND_TIMEOUT_MS, ...spawnOptions } = options;
651
+ const detached = process.platform !== 'win32';
428
652
  return new Promise((resolve, reject) => {
429
653
  const child = spawn(pythonBin, [installScript, 'install', ...args], {
430
654
  stdio: 'inherit',
431
655
  env: { ...process.env, FORCE_COLOR: '1' },
432
- ...options,
656
+ detached,
657
+ ...spawnOptions,
433
658
  });
659
+ const timer =
660
+ timeout > 0
661
+ ? setTimeout(() => {
662
+ try {
663
+ if (child.pid === undefined) {
664
+ child.kill('SIGKILL');
665
+ } else if (detached) {
666
+ process.kill(-child.pid, 'SIGKILL'); // negative pid = whole group
667
+ } else {
668
+ child.kill('SIGKILL');
669
+ }
670
+ } catch {
671
+ // Process already gone — nothing to reap.
672
+ }
673
+ }, timeout)
674
+ : null;
434
675
  child.on('close', (code) => {
676
+ if (timer) clearTimeout(timer);
435
677
  // Resolve with the exit code (rather than rejecting) so the caller can
436
678
  // distinguish "real failure" from "user cancelled the selection" and
437
- // avoid reporting a false success.
438
- resolve(code);
679
+ // avoid reporting a false success. A signal-killed child reports a null
680
+ // code, which the shell would otherwise read as success; normalize to a
681
+ // non-zero failure.
682
+ resolve(typeof code === 'number' ? code : 1);
683
+ });
684
+ child.on('error', (err) => {
685
+ if (timer) clearTimeout(timer);
686
+ reject(err);
439
687
  });
440
- child.on('error', reject);
441
688
  });
442
689
  }
443
690
 
@@ -735,15 +982,39 @@ async function main(options = {}) {
735
982
  ]);
736
983
 
737
984
  info('Fetching latest release from GitHub...');
738
- let release;
985
+ let releases;
739
986
  try {
740
- release = await fetchJson(RELEASE_API_URL, token);
987
+ releases = await fetchJson(RELEASES_API_URL, token);
741
988
  } catch (err) {
742
989
  error(`Could not fetch release info: ${err.message}`);
743
990
  hint('You can set GITHUB_TOKEN for higher API rate limits.');
744
991
  return 1;
745
992
  }
746
993
 
994
+ // The list endpoint returns releases newest-published first. Select the first
995
+ // that is a stable release carrying BOTH assets we need, so a release from
996
+ // another sub-project (or one whose assets are still uploading) is skipped
997
+ // rather than mistaken for the latest skill release.
998
+ const release = Array.isArray(releases)
999
+ ? releases.find(
1000
+ (r) =>
1001
+ r &&
1002
+ !r.draft &&
1003
+ !r.prerelease &&
1004
+ Array.isArray(r.assets) &&
1005
+ r.assets.some((a) => a && a.name === TARBALL_ASSET_NAME) &&
1006
+ r.assets.some((a) => a && a.name === CHECKSUMS_ASSET_NAME)
1007
+ )
1008
+ : releases;
1009
+
1010
+ if (!release) {
1011
+ error(
1012
+ `No published release in the last 100 carries a ${TARBALL_ASSET_NAME} + ${CHECKSUMS_ASSET_NAME} asset pair.`
1013
+ );
1014
+ hint('This is usually a GitHub API rate limit; retry with GITHUB_TOKEN set.');
1015
+ return 1;
1016
+ }
1017
+
747
1018
  const tag = release.tag_name;
748
1019
  if (!tag) {
749
1020
  error('Latest release has no tag.');
@@ -753,6 +1024,9 @@ async function main(options = {}) {
753
1024
 
754
1025
  const tarballAsset = release.assets?.find((a) => a.name === TARBALL_ASSET_NAME);
755
1026
  const checksumAsset = release.assets?.find((a) => a.name === CHECKSUMS_ASSET_NAME);
1027
+ // Optional `.asc` signature; its absence only downgrades authenticity to
1028
+ // integrity (see the best-effort verify below), never fails the install.
1029
+ const signatureAsset = release.assets?.find((a) => a.name === TARBALL_SIGNATURE_ASSET_NAME);
756
1030
 
757
1031
  if (!tarballAsset) {
758
1032
  error(`Release is missing the ${TARBALL_ASSET_NAME} asset.`);
@@ -769,12 +1043,23 @@ async function main(options = {}) {
769
1043
 
770
1044
  try {
771
1045
  info(`Downloading release tarball (${TARBALL_ASSET_NAME})...`);
772
- await downloadFile(tarballAsset.browser_download_url, tarballPath, token, { progress: true });
1046
+ try {
1047
+ await downloadFile(tarballAsset.browser_download_url, tarballPath, token, { progress: true });
1048
+ } catch (err) {
1049
+ error(`Could not download release tarball: ${err.message}`);
1050
+ hint(`A 403/404 usually means the GitHub rate limit or an expired asset URL; retry with GITHUB_TOKEN set.`);
1051
+ return 1;
1052
+ }
773
1053
 
774
1054
  info('Downloading checksums...');
775
- await downloadFile(checksumAsset.browser_download_url, checksumPath, token, {
776
- maxBytes: CHECKSUMS_MAX_BYTES,
777
- });
1055
+ try {
1056
+ await downloadFile(checksumAsset.browser_download_url, checksumPath, token, {
1057
+ maxBytes: CHECKSUMS_MAX_BYTES,
1058
+ });
1059
+ } catch (err) {
1060
+ error(`Could not download ${CHECKSUMS_ASSET_NAME}: ${err.message}`);
1061
+ return 1;
1062
+ }
778
1063
 
779
1064
  info('Verifying checksum...');
780
1065
  const checksums = parseChecksums(fs.readFileSync(checksumPath, 'utf8'));
@@ -804,6 +1089,38 @@ async function main(options = {}) {
804
1089
  `\x1b[32m✓\x1b[0m SHA256 checksum verified`,
805
1090
  ]);
806
1091
 
1092
+ // GPG authenticity check (best-effort, defense-in-depth). The release also
1093
+ // publishes a detached ASCII-armored signature over the tarball. When a
1094
+ // `gpg` binary is present we verify it against the embedded signing key: a
1095
+ // *valid* signature upgrades the release from "integrity-guaranteed" to
1096
+ // "authenticity-guaranteed". If gpg is unavailable we degrade to a warning
1097
+ // (SHA256 already guards integrity); if gpg IS present but the signature
1098
+ // fails, we still install but report it loudly — a failed signature here is
1099
+ // almost always a key-rotation lag, never a hidden fault.
1100
+ if (signatureAsset) {
1101
+ const signaturePath = path.join(tmpDir, TARBALL_SIGNATURE_ASSET_NAME);
1102
+ try {
1103
+ await downloadFile(signatureAsset.browser_download_url, signaturePath, token, {
1104
+ maxBytes: CHECKSUMS_MAX_BYTES,
1105
+ });
1106
+ const sig = verifyGpgDetachedSignature(tarballPath, signaturePath, ITERATE_SIGNING_PUBLIC_KEY, tmpDir);
1107
+ if (sig.status === 'verified') {
1108
+ success('GPG signature verified.');
1109
+ } else if (sig.status === 'invalid') {
1110
+ warning(
1111
+ `GPG signature could NOT be verified (${sig.detail}).\n` +
1112
+ ' SHA256 integrity still passed; re-run after the release key is rotated if this persists.'
1113
+ );
1114
+ } else {
1115
+ warning(
1116
+ `gpg not available (${sig.detail}); skipping signature check — SHA256 integrity still enforced.`
1117
+ );
1118
+ }
1119
+ } catch (err) {
1120
+ warning(`Could not download/verify the GPG signature (${err.message}); SHA256 integrity still enforced.`);
1121
+ }
1122
+ }
1123
+
807
1124
  info('Extracting release...');
808
1125
  const sourceDir = path.join(tmpDir, 'source');
809
1126
  fs.mkdirSync(sourceDir, { recursive: true });
@@ -883,10 +1200,17 @@ module.exports = {
883
1200
  resolveInstallMode,
884
1201
  askYesNo,
885
1202
  parseChecksums,
1203
+ enforceDownloadedSize,
886
1204
  extractTarball,
887
1205
  rejectLinkEntries,
888
1206
  isGithubApiUrl,
889
1207
  normalizeToken,
890
1208
  buildAuthFlags,
891
1209
  supportsCurlFailWithBody,
1210
+ // Executed under test for the timeout/null-exit-code hardening; retaining
1211
+ // the exports also keeps them statically reachable for QA/debugging.
1212
+ runCommand,
1213
+ runPythonInstall,
1214
+ TailBuffer,
1215
+ DEFAULT_COMMAND_TIMEOUT_MS,
892
1216
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "iterate-skill-installer",
3
- "version": "3.4.1",
3
+ "version": "3.4.4",
4
4
  "description": "One-command installer for iterate-skill across AI coding assistants",
5
5
  "main": "lib/installer.js",
6
6
  "files": [