iterate-skill-installer 3.5.0 → 3.5.1

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 +275 -34
  2. package/package.json +2 -2
package/lib/installer.js CHANGED
@@ -67,6 +67,23 @@ const JSON_MAX_BYTES = 10 * 1024 * 1024; // 10 MiB
67
67
  const CHECKSUMS_MAX_BYTES = 1 * 1024 * 1024; // 1 MiB
68
68
  const TARBALL_MAX_BYTES = 100 * 1024 * 1024; // 100 MiB
69
69
 
70
+ // Hard cap on tarball LISTING output (``tar -tzf`` / ``tar -tvzf``). The
71
+ // listing must be seen IN FULL: the link-rejection, single-top-level-directory
72
+ // and path-escape guards in extractTarball are meaningless on a truncated view
73
+ // (a 1 MiB TailBuffer used to drop the OLDEST entries, exactly where a tampered
74
+ // archive hides its symlink). 16 MiB is far beyond the real release (a few
75
+ // hundred names) yet still bounded; exceeding it is fail-closed, never a
76
+ // silent truncation.
77
+ const MAX_LISTING_CHARS = 16 * 1024 * 1024;
78
+
79
+ // Post-extraction size budget, mirroring scripts/install.py (which enforces it
80
+ // during extraction). JS extraction shells out to ``tar -xzf``, which has no
81
+ // such bound, so a highly compressed "decompression bomb" could expand to
82
+ // many GiB on disk before anything noticed. The extracted tree is re-measured
83
+ // and removed when it exceeds these ceilings.
84
+ const MAX_EXTRACTED_BYTES = 500 * 1024 * 1024; // 500 MiB total
85
+ const MAX_EXTRACTED_MEMBER_BYTES = 256 * 1024 * 1024; // 256 MiB per file
86
+
70
87
  // A strict upper bound for an interactive yes/no prompt. On EOF (piped stdin
71
88
  // that ends) or a TTY that stops responding, the prompt must fall back to the
72
89
  // default rather than hang the installer forever.
@@ -284,6 +301,32 @@ function normalizeToken(raw) {
284
301
  return trimmed === '' ? null : trimmed;
285
302
  }
286
303
 
304
+ // GitHub PAT prefixes accepted by scripts/install.py (kept in sync so the two
305
+ // installers agree about what a "malformed token" is). A typo'd token used to
306
+ // be sent verbatim and surfaced only as a confusing 401/403 later.
307
+ const GITHUB_TOKEN_PREFIXES = ['ghp_', 'gho_', 'ghu_', 'ghs_', 'ghr_', 'github_pat_'];
308
+
309
+ /**
310
+ * Return an error message when ``token`` cannot be a GitHub PAT, else null.
311
+ * Mirrors install.py's ``_validate_github_token`` (length + prefix whitelist).
312
+ */
313
+ function validateTokenShape(token) {
314
+ if (!token) return null;
315
+ if (token.length < 20 || token.length > 256) {
316
+ return (
317
+ 'GitHub token looks malformed: expected a PAT (starts with ghp_/gho_/github_pat_ etc.) ' +
318
+ 'of ~40-256 chars, got a very different length.'
319
+ );
320
+ }
321
+ if (!GITHUB_TOKEN_PREFIXES.some((prefix) => token.startsWith(prefix))) {
322
+ return (
323
+ 'GitHub token does not look valid: expected a Personal Access Token starting with ' +
324
+ 'one of (ghp_, gho_, ghu_, ghs_, ghr_, github_pat_). Double-check the --token value.'
325
+ );
326
+ }
327
+ return null;
328
+ }
329
+
287
330
  /**
288
331
  * Compose the curl flags that attach the caller's PAT — and only those. The
289
332
  * token is never attached to a non-API host; and marking it up differently
@@ -307,7 +350,14 @@ async function fetchJson(url, token) {
307
350
  const failFlag = (await supportsCurlFailWithBody()) ? '--fail-with-body' : '--fail';
308
351
  const args = ['-sSL', failFlag, '--max-time', CURL_MAX_TIME_SECONDS, '--max-filesize', String(JSON_MAX_BYTES), '-H', 'Accept: application/vnd.github+json', '-H', 'X-GitHub-Api-Version: 2022-11-28', '-H', 'User-Agent: iterate-skill-installer', ...buildAuthFlags(url, token)];
309
352
  args.push(url);
310
- const stdout = await runCommand('curl', args);
353
+ // stdoutMustBeComplete with an explicit cap: the default 1 MiB TailBuffer
354
+ // would truncate a large /releases payload and JSON.parse then failed with a
355
+ // baffling "Unexpected end of JSON input". The cap now matches the
356
+ // --max-filesize ceiling and an oversized body reports itself as such.
357
+ const stdout = await runCommand('curl', args, {
358
+ stdoutCap: JSON_MAX_BYTES,
359
+ stdoutMustBeComplete: true,
360
+ });
311
361
  try {
312
362
  return JSON.parse(stdout);
313
363
  } catch (err) {
@@ -487,6 +537,33 @@ class TailBuffer {
487
537
  }
488
538
  }
489
539
 
540
+ /**
541
+ * Fail-closed output buffer for output that must be observed COMPLETELY
542
+ * (tarball listings, API JSON). Unlike TailBuffer — which keeps the newest
543
+ * text and is right for diagnostics — this one records that the cap was
544
+ * exceeded instead of discarding the oldest content, because a partially seen
545
+ * SECURITY listing is worse than no listing at all.
546
+ */
547
+ class FullBuffer {
548
+ constructor(cap) {
549
+ this.cap = cap;
550
+ this.text = '';
551
+ this.overflowed = false;
552
+ }
553
+ push(chunk) {
554
+ if (!chunk || this.overflowed) return;
555
+ if (this.text.length + chunk.length > this.cap) {
556
+ this.overflowed = true;
557
+ this.text = (this.text + chunk).slice(0, this.cap);
558
+ return;
559
+ }
560
+ this.text += chunk;
561
+ }
562
+ toString() {
563
+ return this.text;
564
+ }
565
+ }
566
+
490
567
  // Default ceiling for a single child command: 10 minutes then SIGKILL. Long
491
568
  // operations in this file (pip/pipx installs, venv creation) can legitimately
492
569
  // take minutes on slow networks, but a wedged child must never hang the
@@ -527,13 +604,23 @@ function killProcessTree(child) {
527
604
  }
528
605
 
529
606
  function runCommand(bin, args, options = {}) {
530
- const { timeout = DEFAULT_COMMAND_TIMEOUT_MS, ...spawnOptions } = options;
607
+ const {
608
+ timeout = DEFAULT_COMMAND_TIMEOUT_MS,
609
+ // Cap on retained stdout. Diagnostics want a TAIL (newest text); security
610
+ // listings / parseable payloads want the WHOLE thing or nothing, which is
611
+ // what ``stdoutMustBeComplete`` switches on.
612
+ stdoutCap = MAX_CAPTURED_OUTPUT_CHARS,
613
+ stdoutMustBeComplete = false,
614
+ ...spawnOptions
615
+ } = options;
531
616
  return new Promise((resolve, reject) => {
532
617
  // `detached` on POSIX puts the child in its own process group so the
533
618
  // timeout can kill the entire tree, not just the leader. It does not
534
619
  // change how we await the child (we never unref it).
535
620
  const child = spawn(bin, args, { stdio: 'pipe', detached: IS_POSIX, ...spawnOptions });
536
- const stdoutTail = new TailBuffer(MAX_CAPTURED_OUTPUT_CHARS);
621
+ const stdoutTail = stdoutMustBeComplete
622
+ ? new FullBuffer(stdoutCap)
623
+ : new TailBuffer(stdoutCap);
537
624
  const stderrTail = new TailBuffer(MAX_CAPTURED_OUTPUT_CHARS);
538
625
  child.stdout?.on('data', (data) => stdoutTail.push(data.toString()));
539
626
  child.stderr?.on('data', (data) => stderrTail.push(data.toString()));
@@ -566,6 +653,15 @@ function runCommand(bin, args, options = {}) {
566
653
  return;
567
654
  }
568
655
  if (code === 0) {
656
+ if (stdoutMustBeComplete && stdoutTail.overflowed) {
657
+ settle(
658
+ reject,
659
+ new InstallerError(
660
+ `${bin} produced more than ${stdoutCap} characters of output; refusing to act on a truncated result.`,
661
+ ),
662
+ );
663
+ return;
664
+ }
569
665
  settle(resolve, stdoutTail.toString());
570
666
  } else {
571
667
  settle(reject, new InstallerError(`${bin} exited with ${code}: ${stderrTail.toString() || stdoutTail.toString()}`));
@@ -580,13 +676,22 @@ function runCommand(bin, args, options = {}) {
580
676
  });
581
677
  }
582
678
 
583
- async function rejectLinkEntries(tarballPath) {
679
+ async function rejectLinkEntries(tarballPath, { listingCap = MAX_LISTING_CHARS } = {}) {
584
680
  // Inspect the verbose listing and reject any symbolic ("l") or hard ("h")
585
681
  // link entry. We deliberately reject *all* links rather than validating
586
682
  // their targets: the release tarball contains none, so any link is a sign
587
683
  // of a tampered archive and refusing it outright is the safest response to
588
684
  // a symlink-escape attempt (see extractTarball above).
589
- const verbose = await runCommand('tar', ['-tvzf', tarballPath]);
685
+ //
686
+ // stdoutMustBeComplete: this listing is a SECURITY input. The old 1 MiB
687
+ // tail buffer silently dropped the OLDEST entries, so an archive with enough
688
+ // filler members to push its listing past the cap hid a symlink/hardlink
689
+ // entry from this scan entirely (while `tar -xzf` still extracted it).
690
+ // A listing over the cap now fails closed instead.
691
+ const verbose = await runCommand('tar', ['-tvzf', tarballPath], {
692
+ stdoutCap: listingCap,
693
+ stdoutMustBeComplete: true,
694
+ });
590
695
  for (const line of verbose.split(/\r?\n/)) {
591
696
  if (!line) {
592
697
  continue;
@@ -605,7 +710,7 @@ async function rejectLinkEntries(tarballPath) {
605
710
  }
606
711
  }
607
712
 
608
- async function extractTarball(tarballPath, destDir) {
713
+ async function extractTarball(tarballPath, destDir, { listingCap = MAX_LISTING_CHARS } = {}) {
609
714
  // Use the system tar command (available on macOS, Linux, and Windows 10+),
610
715
  // but defend against path-traversal tarballs: before extracting we list the
611
716
  // archive contents and refuse to extract any entry whose resolved path
@@ -626,8 +731,13 @@ async function extractTarball(tarballPath, destDir) {
626
731
  const absoluteDest = path.resolve(destDir);
627
732
  fs.mkdirSync(absoluteDest, { recursive: true });
628
733
 
629
- // 1. List archive entries (no extraction yet).
630
- const listing = await runCommand('tar', ['-tzf', tarballPath]);
734
+ // 1. List archive entries (no extraction yet). Must be complete — see
735
+ // rejectLinkEntries: every guard below reads THIS listing, and a tail
736
+ // buffer would let a big archive hide entries from all of them.
737
+ const listing = await runCommand('tar', ['-tzf', tarballPath], {
738
+ stdoutCap: listingCap,
739
+ stdoutMustBeComplete: true,
740
+ });
631
741
  const entries = listing.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
632
742
  if (entries.length === 0) {
633
743
  throw new InstallerError('Refusing to extract tarball: archive contains no entries.');
@@ -652,7 +762,7 @@ async function extractTarball(tarballPath, destDir) {
652
762
  // and bsdtar (macOS): '-' regular, 'd' directory, 'l' symbolic link, 'h'
653
763
  // hard link. We only need to detect links, so a cheap first-char scan is
654
764
  // sufficient and avoids relying on the rest of the format.
655
- await rejectLinkEntries(tarballPath);
765
+ await rejectLinkEntries(tarballPath, { listingCap });
656
766
 
657
767
  // 2. Enforce the single-top-level-directory contract required by
658
768
  // --strip-components=1.
@@ -690,6 +800,77 @@ async function extractTarball(tarballPath, destDir) {
690
800
 
691
801
  // 4. Now extract safely — the content has been validated above.
692
802
  await runCommand('tar', ['-xzf', tarballPath, '-C', absoluteDest, '--strip-components=1']);
803
+
804
+ // 5. Decompression-bomb guard (mirrors scripts/install.py). ``tar`` itself
805
+ // has no size bound, and ``--max-filesize`` on the download only limits
806
+ // the COMPRESSED size, so a small, highly compressible archive could have
807
+ // expanded to many GiB already. Re-measure the extracted tree and fail
808
+ // closed (removing it) when it exceeds the budget.
809
+ enforceExtractedSize(absoluteDest);
810
+ }
811
+
812
+ /**
813
+ * Fail closed when an extracted tree exceeds the size budget.
814
+ *
815
+ * Separate, injectable-budget helper so the bomb guard is unit-testable
816
+ * without writing 500 MiB to disk. Removes ``root`` on violation.
817
+ */
818
+ function enforceExtractedSize(
819
+ root,
820
+ { maxTotal = MAX_EXTRACTED_BYTES, maxMember = MAX_EXTRACTED_MEMBER_BYTES } = {},
821
+ ) {
822
+ const extracted = measureTree(root);
823
+ if (extracted.total > maxTotal || extracted.largest > maxMember) {
824
+ try {
825
+ fs.rmSync(root, { recursive: true, force: true });
826
+ } catch {
827
+ // Best effort: the size violation below is the actionable message.
828
+ }
829
+ throw new InstallerError(
830
+ `Refusing to keep extracted tarball: expanded size ${extracted.total} bytes ` +
831
+ `(largest member ${extracted.largest} bytes) exceeds the safety budget ` +
832
+ `(${maxTotal} total / ${maxMember} per file).`,
833
+ );
834
+ }
835
+ return extracted;
836
+ }
837
+
838
+ /**
839
+ * Total and largest-file sizes of a directory tree.
840
+ *
841
+ * Symlinks are skipped (``lstat``): following them could walk outside the
842
+ * extraction directory, and the release ships none (any link was already
843
+ * rejected before extraction).
844
+ */
845
+ function measureTree(root) {
846
+ let total = 0;
847
+ let largest = 0;
848
+ const stack = [root];
849
+ while (stack.length > 0) {
850
+ const current = stack.pop();
851
+ let entries;
852
+ try {
853
+ entries = fs.readdirSync(current, { withFileTypes: true });
854
+ } catch {
855
+ continue; // Unreadable entry: nothing to account for.
856
+ }
857
+ for (const entry of entries) {
858
+ const full = path.join(current, entry.name);
859
+ if (entry.isSymbolicLink()) continue;
860
+ if (entry.isDirectory()) {
861
+ stack.push(full);
862
+ continue;
863
+ }
864
+ try {
865
+ const size = fs.lstatSync(full).size;
866
+ total += size;
867
+ if (size > largest) largest = size;
868
+ } catch {
869
+ // Raced/removed entry: skip.
870
+ }
871
+ }
872
+ }
873
+ return { total, largest };
693
874
  }
694
875
 
695
876
  function runPythonInstall(pythonBin, installScript, args, options = {}) {
@@ -792,7 +973,11 @@ async function installCli(pythonBin, sourceDir) {
792
973
  }
793
974
  } catch (err) {
794
975
  warning(`Could not install iterate CLI: ${err.message}`);
795
- hint('Install it later with: pipx install <repo> or pip install .');
976
+ // Not on PyPI: the only public install paths are the repo (or its
977
+ // release source dir) — the old "pipx install <repo>" placeholder was not
978
+ // copy-pasteable at all.
979
+ hint('Install it later with: pipx install "git+https://github.com/jingzhao-l/iterate-skill.git"');
980
+ hint(' (or, from a downloaded release/repo root: pipx install . / pip install .)');
796
981
  return;
797
982
  }
798
983
 
@@ -854,8 +1039,14 @@ async function resolveInstallMode(options, ask = askYesNo) {
854
1039
  const cwd = process.cwd();
855
1040
  const home = os.homedir();
856
1041
  if (cwd !== home) {
1042
+ // defaultNo = true (kept as the behavior AND made visible in the prompt):
1043
+ // the question used to render "[Y/n]" while Enter, EOF and the timeout all
1044
+ // resolved to No — a user pressing Enter to accept was silently sent to a
1045
+ // global install instead. Now the prompt shows "[y/N]" and matches.
857
1046
  const answer = await ask(
858
- `当前目录 ${cwd} 看起来是项目目录,是否安装到此项目?(输入 n 则全局安装到用户目录)`,
1047
+ `Install into this project? / 当前目录 ${cwd} 看起来是项目目录,是否安装到此项目?` +
1048
+ ' (n = install globally to your home directory / 全局安装)',
1049
+ true,
859
1050
  );
860
1051
  if (answer) {
861
1052
  options.target = cwd;
@@ -1018,6 +1209,14 @@ async function main(options = {}) {
1018
1209
  noCli = false,
1019
1210
  } = options;
1020
1211
 
1212
+ // Fail fast on a malformed PAT (same rule as scripts/install.py): sending a
1213
+ // typo'd token produced a confusing 401/403 only AFTER the API round-trip.
1214
+ const tokenError = validateTokenShape(token);
1215
+ if (tokenError) {
1216
+ error(tokenError);
1217
+ return 1;
1218
+ }
1219
+
1021
1220
  step('Checking environment');
1022
1221
  const pythonBin = await findPython();
1023
1222
  if (!pythonBin) {
@@ -1045,17 +1244,37 @@ async function main(options = {}) {
1045
1244
  // that is a stable release carrying BOTH assets we need, so a release from
1046
1245
  // another sub-project (or one whose assets are still uploading) is skipped
1047
1246
  // 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;
1247
+ if (!Array.isArray(releases)) {
1248
+ // A non-array payload (an error object, a single release object) used to
1249
+ // be used AS the release and then failed with the misleading "Latest
1250
+ // release has no tag." — say what actually went wrong.
1251
+ error('GitHub API returned an unexpected payload (expected an array of releases).');
1252
+ hint('Retry, or set GITHUB_TOKEN if this is a rate-limit artefact.');
1253
+ return 1;
1254
+ }
1255
+ // Announce releases that carry the tarball but not the checksum pair: they
1256
+ // are skipped, which silently installs an OLDER release when the newest one
1257
+ // is mid-upload. Saying so out loud keeps "installed an old version" visible.
1258
+ for (const candidate of releases) {
1259
+ if (!candidate || candidate.draft || candidate.prerelease) continue;
1260
+ const assets = Array.isArray(candidate.assets) ? candidate.assets : [];
1261
+ const hasTarball = assets.some((a) => a && a.name === TARBALL_ASSET_NAME);
1262
+ const hasChecksums = assets.some((a) => a && a.name === CHECKSUMS_ASSET_NAME);
1263
+ if (hasTarball && !hasChecksums) {
1264
+ warning(
1265
+ `Skipping release ${candidate.tag_name}: has ${TARBALL_ASSET_NAME} but no ${CHECKSUMS_ASSET_NAME}.`,
1266
+ );
1267
+ }
1268
+ }
1269
+ const release = releases.find(
1270
+ (r) =>
1271
+ r &&
1272
+ !r.draft &&
1273
+ !r.prerelease &&
1274
+ Array.isArray(r.assets) &&
1275
+ r.assets.some((a) => a && a.name === TARBALL_ASSET_NAME) &&
1276
+ r.assets.some((a) => a && a.name === CHECKSUMS_ASSET_NAME),
1277
+ );
1059
1278
 
1060
1279
  if (!release) {
1061
1280
  error(
@@ -1139,14 +1358,19 @@ async function main(options = {}) {
1139
1358
  `\x1b[32m✓\x1b[0m SHA256 checksum verified`,
1140
1359
  ]);
1141
1360
 
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.
1361
+ // GPG authenticity check (defense-in-depth, fail-closed when it runs). The
1362
+ // release also publishes a detached ASCII-armored signature over the
1363
+ // tarball. When a `gpg` binary is present we verify it against the embedded
1364
+ // signing key: a *valid* signature upgrades the release from
1365
+ // "integrity-guaranteed" to "authenticity-guaranteed". If gpg is
1366
+ // unavailable we degrade to a warning (SHA256 already guards integrity —
1367
+ // that check still stands on its own).
1368
+ //
1369
+ // If gpg IS present and the signature is present but does NOT verify, the
1370
+ // install now FAILS. SHA256SUMS travels in the same release as the tarball,
1371
+ // so an attacker who can replace one can replace both; the pinned public
1372
+ // key is the single anchor they cannot forge. Reporting "signature
1373
+ // invalid" and installing anyway made that anchor decorative.
1150
1374
  if (signatureAsset) {
1151
1375
  const signaturePath = path.join(tmpDir, TARBALL_SIGNATURE_ASSET_NAME);
1152
1376
  try {
@@ -1157,10 +1381,13 @@ async function main(options = {}) {
1157
1381
  if (sig.status === 'verified') {
1158
1382
  success('GPG signature verified.');
1159
1383
  } 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.'
1384
+ error(
1385
+ `GPG signature verification FAILED (${sig.detail}) — refusing to install.\n` +
1386
+ ' The tarball was signed with a key that does not match the pinned ' +
1387
+ 'iterate-skill signing key: this is either tampering or a key rotation ' +
1388
+ 'that this installer version does not know about. Report it before retrying.',
1163
1389
  );
1390
+ return 1;
1164
1391
  } else {
1165
1392
  warning(
1166
1393
  `gpg not available (${sig.detail}); skipping signature check — SHA256 integrity still enforced.`
@@ -1169,6 +1396,13 @@ async function main(options = {}) {
1169
1396
  } catch (err) {
1170
1397
  warning(`Could not download/verify the GPG signature (${err.message}); SHA256 integrity still enforced.`);
1171
1398
  }
1399
+ } else {
1400
+ // Say it out loud: a silent skip meant an operator could believe the
1401
+ // signature layer ran on every install. SHA256 still guards integrity,
1402
+ // but authenticity was NOT verified for this release.
1403
+ warning(
1404
+ 'Release publishes no GPG signature asset; authenticity check skipped (SHA256 integrity still enforced).',
1405
+ );
1172
1406
  }
1173
1407
 
1174
1408
  info('Extracting release...');
@@ -1221,7 +1455,7 @@ async function main(options = {}) {
1221
1455
  // Skill-only install: explain how to add the CLI later instead of
1222
1456
  // silently installing it globally.
1223
1457
  hint('Skipped installing the iterate CLI (--no-cli).');
1224
- hint('Install it later for `iterate onboard` with: pipx install <repo> (or) pip install .');
1458
+ hint('Install it later for `iterate onboard` with: pipx install "git+https://github.com/jingzhao-l/iterate-skill.git"');
1225
1459
  } else {
1226
1460
  // Install the iterate CLI so `iterate onboard` works right after
1227
1461
  // install. Made explicit up front so skill-only users are not surprised
@@ -1255,6 +1489,7 @@ module.exports = {
1255
1489
  rejectLinkEntries,
1256
1490
  isGithubApiUrl,
1257
1491
  normalizeToken,
1492
+ validateTokenShape,
1258
1493
  buildAuthFlags,
1259
1494
  supportsCurlFailWithBody,
1260
1495
  // Executed under test for the timeout/null-exit-code hardening; retaining
@@ -1262,5 +1497,11 @@ module.exports = {
1262
1497
  runCommand,
1263
1498
  runPythonInstall,
1264
1499
  TailBuffer,
1500
+ FullBuffer,
1501
+ measureTree,
1502
+ enforceExtractedSize,
1503
+ MAX_LISTING_CHARS,
1504
+ MAX_EXTRACTED_BYTES,
1505
+ MAX_EXTRACTED_MEMBER_BYTES,
1265
1506
  DEFAULT_COMMAND_TIMEOUT_MS,
1266
1507
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "iterate-skill-installer",
3
- "version": "3.5.0",
3
+ "version": "3.5.1",
4
4
  "description": "One-command installer for iterate-skill across AI coding assistants",
5
5
  "main": "lib/installer.js",
6
6
  "files": [
@@ -14,7 +14,7 @@
14
14
  "iterate-skill-installer": "bin/cli.js"
15
15
  },
16
16
  "scripts": {
17
- "test": "node --check bin/cli.js && node --check lib/installer.js && node test/mode.test.js"
17
+ "test": "node --check bin/cli.js && node --check lib/installer.js && node test/mode.test.js && node test/tarball.test.js"
18
18
  },
19
19
  "keywords": [
20
20
  "iterate-skill",