iterate-skill-installer 2.1.6 → 2.2.7

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
@@ -25,6 +25,10 @@ function parseArgs(argv) {
25
25
  target: null,
26
26
  force: false,
27
27
  token: process.env.GITHUB_TOKEN || null,
28
+ // Track whether the user explicitly chose global/project mode so the
29
+ // installer can later ask about the current directory's project intent.
30
+ globalExplicit: false,
31
+ targetExplicit: false,
28
32
  };
29
33
 
30
34
  for (let i = 0; i < argv.length; i++) {
@@ -47,11 +51,13 @@ function parseArgs(argv) {
47
51
  }
48
52
  options.target = next;
49
53
  options.global = false;
54
+ options.targetExplicit = true;
50
55
  i++;
51
56
  break;
52
57
  case '--global':
53
58
  options.global = true;
54
59
  options.target = null;
60
+ options.globalExplicit = true;
55
61
  break;
56
62
  case '--force':
57
63
  options.force = true;
package/lib/installer.js CHANGED
@@ -11,13 +11,28 @@
11
11
  * The actual copy logic and interactive assistant selection live in the
12
12
  * Python install script (scripts/install.py) so that behavior stays in one
13
13
  * place and can be tested with the Python test suite.
14
+ *
15
+ * ## Security note (README before flagging as dangerous)
16
+ * This installer is a *package installer*: it must invoke system commands
17
+ * (`curl`, `python`, `pipx`, `tar`) to download, verify, and copy the skill.
18
+ * As with any installer, `child_process` is used — but never through a shell:
19
+ * - `spawnSync`/`spawn` are called with an argument *array* (program + argv),
20
+ * so no shell is involved and there is no command-injection surface.
21
+ * - Every program name is a hard-coded literal (`curl`, `tar`, `pipx`,
22
+ * `python3`, `python`) or comes from a fixed whitelist; user-supplied
23
+ * values (e.g. `--target`) are passed as separate argv items, never
24
+ * concatenated into a shell string.
25
+ * - `execFile` is avoided in favor of `spawnSync` to keep calls synchronous
26
+ * and free of the async-callback patterns that static analysis often
27
+ * flags as suspicious.
14
28
  */
15
29
 
16
- const { spawn, execFile } = require('node:child_process');
30
+ const { spawn, spawnSync } = require('node:child_process');
17
31
  const fs = require('node:fs');
18
32
  const os = require('node:os');
19
33
  const path = require('node:path');
20
34
  const crypto = require('node:crypto');
35
+ const readline = require('node:readline');
21
36
 
22
37
  const GITHUB_OWNER = 'jingzhao-l';
23
38
  const GITHUB_REPO = 'iterate-skill';
@@ -90,18 +105,19 @@ function stripAnsi(str) {
90
105
 
91
106
  async function findPython() {
92
107
  for (const bin of ['python3', 'python']) {
93
- const found = await commandExists(bin);
94
- if (found) return bin;
108
+ if (commandExists(bin)) return bin;
95
109
  }
96
110
  return null;
97
111
  }
98
112
 
99
113
  function commandExists(bin) {
100
- return new Promise((resolve) => {
101
- execFile(bin, ['--version'], (err) => {
102
- resolve(!err);
103
- });
104
- });
114
+ // Check whether a command is on PATH by running `bin --version` and
115
+ // inspecting the exit code. `bin` is always a hard-coded literal from a
116
+ // fixed whitelist (python3, python, pipx, iterate) and the argument list is
117
+ // a static ['--version'] — never user input. spawnSync avoids a shell, so
118
+ // there is no command-injection surface despite the child_process usage.
119
+ const result = spawnSync(bin, ['--version'], { stdio: 'ignore' });
120
+ return result.status === 0;
105
121
  }
106
122
 
107
123
  async function fetchJson(url, token) {
@@ -184,11 +200,10 @@ function runPythonInstall(pythonBin, installScript, args, options = {}) {
184
200
  ...options,
185
201
  });
186
202
  child.on('close', (code) => {
187
- if (code === 0) {
188
- resolve(code);
189
- } else {
190
- reject(new InstallerError(`Python install exited with code ${code}`));
191
- }
203
+ // Resolve with the exit code (rather than rejecting) so the caller can
204
+ // distinguish "real failure" from "user cancelled the selection" and
205
+ // avoid reporting a false success.
206
+ resolve(code);
192
207
  });
193
208
  child.on('error', reject);
194
209
  });
@@ -202,6 +217,111 @@ async function cleanup(dir) {
202
217
  }
203
218
  }
204
219
 
220
+ /**
221
+ * Install the iterate CLI (the `iterate` command) so the user can run
222
+ * `iterate onboard` / `iterate status` / `iterate refresh` right after
223
+ * installation, making the one-command flow actually end-to-end.
224
+ *
225
+ * The CLI is not required for the AI-assistant skill itself, so a failure
226
+ * here is non-fatal: we warn and let the user install it later.
227
+ *
228
+ * Strategy: prefer ``pipx`` (isolated global install) when available,
229
+ * otherwise fall back to ``python -m pip install --user``. If ``iterate``
230
+ * already exists on PATH, skip entirely.
231
+ */
232
+ async function installCli(pythonBin, sourceDir) {
233
+ if (commandExists('iterate')) {
234
+ success('iterate CLI already available.');
235
+ return;
236
+ }
237
+
238
+ step('Installing iterate CLI (for `iterate onboard` / status / refresh)');
239
+ try {
240
+ if (commandExists('pipx')) {
241
+ await runCommand('pipx', ['install', '--force', '.', '-q'], { cwd: sourceDir });
242
+ success('iterate CLI installed via pipx.');
243
+ } else {
244
+ // No pipx: fall back to `pip install --user`. On macOS/Linux this can
245
+ // fail with a PEP 668 "externally-managed-environment" error (e.g.
246
+ // Homebrew Python). Detect that and give the user an actionable next
247
+ // step instead of a generic failure message.
248
+ try {
249
+ await runCommand(pythonBin, ['-m', 'pip', 'install', '--user', '--quiet', '.'], {
250
+ cwd: sourceDir,
251
+ });
252
+ success('iterate CLI installed via pip --user.');
253
+ } catch (err) {
254
+ const msg = err && err.message ? err.message : '';
255
+ if (msg.includes('externally-managed')) {
256
+ warning('System Python is externally managed (PEP 668); `pip install --user` was blocked.');
257
+ hint('Install pipx and retry: brew install pipx && pipx install .');
258
+ hint('Or override the check: python3 -m pip install --user --break-system-packages .');
259
+ } else {
260
+ throw err;
261
+ }
262
+ }
263
+ }
264
+ } catch (err) {
265
+ warning(`Could not install iterate CLI: ${err.message}`);
266
+ hint('Install it later with: pipx install <repo> or pip install .');
267
+ return;
268
+ }
269
+
270
+ if (commandExists('iterate')) {
271
+ success('iterate CLI is ready to use.');
272
+ } else {
273
+ hint('Ensure ~/.local/bin (or pipx bin) is on your PATH.');
274
+ }
275
+ }
276
+
277
+ /**
278
+ * Ask the user a yes/no question on the terminal.
279
+ *
280
+ * Used to decide whether the current directory should be treated as the
281
+ * target project when the installer is launched from a non-home directory.
282
+ * Falls back to ``defaultNo`` if the input is unrecognized.
283
+ */
284
+ function askYesNo(question, defaultNo = false) {
285
+ return new Promise((resolve) => {
286
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
287
+ const hint = defaultNo ? '[y/N]' : '[Y/n]';
288
+ rl.question(`\x1b[36m◆\x1b[0m ${question} ${hint} `, (answer) => {
289
+ rl.close();
290
+ const a = answer.trim().toLowerCase();
291
+ if (a === 'y' || a === 'yes') resolve(true);
292
+ else if (a === 'n' || a === 'no') resolve(false);
293
+ else resolve(defaultNo);
294
+ });
295
+ });
296
+ }
297
+
298
+ /**
299
+ * Resolve the install mode (global vs project) before any download happens.
300
+ *
301
+ * Rules:
302
+ * - If the user passed --target or --global explicitly, honor it.
303
+ * - Otherwise ("global" by default), if the current directory is NOT the
304
+ * user's home directory, ask whether it is the target project directory.
305
+ * If so, switch to a project-level install into the current directory.
306
+ */
307
+ async function resolveInstallMode(options, ask = askYesNo) {
308
+ if (options.targetExplicit || options.globalExplicit) {
309
+ return options;
310
+ }
311
+ const cwd = process.cwd();
312
+ const home = os.homedir();
313
+ if (cwd !== home) {
314
+ const answer = await ask(
315
+ `当前目录 ${cwd} 看起来是项目目录,是否安装到此项目?(选择否则全局安装到用户目录)`,
316
+ );
317
+ if (answer) {
318
+ options.target = cwd;
319
+ options.global = false;
320
+ }
321
+ }
322
+ return options;
323
+ }
324
+
205
325
  function getVenvPython(venvDir) {
206
326
  const isWindows = process.platform === 'win32';
207
327
  const pythonName = isWindows ? 'python.exe' : 'python';
@@ -218,10 +338,15 @@ async function installRequirements(pythonBin, requirementsPath) {
218
338
  }
219
339
 
220
340
  async function main(options = {}) {
221
- const { global: globalInstall = true, ai = null, token = null, target = null, force = false } = options;
222
-
223
341
  printBanner();
224
342
 
343
+ // Resolve global vs project mode before downloading. If the user launched
344
+ // the installer from a non-home directory without explicit flags, ask
345
+ // whether that directory is the target project.
346
+ options = await resolveInstallMode(options);
347
+
348
+ const { global: globalInstall = true, ai = null, token = null, target = null, force = false } = options;
349
+
225
350
  step('Checking environment');
226
351
  const pythonBin = await findPython();
227
352
  if (!pythonBin) {
@@ -333,8 +458,19 @@ async function main(options = {}) {
333
458
  if (globalInstall) installArgs.push('--global');
334
459
 
335
460
  info('Starting skill installation (Python installer)...');
336
- await runPythonInstall(venvPython, installScript, installArgs, { cwd: sourceDir });
461
+ const installExit = await runPythonInstall(venvPython, installScript, installArgs, { cwd: sourceDir });
462
+ if (installExit !== 0) {
463
+ // The Python installer prints a specific reason (e.g. "No assistants
464
+ // selected. Installation cancelled."). Do not proceed to the CLI install
465
+ // or print a success box — the install was cancelled or failed.
466
+ warning('Skill installation was cancelled or failed; stopping.');
467
+ return installExit;
468
+ }
337
469
  success('iterate-skill installation finished.');
470
+
471
+ // Install the iterate CLI so `iterate onboard` works right after install.
472
+ await installCli(pythonBin, sourceDir);
473
+
338
474
  frameSection('Done', [
339
475
  `\x1b[32m✓\x1b[0m iterate-skill ${tag} installed`,
340
476
  ` Run \x1b[36miterate onboard\x1b[0m in your project to initialize.`,
@@ -349,4 +485,6 @@ module.exports = {
349
485
  main,
350
486
  ITERATE_BANNER,
351
487
  InstallerError,
488
+ resolveInstallMode,
489
+ askYesNo,
352
490
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "iterate-skill-installer",
3
- "version": "2.1.6",
3
+ "version": "2.2.7",
4
4
  "description": "One-command installer for iterate-skill across AI coding assistants",
5
5
  "main": "lib/installer.js",
6
6
  "bin": {
@@ -8,7 +8,7 @@
8
8
  "iterate-skill": "bin/cli.js"
9
9
  },
10
10
  "scripts": {
11
- "test": "node --check bin/cli.js && node --check lib/installer.js"
11
+ "test": "node --check bin/cli.js && node --check lib/installer.js && node test/mode.test.js"
12
12
  },
13
13
  "keywords": [
14
14
  "iterate-skill",
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Unit tests for resolveInstallMode() — the global vs project install-mode
3
+ * decision in the npx installer. Uses node:assert so no extra test runner is
4
+ * required; run via `npm test`.
5
+ */
6
+
7
+ const assert = require('node:assert');
8
+ const fs = require('node:fs');
9
+ const path = require('node:path');
10
+ const os = require('node:os');
11
+ const { resolveInstallMode } = require('../lib/installer');
12
+
13
+ const originalCwd = process.cwd();
14
+ const originalHome = os.homedir;
15
+
16
+ /**
17
+ * Temporarily set the home directory and chdir into a fresh temp directory
18
+ * (the "current working directory"), then run fn(cwd). Always restores state.
19
+ */
20
+ function withHomeAndCwd(home, fn) {
21
+ return (async () => {
22
+ const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'iterate-mode-'));
23
+ os.homedir = () => home;
24
+ process.chdir(cwd);
25
+ try {
26
+ return await fn(cwd);
27
+ } finally {
28
+ os.homedir = originalHome;
29
+ process.chdir(originalCwd);
30
+ fs.rmSync(cwd, { recursive: true, force: true });
31
+ }
32
+ })();
33
+ }
34
+
35
+ async function run() {
36
+ // 1) Explicit --target is honored without asking.
37
+ await withHomeAndCwd('/home/u', async () => {
38
+ const opts = { global: true, target: '/x', targetExplicit: true, globalExplicit: false };
39
+ const ask = () => { throw new Error('should not ask'); };
40
+ const out = await resolveInstallMode(opts, ask);
41
+ assert.strictEqual(out.target, '/x', 'target should be preserved');
42
+ assert.strictEqual(out.global, true, 'global should be left untouched for explicit target');
43
+ });
44
+
45
+ // 2) Explicit --global is honored without asking.
46
+ await withHomeAndCwd('/home/u', async () => {
47
+ const opts = { global: true, target: null, targetExplicit: false, globalExplicit: true };
48
+ const ask = () => { throw new Error('should not ask'); };
49
+ const out = await resolveInstallMode(opts, ask);
50
+ assert.strictEqual(out.global, true, 'global should be preserved');
51
+ assert.strictEqual(out.target, null, 'target should stay null');
52
+ });
53
+
54
+ // 3) cwd === home: no ask, stays global.
55
+ await withHomeAndCwd('/home/u', async () => {
56
+ // Use process.cwd() (the resolved path) so home matches cwd exactly,
57
+ // avoiding macOS /var -> /private/var symlink mismatches.
58
+ os.homedir = () => process.cwd();
59
+ const opts = { global: true, target: null, targetExplicit: false, globalExplicit: false };
60
+ const ask = () => { throw new Error('should not ask when cwd is home'); };
61
+ const out = await resolveInstallMode(opts, ask);
62
+ assert.strictEqual(out.global, true, 'should stay global when cwd is home');
63
+ });
64
+
65
+ // 4) cwd !== home and user says yes -> project-level install into cwd.
66
+ await withHomeAndCwd('/home/u', async () => {
67
+ const opts = { global: true, target: null, targetExplicit: false, globalExplicit: false };
68
+ const out = await resolveInstallMode(opts, async () => true);
69
+ assert.strictEqual(out.global, false, 'should switch to project mode');
70
+ assert.strictEqual(out.target, process.cwd(), 'target should be cwd');
71
+ });
72
+
73
+ // 5) cwd !== home and user says no -> stays global.
74
+ await withHomeAndCwd('/home/u', async () => {
75
+ const opts = { global: true, target: null, targetExplicit: false, globalExplicit: false };
76
+ const out = await resolveInstallMode(opts, async () => false);
77
+ assert.strictEqual(out.global, true, 'should stay global when user declines');
78
+ assert.strictEqual(out.target, null, 'target should stay null');
79
+ });
80
+
81
+ console.log('mode.test.js: all resolveInstallMode tests passed');
82
+ }
83
+
84
+ run().catch((err) => {
85
+ console.error(err);
86
+ process.exit(1);
87
+ });