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 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 args = ['-sSL', '--fail-with-body', '--max-time', CURL_MAX_TIME_SECONDS, '-H', 'Accept: application/vnd.github+json', '-H', 'X-GitHub-Api-Version: 2022-11-28', '-H', 'User-Agent: iterate-skill-installer'];
140
- if (token) {
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 args = ['-sSL', '--fail-with-body', '--max-time', CURL_MAX_TIME_SECONDS, '-o', destPath, '-H', 'User-Agent: iterate-skill-installer'];
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 >= 2) {
191
- // Normalize the filename the same way scripts/install.py does
192
- // (_parse_checksum): strip all leading GNU tar binary-mode markers a la
193
- // Python's name.lstrip("*") (ONE '*', or several in a malformed entry —
194
- // strip them all so the two parsers can never disagree), then strip the
195
- // './' prefix, then match on the basename so entries with a subpath or
196
- // versioned component (e.g. "dist/iterate-skill.tar.gz" or
197
- // "./iterate-skill.tar.gz") still resolve to the expected asset.
198
- const normalized = parts[1].replace(/^\*+/, '').replace(/^\.\//, '');
199
- const basename = normalized.split('/').pop();
200
- 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
+ );
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
- 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);
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') resolve(true);
419
- else if (a === 'n' || a === 'no') resolve(false);
420
- else resolve(defaultNo);
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
- if (actualHash.toLowerCase() !== expectedHash.toLowerCase()) {
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.0",
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
- });