iterate-skill-installer 3.1.0 → 3.2.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 +143 -34
- package/package.json +9 -3
- package/test/mode.test.js +0 -220
package/lib/installer.js
CHANGED
|
@@ -47,6 +47,28 @@ const CHECKSUMS_ASSET_NAME = 'SHA256SUMS.txt';
|
|
|
47
47
|
// must never hang the installer forever.
|
|
48
48
|
const CURL_MAX_TIME_SECONDS = '120';
|
|
49
49
|
|
|
50
|
+
// Byte caps so a misbehaving upstream can never exhaust disk or memory:
|
|
51
|
+
// JSON API responses are tiny, checksums are a few KB, and the release
|
|
52
|
+
// tarball is far smaller than the generous ceiling below.
|
|
53
|
+
const JSON_MAX_BYTES = 10 * 1024 * 1024; // 10 MiB
|
|
54
|
+
const CHECKSUMS_MAX_BYTES = 1 * 1024 * 1024; // 1 MiB
|
|
55
|
+
const TARBALL_MAX_BYTES = 100 * 1024 * 1024; // 100 MiB
|
|
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
|
+
|
|
67
|
+
// Curl added --fail-with-body in 7.76. On older curl the installer must fall
|
|
68
|
+
// back to plain --fail so the flag is never rejected by the system curl.
|
|
69
|
+
const CURL_FAIL_WITH_BODY_MIN = { major: 7, minor: 76 };
|
|
70
|
+
let CURL_SUPPORTS_FAIL_WITH_BODY = null; // lazily probed once
|
|
71
|
+
|
|
50
72
|
// Package version read from package.json at runtime so the --version/-v flag
|
|
51
73
|
// and the published npm version always stay in sync.
|
|
52
74
|
const VERSION = require('../package.json').version;
|
|
@@ -133,11 +155,50 @@ function commandExists(bin) {
|
|
|
133
155
|
return result.status === 0;
|
|
134
156
|
}
|
|
135
157
|
|
|
158
|
+
async function supportsCurlFailWithBody() {
|
|
159
|
+
// Lazily probe ``curl --version`` once; the result is cached for the rest
|
|
160
|
+
// of the run. Falls back to the plain ``--fail`` flag (no probing) when
|
|
161
|
+
// curl itself cannot be invoked or its output is unexpected.
|
|
162
|
+
if (CURL_SUPPORTS_FAIL_WITH_BODY === null) {
|
|
163
|
+
try {
|
|
164
|
+
const versionOut = await runCommand('curl', ['--version']);
|
|
165
|
+
const match = versionOut.match(/curl (\d+)\.(\d+)\.\d+/);
|
|
166
|
+
if (!match) {
|
|
167
|
+
CURL_SUPPORTS_FAIL_WITH_BODY = false;
|
|
168
|
+
} else {
|
|
169
|
+
const major = Number(match[1]);
|
|
170
|
+
const minor = Number(match[2]);
|
|
171
|
+
CURL_SUPPORTS_FAIL_WITH_BODY =
|
|
172
|
+
major > CURL_FAIL_WITH_BODY_MIN.major ||
|
|
173
|
+
(major === CURL_FAIL_WITH_BODY_MIN.major &&
|
|
174
|
+
minor >= CURL_FAIL_WITH_BODY_MIN.minor);
|
|
175
|
+
}
|
|
176
|
+
} catch {
|
|
177
|
+
CURL_SUPPORTS_FAIL_WITH_BODY = false;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return CURL_SUPPORTS_FAIL_WITH_BODY;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function isGithubApiUrl(url) {
|
|
184
|
+
// True only for the GitHub API host. Release-asset and other URLs are
|
|
185
|
+
// public and must never receive the caller's PAT (credential surface
|
|
186
|
+
// minimalism) — mirrors scripts/install.py::_is_github_api_url.
|
|
187
|
+
let hostname = null;
|
|
188
|
+
try {
|
|
189
|
+
hostname = new URL(url).hostname.toLowerCase();
|
|
190
|
+
} catch {
|
|
191
|
+
return false;
|
|
192
|
+
}
|
|
193
|
+
return hostname === 'api.github.com' || hostname.endsWith('.api.github.com');
|
|
194
|
+
}
|
|
195
|
+
|
|
136
196
|
async function fetchJson(url, token) {
|
|
137
197
|
// Prefer curl over Node.js fetch because curl uses the system CA store
|
|
138
198
|
// and avoids Node-specific certificate issues in some environments.
|
|
139
|
-
const
|
|
140
|
-
|
|
199
|
+
const failFlag = (await supportsCurlFailWithBody()) ? '--fail-with-body' : '--fail';
|
|
200
|
+
const args = ['-sSL', failFlag, '--max-time', CURL_MAX_TIME_SECONDS, '--max-filesize', String(JSON_MAX_BYTES), '-H', 'Accept: application/vnd.github+json', '-H', 'X-GitHub-Api-Version: 2022-11-28', '-H', 'User-Agent: iterate-skill-installer'];
|
|
201
|
+
if (token && isGithubApiUrl(url)) {
|
|
141
202
|
args.push('-H', `Authorization: Bearer ${token}`);
|
|
142
203
|
}
|
|
143
204
|
args.push(url);
|
|
@@ -152,14 +213,20 @@ async function fetchJson(url, token) {
|
|
|
152
213
|
/**
|
|
153
214
|
* Download a file with curl.
|
|
154
215
|
*
|
|
216
|
+
* The caller's PAT is only attached for GitHub API hosts; release assets are
|
|
217
|
+
* public downloads and must not receive it. ``maxBytes`` is enforced with
|
|
218
|
+
* curl's ``--max-filesize`` so a misbehaving upstream is aborted rather than
|
|
219
|
+
* exhausting disk.
|
|
220
|
+
*
|
|
155
221
|
* When ``progress`` is enabled (used for the tarball), curl's ``--progress-bar``
|
|
156
222
|
* writes a live percentage bar straight to the terminal (stderr is inherited so
|
|
157
223
|
* the bar is visible; stdout stays captured for non-progress downloads).
|
|
158
224
|
*/
|
|
159
|
-
async function downloadFile(url, destPath, token, { progress = false } = {}) {
|
|
160
|
-
const
|
|
225
|
+
async function downloadFile(url, destPath, token, { progress = false, maxBytes = TARBALL_MAX_BYTES } = {}) {
|
|
226
|
+
const failFlag = (await supportsCurlFailWithBody()) ? '--fail-with-body' : '--fail';
|
|
227
|
+
const args = ['-sSL', failFlag, '--max-time', CURL_MAX_TIME_SECONDS, '--max-filesize', String(maxBytes), '-o', destPath, '-H', 'User-Agent: iterate-skill-installer'];
|
|
161
228
|
if (progress) args.push('--progress-bar');
|
|
162
|
-
if (token) {
|
|
229
|
+
if (token && isGithubApiUrl(url)) {
|
|
163
230
|
args.push('-H', `Authorization: Bearer ${token}`);
|
|
164
231
|
}
|
|
165
232
|
args.push(url);
|
|
@@ -185,20 +252,32 @@ function parseChecksums(text) {
|
|
|
185
252
|
const map = new Map();
|
|
186
253
|
for (const line of text.split(/\r?\n/)) {
|
|
187
254
|
const trimmed = line.trim();
|
|
188
|
-
if (!trimmed) continue;
|
|
255
|
+
if (!trimmed || trimmed.startsWith('#')) continue;
|
|
189
256
|
const parts = trimmed.split(/\s+/);
|
|
190
|
-
if (parts.length
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
+
);
|
|
201
279
|
}
|
|
280
|
+
map.set(basename, parts[0]);
|
|
202
281
|
}
|
|
203
282
|
return map;
|
|
204
283
|
}
|
|
@@ -406,18 +485,32 @@ async function installCli(pythonBin, sourceDir) {
|
|
|
406
485
|
*
|
|
407
486
|
* Used to decide whether the current directory should be treated as the
|
|
408
487
|
* target project when the installer is launched from a non-home directory.
|
|
409
|
-
* 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.
|
|
410
491
|
*/
|
|
411
492
|
function askYesNo(question, defaultNo = false) {
|
|
412
493
|
return new Promise((resolve) => {
|
|
413
494
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
414
495
|
const hint = defaultNo ? '[y/N]' : '[Y/n]';
|
|
415
|
-
|
|
496
|
+
let settled = false;
|
|
497
|
+
const finish = (value) => {
|
|
498
|
+
if (settled) return;
|
|
499
|
+
settled = true;
|
|
500
|
+
clearTimeout(timer);
|
|
416
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) => {
|
|
417
510
|
const a = answer.trim().toLowerCase();
|
|
418
|
-
if (a === 'y' || a === 'yes')
|
|
419
|
-
else if (a === 'n' || a === 'no')
|
|
420
|
-
else
|
|
511
|
+
if (a === 'y' || a === 'yes') finish(true);
|
|
512
|
+
else if (a === 'n' || a === 'no') finish(false);
|
|
513
|
+
else finish(defaultNo);
|
|
421
514
|
});
|
|
422
515
|
});
|
|
423
516
|
}
|
|
@@ -512,6 +605,18 @@ function parseArgs(argv) {
|
|
|
512
605
|
const arg = argv[i];
|
|
513
606
|
const next = argv[i + 1];
|
|
514
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
|
+
|
|
515
620
|
switch (arg) {
|
|
516
621
|
case '--ai':
|
|
517
622
|
if (!next || next.startsWith('-')) {
|
|
@@ -550,16 +655,6 @@ function parseArgs(argv) {
|
|
|
550
655
|
// user who only wants the skill is not surprised by a global install.
|
|
551
656
|
options.noCli = true;
|
|
552
657
|
break;
|
|
553
|
-
case '--help':
|
|
554
|
-
case '-h':
|
|
555
|
-
// Show usage and exit 0 (handled by bin/cli.js).
|
|
556
|
-
options.mode = 'help';
|
|
557
|
-
break;
|
|
558
|
-
case '--version':
|
|
559
|
-
case '-v':
|
|
560
|
-
// Print the package version and exit 0 (handled by bin/cli.js).
|
|
561
|
-
options.mode = 'version';
|
|
562
|
-
break;
|
|
563
658
|
case '--token':
|
|
564
659
|
if (!next || next.startsWith('-')) {
|
|
565
660
|
console.error('Error: --token requires a value (a flag was found instead)');
|
|
@@ -651,7 +746,9 @@ async function main(options = {}) {
|
|
|
651
746
|
await downloadFile(tarballAsset.browser_download_url, tarballPath, token, { progress: true });
|
|
652
747
|
|
|
653
748
|
info('Downloading checksums...');
|
|
654
|
-
await downloadFile(checksumAsset.browser_download_url, checksumPath, token
|
|
749
|
+
await downloadFile(checksumAsset.browser_download_url, checksumPath, token, {
|
|
750
|
+
maxBytes: CHECKSUMS_MAX_BYTES,
|
|
751
|
+
});
|
|
655
752
|
|
|
656
753
|
info('Verifying checksum...');
|
|
657
754
|
const checksums = parseChecksums(fs.readFileSync(checksumPath, 'utf8'));
|
|
@@ -661,7 +758,17 @@ async function main(options = {}) {
|
|
|
661
758
|
return 1;
|
|
662
759
|
}
|
|
663
760
|
const actualHash = await sha256File(tarballPath);
|
|
664
|
-
|
|
761
|
+
// Constant-time compare (timingSafeEqual): the expected hash travels
|
|
762
|
+
// over the network with the tarball, so an early-exit string inequality
|
|
763
|
+
// would leak prefix information usable to forge a checksum. The length
|
|
764
|
+
// guard keeps timingSafeEqual from throwing on mismatched lengths.
|
|
765
|
+
const actualBuf = Buffer.from(actualHash.toLowerCase(), 'utf8');
|
|
766
|
+
const expectedBuf = Buffer.from(expectedHash.toLowerCase(), 'utf8');
|
|
767
|
+
const hashMatches =
|
|
768
|
+
actualBuf.length === expectedBuf.length &&
|
|
769
|
+
actualBuf.length > 0 &&
|
|
770
|
+
crypto.timingSafeEqual(actualBuf, expectedBuf);
|
|
771
|
+
if (!hashMatches) {
|
|
665
772
|
error('Checksum mismatch — refusing to install.');
|
|
666
773
|
return 1;
|
|
667
774
|
}
|
|
@@ -752,4 +859,6 @@ module.exports = {
|
|
|
752
859
|
parseChecksums,
|
|
753
860
|
extractTarball,
|
|
754
861
|
rejectLinkEntries,
|
|
862
|
+
isGithubApiUrl,
|
|
863
|
+
supportsCurlFailWithBody,
|
|
755
864
|
};
|
package/package.json
CHANGED
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "iterate-skill-installer",
|
|
3
|
-
"version": "3.1
|
|
3
|
+
"version": "3.2.1",
|
|
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,220 +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 } = 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
|
-
|
|
217
|
-
run().catch((err) => {
|
|
218
|
-
console.error(err);
|
|
219
|
-
process.exit(1);
|
|
220
|
-
});
|