iterate-skill-installer 3.2.0 → 3.2.2

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
@@ -54,6 +54,16 @@ const JSON_MAX_BYTES = 10 * 1024 * 1024; // 10 MiB
54
54
  const CHECKSUMS_MAX_BYTES = 1 * 1024 * 1024; // 1 MiB
55
55
  const TARBALL_MAX_BYTES = 100 * 1024 * 1024; // 100 MiB
56
56
 
57
+ // A strict upper bound for an interactive yes/no prompt. On EOF (piped stdin
58
+ // that ends) or a TTY that stops responding, the prompt must fall back to the
59
+ // default rather than hang the installer forever.
60
+ const ASK_YES_NO_TIMEOUT_MS = 30_000;
61
+
62
+ // SHA256 digests are exactly 64 lowercase/uppercase hex characters. Any other
63
+ // value in a checksum file is corrupt or tampered and must fail closed rather
64
+ // than feed a garbage hash into the integrity comparison.
65
+ const SHA256_HEX_RE = /^[0-9a-fA-F]{64}$/;
66
+
57
67
  // Curl added --fail-with-body in 7.76. On older curl the installer must fall
58
68
  // back to plain --fail so the flag is never rejected by the system curl.
59
69
  const CURL_FAIL_WITH_BODY_MIN = { major: 7, minor: 76 };
@@ -242,20 +252,32 @@ function parseChecksums(text) {
242
252
  const map = new Map();
243
253
  for (const line of text.split(/\r?\n/)) {
244
254
  const trimmed = line.trim();
245
- if (!trimmed) continue;
255
+ if (!trimmed || trimmed.startsWith('#')) continue;
246
256
  const parts = trimmed.split(/\s+/);
247
- if (parts.length >= 2) {
248
- // Normalize the filename the same way scripts/install.py does
249
- // (_parse_checksum): strip all leading GNU tar binary-mode markers a la
250
- // Python's name.lstrip("*") (ONE '*', or several in a malformed entry —
251
- // strip them all so the two parsers can never disagree), then strip the
252
- // './' prefix, then match on the basename so entries with a subpath or
253
- // versioned component (e.g. "dist/iterate-skill.tar.gz" or
254
- // "./iterate-skill.tar.gz") still resolve to the expected asset.
255
- const normalized = parts[1].replace(/^\*+/, '').replace(/^\.\//, '');
256
- const basename = normalized.split('/').pop();
257
- map.set(basename, parts[0]);
257
+ if (parts.length < 2) continue;
258
+ // Reject any entry whose digest is not a 64-char hex string so a corrupt
259
+ // or tampered checksum file fails closed: a missing asset ("not found")
260
+ // aborts the install, whereas a garbage digest would masquerade as a
261
+ // valid expected hash.
262
+ if (!SHA256_HEX_RE.test(parts[0])) continue;
263
+ // Normalize the filename the same way scripts/install.py does
264
+ // (_parse_checksum): strip all leading GNU tar binary-mode markers a la
265
+ // Python's name.lstrip("*") (ONE '*', or several in a malformed entry —
266
+ // strip them all so the two parsers can never disagree), then strip the
267
+ // './' prefix, then match on the basename so entries with a subpath or
268
+ // versioned component (e.g. "dist/iterate-skill.tar.gz" or
269
+ // "./iterate-skill.tar.gz") still resolve to the expected asset.
270
+ const normalized = parts[1].replace(/^\*+/, '').replace(/^\.\//, '');
271
+ const basename = normalized.split('/').pop();
272
+ // A duplicate basename carrying a *different* digest is a red flag (the
273
+ // file is hand-corrupted or two sources disagree); refuse the whole file
274
+ // instead of silently letting one value win.
275
+ if (map.has(basename) && map.get(basename) !== parts[0]) {
276
+ throw new InstallerError(
277
+ `Duplicate checksum entry for ${basename} with a different value — refusing to proceed.`,
278
+ );
258
279
  }
280
+ map.set(basename, parts[0]);
259
281
  }
260
282
  return map;
261
283
  }
@@ -463,18 +485,32 @@ async function installCli(pythonBin, sourceDir) {
463
485
  *
464
486
  * Used to decide whether the current directory should be treated as the
465
487
  * target project when the installer is launched from a non-home directory.
466
- * Falls back to ``defaultNo`` if the input is unrecognized.
488
+ * Falls back to ``defaultNo`` if the input is unrecognized, on EOF (piped
489
+ * stdin that ends without an answer) or after a timeout, so a non-interactive
490
+ * or hung invocation resolves instead of blocking forever.
467
491
  */
468
492
  function askYesNo(question, defaultNo = false) {
469
493
  return new Promise((resolve) => {
470
494
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
471
495
  const hint = defaultNo ? '[y/N]' : '[Y/n]';
472
- rl.question(`\x1b[36m◆\x1b[0m ${question} ${hint} `, (answer) => {
496
+ let settled = false;
497
+ const finish = (value) => {
498
+ if (settled) return;
499
+ settled = true;
500
+ clearTimeout(timer);
473
501
  rl.close();
502
+ resolve(value);
503
+ };
504
+ // EOF (stdin is a closed pipe, or the terminal disappears) never invokes
505
+ // rl.question's callback; the interface 'close' event is the signal that
506
+ // no answer is coming, so fall back to the default instead of hanging.
507
+ rl.on('close', () => finish(defaultNo));
508
+ const timer = setTimeout(() => finish(defaultNo), ASK_YES_NO_TIMEOUT_MS);
509
+ rl.question(`\x1b[36m◆\x1b[0m ${question} ${hint} `, (answer) => {
474
510
  const a = answer.trim().toLowerCase();
475
- if (a === 'y' || a === 'yes') resolve(true);
476
- else if (a === 'n' || a === 'no') resolve(false);
477
- else resolve(defaultNo);
511
+ if (a === 'y' || a === 'yes') finish(true);
512
+ else if (a === 'n' || a === 'no') finish(false);
513
+ else finish(defaultNo);
478
514
  });
479
515
  });
480
516
  }
@@ -569,6 +605,18 @@ function parseArgs(argv) {
569
605
  const arg = argv[i];
570
606
  const next = argv[i + 1];
571
607
 
608
+ // -h/--help and -v/--version short-circuit parsing: later arguments (even
609
+ // malformed or value-consuming flags) must not turn `--help`/`--version`
610
+ // into an error exit. bin/cli.js exits 0 on these modes before installing.
611
+ if (arg === '--help' || arg === '-h') {
612
+ options.mode = 'help';
613
+ break;
614
+ }
615
+ if (arg === '--version' || arg === '-v') {
616
+ options.mode = 'version';
617
+ break;
618
+ }
619
+
572
620
  switch (arg) {
573
621
  case '--ai':
574
622
  if (!next || next.startsWith('-')) {
@@ -607,16 +655,6 @@ function parseArgs(argv) {
607
655
  // user who only wants the skill is not surprised by a global install.
608
656
  options.noCli = true;
609
657
  break;
610
- case '--help':
611
- case '-h':
612
- // Show usage and exit 0 (handled by bin/cli.js).
613
- options.mode = 'help';
614
- break;
615
- case '--version':
616
- case '-v':
617
- // Print the package version and exit 0 (handled by bin/cli.js).
618
- options.mode = 'version';
619
- break;
620
658
  case '--token':
621
659
  if (!next || next.startsWith('-')) {
622
660
  console.error('Error: --token requires a value (a flag was found instead)');
package/package.json CHANGED
@@ -1,11 +1,17 @@
1
1
  {
2
2
  "name": "iterate-skill-installer",
3
- "version": "3.2.0",
3
+ "version": "3.2.2",
4
4
  "description": "One-command installer for iterate-skill across AI coding assistants",
5
5
  "main": "lib/installer.js",
6
+ "files": [
7
+ "bin/",
8
+ "lib/",
9
+ "LICENSE",
10
+ "README.md",
11
+ "README.zh-CN.md"
12
+ ],
6
13
  "bin": {
7
- "iterate-skill-installer": "bin/cli.js",
8
- "iterate-skill": "bin/cli.js"
14
+ "iterate-skill-installer": "bin/cli.js"
9
15
  },
10
16
  "scripts": {
11
17
  "test": "node --check bin/cli.js && node --check lib/installer.js && node test/mode.test.js"
package/test/mode.test.js DELETED
@@ -1,231 +0,0 @@
1
- /**
2
- * Unit tests for resolveInstallMode() — the global vs project install-mode
3
- * decision — and parseChecksums() — the SHA256SUMS.txt parser — in the npx
4
- * installer. Uses node:assert so no extra test runner is required; run via
5
- * `npm test`.
6
- */
7
-
8
- const assert = require('node:assert');
9
- const fs = require('node:fs');
10
- const path = require('node:path');
11
- const os = require('node:os');
12
- const { resolveInstallMode, parseChecksums, parseArgs, buildPythonInstallArgs, isGithubApiUrl } = require('../lib/installer');
13
-
14
- const originalCwd = process.cwd();
15
- const originalHome = os.homedir;
16
-
17
- /**
18
- * Temporarily set the home directory and chdir into a fresh temp directory
19
- * (the "current working directory"), then run fn(cwd). Always restores state.
20
- */
21
- function withHomeAndCwd(home, fn) {
22
- return (async () => {
23
- const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'iterate-mode-'));
24
- os.homedir = () => home;
25
- process.chdir(cwd);
26
- try {
27
- return await fn(cwd);
28
- } finally {
29
- os.homedir = originalHome;
30
- process.chdir(originalCwd);
31
- fs.rmSync(cwd, { recursive: true, force: true });
32
- }
33
- })();
34
- }
35
-
36
- async function run() {
37
- // 1) Explicit --target is honored without asking.
38
- await withHomeAndCwd('/home/u', async () => {
39
- const opts = { global: true, target: '/x', targetExplicit: true, globalExplicit: false };
40
- const ask = () => { throw new Error('should not ask'); };
41
- const out = await resolveInstallMode(opts, ask);
42
- assert.strictEqual(out.target, '/x', 'target should be preserved');
43
- assert.strictEqual(out.global, true, 'global should be left untouched for explicit target');
44
- });
45
-
46
- // 2) Explicit --global is honored without asking.
47
- await withHomeAndCwd('/home/u', async () => {
48
- const opts = { global: true, target: null, targetExplicit: false, globalExplicit: true };
49
- const ask = () => { throw new Error('should not ask'); };
50
- const out = await resolveInstallMode(opts, ask);
51
- assert.strictEqual(out.global, true, 'global should be preserved');
52
- assert.strictEqual(out.target, null, 'target should stay null');
53
- });
54
-
55
- // 3) cwd === home: no ask, stays global.
56
- await withHomeAndCwd('/home/u', async () => {
57
- // Use process.cwd() (the resolved path) so home matches cwd exactly,
58
- // avoiding macOS /var -> /private/var symlink mismatches.
59
- os.homedir = () => process.cwd();
60
- const opts = { global: true, target: null, targetExplicit: false, globalExplicit: false };
61
- const ask = () => { throw new Error('should not ask when cwd is home'); };
62
- const out = await resolveInstallMode(opts, ask);
63
- assert.strictEqual(out.global, true, 'should stay global when cwd is home');
64
- });
65
-
66
- // 4) cwd !== home and user says yes -> project-level install into cwd.
67
- await withHomeAndCwd('/home/u', async () => {
68
- const opts = { global: true, target: null, targetExplicit: false, globalExplicit: false };
69
- const out = await resolveInstallMode(opts, async () => true);
70
- assert.strictEqual(out.global, false, 'should switch to project mode');
71
- assert.strictEqual(out.target, process.cwd(), 'target should be cwd');
72
- });
73
-
74
- // 5) cwd !== home and user says no -> stays global.
75
- await withHomeAndCwd('/home/u', async () => {
76
- const opts = { global: true, target: null, targetExplicit: false, globalExplicit: false };
77
- const out = await resolveInstallMode(opts, async () => false);
78
- assert.strictEqual(out.global, true, 'should stay global when user declines');
79
- assert.strictEqual(out.target, null, 'target should stay null');
80
- });
81
-
82
- console.log('mode.test.js: all resolveInstallMode tests passed');
83
-
84
- // parseChecksums: must strip the GNU tar binary-mode marker ('*') so both
85
- // "HASH name" and "HASH *name" formats resolve, matching the Python side.
86
- const plain = parseChecksums('abc123 iterate-skill.tar.gz\n');
87
- assert.strictEqual(plain.get('iterate-skill.tar.gz'), 'abc123', 'plain format should parse');
88
-
89
- const starred = parseChecksums('def456 *iterate-skill.tar.gz\n');
90
- assert.strictEqual(starred.get('iterate-skill.tar.gz'), 'def456', 'starred format should strip the * marker');
91
-
92
- const crlf = parseChecksums('abc123 iterate-skill.tar.gz\r\n');
93
- assert.strictEqual(crlf.get('iterate-skill.tar.gz'), 'abc123', 'CRLF line endings should parse');
94
-
95
- const multi = parseChecksums('aa a.txt\nbb b.txt\n');
96
- assert.strictEqual(multi.get('a.txt'), 'aa');
97
- assert.strictEqual(multi.get('b.txt'), 'bb');
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
-
110
- // Multiple leading '*' markers must be stripped like Python's lstrip("*")
111
- // (a malformed entry must never leave a leading '*' in the basename).
112
- const multiStar = parseChecksums('abc123 **iterate-skill.tar.gz\n');
113
- assert.strictEqual(multiStar.get('iterate-skill.tar.gz'), 'abc123', 'all leading * markers should strip (lstrip matching)');
114
-
115
- console.log('mode.test.js: all parseChecksums tests passed');
116
-
117
- // parseArgs: --no-cli must toggle skill-only mode, and default to off.
118
- assert.strictEqual(parseArgs([]).noCli, false, 'noCli should default to false');
119
- assert.strictEqual(parseArgs(['--no-cli']).noCli, true, '--no-cli should set noCli');
120
- assert.strictEqual(parseArgs(['--no-cli', '--force', '--global']).noCli, true, 'noCli persists with other flags');
121
- assert.strictEqual(parseArgs(['--global']).noCli, false, 'unrelated flags do not set noCli');
122
-
123
- // parseArgs: unrelated flag surface is preserved (target/global/ai/force).
124
- const combo = parseArgs(['--ai', 'trae', '--target', '/proj', '--force']);
125
- assert.strictEqual(combo.ai, 'trae', 'ai should parse');
126
- assert.strictEqual(combo.target, '/proj', 'target should parse');
127
- assert.strictEqual(combo.force, true, 'force should parse');
128
- assert.strictEqual(combo.global, false, 'target forces project mode');
129
-
130
- // parseArgs: --version/-v/--help/-h must set the corresponding mode instead
131
- // of falling through to the unknown-option error.
132
- assert.strictEqual(parseArgs([]).mode, null, 'mode should default to null');
133
- assert.strictEqual(parseArgs(['--version']).mode, 'version', '--version should set version mode');
134
- assert.strictEqual(parseArgs(['-v']).mode, 'version', '-v should set version mode');
135
- assert.strictEqual(parseArgs(['--help']).mode, 'help', '--help should set help mode');
136
- assert.strictEqual(parseArgs(['-h']).mode, 'help', '-h should set help mode');
137
-
138
- // parseArgs: value-consuming flags must reject a following flag as their
139
- // value instead of silently swallowing it (regression: `--ai --force`
140
- // previously set ai='--force' and misled the Python side with an invalid
141
- // choice). process.exit is stubbed so the error path is assertable.
142
- const origExit = process.exit;
143
- process.exit = (code) => {
144
- throw new Error(`exit(${code})`);
145
- };
146
- try {
147
- assert.throws(() => parseArgs(['--ai', '--force']), /exit\(1\)/, '--ai must reject a flag as its value');
148
- assert.throws(() => parseArgs(['--target', '--global']), /exit\(1\)/, '--target must reject a flag as its value');
149
- assert.throws(() => parseArgs(['--token', '--yes']), /exit\(1\)/, '--token must reject a flag as its value');
150
- assert.throws(() => parseArgs(['--ai']), /exit\(1\)/, '--ai without a value must error');
151
- assert.throws(() => parseArgs(['--token']), /exit\(1\)/, '--token without a value must error');
152
- } finally {
153
- process.exit = origExit;
154
- }
155
-
156
- // parseArgs: a bare positional argument must produce a warning instead of
157
- // being silently ignored (fix: typos like "npx iterate-skill-installer trae").
158
- const origLog = console.log;
159
- const warnings = [];
160
- console.log = (msg) => { warnings.push(msg); };
161
- try {
162
- const opts = parseArgs(['trae', '--force']);
163
- assert.strictEqual(opts.force, true, 'known flags still parse alongside positionals');
164
- assert.ok(
165
- warnings.some((w) => w.includes('Ignoring unexpected positional argument: trae')),
166
- `positional arg should warn, got: ${JSON.stringify(warnings)}`,
167
- );
168
- } finally {
169
- console.log = origLog;
170
- }
171
-
172
- // parseArgs: --global + --target together must warn that the later flag wins.
173
- const conflictLogs = [];
174
- console.log = (msg) => { conflictLogs.push(msg); };
175
- try {
176
- const targetThenGlobal = parseArgs(['--target', '/proj', '--global']);
177
- assert.strictEqual(targetThenGlobal.global, true, 'later --global wins');
178
- assert.strictEqual(targetThenGlobal.target, null, 'later --global clears the target');
179
- assert.ok(
180
- conflictLogs.some((w) => w.includes('--global overrides --target')),
181
- `conflict should warn, got: ${JSON.stringify(conflictLogs)}`,
182
- );
183
- const globalThenTarget = parseArgs(['--global', '--target', '/proj']);
184
- assert.strictEqual(globalThenTarget.global, false, 'later --target wins');
185
- assert.strictEqual(globalThenTarget.target, '/proj', 'later --target is kept');
186
- assert.ok(
187
- conflictLogs.some((w) => w.includes('--target overrides --global')),
188
- `conflict should warn, got: ${JSON.stringify(conflictLogs)}`,
189
- );
190
- } finally {
191
- console.log = origLog;
192
- }
193
-
194
- console.log('mode.test.js: all parseArgs tests passed');
195
-
196
- // buildPythonInstallArgs: a relative --target must be resolved against the
197
- // current working directory (the Python installer runs with a different
198
- // cwd, so the raw relative path would point at the wrong place).
199
- const resolved = buildPythonInstallArgs({ ai: 'trae', target: 'rel/sub', force: true, globalInstall: false });
200
- assert.deepStrictEqual(
201
- resolved,
202
- ['--ai', 'trae', '--target', path.resolve('rel/sub'), '--force'],
203
- 'relative --target should be resolved against cwd',
204
- );
205
- const globalArgs = buildPythonInstallArgs({ ai: null, target: null, force: false, globalInstall: true });
206
- assert.deepStrictEqual(globalArgs, ['--global'], 'global flag should be forwarded');
207
- assert.deepStrictEqual(
208
- buildPythonInstallArgs({ ai: null, target: '/abs/target', force: false, globalInstall: false }),
209
- ['--target', '/abs/target'],
210
- 'absolute --target is passed through unchanged',
211
- );
212
- assert.deepStrictEqual(buildPythonInstallArgs({ ai: null, target: null, force: false, globalInstall: false }), [], 'no flags -> empty argv');
213
-
214
- console.log('mode.test.js: all buildPythonInstallArgs tests passed');
215
-
216
- // isGithubApiUrl: the caller's PAT must only ever be attached to the
217
- // api.github.com host — never to public release-asset / other URLs
218
- // (credential surface minimalism, mirroring scripts/install.py).
219
- assert.strictEqual(isGithubApiUrl('https://api.github.com/repos/jingzhao-l/iterate-skill/releases/latest'), true, 'api host should match');
220
- assert.strictEqual(isGithubApiUrl('https://api.github.com.evil.com/x'), false, 'lookalike host must not match');
221
- assert.strictEqual(isGithubApiUrl('https://release-assets.githubusercontent.com/foo.tar.gz'), false, 'asset host must not receive the token');
222
- assert.strictEqual(isGithubApiUrl('https://github.com/x'), false, 'github.com is not the API host');
223
- assert.strictEqual(isGithubApiUrl('not a url'), false, 'malformed url must not match');
224
-
225
- console.log('mode.test.js: all isGithubApiUrl tests passed');
226
- }
227
-
228
- run().catch((err) => {
229
- console.error(err);
230
- process.exit(1);
231
- });