iterate-skill-installer 3.4.2 → 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.
Files changed (2) hide show
  1. package/lib/installer.js +277 -9
  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/)) {
@@ -427,6 +583,20 @@ async function extractTarball(tarballPath, destDir) {
427
583
  throw new InstallerError('Refusing to extract tarball: archive contains no entries.');
428
584
  }
429
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
+
430
600
  // 1b. Reject any symbolic or hard link entry before touching the tree.
431
601
  // The verbose listing's first character is the entry type for both GNU tar
432
602
  // and bsdtar (macOS): '-' regular, 'd' directory, 'l' symbolic link, 'h'
@@ -473,13 +643,37 @@ async function extractTarball(tarballPath, destDir) {
473
643
  }
474
644
 
475
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';
476
652
  return new Promise((resolve, reject) => {
477
653
  const child = spawn(pythonBin, [installScript, 'install', ...args], {
478
654
  stdio: 'inherit',
479
655
  env: { ...process.env, FORCE_COLOR: '1' },
480
- ...options,
656
+ detached,
657
+ ...spawnOptions,
481
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;
482
675
  child.on('close', (code) => {
676
+ if (timer) clearTimeout(timer);
483
677
  // Resolve with the exit code (rather than rejecting) so the caller can
484
678
  // distinguish "real failure" from "user cancelled the selection" and
485
679
  // avoid reporting a false success. A signal-killed child reports a null
@@ -487,7 +681,10 @@ function runPythonInstall(pythonBin, installScript, args, options = {}) {
487
681
  // non-zero failure.
488
682
  resolve(typeof code === 'number' ? code : 1);
489
683
  });
490
- child.on('error', reject);
684
+ child.on('error', (err) => {
685
+ if (timer) clearTimeout(timer);
686
+ reject(err);
687
+ });
491
688
  });
492
689
  }
493
690
 
@@ -785,15 +982,39 @@ async function main(options = {}) {
785
982
  ]);
786
983
 
787
984
  info('Fetching latest release from GitHub...');
788
- let release;
985
+ let releases;
789
986
  try {
790
- release = await fetchJson(RELEASE_API_URL, token);
987
+ releases = await fetchJson(RELEASES_API_URL, token);
791
988
  } catch (err) {
792
989
  error(`Could not fetch release info: ${err.message}`);
793
990
  hint('You can set GITHUB_TOKEN for higher API rate limits.');
794
991
  return 1;
795
992
  }
796
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
+
797
1018
  const tag = release.tag_name;
798
1019
  if (!tag) {
799
1020
  error('Latest release has no tag.');
@@ -803,6 +1024,9 @@ async function main(options = {}) {
803
1024
 
804
1025
  const tarballAsset = release.assets?.find((a) => a.name === TARBALL_ASSET_NAME);
805
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);
806
1030
 
807
1031
  if (!tarballAsset) {
808
1032
  error(`Release is missing the ${TARBALL_ASSET_NAME} asset.`);
@@ -819,12 +1043,23 @@ async function main(options = {}) {
819
1043
 
820
1044
  try {
821
1045
  info(`Downloading release tarball (${TARBALL_ASSET_NAME})...`);
822
- 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
+ }
823
1053
 
824
1054
  info('Downloading checksums...');
825
- await downloadFile(checksumAsset.browser_download_url, checksumPath, token, {
826
- maxBytes: CHECKSUMS_MAX_BYTES,
827
- });
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
+ }
828
1063
 
829
1064
  info('Verifying checksum...');
830
1065
  const checksums = parseChecksums(fs.readFileSync(checksumPath, 'utf8'));
@@ -854,6 +1089,38 @@ async function main(options = {}) {
854
1089
  `\x1b[32m✓\x1b[0m SHA256 checksum verified`,
855
1090
  ]);
856
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
+
857
1124
  info('Extracting release...');
858
1125
  const sourceDir = path.join(tmpDir, 'source');
859
1126
  fs.mkdirSync(sourceDir, { recursive: true });
@@ -933,6 +1200,7 @@ module.exports = {
933
1200
  resolveInstallMode,
934
1201
  askYesNo,
935
1202
  parseChecksums,
1203
+ enforceDownloadedSize,
936
1204
  extractTarball,
937
1205
  rejectLinkEntries,
938
1206
  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.4.4",
4
4
  "description": "One-command installer for iterate-skill across AI coding assistants",
5
5
  "main": "lib/installer.js",
6
6
  "files": [