iterate-skill-installer 2.1.6 → 2.2.6

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
@@ -18,6 +18,7 @@ const fs = require('node:fs');
18
18
  const os = require('node:os');
19
19
  const path = require('node:path');
20
20
  const crypto = require('node:crypto');
21
+ const readline = require('node:readline');
21
22
 
22
23
  const GITHUB_OWNER = 'jingzhao-l';
23
24
  const GITHUB_REPO = 'iterate-skill';
@@ -184,11 +185,10 @@ function runPythonInstall(pythonBin, installScript, args, options = {}) {
184
185
  ...options,
185
186
  });
186
187
  child.on('close', (code) => {
187
- if (code === 0) {
188
- resolve(code);
189
- } else {
190
- reject(new InstallerError(`Python install exited with code ${code}`));
191
- }
188
+ // Resolve with the exit code (rather than rejecting) so the caller can
189
+ // distinguish "real failure" from "user cancelled the selection" and
190
+ // avoid reporting a false success.
191
+ resolve(code);
192
192
  });
193
193
  child.on('error', reject);
194
194
  });
@@ -202,6 +202,111 @@ async function cleanup(dir) {
202
202
  }
203
203
  }
204
204
 
205
+ /**
206
+ * Install the iterate CLI (the `iterate` command) so the user can run
207
+ * `iterate onboard` / `iterate status` / `iterate refresh` right after
208
+ * installation, making the one-command flow actually end-to-end.
209
+ *
210
+ * The CLI is not required for the AI-assistant skill itself, so a failure
211
+ * here is non-fatal: we warn and let the user install it later.
212
+ *
213
+ * Strategy: prefer ``pipx`` (isolated global install) when available,
214
+ * otherwise fall back to ``python -m pip install --user``. If ``iterate``
215
+ * already exists on PATH, skip entirely.
216
+ */
217
+ async function installCli(pythonBin, sourceDir) {
218
+ if (await commandExists('iterate')) {
219
+ success('iterate CLI already available.');
220
+ return;
221
+ }
222
+
223
+ step('Installing iterate CLI (for `iterate onboard` / status / refresh)');
224
+ try {
225
+ if (await commandExists('pipx')) {
226
+ await runCommand('pipx', ['install', '--force', '.', '-q'], { cwd: sourceDir });
227
+ success('iterate CLI installed via pipx.');
228
+ } else {
229
+ // No pipx: fall back to `pip install --user`. On macOS/Linux this can
230
+ // fail with a PEP 668 "externally-managed-environment" error (e.g.
231
+ // Homebrew Python). Detect that and give the user an actionable next
232
+ // step instead of a generic failure message.
233
+ try {
234
+ await runCommand(pythonBin, ['-m', 'pip', 'install', '--user', '--quiet', '.'], {
235
+ cwd: sourceDir,
236
+ });
237
+ success('iterate CLI installed via pip --user.');
238
+ } catch (err) {
239
+ const msg = err && err.message ? err.message : '';
240
+ if (msg.includes('externally-managed')) {
241
+ warning('System Python is externally managed (PEP 668); `pip install --user` was blocked.');
242
+ hint('Install pipx and retry: brew install pipx && pipx install .');
243
+ hint('Or override the check: python3 -m pip install --user --break-system-packages .');
244
+ } else {
245
+ throw err;
246
+ }
247
+ }
248
+ }
249
+ } catch (err) {
250
+ warning(`Could not install iterate CLI: ${err.message}`);
251
+ hint('Install it later with: pipx install <repo> or pip install .');
252
+ return;
253
+ }
254
+
255
+ if (await commandExists('iterate')) {
256
+ success('iterate CLI is ready to use.');
257
+ } else {
258
+ hint('Ensure ~/.local/bin (or pipx bin) is on your PATH.');
259
+ }
260
+ }
261
+
262
+ /**
263
+ * Ask the user a yes/no question on the terminal.
264
+ *
265
+ * Used to decide whether the current directory should be treated as the
266
+ * target project when the installer is launched from a non-home directory.
267
+ * Falls back to ``defaultNo`` if the input is unrecognized.
268
+ */
269
+ function askYesNo(question, defaultNo = false) {
270
+ return new Promise((resolve) => {
271
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
272
+ const hint = defaultNo ? '[y/N]' : '[Y/n]';
273
+ rl.question(`\x1b[36m◆\x1b[0m ${question} ${hint} `, (answer) => {
274
+ rl.close();
275
+ const a = answer.trim().toLowerCase();
276
+ if (a === 'y' || a === 'yes') resolve(true);
277
+ else if (a === 'n' || a === 'no') resolve(false);
278
+ else resolve(defaultNo);
279
+ });
280
+ });
281
+ }
282
+
283
+ /**
284
+ * Resolve the install mode (global vs project) before any download happens.
285
+ *
286
+ * Rules:
287
+ * - If the user passed --target or --global explicitly, honor it.
288
+ * - Otherwise ("global" by default), if the current directory is NOT the
289
+ * user's home directory, ask whether it is the target project directory.
290
+ * If so, switch to a project-level install into the current directory.
291
+ */
292
+ async function resolveInstallMode(options, ask = askYesNo) {
293
+ if (options.targetExplicit || options.globalExplicit) {
294
+ return options;
295
+ }
296
+ const cwd = process.cwd();
297
+ const home = os.homedir();
298
+ if (cwd !== home) {
299
+ const answer = await ask(
300
+ `当前目录 ${cwd} 看起来是项目目录,是否安装到此项目?(选择否则全局安装到用户目录)`,
301
+ );
302
+ if (answer) {
303
+ options.target = cwd;
304
+ options.global = false;
305
+ }
306
+ }
307
+ return options;
308
+ }
309
+
205
310
  function getVenvPython(venvDir) {
206
311
  const isWindows = process.platform === 'win32';
207
312
  const pythonName = isWindows ? 'python.exe' : 'python';
@@ -218,10 +323,15 @@ async function installRequirements(pythonBin, requirementsPath) {
218
323
  }
219
324
 
220
325
  async function main(options = {}) {
221
- const { global: globalInstall = true, ai = null, token = null, target = null, force = false } = options;
222
-
223
326
  printBanner();
224
327
 
328
+ // Resolve global vs project mode before downloading. If the user launched
329
+ // the installer from a non-home directory without explicit flags, ask
330
+ // whether that directory is the target project.
331
+ options = await resolveInstallMode(options);
332
+
333
+ const { global: globalInstall = true, ai = null, token = null, target = null, force = false } = options;
334
+
225
335
  step('Checking environment');
226
336
  const pythonBin = await findPython();
227
337
  if (!pythonBin) {
@@ -333,8 +443,19 @@ async function main(options = {}) {
333
443
  if (globalInstall) installArgs.push('--global');
334
444
 
335
445
  info('Starting skill installation (Python installer)...');
336
- await runPythonInstall(venvPython, installScript, installArgs, { cwd: sourceDir });
446
+ const installExit = await runPythonInstall(venvPython, installScript, installArgs, { cwd: sourceDir });
447
+ if (installExit !== 0) {
448
+ // The Python installer prints a specific reason (e.g. "No assistants
449
+ // selected. Installation cancelled."). Do not proceed to the CLI install
450
+ // or print a success box — the install was cancelled or failed.
451
+ warning('Skill installation was cancelled or failed; stopping.');
452
+ return installExit;
453
+ }
337
454
  success('iterate-skill installation finished.');
455
+
456
+ // Install the iterate CLI so `iterate onboard` works right after install.
457
+ await installCli(pythonBin, sourceDir);
458
+
338
459
  frameSection('Done', [
339
460
  `\x1b[32m✓\x1b[0m iterate-skill ${tag} installed`,
340
461
  ` Run \x1b[36miterate onboard\x1b[0m in your project to initialize.`,
@@ -349,4 +470,6 @@ module.exports = {
349
470
  main,
350
471
  ITERATE_BANNER,
351
472
  InstallerError,
473
+ resolveInstallMode,
474
+ askYesNo,
352
475
  };
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.6",
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
+ });