iterate-skill-installer 2.8.1 → 2.9.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.
package/lib/installer.js CHANGED
@@ -38,6 +38,15 @@ const GITHUB_OWNER = 'jingzhao-l';
38
38
  const GITHUB_REPO = 'iterate-skill';
39
39
  const RELEASE_API_URL = `https://api.github.com/repos/${GITHUB_OWNER}/${GITHUB_REPO}/releases/latest`;
40
40
 
41
+ // Release asset filenames — named constants so the magic strings are not
42
+ // scattered through the download / verify pipeline.
43
+ const TARBALL_ASSET_NAME = 'iterate-skill.tar.gz';
44
+ const CHECKSUMS_ASSET_NAME = 'SHA256SUMS.txt';
45
+
46
+ // Hard cap on a single curl download: a misbehaving CDN or a corrupt release
47
+ // must never hang the installer forever.
48
+ const CURL_MAX_TIME_SECONDS = '120';
49
+
41
50
  // Package version read from package.json at runtime so the --version/-v flag
42
51
  // and the published npm version always stay in sync.
43
52
  const VERSION = require('../package.json').version;
@@ -127,7 +136,7 @@ function commandExists(bin) {
127
136
  async function fetchJson(url, token) {
128
137
  // Prefer curl over Node.js fetch because curl uses the system CA store
129
138
  // and avoids Node-specific certificate issues in some environments.
130
- const args = ['-sSL', '--fail-with-body', '-H', 'Accept: application/vnd.github+json', '-H', 'X-GitHub-Api-Version: 2022-11-28', '-H', 'User-Agent: iterate-skill-installer'];
139
+ const args = ['-sSL', '--fail-with-body', '--max-time', CURL_MAX_TIME_SECONDS, '-H', 'Accept: application/vnd.github+json', '-H', 'X-GitHub-Api-Version: 2022-11-28', '-H', 'User-Agent: iterate-skill-installer'];
131
140
  if (token) {
132
141
  args.push('-H', `Authorization: Bearer ${token}`);
133
142
  }
@@ -140,13 +149,26 @@ async function fetchJson(url, token) {
140
149
  }
141
150
  }
142
151
 
143
- async function downloadFile(url, destPath, token) {
144
- const args = ['-sSL', '--fail-with-body', '-o', destPath, '-H', 'User-Agent: iterate-skill-installer'];
152
+ /**
153
+ * Download a file with curl.
154
+ *
155
+ * When ``progress`` is enabled (used for the tarball), curl's ``--progress-bar``
156
+ * writes a live percentage bar straight to the terminal (stderr is inherited so
157
+ * the bar is visible; stdout stays captured for non-progress downloads).
158
+ */
159
+ async function downloadFile(url, destPath, token, { progress = false } = {}) {
160
+ const args = ['-sSL', '--fail-with-body', '--max-time', CURL_MAX_TIME_SECONDS, '-o', destPath, '-H', 'User-Agent: iterate-skill-installer'];
161
+ if (progress) args.push('--progress-bar');
145
162
  if (token) {
146
163
  args.push('-H', `Authorization: Bearer ${token}`);
147
164
  }
148
165
  args.push(url);
149
- await runCommand('curl', args);
166
+ if (progress) {
167
+ // Let curl write its progress bar directly to the terminal.
168
+ await runCommand('curl', args, { stdio: ['ignore', 'ignore', 'inherit'] });
169
+ } else {
170
+ await runCommand('curl', args);
171
+ }
150
172
  }
151
173
 
152
174
  function sha256File(filePath) {
@@ -166,11 +188,14 @@ function parseChecksums(text) {
166
188
  if (!trimmed) continue;
167
189
  const parts = trimmed.split(/\s+/);
168
190
  if (parts.length >= 2) {
169
- // Strip the GNU tar binary-mode marker ('*') so that both
170
- // "HASH filename" and "HASH *filename" formats match the target
171
- // filename, consistent with scripts/install.py (name.lstrip('*')).
172
- const name = parts[1].replace(/^\*/, '');
173
- map.set(name, parts[0]);
191
+ // Normalize the filename the same way scripts/install.py does
192
+ // (_parse_checksum): strip the GNU tar binary-mode marker ('*') and the
193
+ // './' prefix, then match on the basename so entries with a subpath or
194
+ // versioned component (e.g. "dist/iterate-skill.tar.gz" or
195
+ // "./iterate-skill.tar.gz") still resolve to the expected asset.
196
+ const normalized = parts[1].replace(/^\*/, '').replace(/^\.\//, '');
197
+ const basename = normalized.split('/').pop();
198
+ map.set(basename, parts[0]);
174
199
  }
175
200
  }
176
201
  return map;
@@ -412,7 +437,7 @@ async function resolveInstallMode(options, ask = askYesNo) {
412
437
  const home = os.homedir();
413
438
  if (cwd !== home) {
414
439
  const answer = await ask(
415
- `当前目录 ${cwd} 看起来是项目目录,是否安装到此项目?(选择否则全局安装到用户目录)`,
440
+ `当前目录 ${cwd} 看起来是项目目录,是否安装到此项目?(输入 n 则全局安装到用户目录)`,
416
441
  );
417
442
  if (answer) {
418
443
  options.target = cwd;
@@ -437,6 +462,25 @@ async function installRequirements(pythonBin, requirementsPath) {
437
462
  await runCommand(pythonBin, ['-m', 'pip', 'install', '--quiet', '--requirement', requirementsPath]);
438
463
  }
439
464
 
465
+ /**
466
+ * Build the argv passed to the Python installer (scripts/install.py install).
467
+ *
468
+ * Exported as a pure function so the flag surface — including the relative
469
+ * ``--target`` resolution — can be unit-tested without running the full
470
+ * download pipeline. A relative ``--target`` is resolved against the current
471
+ * working directory here because the Python installer runs with ``cwd`` set
472
+ * to the extracted release directory; passing the raw relative path would
473
+ * make it point at the wrong place.
474
+ */
475
+ function buildPythonInstallArgs(options) {
476
+ const args = [];
477
+ if (options.ai) args.push('--ai', options.ai);
478
+ if (options.target) args.push('--target', path.resolve(options.target));
479
+ if (options.force) args.push('--force');
480
+ if (options.globalInstall) args.push('--global');
481
+ return args;
482
+ }
483
+
440
484
  /**
441
485
  * Parse the installer's command-line arguments.
442
486
  *
@@ -468,24 +512,30 @@ function parseArgs(argv) {
468
512
 
469
513
  switch (arg) {
470
514
  case '--ai':
471
- if (!next) {
472
- console.error('Error: --ai requires a value');
515
+ if (!next || next.startsWith('-')) {
516
+ console.error('Error: --ai requires a value (a flag was found instead)');
473
517
  process.exit(1);
474
518
  }
475
519
  options.ai = next;
476
520
  i++;
477
521
  break;
478
522
  case '--target':
479
- if (!next) {
480
- console.error('Error: --target requires a value');
523
+ if (!next || next.startsWith('-')) {
524
+ console.error('Error: --target requires a value (a flag was found instead)');
481
525
  process.exit(1);
482
526
  }
527
+ if (options.globalExplicit) {
528
+ warning('--target overrides --global: installing into the project directory instead.');
529
+ }
483
530
  options.target = next;
484
531
  options.global = false;
485
532
  options.targetExplicit = true;
486
533
  i++;
487
534
  break;
488
535
  case '--global':
536
+ if (options.targetExplicit) {
537
+ warning('--global overrides --target: installing into the user home directory instead.');
538
+ }
489
539
  options.global = true;
490
540
  options.target = null;
491
541
  options.globalExplicit = true;
@@ -509,8 +559,8 @@ function parseArgs(argv) {
509
559
  options.mode = 'version';
510
560
  break;
511
561
  case '--token':
512
- if (!next) {
513
- console.error('Error: --token requires a value');
562
+ if (!next || next.startsWith('-')) {
563
+ console.error('Error: --token requires a value (a flag was found instead)');
514
564
  process.exit(1);
515
565
  }
516
566
  options.token = next;
@@ -521,6 +571,9 @@ function parseArgs(argv) {
521
571
  console.error(`Error: unknown option ${arg}`);
522
572
  process.exit(1);
523
573
  }
574
+ // A bare positional argument is not a valid installer flag; warn so a
575
+ // typo like "npx iterate-skill-installer trae" is not silently ignored.
576
+ warning(`Ignoring unexpected positional argument: ${arg}`);
524
577
  break;
525
578
  }
526
579
  }
@@ -575,34 +628,34 @@ async function main(options = {}) {
575
628
  }
576
629
  success(`Latest release: ${tag}`);
577
630
 
578
- const tarballAsset = release.assets?.find((a) => a.name === 'iterate-skill.tar.gz');
579
- const checksumAsset = release.assets?.find((a) => a.name === 'SHA256SUMS.txt');
631
+ const tarballAsset = release.assets?.find((a) => a.name === TARBALL_ASSET_NAME);
632
+ const checksumAsset = release.assets?.find((a) => a.name === CHECKSUMS_ASSET_NAME);
580
633
 
581
634
  if (!tarballAsset) {
582
- error('Release is missing the iterate-skill.tar.gz asset.');
635
+ error(`Release is missing the ${TARBALL_ASSET_NAME} asset.`);
583
636
  return 1;
584
637
  }
585
638
  if (!checksumAsset) {
586
- error('Release is missing SHA256SUMS.txt asset.');
639
+ error(`Release is missing ${CHECKSUMS_ASSET_NAME} asset.`);
587
640
  return 1;
588
641
  }
589
642
 
590
643
  const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'iterate-skill-install-'));
591
- const tarballPath = path.join(tmpDir, 'iterate-skill.tar.gz');
592
- const checksumPath = path.join(tmpDir, 'SHA256SUMS.txt');
644
+ const tarballPath = path.join(tmpDir, TARBALL_ASSET_NAME);
645
+ const checksumPath = path.join(tmpDir, CHECKSUMS_ASSET_NAME);
593
646
 
594
647
  try {
595
- info('Downloading release tarball...');
596
- await downloadFile(tarballAsset.browser_download_url, tarballPath, token);
648
+ info(`Downloading release tarball (${TARBALL_ASSET_NAME})...`);
649
+ await downloadFile(tarballAsset.browser_download_url, tarballPath, token, { progress: true });
597
650
 
598
651
  info('Downloading checksums...');
599
652
  await downloadFile(checksumAsset.browser_download_url, checksumPath, token);
600
653
 
601
654
  info('Verifying checksum...');
602
655
  const checksums = parseChecksums(fs.readFileSync(checksumPath, 'utf8'));
603
- const expectedHash = checksums.get('iterate-skill.tar.gz');
656
+ const expectedHash = checksums.get(TARBALL_ASSET_NAME);
604
657
  if (!expectedHash) {
605
- error('Checksum file does not contain iterate-skill.tar.gz');
658
+ error(`Checksum file does not contain ${TARBALL_ASSET_NAME}`);
606
659
  return 1;
607
660
  }
608
661
  const actualHash = await sha256File(tarballPath);
@@ -649,11 +702,7 @@ async function main(options = {}) {
649
702
  return 1;
650
703
  }
651
704
 
652
- const installArgs = [];
653
- if (ai) installArgs.push('--ai', ai);
654
- if (target) installArgs.push('--target', target);
655
- if (force) installArgs.push('--force');
656
- if (globalInstall) installArgs.push('--global');
705
+ const installArgs = buildPythonInstallArgs({ ai, target, force, globalInstall });
657
706
 
658
707
  info('Starting skill installation (Python installer)...');
659
708
  const installExit = await runPythonInstall(venvPython, installScript, installArgs, { cwd: sourceDir });
@@ -692,6 +741,7 @@ async function main(options = {}) {
692
741
  module.exports = {
693
742
  main,
694
743
  parseArgs,
744
+ buildPythonInstallArgs,
695
745
  VERSION,
696
746
  ITERATE_BANNER,
697
747
  InstallerError,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "iterate-skill-installer",
3
- "version": "2.8.1",
3
+ "version": "2.9.1",
4
4
  "description": "One-command installer for iterate-skill across AI coding assistants",
5
5
  "main": "lib/installer.js",
6
6
  "bin": {
package/test/mode.test.js CHANGED
@@ -9,7 +9,7 @@ const assert = require('node:assert');
9
9
  const fs = require('node:fs');
10
10
  const path = require('node:path');
11
11
  const os = require('node:os');
12
- const { resolveInstallMode, parseChecksums, parseArgs } = require('../lib/installer');
12
+ const { resolveInstallMode, parseChecksums, parseArgs, buildPythonInstallArgs } = require('../lib/installer');
13
13
 
14
14
  const originalCwd = process.cwd();
15
15
  const originalHome = os.homedir;
@@ -96,6 +96,17 @@ async function run() {
96
96
  assert.strictEqual(multi.get('a.txt'), 'aa');
97
97
  assert.strictEqual(multi.get('b.txt'), 'bb');
98
98
 
99
+ // './' prefix and subpath/basename matching must resolve like the Python
100
+ // side (scripts/install.py _parse_checksum), which matches on basename.
101
+ const dotSlash = parseChecksums('abc123 ./iterate-skill.tar.gz\n');
102
+ assert.strictEqual(dotSlash.get('iterate-skill.tar.gz'), 'abc123', './ prefix should parse');
103
+
104
+ const subpath = parseChecksums('abc123 dist/iterate-skill.tar.gz\n');
105
+ assert.strictEqual(subpath.get('iterate-skill.tar.gz'), 'abc123', 'subpath entries should match by basename');
106
+
107
+ const starredSubpath = parseChecksums('abc123 *./release/iterate-skill-1.2.3.tar.gz\n');
108
+ assert.strictEqual(starredSubpath.get('iterate-skill-1.2.3.tar.gz'), 'abc123', '*./ and versioned subpath should match by basename');
109
+
99
110
  console.log('mode.test.js: all parseChecksums tests passed');
100
111
 
101
112
  // parseArgs: --no-cli must toggle skill-only mode, and default to off.
@@ -119,7 +130,83 @@ async function run() {
119
130
  assert.strictEqual(parseArgs(['--help']).mode, 'help', '--help should set help mode');
120
131
  assert.strictEqual(parseArgs(['-h']).mode, 'help', '-h should set help mode');
121
132
 
133
+ // parseArgs: value-consuming flags must reject a following flag as their
134
+ // value instead of silently swallowing it (regression: `--ai --force`
135
+ // previously set ai='--force' and misled the Python side with an invalid
136
+ // choice). process.exit is stubbed so the error path is assertable.
137
+ const origExit = process.exit;
138
+ process.exit = (code) => {
139
+ throw new Error(`exit(${code})`);
140
+ };
141
+ try {
142
+ assert.throws(() => parseArgs(['--ai', '--force']), /exit\(1\)/, '--ai must reject a flag as its value');
143
+ assert.throws(() => parseArgs(['--target', '--global']), /exit\(1\)/, '--target must reject a flag as its value');
144
+ assert.throws(() => parseArgs(['--token', '--yes']), /exit\(1\)/, '--token must reject a flag as its value');
145
+ assert.throws(() => parseArgs(['--ai']), /exit\(1\)/, '--ai without a value must error');
146
+ assert.throws(() => parseArgs(['--token']), /exit\(1\)/, '--token without a value must error');
147
+ } finally {
148
+ process.exit = origExit;
149
+ }
150
+
151
+ // parseArgs: a bare positional argument must produce a warning instead of
152
+ // being silently ignored (fix: typos like "npx iterate-skill-installer trae").
153
+ const origLog = console.log;
154
+ const warnings = [];
155
+ console.log = (msg) => { warnings.push(msg); };
156
+ try {
157
+ const opts = parseArgs(['trae', '--force']);
158
+ assert.strictEqual(opts.force, true, 'known flags still parse alongside positionals');
159
+ assert.ok(
160
+ warnings.some((w) => w.includes('Ignoring unexpected positional argument: trae')),
161
+ `positional arg should warn, got: ${JSON.stringify(warnings)}`,
162
+ );
163
+ } finally {
164
+ console.log = origLog;
165
+ }
166
+
167
+ // parseArgs: --global + --target together must warn that the later flag wins.
168
+ const conflictLogs = [];
169
+ console.log = (msg) => { conflictLogs.push(msg); };
170
+ try {
171
+ const targetThenGlobal = parseArgs(['--target', '/proj', '--global']);
172
+ assert.strictEqual(targetThenGlobal.global, true, 'later --global wins');
173
+ assert.strictEqual(targetThenGlobal.target, null, 'later --global clears the target');
174
+ assert.ok(
175
+ conflictLogs.some((w) => w.includes('--global overrides --target')),
176
+ `conflict should warn, got: ${JSON.stringify(conflictLogs)}`,
177
+ );
178
+ const globalThenTarget = parseArgs(['--global', '--target', '/proj']);
179
+ assert.strictEqual(globalThenTarget.global, false, 'later --target wins');
180
+ assert.strictEqual(globalThenTarget.target, '/proj', 'later --target is kept');
181
+ assert.ok(
182
+ conflictLogs.some((w) => w.includes('--target overrides --global')),
183
+ `conflict should warn, got: ${JSON.stringify(conflictLogs)}`,
184
+ );
185
+ } finally {
186
+ console.log = origLog;
187
+ }
188
+
122
189
  console.log('mode.test.js: all parseArgs tests passed');
190
+
191
+ // buildPythonInstallArgs: a relative --target must be resolved against the
192
+ // current working directory (the Python installer runs with a different
193
+ // cwd, so the raw relative path would point at the wrong place).
194
+ const resolved = buildPythonInstallArgs({ ai: 'trae', target: 'rel/sub', force: true, globalInstall: false });
195
+ assert.deepStrictEqual(
196
+ resolved,
197
+ ['--ai', 'trae', '--target', path.resolve('rel/sub'), '--force'],
198
+ 'relative --target should be resolved against cwd',
199
+ );
200
+ const globalArgs = buildPythonInstallArgs({ ai: null, target: null, force: false, globalInstall: true });
201
+ assert.deepStrictEqual(globalArgs, ['--global'], 'global flag should be forwarded');
202
+ assert.deepStrictEqual(
203
+ buildPythonInstallArgs({ ai: null, target: '/abs/target', force: false, globalInstall: false }),
204
+ ['--target', '/abs/target'],
205
+ 'absolute --target is passed through unchanged',
206
+ );
207
+ assert.deepStrictEqual(buildPythonInstallArgs({ ai: null, target: null, force: false, globalInstall: false }), [], 'no flags -> empty argv');
208
+
209
+ console.log('mode.test.js: all buildPythonInstallArgs tests passed');
123
210
  }
124
211
 
125
212
  run().catch((err) => {