iterate-skill-installer 2.1.4 → 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/README.md +1 -1
- package/bin/cli.js +6 -0
- package/lib/installer.js +132 -9
- package/package.json +2 -2
- package/test/mode.test.js +87 -0
package/README.md
CHANGED
|
@@ -38,7 +38,7 @@ Trae, Claude / Claude Code, Cursor, Windsurf, GitHub Copilot, Codex, Gemini CLI,
|
|
|
38
38
|
## Requirements
|
|
39
39
|
|
|
40
40
|
- Node.js 18+
|
|
41
|
-
- Python 3.
|
|
41
|
+
- Python 3.10+
|
|
42
42
|
- `tar` command available on PATH (available by default on macOS, Linux, Windows 10+)
|
|
43
43
|
|
|
44
44
|
## License
|
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
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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,15 +323,20 @@ 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) {
|
|
228
338
|
error('Python is required but was not found on PATH.');
|
|
229
|
-
hint('Install Python 3.
|
|
339
|
+
hint('Install Python 3.10+ and ensure "python3" or "python" is available.');
|
|
230
340
|
return 1;
|
|
231
341
|
}
|
|
232
342
|
success(`Found Python: ${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.
|
|
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
|
+
});
|