iterate-skill-installer 3.5.2 → 3.5.3

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
@@ -42,28 +42,48 @@ Examples:
42
42
  }
43
43
 
44
44
  async function run() {
45
- const options = parseArgs(process.argv.slice(2));
45
+ let options;
46
+ try {
47
+ options = parseArgs(process.argv.slice(2));
48
+ } catch (err) {
49
+ // Hard argument errors are THROWN by parseArgs (library code never calls
50
+ // process.exit); report them cleanly at this boundary.
51
+ console.error(`Error: ${err.message}`);
52
+ return 1;
53
+ }
46
54
 
47
55
  if (options.mode === 'help') {
48
56
  printHelp();
49
- process.exit(0);
57
+ return 0;
50
58
  }
51
59
 
52
60
  if (options.mode === 'version') {
53
61
  console.log(`iterate-skill-installer ${VERSION}`);
54
- process.exit(0);
62
+ return 0;
55
63
  }
56
64
 
57
65
  try {
58
66
  const code = await main(options);
59
67
  // Normalize: a null/non-numeric code (main should never produce one, but a
60
68
  // 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);
69
+ // the success exit 0 that a null exit code would yield.
70
+ return typeof code === 'number' ? code : 1;
63
71
  } catch (err) {
64
72
  console.error(`\nUnexpected error: ${err.message}`);
65
- process.exit(1);
73
+ return 1;
66
74
  }
67
75
  }
68
76
 
69
- run();
77
+ // CLI boundary: set process.exitCode rather than calling process.exit(), so
78
+ // buffered stdout/stderr still flush and no caller cleanup is skipped. The
79
+ // trailing catch is the top-level net: an unexpected rejection (e.g. a throw
80
+ // before run()'s inner try) becomes a clean message + non-zero code, never an
81
+ // unhandled-rejection stack trace.
82
+ run()
83
+ .then((code) => {
84
+ process.exitCode = typeof code === 'number' ? code : 1;
85
+ })
86
+ .catch((err) => {
87
+ console.error(`\nUnexpected error: ${err && err.message ? err.message : err}`);
88
+ process.exitCode = 1;
89
+ });
package/lib/installer.js CHANGED
@@ -339,9 +339,14 @@ function validateTokenShape(token) {
339
339
  * loudly instead of leaking the PAT to a foreign host.
340
340
  */
341
341
  function buildAuthFlags(url, token) {
342
- if (!token) return [];
342
+ // Re-normalize here as well as at the CLI boundary: this is the exact point
343
+ // the token is interpolated into a header, so a raw CR/LF (e.g. a token read
344
+ // from a Windows-created file) must never reach curl — an embedded newline
345
+ // would split the header (CRLF/header injection) or silently break auth.
346
+ const clean = normalizeToken(token);
347
+ if (!clean) return [];
343
348
  if (!isGithubApiUrl(url)) return [];
344
- return ['-H', `Authorization: Bearer ${token}`, '--max-redirs', '0'];
349
+ return ['-H', `Authorization: Bearer ${clean}`, '--max-redirs', '0'];
345
350
  }
346
351
 
347
352
  async function fetchJson(url, token) {
@@ -349,7 +354,10 @@ async function fetchJson(url, token) {
349
354
  // and avoids Node-specific certificate issues in some environments.
350
355
  const failFlag = (await supportsCurlFailWithBody()) ? '--fail-with-body' : '--fail';
351
356
  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)];
352
- args.push(url);
357
+ // `--` ends curl's option parsing so a URL that begins with `-` (e.g. a
358
+ // hostile/odd `browser_download_url` served by the API) is treated as a URL,
359
+ // never smuggled in as a curl flag.
360
+ args.push('--', url);
353
361
  // stdoutMustBeComplete with an explicit cap: the default 1 MiB TailBuffer
354
362
  // would truncate a large /releases payload and JSON.parse then failed with a
355
363
  // baffling "Unexpected end of JSON input". The cap now matches the
@@ -381,7 +389,9 @@ async function downloadFile(url, destPath, token, { progress = false, maxBytes =
381
389
  const failFlag = (await supportsCurlFailWithBody()) ? '--fail-with-body' : '--fail';
382
390
  const args = ['-sSL', failFlag, '--max-time', CURL_MAX_TIME_SECONDS, '--max-filesize', String(maxBytes), '-o', destPath, '-H', 'User-Agent: iterate-skill-installer', ...buildAuthFlags(url, token)];
383
391
  if (progress) args.push('--progress-bar');
384
- args.push(url);
392
+ // `--` ends curl's option parsing so a `-`-leading URL can never be read as
393
+ // a curl flag (option injection); it is always the final positional URL.
394
+ args.push('--', url);
385
395
  if (progress) {
386
396
  // Let curl write its progress bar directly to the terminal.
387
397
  await runCommand('curl', args, { stdio: ['ignore', 'ignore', 'inherit'] });
@@ -429,8 +439,16 @@ function enforceDownloadedSize(destPath, maxBytes) {
429
439
  // project config and shows up in the invoking environment for audit.
430
440
  const ALLOW_UNSIGNED_ENV = 'ITERATE_ALLOW_UNSIGNED';
431
441
 
442
+ // Only these (case-insensitive) values enable the override. A bare "any
443
+ // non-empty string" check meant `ITERATE_ALLOW_UNSIGNED=0` (or "false", or a
444
+ // stray "no") ENABLED unsigned installs — the exact opposite of what the
445
+ // operator intended, and a silent downgrade of the authenticity gate.
446
+ const ALLOW_UNSIGNED_TRUTHY = new Set(['1', 'true', 'yes']);
447
+
432
448
  function allowUnsigned() {
433
- return String(process.env[ALLOW_UNSIGNED_ENV] || '').trim() !== '';
449
+ const raw = process.env[ALLOW_UNSIGNED_ENV];
450
+ if (raw == null) return false;
451
+ return ALLOW_UNSIGNED_TRUTHY.has(String(raw).trim().toLowerCase());
434
452
  }
435
453
 
436
454
  /**
@@ -499,6 +517,31 @@ function sha256File(filePath) {
499
517
  });
500
518
  }
501
519
 
520
+ /**
521
+ * Constant-time, case-insensitive comparison of two SHA256 hex digests.
522
+ *
523
+ * Casing must never decide the outcome: `sha256sum`/other tools may emit
524
+ * upper-case hex, and a case-sensitive `===` would reject a perfectly good
525
+ * release. Both sides are lower-cased and compared with `timingSafeEqual` —
526
+ * the expected hash travels over the network beside the tarball, so an
527
+ * early-exit string inequality would leak prefix information usable to forge a
528
+ * checksum. The length guard keeps `timingSafeEqual` from throwing on
529
+ * mismatched lengths (an empty/short value is refused outright).
530
+ *
531
+ * Exported so the comparison itself is unit-testable (the inline version it
532
+ * replaces could only be exercised by a full network install).
533
+ */
534
+ function constantTimeHashEqual(actualHash, expectedHash) {
535
+ if (typeof actualHash !== 'string' || typeof expectedHash !== 'string') return false;
536
+ const actualBuf = Buffer.from(actualHash.trim().toLowerCase(), 'utf8');
537
+ const expectedBuf = Buffer.from(expectedHash.trim().toLowerCase(), 'utf8');
538
+ return (
539
+ actualBuf.length === expectedBuf.length &&
540
+ actualBuf.length > 0 &&
541
+ crypto.timingSafeEqual(actualBuf, expectedBuf)
542
+ );
543
+ }
544
+
502
545
  // Best-effort GPG authenticity verification of a downloaded artifact against
503
546
  // the embedded signing key. Returns one of:
504
547
  // 'verified' — `gpg` present and the detached signature is valid.
@@ -747,77 +790,118 @@ function runCommand(bin, args, options = {}) {
747
790
 
748
791
  async function rejectLinkEntries(tarballPath, { listingCap = MAX_LISTING_CHARS } = {}) {
749
792
  // Inspect the verbose listing and reject any symbolic ("l") or hard ("h")
750
- // link entry. We deliberately reject *all* links rather than validating
751
- // their targets: the release tarball contains none, so any link is a sign
752
- // of a tampered archive and refusing it outright is the safest response to
753
- // a symlink-escape attempt (see extractTarball above).
793
+ // link entry — and any device ("b" block / "c" character), FIFO ("p") or
794
+ // socket special node. We deliberately reject *all* links rather than
795
+ // validating their targets: the release tarball contains none, so any link is
796
+ // a sign of a tampered archive and refusing it outright is the safest
797
+ // response to a symlink-escape attempt (see extractTarball above). Special
798
+ // nodes are rejected for the same reason: neither the release nor its
799
+ // installer ever ships one, and a FIFO/device could wedge or bypass a later
800
+ // read of the extracted tree.
754
801
  //
755
802
  // stdoutMustBeComplete: this listing is a SECURITY input. The old 1 MiB
756
803
  // tail buffer silently dropped the OLDEST entries, so an archive with enough
757
804
  // filler members to push its listing past the cap hid a symlink/hardlink
758
805
  // entry from this scan entirely (while `tar -xzf` still extracted it).
759
806
  // A listing over the cap now fails closed instead.
807
+ //
808
+ // Returns the COMPLETE verbose listing so callers (extractTarball) can reuse
809
+ // it for the pre-extraction declared-size check without a second tar run.
760
810
  const verbose = await runCommand('tar', ['-tvzf', tarballPath], {
761
811
  stdoutCap: listingCap,
762
812
  stdoutMustBeComplete: true,
763
813
  });
814
+ const TYPE_LABELS = {
815
+ l: 'symbolic link',
816
+ h: 'hard link',
817
+ b: 'block device node',
818
+ c: 'character device node',
819
+ p: 'FIFO',
820
+ s: 'socket',
821
+ };
764
822
  for (const line of verbose.split(/\r?\n/)) {
765
823
  if (!line) {
766
824
  continue;
767
825
  }
768
826
  const type = line[0];
769
- if (type === 'l') {
770
- throw new InstallerError(
771
- 'Refusing to extract tarball: archive contains a symbolic link, which the release never ships.',
772
- );
773
- }
774
- if (type === 'h') {
827
+ const label = TYPE_LABELS[type];
828
+ if (label) {
775
829
  throw new InstallerError(
776
- 'Refusing to extract tarball: archive contains a hard link, which the release never ships.',
830
+ `Refusing to extract tarball: archive contains a ${label}, which the release never ships.`,
777
831
  );
778
832
  }
779
833
  }
834
+ return verbose;
780
835
  }
781
836
 
782
- async function extractTarball(tarballPath, destDir, { listingCap = MAX_LISTING_CHARS } = {}) {
783
- // Use the system tar command (available on macOS, Linux, and Windows 10+),
784
- // but defend against path-traversal tarballs: before extracting we list the
785
- // archive contents and refuse to extract any entry whose resolved path
786
- // escapes destDir (e.g. a "../" prefix). This keeps the installer
787
- // dependency-free while closing the theoretical escape vector.
788
- //
789
- // Extraction uses --strip-components=1, which relies on the archive having
790
- // exactly one top-level directory (see README "Release tarball structure").
791
- // Archives that violate that contract — multiple top-level directories, or
792
- // entries with no path left after stripping — are rejected outright.
793
- //
794
- // A name-based `../` check alone is not enough: a tarball can smuggle a
795
- // symlink whose in-archive name looks safe but whose target points outside
796
- // destDir, then place files "through" that symlink (e.g. `link/passwd` where
797
- // `link -> /etc`). Because extraction happens with a system `tar`, the write
798
- // would follow the link before we could validate — so any symbolic or hard
799
- // link is rejected *before* extraction. The release tarball never uses links.
800
- const absoluteDest = path.resolve(destDir);
801
- fs.mkdirSync(absoluteDest, { recursive: true });
837
+ // A month abbreviation ("Jan".."Dec") appears in the timestamp column of the
838
+ // bsdtar verbose listing; GNU tar prints an ISO date ("2023-01-01"). Both mark
839
+ // the end of the size/user fields, so the numeric token immediately before the
840
+ // first such token is the entry's declared uncompressed size in EITHER format.
841
+ const MONTH_ABBR_RE = /^(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)$/;
842
+ const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
802
843
 
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
- });
810
- const entries = listing.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
811
- if (entries.length === 0) {
812
- throw new InstallerError('Refusing to extract tarball: archive contains no entries.');
844
+ /**
845
+ * Parse one `tar -tvzf` line's declared uncompressed size, or null when the
846
+ * line cannot be parsed. `size` is the numeric field immediately preceding the
847
+ * timestamp column (see MONTH_ABBR_RE / ISO_DATE_RE).
848
+ */
849
+ function declaredEntrySize(line) {
850
+ const tokens = line.trim().split(/\s+/);
851
+ for (let i = 1; i < tokens.length; i += 1) {
852
+ if (ISO_DATE_RE.test(tokens[i]) || MONTH_ABBR_RE.test(tokens[i])) {
853
+ const candidate = tokens[i - 1];
854
+ return /^\d+$/.test(candidate) ? Number(candidate) : null;
855
+ }
856
+ }
857
+ return null;
858
+ }
859
+
860
+ /**
861
+ * Sum the declared uncompressed sizes in a `tar -tvzf` listing.
862
+ *
863
+ * This is the PRE-extraction half of the decompression-bomb guard: `tar -xzf`
864
+ * has no size bound and the download cap only limits the COMPRESSED size, so a
865
+ * small highly-compressible archive can declare (and would expand to) many GiB.
866
+ * Reading the declared sizes out of the listing lets us refuse BEFORE writing
867
+ * anything to disk. Returns the total plus how many entries were parsed so a
868
+ * caller can distinguish "empty" from "unparseable format".
869
+ */
870
+ function sumDeclaredSizes(verboseListing) {
871
+ let total = 0;
872
+ let parsed = 0;
873
+ let entries = 0;
874
+ for (const line of verboseListing.split(/\r?\n/)) {
875
+ if (!line.trim()) continue;
876
+ entries += 1;
877
+ const size = declaredEntrySize(line);
878
+ if (size !== null) {
879
+ total += size;
880
+ parsed += 1;
881
+ }
813
882
  }
883
+ return { total, parsed, entries };
884
+ }
814
885
 
815
- // 1a. Reject absolute-path entries outright before any other accounting.
816
- // A member named ``/etc/cron.d/x`` or ``C:\\...`` would resolve through the
817
- // top-level-strip logic into destDir, but accepting its name as a "safe"
818
- // component invites ambiguity between tar implementations about what the
819
- // first component really is — never claim a release archive can contain
820
- // absolute paths, so fail closed instead.
886
+ /**
887
+ * Validate every archive member NAME before extraction.
888
+ *
889
+ * Rejects, in order:
890
+ * - absolute paths (`/etc/...`, `C:\...`), which invite ambiguity between
891
+ * tar implementations about what the "first component" is;
892
+ * - any `..` path component (path traversal), which — even when the
893
+ * top-level-strip logic happens to keep it inside destDir — violates the
894
+ * release's "single top-level directory" contract and must never appear;
895
+ * - archives without exactly one top-level directory (required by
896
+ * --strip-components=1);
897
+ * - entries that still escape destDir after the top-level component is
898
+ * stripped.
899
+ *
900
+ * Pure and exported so the guards are unit-testable without crafting hostile
901
+ * tarballs. Returns the resolved top-level directory name.
902
+ */
903
+ function validateEntryNames(entries, absoluteDest) {
904
+ // Reject absolute-path entries outright before any other accounting.
821
905
  for (const entry of entries) {
822
906
  if (entry.startsWith('/') || /^[A-Za-z]:[\\/]/.test(entry)) {
823
907
  throw new InstallerError(
@@ -826,15 +910,20 @@ async function extractTarball(tarballPath, destDir, { listingCap = MAX_LISTING_C
826
910
  }
827
911
  }
828
912
 
829
- // 1b. Reject any symbolic or hard link entry before touching the tree.
830
- // The verbose listing's first character is the entry type for both GNU tar
831
- // and bsdtar (macOS): '-' regular, 'd' directory, 'l' symbolic link, 'h'
832
- // hard link. We only need to detect links, so a cheap first-char scan is
833
- // sufficient and avoids relying on the rest of the format.
834
- await rejectLinkEntries(tarballPath, { listingCap });
913
+ // Reject any ``..`` component. A name-based resolve() check below already
914
+ // catches escapes, but an explicit rejection is unambiguous and also refuses
915
+ // the "stripped away" cases (e.g. a lone `..` top-level) that resolve back
916
+ // inside destDir while still violating the archive contract.
917
+ for (const entry of entries) {
918
+ if (entry.split('/').includes('..')) {
919
+ throw new InstallerError(
920
+ `Refusing to extract tarball: entry "${entry}" contains a ".." path component.`,
921
+ );
922
+ }
923
+ }
835
924
 
836
- // 2. Enforce the single-top-level-directory contract required by
837
- // --strip-components=1.
925
+ // Enforce the single-top-level-directory contract required by
926
+ // --strip-components=1.
838
927
  const topLevelComponents = new Set(entries.map((entry) => entry.split('/')[0]));
839
928
  if (topLevelComponents.size !== 1) {
840
929
  const tops = [...topLevelComponents].join(', ');
@@ -844,8 +933,8 @@ async function extractTarball(tarballPath, destDir, { listingCap = MAX_LISTING_C
844
933
  }
845
934
  const topLevel = entries[0].split('/')[0];
846
935
 
847
- // 3. Validate every entry stays inside destDir after stripping the single
848
- // top-level directory component (--strip-components=1).
936
+ // Validate every entry stays inside destDir after stripping the single
937
+ // top-level directory component (--strip-components=1).
849
938
  for (const entry of entries) {
850
939
  const stripped = entry.split('/').slice(1).join('/');
851
940
  if (stripped === '') {
@@ -867,7 +956,73 @@ async function extractTarball(tarballPath, destDir, { listingCap = MAX_LISTING_C
867
956
  }
868
957
  }
869
958
 
959
+ return topLevel;
960
+ }
961
+
962
+ async function extractTarball(
963
+ tarballPath,
964
+ destDir,
965
+ { listingCap = MAX_LISTING_CHARS, declaredSizeCap = MAX_EXTRACTED_BYTES } = {},
966
+ ) {
967
+ // Use the system tar command (available on macOS, Linux, and Windows 10+),
968
+ // but defend against path-traversal tarballs: before extracting we list the
969
+ // archive contents and refuse to extract any entry whose resolved path
970
+ // escapes destDir (e.g. a "../" prefix). This keeps the installer
971
+ // dependency-free while closing the theoretical escape vector.
972
+ //
973
+ // Extraction uses --strip-components=1, which relies on the archive having
974
+ // exactly one top-level directory (see README "Release tarball structure").
975
+ // Archives that violate that contract — multiple top-level directories, or
976
+ // entries with no path left after stripping — are rejected outright.
977
+ //
978
+ // A name-based `../` check alone is not enough: a tarball can smuggle a
979
+ // symlink whose in-archive name looks safe but whose target points outside
980
+ // destDir, then place files "through" that symlink (e.g. `link/passwd` where
981
+ // `link -> /etc`). Because extraction happens with a system `tar`, the write
982
+ // would follow the link before we could validate — so any symbolic or hard
983
+ // link is rejected *before* extraction. The release tarball never uses links.
984
+ // Note: dest is created AFTER every guard below (not before), so a refused
985
+ // archive never leaves a directory behind. `tar -xzf` requires the target to
986
+ // exist, so mkdir stays inside this function rather than with the caller.
987
+ const absoluteDest = path.resolve(destDir);
988
+
989
+ // 1. List archive entries (no extraction yet). Must be complete — see
990
+ // rejectLinkEntries: every guard below reads THIS listing, and a tail
991
+ // buffer would let a big archive hide entries from all of them.
992
+ const listing = await runCommand('tar', ['-tzf', tarballPath], {
993
+ stdoutCap: listingCap,
994
+ stdoutMustBeComplete: true,
995
+ });
996
+ const entries = listing.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
997
+ if (entries.length === 0) {
998
+ throw new InstallerError('Refusing to extract tarball: archive contains no entries.');
999
+ }
1000
+
1001
+ // 1a/2/3. Reject absolute paths and `..` components, enforce the single
1002
+ // top-level directory, and prove every entry stays inside destDir.
1003
+ validateEntryNames(entries, absoluteDest);
1004
+
1005
+ // 1b. Reject any symbolic/hard link — and any device/FIFO/socket node —
1006
+ // before touching the tree. The verbose listing's first character is the
1007
+ // entry type for both GNU tar and bsdtar (macOS): '-' regular, 'd' directory,
1008
+ // 'l' symbolic link, 'h' hard link, 'b'/'c' device, 'p' FIFO. The COMPLETE
1009
+ // listing is returned so the declared-size check below can reuse it.
1010
+ const verbose = await rejectLinkEntries(tarballPath, { listingCap });
1011
+
1012
+ // 1c. PRE-extraction decompression-bomb check: refuse an archive whose
1013
+ // declared uncompressed total exceeds the budget, using the sizes from
1014
+ // the listing we already parsed. This fails before a single byte is
1015
+ // written, complementing the post-extraction re-measure in step 5.
1016
+ const declared = sumDeclaredSizes(verbose);
1017
+ if (declared.total > declaredSizeCap) {
1018
+ throw new InstallerError(
1019
+ `Refusing to extract tarball: declared uncompressed size ${declared.total} bytes ` +
1020
+ `exceeds the safety budget (${declaredSizeCap} bytes).`,
1021
+ );
1022
+ }
1023
+
870
1024
  // 4. Now extract safely — the content has been validated above.
1025
+ fs.mkdirSync(absoluteDest, { recursive: true });
871
1026
  await runCommand('tar', ['-xzf', tarballPath, '-C', absoluteDest, '--strip-components=1']);
872
1027
 
873
1028
  // 5. Decompression-bomb guard (mirrors scripts/install.py). ``tar`` itself
@@ -1164,8 +1319,10 @@ function buildPythonInstallArgs(options) {
1164
1319
  *
1165
1320
  * Kept as a pure function (exported) so the flag surface — including the
1166
1321
  * skill-only ``--no-cli`` switch — can be unit-tested without spawning the
1167
- * full download-and-install pipeline. ``process.exit`` is only reached for
1168
- * hard argument errors (e.g. a value-consuming flag missing its value).
1322
+ * full download-and-install pipeline. Hard argument errors (a value-consuming
1323
+ * flag missing its value, an unknown option) THROW an ``InstallerError`` for
1324
+ * the CLI boundary to report; library code must never call ``process.exit``
1325
+ * (which would skip the caller's cleanup/flush and is untestable in-process).
1169
1326
  */
1170
1327
  function parseArgs(argv) {
1171
1328
  const options = {
@@ -1203,16 +1360,14 @@ function parseArgs(argv) {
1203
1360
  switch (arg) {
1204
1361
  case '--ai':
1205
1362
  if (!next || next.startsWith('-')) {
1206
- console.error('Error: --ai requires a value (a flag was found instead)');
1207
- process.exit(1);
1363
+ throw new InstallerError('--ai requires a value (a flag was found instead)');
1208
1364
  }
1209
1365
  options.ai = next;
1210
1366
  i++;
1211
1367
  break;
1212
1368
  case '--target':
1213
1369
  if (!next || next.startsWith('-')) {
1214
- console.error('Error: --target requires a value (a flag was found instead)');
1215
- process.exit(1);
1370
+ throw new InstallerError('--target requires a value (a flag was found instead)');
1216
1371
  }
1217
1372
  if (options.globalExplicit) {
1218
1373
  warning('--target overrides --global: installing into the project directory instead.');
@@ -1240,16 +1395,14 @@ function parseArgs(argv) {
1240
1395
  break;
1241
1396
  case '--token':
1242
1397
  if (!next || next.startsWith('-')) {
1243
- console.error('Error: --token requires a value (a flag was found instead)');
1244
- process.exit(1);
1398
+ throw new InstallerError('--token requires a value (a flag was found instead)');
1245
1399
  }
1246
1400
  options.token = normalizeToken(next);
1247
1401
  i++;
1248
1402
  break;
1249
1403
  default:
1250
1404
  if (arg.startsWith('-')) {
1251
- console.error(`Error: unknown option ${arg}`);
1252
- process.exit(1);
1405
+ throw new InstallerError(`unknown option ${arg}`);
1253
1406
  }
1254
1407
  // A bare positional argument is not a valid installer flag; warn so a
1255
1408
  // typo like "npx iterate-skill-installer trae" is not silently ignored.
@@ -1416,16 +1569,8 @@ async function main(options = {}) {
1416
1569
  return 1;
1417
1570
  }
1418
1571
  const actualHash = await sha256File(tarballPath);
1419
- // Constant-time compare (timingSafeEqual): the expected hash travels
1420
- // over the network with the tarball, so an early-exit string inequality
1421
- // would leak prefix information usable to forge a checksum. The length
1422
- // guard keeps timingSafeEqual from throwing on mismatched lengths.
1423
- const actualBuf = Buffer.from(actualHash.toLowerCase(), 'utf8');
1424
- const expectedBuf = Buffer.from(expectedHash.toLowerCase(), 'utf8');
1425
- const hashMatches =
1426
- actualBuf.length === expectedBuf.length &&
1427
- actualBuf.length > 0 &&
1428
- crypto.timingSafeEqual(actualBuf, expectedBuf);
1572
+ // Constant-time, case-insensitive compare (see constantTimeHashEqual).
1573
+ const hashMatches = constantTimeHashEqual(actualHash, expectedHash);
1429
1574
  if (!hashMatches) {
1430
1575
  error('Checksum mismatch — refusing to install.');
1431
1576
  return 1;
@@ -1558,9 +1703,13 @@ module.exports = {
1558
1703
  resolveInstallMode,
1559
1704
  askYesNo,
1560
1705
  parseChecksums,
1706
+ constantTimeHashEqual,
1561
1707
  enforceDownloadedSize,
1562
1708
  extractTarball,
1563
1709
  rejectLinkEntries,
1710
+ sumDeclaredSizes,
1711
+ declaredEntrySize,
1712
+ validateEntryNames,
1564
1713
  isGithubApiUrl,
1565
1714
  normalizeToken,
1566
1715
  validateTokenShape,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "iterate-skill-installer",
3
- "version": "3.5.2",
3
+ "version": "3.5.3",
4
4
  "description": "One-command installer for iterate-skill across AI coding assistants",
5
5
  "main": "lib/installer.js",
6
6
  "files": [