@whobot/cli-test 0.0.0-stage → 0.0.2

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.
@@ -0,0 +1,1878 @@
1
+ #!/usr/bin/env node
2
+ import { createHash } from "node:crypto";
3
+ import { spawnSync } from "node:child_process";
4
+ import {
5
+ accessSync,
6
+ constants,
7
+ createWriteStream,
8
+ openSync,
9
+ fsyncSync,
10
+ closeSync,
11
+ realpathSync,
12
+ } from "node:fs";
13
+ import fs from "node:fs/promises";
14
+ import http from "node:http";
15
+ import https from "node:https";
16
+ import os from "node:os";
17
+ import path from "node:path";
18
+ import tls from "node:tls";
19
+ import { fileURLToPath } from "node:url";
20
+ import { gunzipSync, inflateRawSync } from "node:zlib";
21
+
22
+ const here = path.dirname(fileURLToPath(import.meta.url));
23
+ const pkgRoot = path.resolve(here, "..");
24
+ const bootstrap = JSON.parse(
25
+ await fs.readFile(path.join(pkgRoot, "bootstrap.json"), "utf8"),
26
+ );
27
+ const pkg = JSON.parse(
28
+ await fs.readFile(path.join(pkgRoot, "package.json"), "utf8"),
29
+ );
30
+
31
+ // The release profile (ADR-0015) is baked into the published package: its npm
32
+ // entry command is the single package.json bin key and its CLI home directory
33
+ // is bootstrap.homeName.
34
+ const COMMAND = cliCommand(pkg);
35
+ const HOME_NAME = cliHomeName(bootstrap);
36
+
37
+ function cliCommand(p) {
38
+ const names = Object.keys(p.bin || {});
39
+ if (names.length !== 1 || !/^whobot(-[a-z]+)?$/.test(names[0])) {
40
+ throw new Error(`package.json must register exactly one whobot command, got ${JSON.stringify(p.bin)}`);
41
+ }
42
+ return names[0];
43
+ }
44
+
45
+ function cliHomeName(b) {
46
+ const name = String(b.homeName || "");
47
+ if (!/^\.whobot(-[a-z]+)?-cli$/.test(name)) {
48
+ throw new Error(`bootstrap.json homeName is missing or invalid: ${JSON.stringify(b.homeName)}`);
49
+ }
50
+ return name;
51
+ }
52
+
53
+ const OS_MAP = { darwin: "darwin", linux: "linux", win32: "windows" };
54
+ const ARCH_MAP = { x64: "amd64", arm64: "arm64" };
55
+ const BINARY_UNIX = COMMAND;
56
+ const BINARY_WINDOWS = `${COMMAND}.exe`;
57
+ const ALLOWED_FILES = new Set([
58
+ BINARY_UNIX,
59
+ BINARY_WINDOWS,
60
+ "LICENSE",
61
+ "LICENSE.md",
62
+ "LICENSE.txt",
63
+ "NOTICE",
64
+ "NOTICE.md",
65
+ "NOTICE.txt",
66
+ "README",
67
+ "README.md",
68
+ "README.txt",
69
+ ]);
70
+ const DIST_TAGS = new Set(["latest", "next", "canary", "stable", "intranet", "*"]);
71
+ const SEMVER =
72
+ /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
73
+ const STABLE_TARGET =
74
+ /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
75
+ const SHA256_HEX = /^[0-9a-fA-F]{64}$/;
76
+ const DOWNLOAD_TIMEOUT_MS = 60_000;
77
+ const SMOKE_TIMEOUT_MS = 15_000;
78
+ const ZIP_EOCD_SIZE = 22;
79
+ const ZIP_CENTRAL_HEADER_SIZE = 46;
80
+ const ZIP_LOCAL_HEADER_SIZE = 30;
81
+ const ZIP_ENCRYPTED_FLAG = 0x1;
82
+ const ZIP_STRONG_ENCRYPTION_FLAG = 0x40;
83
+ const ZIP64_UINT32 = 0xffffffff;
84
+
85
+ export class InstallerError extends Error {
86
+ constructor(message, code = "INTERNAL") {
87
+ super(message);
88
+ this.name = "InstallerError";
89
+ this.code = code;
90
+ }
91
+ }
92
+
93
+ function writeJSONFailure(code, message) {
94
+ const body = {
95
+ ok: false,
96
+ error: {
97
+ code,
98
+ category: code === "CHANNEL_INVALID" ? "input" : "unavailable",
99
+ userMessage: code === "CHANNEL_INVALID" ? "安装包的发行渠道信息有误,请重新安装。" : "CLI 更新未完成,已有身份数据已保留。",
100
+ detail: message,
101
+ retry: { safe: false },
102
+ },
103
+ next: { action: "stop" },
104
+ meta: {
105
+ requestId: "req_00000000000000000000000000000000",
106
+ cli: pkg.version,
107
+ protocol: 2,
108
+ notices: [],
109
+ },
110
+ };
111
+ process.stdout.write(`${JSON.stringify(body)}\n`);
112
+ }
113
+
114
+ function bakedChannel() {
115
+ const ch = String(bootstrap.channel || "").trim();
116
+ if (ch !== "intranet" && ch !== "latest") {
117
+ throw new InstallerError("发行渠道缺失或非法", "CHANNEL_INVALID");
118
+ }
119
+ return ch;
120
+ }
121
+
122
+ function logStderr(message) {
123
+ process.stderr.write(`${COMMAND}: ${message}\n`);
124
+ }
125
+
126
+ export function nativeBinaryPath(root = pkgRoot) {
127
+ const platform = mapPlatform(process.platform, process.arch, bootstrap);
128
+ return path.join(root, "bin", platform.binary);
129
+ }
130
+
131
+ async function isRegularFile(p) {
132
+ try {
133
+ const st = await fs.stat(p);
134
+ return st.isFile();
135
+ } catch {
136
+ return false;
137
+ }
138
+ }
139
+
140
+ export async function ensureNativeBinary(env, { force = false, dest: destOverride, version: versionOverride } = {}) {
141
+ const version = resolveVersion(undefined, {}, versionOverride || pkg.version);
142
+ const platform = mapPlatform(process.platform, process.arch, bootstrap);
143
+ const dest = destOverride || path.join(pkgRoot, "bin", platform.binary);
144
+ if (!force && (await isRegularFile(dest))) {
145
+ return dest;
146
+ }
147
+ logStderr(`downloading native CLI ${version} for ${platform.os}/${platform.arch}`);
148
+ const cwd = process.cwd();
149
+ const smokeHome = String(env.WHOBOT_AGENT_CLI_HOME || "").trim() || cwd;
150
+ await fs.mkdir(path.dirname(dest), { recursive: true, mode: 0o755 });
151
+ const smokeStage = path.join(
152
+ path.dirname(dest),
153
+ `.${platform.binary}.stage.${process.pid}.${Math.random().toString(16).slice(2)}`,
154
+ );
155
+ const base = resolveDownloadBase(env);
156
+ const work = await makeWorkDir([tmpDirFromEnv(env), pkgRoot, cwd]);
157
+ try {
158
+ const manifest = await fetchManifest(base, version, env);
159
+ const artifact = selectArtifact(manifest, version, platform, bootstrap);
160
+ const archivePath = path.join(work, artifact.name);
161
+ await downloadFile(
162
+ `${base}/${encodeURIComponent(version)}/${encodeURIComponent(artifact.name)}`,
163
+ archivePath,
164
+ artifact.size,
165
+ artifact.sha256,
166
+ env,
167
+ );
168
+ const archive = await fs.readFile(archivePath);
169
+ const entries = inspectAndExtract(archive, artifact.name);
170
+ const stagedBin = selectBinary(entries, platform);
171
+ await fs.writeFile(smokeStage, stagedBin.body, { mode: 0o755 });
172
+ await fs.chmod(smokeStage, 0o755);
173
+ smoke(smokeStage, version, smokeHome, env);
174
+ await replaceAtomically(smokeStage, dest);
175
+ smoke(dest, version, smokeHome, env);
176
+ return dest;
177
+ } finally {
178
+ await fs.unlink(smokeStage).catch(() => {});
179
+ await fs.rm(work, { recursive: true, force: true });
180
+ }
181
+ }
182
+
183
+ function isNpxPath(value) {
184
+ const v = String(value || "");
185
+ if (!v) {
186
+ return false;
187
+ }
188
+ return (
189
+ v.includes(`${path.sep}_npx${path.sep}`) ||
190
+ v.includes("/_npx/") ||
191
+ v.includes("\\_npx\\") ||
192
+ v.includes(`${path.sep}.npm${path.sep}_npx`) ||
193
+ /(?:^|[\\/])_npx(?:[\\/]|$)/.test(v)
194
+ );
195
+ }
196
+
197
+ export function shouldSkipPostinstall(env) {
198
+ const ua = String(env.npm_config_user_agent || "");
199
+ if (/\bnpx\b/i.test(ua)) {
200
+ return true;
201
+ }
202
+ if (String(env.npm_lifecycle_event || "") === "npx") {
203
+ return true;
204
+ }
205
+ const localPrefix = String(env.npm_config_local_prefix || "");
206
+ if (isNpxPath(localPrefix) || isNpxPath(pkgRoot)) {
207
+ return true;
208
+ }
209
+ return false;
210
+ }
211
+
212
+ export async function runPostinstall(env) {
213
+ if (shouldSkipPostinstall(env)) {
214
+ logStderr("skipping native download in npx cache");
215
+ return;
216
+ }
217
+ try {
218
+ await ensureNativeBinary(env, { force: true });
219
+ } catch (err) {
220
+ const message = err && err.message ? err.message : String(err);
221
+ if (/download base is not set/i.test(message)) {
222
+ logStderr(`${message}; leaving native binary for first command`);
223
+ return;
224
+ }
225
+ throw err;
226
+ }
227
+ }
228
+
229
+ export function whichCommand(name, env, ignoreRealpath = "") {
230
+ const pathEnv = env.PATH || env.Path || "";
231
+ const exts = process.platform === "win32" ? [".cmd", ".exe", ".bat", ""] : [""];
232
+ for (const dir of pathEnv.split(path.delimiter)) {
233
+ if (!dir) {
234
+ continue;
235
+ }
236
+ for (const ext of exts) {
237
+ const candidate = path.join(dir, name + ext);
238
+ try {
239
+ accessSync(candidate, constants.X_OK);
240
+ if (ignoreRealpath && realpathSync(candidate) === ignoreRealpath) continue;
241
+ return candidate;
242
+ } catch {
243
+ // try next
244
+ }
245
+ }
246
+ }
247
+ return "";
248
+ }
249
+
250
+ export function findRuntimeTools(env) {
251
+ const node = whichCommand("node", env);
252
+ const npm = whichCommand("npm", env);
253
+ const npx = whichCommand("npx", env);
254
+ const missing = [];
255
+ if (!node) {
256
+ missing.push("node");
257
+ }
258
+ if (!npm) {
259
+ missing.push("npm");
260
+ }
261
+ if (!npx) {
262
+ missing.push("npx");
263
+ }
264
+ return { node: node || process.execPath, npm, npx, missing };
265
+ }
266
+
267
+ export function runtimeMissingMessage(missing) {
268
+ return (
269
+ `missing required tools: ${missing.join(", ")}. ` +
270
+ `Install Node.js >= 20 from https://nodejs.org (includes npm and npx), then retry. ` +
271
+ `Not installing system packages or running business commands.`
272
+ );
273
+ }
274
+
275
+ export function globalNpmEnv(env) {
276
+ const out = { ...env };
277
+ for (const key of Object.keys(out)) {
278
+ const lower = key.toLowerCase();
279
+ if (lower === "npm_config_prefix" || lower === "npm_config_global_prefix") {
280
+ if (isNpxPath(out[key])) {
281
+ delete out[key];
282
+ }
283
+ }
284
+ if (
285
+ lower === "npm_config_user_agent" ||
286
+ lower === "npm_lifecycle_event" ||
287
+ lower === "npm_lifecycle_script"
288
+ ) {
289
+ delete out[key];
290
+ }
291
+ }
292
+ if (isNpxPath(out.NPM_CONFIG_PREFIX)) {
293
+ delete out.NPM_CONFIG_PREFIX;
294
+ }
295
+ out.npm_config_yes = "true";
296
+ out.npm_config_fund = "false";
297
+ out.npm_config_audit = "false";
298
+ out.npm_config_update_notifier = "false";
299
+ return out;
300
+ }
301
+
302
+ export function globalWhobotEntry(prefix) {
303
+ if (process.platform === "win32") {
304
+ return path.join(prefix, `${COMMAND}.cmd`);
305
+ }
306
+ return path.join(prefix, "bin", COMMAND);
307
+ }
308
+
309
+ function spawnNpm(tools, args, env, timeoutMs) {
310
+ const opts = {
311
+ encoding: "utf8",
312
+ env,
313
+ timeout: timeoutMs,
314
+ windowsHide: true,
315
+ stdio: ["ignore", "pipe", "pipe"],
316
+ };
317
+ if (process.platform === "win32") {
318
+ return spawnSync("cmd.exe", ["/c", "npm", ...args], opts);
319
+ }
320
+ return spawnSync(tools.npm, args, opts);
321
+ }
322
+
323
+ export function readNpmGlobalPrefix(tools, env) {
324
+ const result = spawnNpm(tools, ["prefix", "-g"], env, 30_000);
325
+ const prefix = String(result.stdout || "").trim();
326
+ if (result.error) {
327
+ throw new InstallerError(`cannot resolve npm global prefix: ${result.error.message}`);
328
+ }
329
+ if (result.status !== 0 || prefix === "") {
330
+ throw new InstallerError(
331
+ `cannot resolve npm global prefix: ${String(result.stderr || result.stdout || "").trim()}`,
332
+ );
333
+ }
334
+ return prefix;
335
+ }
336
+
337
+ export function readGlobalPackageVersion(tools, env) {
338
+ const result = spawnNpm(
339
+ tools,
340
+ ["list", "-g", "--depth=0", "--json", pkg.name],
341
+ env,
342
+ 30_000,
343
+ );
344
+ let parsed = {};
345
+ try {
346
+ parsed = JSON.parse(result.stdout || "{}");
347
+ } catch {
348
+ return null;
349
+ }
350
+ const ver = parsed?.dependencies?.[pkg.name]?.version;
351
+ return ver ? String(ver) : null;
352
+ }
353
+
354
+ function npmInstallGlobalFrom(tools, spec, env) {
355
+ const result = spawnNpm(
356
+ tools,
357
+ [
358
+ "install",
359
+ "-g",
360
+ "--yes",
361
+ "--no-fund",
362
+ "--no-audit",
363
+ "--foreground-scripts",
364
+ "--install-links",
365
+ spec,
366
+ ],
367
+ env,
368
+ 120_000,
369
+ );
370
+ if (result.error) {
371
+ throw new InstallerError(`npm install -g failed: ${result.error.message}`);
372
+ }
373
+ if (result.status !== 0) {
374
+ throw new InstallerError(
375
+ `npm install -g failed: ${String(result.stderr || result.stdout || "").trim()}`,
376
+ );
377
+ }
378
+ }
379
+
380
+ const CHANNEL_FILE = ".whobot-channel";
381
+ const VERIFY_TIMEOUT_MS = 90_000;
382
+
383
+ function parseChannelAndForward(argv) {
384
+ const forwarded = [];
385
+ for (let i = 0; i < argv.length; i++) {
386
+ const a = argv[i];
387
+ if (a === "--channel" || a.startsWith("--channel=")) {
388
+ throw new InstallerError("发行渠道由发行包内置,不接受 --channel", "CHANNEL_INVALID");
389
+ }
390
+ forwarded.push(a);
391
+ }
392
+ return { forwarded };
393
+ }
394
+
395
+ function positionalCommands(argv) {
396
+ const tokens = [];
397
+ for (let i = 0; i < argv.length; i++) {
398
+ const a = argv[i];
399
+ if (a === "--json") {
400
+ continue;
401
+ }
402
+ if (a === "-Q" || a === "--global-config" || a === "--channel" || a === "--target-version") {
403
+ i++;
404
+ continue;
405
+ }
406
+ if (
407
+ a.startsWith("--global-config=") ||
408
+ a.startsWith("-Q=") ||
409
+ a.startsWith("--channel=") ||
410
+ a.startsWith("-")
411
+ ) {
412
+ continue;
413
+ }
414
+ tokens.push(a);
415
+ }
416
+ return tokens;
417
+ }
418
+
419
+ export function isUpdateApply(argv) {
420
+ const cmds = positionalCommands(argv);
421
+ return cmds.length === 1 && cmds[0] === "update" && argv.some((a) => a === "--yes" || a === "--yes=true");
422
+ }
423
+
424
+ function confirmedTargetVersion(argv) {
425
+ const targets = [];
426
+ for (let i = 0; i < argv.length; i++) {
427
+ if (argv[i] === "--target-version") targets.push(argv[++i]);
428
+ else if (argv[i].startsWith("--target-version=")) targets.push(argv[i].slice("--target-version=".length));
429
+ }
430
+ if (targets.length !== 1 || !STABLE_TARGET.test(targets[0] || "")) {
431
+ throw new InstallerError("请使用更新预览返回的含目标版本的 next.command", "UPDATE_VERIFY_FAILED");
432
+ }
433
+ return targets[0];
434
+ }
435
+
436
+ export function globalPackageDir(prefix, nodeOS = process.platform) {
437
+ if (nodeOS === "win32") {
438
+ return path.join(prefix, "node_modules", ...pkg.name.split("/"));
439
+ }
440
+ return path.join(prefix, "lib", "node_modules", ...pkg.name.split("/"));
441
+ }
442
+
443
+ export function packageNativeBinary(prefix, nodeOS = process.platform, nodeArch = process.arch) {
444
+ const platform = mapPlatform(nodeOS, nodeArch, bootstrap);
445
+ return path.join(globalPackageDir(prefix, nodeOS), "bin", platform.binary);
446
+ }
447
+
448
+ export function nativeBackupPath(nativePath) {
449
+ return `${nativePath}.old`;
450
+ }
451
+
452
+ async function unlinkIfExists(p) {
453
+ try {
454
+ await fs.unlink(p);
455
+ } catch (err) {
456
+ if (!err || err.code !== "ENOENT") {
457
+ throw err;
458
+ }
459
+ }
460
+ }
461
+
462
+ export async function prepareNativeReplace(nativePath, platform = process.platform) {
463
+ const noop = { restore: async () => {}, discard: async () => {} };
464
+ if (platform !== "win32") {
465
+ return noop;
466
+ }
467
+ const backup = nativeBackupPath(nativePath);
468
+ await unlinkIfExists(backup);
469
+ if (!(await isRegularFile(nativePath))) {
470
+ return noop;
471
+ }
472
+ await fs.rename(nativePath, backup);
473
+ return {
474
+ restore: async () => {
475
+ if (!(await isRegularFile(backup))) {
476
+ return;
477
+ }
478
+ await fs.rm(nativePath, { force: true }).catch(() => {});
479
+ await fs.rename(backup, nativePath);
480
+ },
481
+ discard: async () => {
482
+ await fs.rm(backup, { force: true }).catch(() => {});
483
+ },
484
+ };
485
+ }
486
+
487
+ export async function recoverNativeBackup(nativePath, platform = process.platform) {
488
+ if (platform !== "win32") {
489
+ return;
490
+ }
491
+ const backup = nativeBackupPath(nativePath);
492
+ if (!(await isRegularFile(backup))) {
493
+ return;
494
+ }
495
+ if (!(await isRegularFile(nativePath))) {
496
+ await fs.rename(backup, nativePath);
497
+ return;
498
+ }
499
+ const result = spawnSync(nativePath, ["version", "--json"], {
500
+ encoding: "utf8",
501
+ timeout: 10_000,
502
+ windowsHide: true,
503
+ stdio: ["ignore", "pipe", "pipe"],
504
+ });
505
+ if (!result.error && result.status === 0) {
506
+ await fs.rm(backup, { force: true }).catch(() => {});
507
+ return;
508
+ }
509
+ await fs.rm(nativePath, { force: true }).catch(() => {});
510
+ await fs.rename(backup, nativePath);
511
+ }
512
+
513
+ function channelPath(root) {
514
+ return path.join(root, CHANNEL_FILE);
515
+ }
516
+
517
+ async function writeInstallChannel(root, channel) {
518
+ if (!channel) {
519
+ return;
520
+ }
521
+ await fs.mkdir(root, { recursive: true });
522
+ await fs.writeFile(channelPath(root), `${channel}\n`, {
523
+ encoding: "utf8",
524
+ mode: 0o644,
525
+ });
526
+ }
527
+
528
+ async function readInstallChannel(root) {
529
+ try {
530
+ return String(await fs.readFile(channelPath(root), "utf8")).trim();
531
+ } catch {
532
+ return "";
533
+ }
534
+ }
535
+
536
+ function readDistTags(tools, env) {
537
+ const result = spawnNpm(tools, ["view", pkg.name, "dist-tags", "--json"], env, 30_000);
538
+ if (result.error) {
539
+ throw new InstallerError(`cannot resolve npm dist-tags: ${result.error.message}`);
540
+ }
541
+ if (result.status !== 0) {
542
+ throw new InstallerError(
543
+ `cannot resolve npm dist-tags for ${pkg.name}: ${String(result.stderr || result.stdout || "").trim()}`,
544
+ );
545
+ }
546
+ try {
547
+ const parsed = JSON.parse(result.stdout || "{}");
548
+ return parsed && typeof parsed === "object" ? parsed : {};
549
+ } catch {
550
+ throw new InstallerError(`cannot parse npm dist-tags for ${pkg.name}`);
551
+ }
552
+ }
553
+
554
+ function rejectExternalChannel(env) {
555
+ if (String(env.WHOBOT_CLI_CHANNEL || "").trim()) {
556
+ throw new InstallerError("发行渠道由发行包内置,不接受 WHOBOT_CLI_CHANNEL", "CHANNEL_INVALID");
557
+ }
558
+ }
559
+
560
+ function verifyNpmEntryVersion(entry, wantVersion, env) {
561
+ const result = spawnSync(entry, ["version", "--json"], {
562
+ encoding: "utf8",
563
+ env,
564
+ timeout: VERIFY_TIMEOUT_MS,
565
+ windowsHide: true,
566
+ stdio: ["ignore", "pipe", "pipe"],
567
+ shell: process.platform === "win32" && /\.(cmd|bat)$/i.test(entry),
568
+ });
569
+ const combined = `${result.stdout || ""}\n${result.stderr || ""}`;
570
+ if (result.error) {
571
+ throw new InstallerError(
572
+ `in-package binary cannot be executed: ${result.error.message}`,
573
+ "UPDATE_VERIFY_FAILED",
574
+ );
575
+ }
576
+ if (result.status !== 0) {
577
+ if (/cannot be executed|not executable/i.test(combined)) {
578
+ throw new InstallerError(
579
+ combined.trim() || `in-package binary cannot be executed: ${entry}`,
580
+ );
581
+ }
582
+ if (/protocol/i.test(combined)) {
583
+ throw new InstallerError(combined.trim() || "protocol incompatible");
584
+ }
585
+ if (/checksum/i.test(combined)) {
586
+ throw new InstallerError(combined.trim() || "checksum mismatch");
587
+ }
588
+ if (/not installed|CLI_NOT_INSTALLED/i.test(combined)) {
589
+ throw new InstallerError(combined.trim());
590
+ }
591
+ throw new InstallerError(
592
+ `version --json verification failed: ${String(result.stderr || result.stdout || "").trim()}`,
593
+ );
594
+ }
595
+ let parsed;
596
+ try {
597
+ parsed = JSON.parse(String(result.stdout || "").trim());
598
+ } catch {
599
+ throw new InstallerError("version --json verification did not emit JSON");
600
+ }
601
+ if (parsed.ok !== true) {
602
+ throw new InstallerError("version --json verification ok=false");
603
+ }
604
+ if (parsed.meta?.protocol !== 2) {
605
+ throw new InstallerError(
606
+ `protocol ${parsed.meta?.protocol} is not 2`,
607
+ );
608
+ }
609
+ if (parsed.data?.version !== wantVersion) {
610
+ throw new InstallerError(
611
+ `installed version ${parsed.data?.version} does not match ${wantVersion}`,
612
+ );
613
+ }
614
+ return parsed;
615
+ }
616
+
617
+ async function repairAndVerifyNative(native, version, env) {
618
+ try {
619
+ verifyNpmEntryVersion(native, version, env);
620
+ } catch {
621
+ // npm registration can survive a missing, damaged or stale native file.
622
+ // Restore that exact package version, then verify the real executable.
623
+ await ensureNativeBinary(env, { force: true, dest: native, version });
624
+ verifyNpmEntryVersion(native, version, env);
625
+ }
626
+ }
627
+
628
+ export async function verifyInstalledCLI(tools, npmEnv, prefix, version, env, ignoreLauncher = "") {
629
+ const registered = readGlobalPackageVersion(tools, npmEnv);
630
+ if (registered !== version) {
631
+ throw new InstallerError(`npm registered version ${registered} does not match ${version}`, "UPDATE_VERIFY_FAILED");
632
+ }
633
+ const native = packageNativeBinary(prefix);
634
+ await repairAndVerifyNative(native, version, env);
635
+ const entry = globalWhobotEntry(prefix);
636
+ verifyNpmEntryVersion(entry, version, env);
637
+ const onPath = whichCommand(COMMAND, env, ignoreLauncher);
638
+ if (onPath && realpathSync(onPath) !== realpathSync(entry)) {
639
+ throw new InstallerError(`PATH resolves ${COMMAND} to another entry: ${onPath}; remove or replace it before using ${entry}`, "UPDATE_VERIFY_FAILED");
640
+ }
641
+ return { entry, native };
642
+ }
643
+
644
+ function parseExplicitInstallArgs(argv) {
645
+ const { forwarded } = parseChannelAndForward(argv);
646
+ for (const a of forwarded) {
647
+ if (a.startsWith("-")) {
648
+ throw new InstallerError(`unknown flag ${a}`);
649
+ }
650
+ throw new InstallerError(`unexpected argument ${a}`);
651
+ }
652
+ }
653
+
654
+ export async function runExplicitInstall(argv, env) {
655
+ rejectExternalChannel(env);
656
+ parseExplicitInstallArgs(argv);
657
+ bakedChannel();
658
+ if (!STABLE_TARGET.test(pkg.version)) {
659
+ throw new InstallerError("安装目标必须是完整的稳定 SemVer 版本", "UPDATE_VERIFY_FAILED");
660
+ }
661
+ const tools = findRuntimeTools(env);
662
+ if (tools.missing.length) {
663
+ throw new InstallerError(runtimeMissingMessage(tools.missing));
664
+ }
665
+ const npmEnv = globalNpmEnv(env);
666
+ const prefix = readNpmGlobalPrefix(tools, npmEnv);
667
+ const entry = globalWhobotEntry(prefix);
668
+ const registered = readGlobalPackageVersion(tools, npmEnv);
669
+ logStderr(`install target ${pkg.name}@${pkg.version} (channel ${bakedChannel()})`);
670
+ // Validate with the new reader before replacing the installed program.
671
+ // Published v1 updaters cannot validate the v2 output protocol.
672
+ const staging = await fs.mkdtemp(path.join(os.tmpdir(), "whobot-install-preflight-"));
673
+ const stagedNative = path.join(staging, path.basename(packageNativeBinary(prefix)));
674
+ const updateArgs = ["update", "--yes", "--target-version", pkg.version];
675
+ let prepared;
676
+ try {
677
+ await ensureNativeBinary(env, { force: true, dest: stagedNative, version: pkg.version });
678
+ verifyNpmEntryVersion(stagedNative, pkg.version, env);
679
+ runInstallPhase(stagedNative, ["update", "check-install", "--previous-binary", packageNativeBinary(prefix)], env, "binding", registered);
680
+ runInstallPhase(stagedNative, updateArgs, env, "preflight", registered);
681
+ prepared = await prepareNativeReplace(packageNativeBinary(prefix));
682
+ if (registered !== pkg.version || !(await pathExists(entry))) {
683
+ logStderr(registered ? `installing global ${pkg.name}@${pkg.version} (was ${registered})` : `installing global ${pkg.name}@${pkg.version}`);
684
+ npmInstallGlobalFrom(tools, pkgRoot, npmEnv);
685
+ }
686
+ // Ignore this npx cache launcher only, not other stale host PATH entries.
687
+ const ignoreLauncher = isNpxPath(pkgRoot) ? realpathSync(path.join(pkgRoot, "bin", "run.mjs")) : "";
688
+ const installed = await verifyInstalledCLI(tools, npmEnv, prefix, pkg.version, env, ignoreLauncher);
689
+ runInstallPhase(installed.native, updateArgs, env, "verify", registered);
690
+ await prepared.discard();
691
+ logStderr(`CLI 软件安装已完成;请更新 Skill 至对应渠道最新版,重载宿主或新建任务后重新预检。v2 后续更新请运行 ${COMMAND} update,并执行确认卡中的命令。`);
692
+ return entry;
693
+ } catch (err) {
694
+ if (prepared) await prepared.restore();
695
+ throw err;
696
+ } finally {
697
+ await fs.rm(staging, { recursive: true, force: true });
698
+ }
699
+ }
700
+
701
+ function runInstallPhase(native, args, env, phase, previous, wantVersion = pkg.version) {
702
+ const result = spawnSync(native, args, {
703
+ encoding: "utf8",
704
+ env: { ...env, WHOBOT_UPDATE_PHASE: phase, WHOBOT_UPDATE_LOCK_PID: String(process.pid), WHOBOT_UPDATE_PREVIOUS_VERSION: previous || "" },
705
+ windowsHide: true,
706
+ });
707
+ if (result.error || result.signal) throw new InstallerError(`安装${phase}检查未完成: ${result.error?.message || result.signal}`, "UPDATE_VERIFY_FAILED");
708
+ let response;
709
+ try { response = JSON.parse(result.stdout); } catch { throw new InstallerError("安装检查没有返回合法 JSON", "UPDATE_VERIFY_FAILED"); }
710
+ if (result.status !== 0 || !response.ok || response.next?.action !== "done" || response.data?.installedVersion !== wantVersion) {
711
+ throw new InstallerError(`安装未完成: ${response.error?.code || "UPDATE_VERIFY_FAILED"} ${response.error?.detail || response.error?.userMessage || "目标程序未通过验收"};请按上述原因处理后重试,不要删除客户端安装身份。`, "UPDATE_VERIFY_FAILED");
712
+ }
713
+ return response;
714
+ }
715
+
716
+ export async function runExplicitUpdate(argv, env) {
717
+ rejectExternalChannel(env);
718
+ const { forwarded } = parseChannelAndForward(argv);
719
+ const tools = findRuntimeTools(env);
720
+ if (tools.missing.length) {
721
+ throw new InstallerError(runtimeMissingMessage(tools.missing));
722
+ }
723
+ const npmEnv = globalNpmEnv(env);
724
+ const prefix = readNpmGlobalPrefix(tools, npmEnv);
725
+ const entry = globalWhobotEntry(prefix);
726
+ const previous = readGlobalPackageVersion(tools, npmEnv);
727
+ const channel = bakedChannel();
728
+ const targetVersion = confirmedTargetVersion(argv);
729
+ const spec = `${pkg.name}@${targetVersion}`;
730
+ const replacing = previous !== targetVersion || !(await pathExists(entry));
731
+ const native = packageNativeBinary(prefix);
732
+ const staging = await fs.mkdtemp(path.join(os.tmpdir(), "whobot-update-target-"));
733
+ let prepared;
734
+ try {
735
+ const targetNative = path.join(staging, path.basename(native));
736
+ await ensureNativeBinary(env, { force: true, dest: targetNative, version: targetVersion });
737
+ const checkArgs = ["update", "check-install", "--previous-binary", native];
738
+ for (let i = 0; i < argv.length; i++) {
739
+ if (argv[i] === "--global-config" || argv[i] === "-Q") checkArgs.push(argv[i], argv[++i]);
740
+ else if (argv[i].startsWith("--global-config=") || argv[i].startsWith("-Q=")) checkArgs.push(argv[i]);
741
+ }
742
+ runInstallPhase(targetNative, checkArgs, env, "binding", previous, targetVersion);
743
+ prepared = replacing ? await prepareNativeReplace(native) : { restore: async () => {}, discard: async () => {} };
744
+ if (replacing) {
745
+ logStderr(
746
+ previous
747
+ ? `updating global ${spec} to ${targetVersion} (was ${previous})`
748
+ : `installing global ${spec} (${targetVersion})`,
749
+ );
750
+ npmInstallGlobalFrom(tools, spec, npmEnv);
751
+ } else {
752
+ logStderr(`global ${spec} already ${targetVersion}`);
753
+ }
754
+ if (!(await pathExists(entry))) {
755
+ throw new InstallerError(`global command entry is missing: ${entry}`);
756
+ }
757
+ await verifyInstalledCLI(tools, npmEnv, prefix, targetVersion, env);
758
+ await prepared.discard();
759
+ return {
760
+ entry,
761
+ native,
762
+ forwarded,
763
+ previous: previous || "",
764
+ installedVersion: targetVersion,
765
+ channel,
766
+ };
767
+ } catch (err) {
768
+ if (prepared) await prepared.restore();
769
+ throw err;
770
+ } finally {
771
+ await fs.rm(staging, { recursive: true, force: true });
772
+ }
773
+ }
774
+
775
+ function launcherExitCode(code) {
776
+ if (code === "CHANNEL_INVALID") {
777
+ return 2;
778
+ }
779
+ return 7;
780
+ }
781
+
782
+ function reportNativeFailure(dest, spawnErr, { jsonFailure = false } = {}) {
783
+ const message = spawnErr
784
+ ? `in-package binary cannot be executed: ${spawnErr.message}`
785
+ : `in-package binary cannot be executed: ${dest}`;
786
+ if (jsonFailure) {
787
+ writeJSONFailure("UPDATE_VERIFY_FAILED", message);
788
+ process.exitCode = 7;
789
+ } else {
790
+ process.stderr.write(`${message}\n`);
791
+ process.exitCode = 1;
792
+ }
793
+ }
794
+
795
+ export function spawnNative(dest, argv, env, { jsonFailure = false } = {}) {
796
+ try {
797
+ accessSync(dest, constants.X_OK);
798
+ } catch {
799
+ reportNativeFailure(dest, null, { jsonFailure });
800
+ return;
801
+ }
802
+ const result = spawnSync(dest, argv, {
803
+ stdio: "inherit",
804
+ env,
805
+ windowsHide: false,
806
+ });
807
+ if (result.error) {
808
+ reportNativeFailure(dest, result.error, { jsonFailure });
809
+ return;
810
+ }
811
+ if (result.signal) {
812
+ process.kill(process.pid, result.signal);
813
+ return;
814
+ }
815
+ process.exit(result.status ?? 1);
816
+ }
817
+
818
+ export async function runWhobot(argv, env) {
819
+ if (argv[0] === "install") {
820
+ try {
821
+ const entry = await runExplicitInstall(argv.slice(1), env);
822
+ process.stdout.write(`${entry}\n`);
823
+ process.exitCode = 0;
824
+ } catch (err) {
825
+ process.stderr.write(`${err && err.message ? err.message : err}\n`);
826
+ process.exitCode = 1;
827
+ }
828
+ return;
829
+ }
830
+ await recoverNativeBackup(nativeBinaryPath(pkgRoot));
831
+ if (isUpdateApply(argv)) {
832
+ try {
833
+ rejectExternalChannel(env);
834
+ parseChannelAndForward(argv);
835
+ confirmedTargetVersion(argv);
836
+ let currentNative = nativeBinaryPath(pkgRoot);
837
+ let preflightDir;
838
+ let pre;
839
+ try {
840
+ try {
841
+ verifyNpmEntryVersion(currentNative, pkg.version, env);
842
+ } catch {
843
+ // A broken current program cannot check activity. Run a verified
844
+ // copy from temporary storage without replacing the installed file.
845
+ preflightDir = await fs.mkdtemp(path.join(os.tmpdir(), "whobot-preflight-"));
846
+ currentNative = path.join(preflightDir, path.basename(currentNative));
847
+ await ensureNativeBinary(env, { force: true, dest: currentNative, version: pkg.version });
848
+ }
849
+ pre = spawnSync(currentNative, argv, {
850
+ encoding: "utf8",
851
+ env: {
852
+ ...env,
853
+ WHOBOT_UPDATE_PHASE: "preflight",
854
+ WHOBOT_UPDATE_LOCK_PID: String(process.pid),
855
+ },
856
+ windowsHide: false,
857
+ });
858
+ } finally {
859
+ if (preflightDir) await fs.rm(preflightDir, { recursive: true, force: true });
860
+ }
861
+ if (pre.stderr) {
862
+ process.stderr.write(pre.stderr);
863
+ }
864
+ if (pre.error || pre.signal) {
865
+ throw new InstallerError(`更新前检查未完成: ${pre.error?.message || pre.signal}`, "UPDATE_VERIFY_FAILED");
866
+ }
867
+ if (pre.status !== 0) {
868
+ if (pre.stdout) {
869
+ process.stdout.write(pre.stdout);
870
+ }
871
+ process.exitCode = pre.status ?? 1;
872
+ return;
873
+ }
874
+ const preflight = JSON.parse(pre.stdout);
875
+ if (!preflight.ok || preflight.data?.installedVersion !== pkg.version || preflight.next?.action !== "done") {
876
+ throw new InstallerError("更新前检查没有确认可以替换程序", "UPDATE_VERIFY_FAILED");
877
+ }
878
+ const updated = await runExplicitUpdate(argv, env);
879
+ spawnNative(
880
+ updated.native,
881
+ updated.forwarded,
882
+ {
883
+ ...env,
884
+ WHOBOT_UPDATE_PREVIOUS_VERSION: updated.previous,
885
+ WHOBOT_UPDATE_PHASE: "verify",
886
+ WHOBOT_UPDATE_LOCK_PID: String(process.pid),
887
+ },
888
+ { jsonFailure: true },
889
+ );
890
+ } catch (err) {
891
+ const code = err?.code === "CHANNEL_INVALID" ? "CHANNEL_INVALID" : "UPDATE_VERIFY_FAILED";
892
+ const message = err && err.message ? err.message : String(err);
893
+ writeJSONFailure(code, message);
894
+ process.stderr.write(`${message}\n`);
895
+ process.exitCode = launcherExitCode(code);
896
+ }
897
+ return;
898
+ }
899
+ let dest;
900
+ try {
901
+ dest = await ensureNativeBinary(env);
902
+ } catch (err) {
903
+ const message = err && err.message ? err.message : String(err);
904
+ process.stderr.write(`${message}\n`);
905
+ process.exitCode = 1;
906
+ return;
907
+ }
908
+ spawnNative(dest, argv, env);
909
+ }
910
+
911
+ export async function runInstall(argv, env) {
912
+ const args = parseArgs(argv);
913
+ const version = resolveVersion(args.version, env, pkg.version);
914
+ const platform = mapPlatform(process.platform, process.arch, bootstrap);
915
+ const cwd = process.cwd();
916
+ const home = await resolveInstallHome(env, platform, cwd);
917
+ const dest = path.join(home, "bin", platform.binary);
918
+ const smokeStage = path.join(
919
+ path.dirname(dest),
920
+ `.${platform.binary}.stage.${process.pid}.${Math.random().toString(16).slice(2)}`,
921
+ );
922
+ const base = resolveDownloadBase(env);
923
+ const work = await makeWorkDir([tmpDirFromEnv(env), home, cwd]);
924
+ try {
925
+ const manifest = await fetchManifest(base, version, env);
926
+ const artifact = selectArtifact(manifest, version, platform, bootstrap);
927
+ const archivePath = path.join(work, artifact.name);
928
+ await downloadFile(
929
+ `${base}/${encodeURIComponent(version)}/${encodeURIComponent(artifact.name)}`,
930
+ archivePath,
931
+ artifact.size,
932
+ artifact.sha256,
933
+ env,
934
+ );
935
+ const archive = await fs.readFile(archivePath);
936
+ const entries = inspectAndExtract(archive, artifact.name);
937
+ const stagedBin = selectBinary(entries, platform);
938
+ await fs.writeFile(smokeStage, stagedBin.body, { mode: 0o755 });
939
+ await fs.chmod(smokeStage, 0o755);
940
+ smoke(smokeStage, version, home, env);
941
+ await replaceAtomically(smokeStage, dest);
942
+ smoke(dest, version, home, env);
943
+ await fs.writeFile(path.join(home, ".installer-managed"), `npx ${pkg.name}\n`, {
944
+ encoding: "utf8",
945
+ mode: 0o644,
946
+ });
947
+ return dest;
948
+ } finally {
949
+ await fs.unlink(smokeStage).catch(() => {});
950
+ await fs.rm(work, { recursive: true, force: true });
951
+ }
952
+ }
953
+
954
+ export function parseArgs(argv) {
955
+ let command = "";
956
+ let version;
957
+ for (let i = 0; i < argv.length; i++) {
958
+ const a = argv[i];
959
+ if (a === "--version") {
960
+ version = argv[++i];
961
+ if (version === undefined) {
962
+ throw new InstallerError("missing value for --version");
963
+ }
964
+ continue;
965
+ }
966
+ if (a.startsWith("--version=")) {
967
+ version = a.slice("--version=".length);
968
+ continue;
969
+ }
970
+ if (a.startsWith("-")) {
971
+ throw new InstallerError(`unknown flag ${a}`);
972
+ }
973
+ if (!command) {
974
+ command = a;
975
+ continue;
976
+ }
977
+ throw new InstallerError(`unexpected argument ${a}`);
978
+ }
979
+ if (!command) {
980
+ command = "install";
981
+ }
982
+ if (command !== "install") {
983
+ throw new InstallerError(`unknown command ${command}`);
984
+ }
985
+ return { command, version };
986
+ }
987
+
988
+ export function resolveVersion(flagVersion, env, packageVersion) {
989
+ const raw = firstNonEmpty(flagVersion, env.WHOBOT_INSTALLER_VERSION, packageVersion);
990
+ if (raw === undefined) {
991
+ throw new InstallerError(
992
+ "installer version is required; automation must pass a concrete SemVer (not latest)",
993
+ );
994
+ }
995
+ const v = String(raw).trim();
996
+ if (v === "") {
997
+ throw new InstallerError(
998
+ "installer version is required; automation must pass a concrete SemVer (not latest)",
999
+ );
1000
+ }
1001
+ if (DIST_TAGS.has(v.toLowerCase())) {
1002
+ throw new InstallerError(
1003
+ `refusing dist-tag ${v}; automation must use a concrete installer SemVer`,
1004
+ );
1005
+ }
1006
+ if (!SEMVER.test(v)) {
1007
+ throw new InstallerError(`invalid installer version ${v}; expected concrete SemVer`);
1008
+ }
1009
+ return v;
1010
+ }
1011
+
1012
+ export function mapPlatform(nodeOS, nodeArch, known) {
1013
+ const osName = OS_MAP[nodeOS];
1014
+ const arch = ARCH_MAP[nodeArch];
1015
+ if (!osName || !known.knownOs.includes(osName)) {
1016
+ throw new InstallerError(`unsupported OS ${nodeOS}`);
1017
+ }
1018
+ if (!arch || !known.knownArch.includes(arch)) {
1019
+ throw new InstallerError(`unsupported CPU ${nodeArch}`);
1020
+ }
1021
+ return {
1022
+ os: osName,
1023
+ arch,
1024
+ binary: osName === "windows" ? BINARY_WINDOWS : BINARY_UNIX,
1025
+ };
1026
+ }
1027
+
1028
+ export function resolveHome(env, platform) {
1029
+ const explicit = String(env.WHOBOT_AGENT_CLI_HOME || "").trim();
1030
+ if (explicit) {
1031
+ return path.resolve(explicit);
1032
+ }
1033
+ const home = lookupUserHome(env, platform);
1034
+ if (!home) {
1035
+ return HOME_NAME;
1036
+ }
1037
+ return path.join(home, HOME_NAME);
1038
+ }
1039
+
1040
+ function lookupUserHome(env, platform) {
1041
+ if (platform?.os === "windows") {
1042
+ for (const key of ["USERPROFILE", "HOME"]) {
1043
+ const v = String(env[key] || "").trim();
1044
+ if (v) {
1045
+ return v;
1046
+ }
1047
+ }
1048
+ const drive = String(env.HOMEDRIVE || "").trim();
1049
+ const homepath = env.HOMEPATH;
1050
+ if (drive && String(homepath || "").trim()) {
1051
+ return drive + homepath;
1052
+ }
1053
+ }
1054
+ try {
1055
+ const home = String(os.homedir() || "").trim();
1056
+ return home;
1057
+ } catch {
1058
+ return "";
1059
+ }
1060
+ }
1061
+
1062
+ function tmpDirFromEnv(env) {
1063
+ for (const key of ["TMPDIR", "TEMP", "TMP"]) {
1064
+ const v = String(env[key] || "").trim();
1065
+ if (v) {
1066
+ return v;
1067
+ }
1068
+ }
1069
+ return os.tmpdir();
1070
+ }
1071
+
1072
+ async function dirWritable(dir) {
1073
+ const probe = path.join(
1074
+ dir,
1075
+ `.whobot-write-probe.${process.pid}.${Math.random().toString(16).slice(2)}`,
1076
+ );
1077
+ try {
1078
+ await fs.mkdir(dir, { recursive: true, mode: 0o755 });
1079
+ await fs.writeFile(probe, "ok");
1080
+ await fs.unlink(probe);
1081
+ return true;
1082
+ } catch {
1083
+ await fs.unlink(probe).catch(() => {});
1084
+ return false;
1085
+ }
1086
+ }
1087
+
1088
+ async function pathExists(p) {
1089
+ try {
1090
+ await fs.stat(p);
1091
+ return true;
1092
+ } catch {
1093
+ return false;
1094
+ }
1095
+ }
1096
+
1097
+ export async function resolveInstallHome(env, platform, cwd) {
1098
+ const preferred = resolveHome(env, platform);
1099
+ const dest = path.join(preferred, "bin", platform.binary);
1100
+ const destExists = await pathExists(dest);
1101
+ if (destExists || (await dirWritable(path.join(preferred, "bin")))) {
1102
+ try {
1103
+ return await fs.realpath(preferred);
1104
+ } catch {
1105
+ return path.resolve(preferred);
1106
+ }
1107
+ }
1108
+ const fallback = path.resolve(cwd, HOME_NAME);
1109
+ if (await dirWritable(path.join(fallback, "bin"))) {
1110
+ return await fs.realpath(fallback);
1111
+ }
1112
+ throw new InstallerError(
1113
+ `cannot write CLI Home ${preferred} or workspace fallback ${fallback}`,
1114
+ );
1115
+ }
1116
+
1117
+ export async function makeWorkDir(candidates) {
1118
+ const errors = [];
1119
+ const seen = new Set();
1120
+ for (const root of candidates) {
1121
+ if (!root) {
1122
+ continue;
1123
+ }
1124
+ const resolved = path.resolve(root);
1125
+ if (seen.has(resolved)) {
1126
+ continue;
1127
+ }
1128
+ seen.add(resolved);
1129
+ try {
1130
+ await fs.mkdir(resolved, { recursive: true, mode: 0o755 });
1131
+ return await fs.mkdtemp(path.join(resolved, "whobot-cli-installer-"));
1132
+ } catch (err) {
1133
+ errors.push(`${resolved}: ${err && err.message ? err.message : err}`);
1134
+ }
1135
+ }
1136
+ throw new InstallerError(
1137
+ `no writable directory for installer temp files (${errors.join("; ")})`,
1138
+ );
1139
+ }
1140
+
1141
+ function resolveDownloadBase(env) {
1142
+ const override = String(env.WHOBOT_CLI_DOWNLOAD_BASE || "").trim();
1143
+ const raw = override || String(bootstrap.downloadBase || "").trim();
1144
+ if (!raw) {
1145
+ throw new InstallerError(
1146
+ "download base is not set; set WHOBOT_CLI_DOWNLOAD_BASE or bootstrap.json downloadBase",
1147
+ );
1148
+ }
1149
+ return raw.replace(/\/+$/, "");
1150
+ }
1151
+
1152
+ async function fetchManifest(base, version, env) {
1153
+ const url = `${base}/${encodeURIComponent(version)}/manifest.json`;
1154
+ const got = await httpGet(url, env, {});
1155
+ let man;
1156
+ try {
1157
+ man = JSON.parse(got.body.toString("utf8"));
1158
+ } catch {
1159
+ throw new InstallerError("manifest is not valid JSON");
1160
+ }
1161
+ if (man.schemaVersion !== bootstrap.schemaVersion) {
1162
+ throw new InstallerError(
1163
+ `unknown manifest schemaVersion ${man.schemaVersion}; expected ${bootstrap.schemaVersion}`,
1164
+ );
1165
+ }
1166
+ if (man.version !== version) {
1167
+ throw new InstallerError(
1168
+ `manifest version ${man.version} does not match requested ${version}`,
1169
+ );
1170
+ }
1171
+ if (!Array.isArray(man.artifacts)) {
1172
+ throw new InstallerError("manifest artifacts must be an array");
1173
+ }
1174
+ return man;
1175
+ }
1176
+
1177
+ export function selectArtifact(manifest, version, platform, known) {
1178
+ const seenNames = new Set();
1179
+ const seenPlat = new Set();
1180
+ let match;
1181
+ for (const raw of manifest.artifacts) {
1182
+ const art = normalizeArtifact(raw, known);
1183
+ if (seenNames.has(art.name)) {
1184
+ throw new InstallerError(`duplicate artifact name ${art.name}`);
1185
+ }
1186
+ seenNames.add(art.name);
1187
+ const key = `${art.os}/${art.arch}`;
1188
+ if (seenPlat.has(key)) {
1189
+ throw new InstallerError(`duplicate artifact platform ${key}`);
1190
+ }
1191
+ seenPlat.add(key);
1192
+ const expected = archiveFileName(version, art.os, art.arch);
1193
+ if (art.name !== expected) {
1194
+ throw new InstallerError(`unexpected artifact name ${art.name}`);
1195
+ }
1196
+ if (art.os === platform.os && art.arch === platform.arch) {
1197
+ match = art;
1198
+ }
1199
+ }
1200
+ if (!match) {
1201
+ throw new InstallerError(
1202
+ `no artifact for ${platform.os}/${platform.arch} in manifest`,
1203
+ );
1204
+ }
1205
+ return match;
1206
+ }
1207
+
1208
+ function normalizeArtifact(raw, known) {
1209
+ if (!raw || typeof raw !== "object") {
1210
+ throw new InstallerError("invalid artifact entry");
1211
+ }
1212
+ const osName = String(raw.os || "");
1213
+ const arch = String(raw.arch || "");
1214
+ if (!known.knownOs.includes(osName)) {
1215
+ throw new InstallerError(`unknown artifact OS ${osName}`);
1216
+ }
1217
+ if (!known.knownArch.includes(arch)) {
1218
+ throw new InstallerError(`unknown artifact arch ${arch}`);
1219
+ }
1220
+ const name = String(raw.name || raw.filename || "");
1221
+ if (name === "" || name.includes("/") || name.includes("\\") || name.includes("..")) {
1222
+ throw new InstallerError(`illegal artifact name ${name}`);
1223
+ }
1224
+ const sha = raw.sha256;
1225
+ if (sha === undefined || sha === null || String(sha).trim() === "") {
1226
+ throw new InstallerError("missing checksum");
1227
+ }
1228
+ if (!SHA256_HEX.test(String(sha))) {
1229
+ throw new InstallerError(`invalid checksum for ${name}`);
1230
+ }
1231
+ const size = raw.size;
1232
+ if (!Number.isInteger(size) || size <= 0) {
1233
+ throw new InstallerError(`invalid size for ${name}`);
1234
+ }
1235
+ return { os: osName, arch, name, sha256: String(sha).toLowerCase(), size };
1236
+ }
1237
+
1238
+ function archiveFileName(version, osName, arch) {
1239
+ const ext = osName === "windows" ? ".zip" : ".tar.gz";
1240
+ return `${COMMAND}_${version}_${osName}_${arch}${ext}`;
1241
+ }
1242
+
1243
+ async function downloadFile(url, dest, expectedSize, expectedSHA, env) {
1244
+ const got = await httpGet(url, env, { destFile: dest, expectedSize });
1245
+ if (got.sha256 !== expectedSHA) {
1246
+ throw new InstallerError(
1247
+ `checksum mismatch: got ${got.sha256} want ${expectedSHA}`,
1248
+ );
1249
+ }
1250
+ }
1251
+
1252
+ export function inspectAndExtract(bytes, filename) {
1253
+ let entries;
1254
+ if (filename.endsWith(".tar.gz")) {
1255
+ entries = readTarGz(bytes);
1256
+ } else if (filename.endsWith(".zip")) {
1257
+ entries = readZip(bytes);
1258
+ } else {
1259
+ throw new InstallerError(`unsupported archive ${filename}`);
1260
+ }
1261
+ validateEntries(entries);
1262
+ return entries;
1263
+ }
1264
+
1265
+ function validateEntries(entries) {
1266
+ const seen = new Set();
1267
+ let hasBinary = false;
1268
+ for (const e of entries) {
1269
+ const clean = inspectName(e.name);
1270
+ if (isScript(clean)) {
1271
+ throw new InstallerError(`archive script ${e.name}`);
1272
+ }
1273
+ if (!ALLOWED_FILES.has(clean)) {
1274
+ throw new InstallerError(`unexpected archive file ${e.name}`);
1275
+ }
1276
+ if (seen.has(clean)) {
1277
+ throw new InstallerError(`duplicate archive name ${e.name}`);
1278
+ }
1279
+ seen.add(clean);
1280
+ if (clean === BINARY_UNIX || clean === BINARY_WINDOWS) {
1281
+ hasBinary = true;
1282
+ }
1283
+ }
1284
+ if (!hasBinary) {
1285
+ throw new InstallerError("missing expected binary");
1286
+ }
1287
+ }
1288
+
1289
+ export function inspectName(raw) {
1290
+ let n = String(raw).replaceAll("\\", "/");
1291
+ if (n.startsWith("./")) {
1292
+ n = n.slice(2);
1293
+ }
1294
+ if (n === "" || n === ".") {
1295
+ throw new InstallerError(`unexpected archive file ${raw}`);
1296
+ }
1297
+ if (n.startsWith("/")) {
1298
+ throw new InstallerError(`path traversal ${raw}`);
1299
+ }
1300
+ const parts = n.split("/");
1301
+ for (const p of parts) {
1302
+ if (p === "..") {
1303
+ throw new InstallerError(`path traversal ${raw}`);
1304
+ }
1305
+ }
1306
+ if (parts.length !== 1) {
1307
+ throw new InstallerError(`unexpected archive file ${raw}`);
1308
+ }
1309
+ if (parts[0].includes(":")) {
1310
+ throw new InstallerError(`path traversal ${raw}`);
1311
+ }
1312
+ return parts[0];
1313
+ }
1314
+
1315
+ function isScript(name) {
1316
+ const lower = name.toLowerCase();
1317
+ return lower.endsWith(".sh") || lower.endsWith(".ps1") || lower.endsWith(".bat");
1318
+ }
1319
+
1320
+ function selectBinary(entries, platform) {
1321
+ const found = entries.find((e) => inspectName(e.name) === platform.binary);
1322
+ if (!found) {
1323
+ throw new InstallerError(`archive missing ${platform.binary}`);
1324
+ }
1325
+ return found;
1326
+ }
1327
+
1328
+ function readTarGz(bytes) {
1329
+ let tar;
1330
+ try {
1331
+ tar = gunzipSync(bytes);
1332
+ } catch (err) {
1333
+ throw new InstallerError(`gzip: ${err.message}`);
1334
+ }
1335
+ return readTar(tar);
1336
+ }
1337
+
1338
+ function readTar(buf) {
1339
+ const files = [];
1340
+ let offset = 0;
1341
+ while (offset + 512 <= buf.length) {
1342
+ const header = buf.subarray(offset, offset + 512);
1343
+ if (isZeroBlock(header)) {
1344
+ break;
1345
+ }
1346
+ const nameRaw = cstring(header.subarray(0, 100));
1347
+ const prefix = cstring(header.subarray(345, 500));
1348
+ const name = prefix ? `${prefix}/${nameRaw}` : nameRaw;
1349
+ const size = parseOctal(header.subarray(124, 136));
1350
+ const typeflag = header[156];
1351
+ if (typeflag !== 0 && typeflag !== 0x30) {
1352
+ throw new InstallerError(`unexpected archive file ${name}`);
1353
+ }
1354
+ offset += 512;
1355
+ if (offset + size > buf.length) {
1356
+ throw new InstallerError("truncated tar archive");
1357
+ }
1358
+ const body = Buffer.from(buf.subarray(offset, offset + size));
1359
+ files.push({ name, body });
1360
+ offset += size + ((512 - (size % 512)) % 512);
1361
+ }
1362
+ return files;
1363
+ }
1364
+
1365
+ function isZeroBlock(buf) {
1366
+ for (let i = 0; i < buf.length; i++) {
1367
+ if (buf[i] !== 0) {
1368
+ return false;
1369
+ }
1370
+ }
1371
+ return true;
1372
+ }
1373
+
1374
+ function cstring(buf) {
1375
+ const end = buf.indexOf(0);
1376
+ return buf.subarray(0, end === -1 ? buf.length : end).toString("utf8").trimEnd();
1377
+ }
1378
+
1379
+ function parseOctal(buf) {
1380
+ const s = buf.toString("ascii").replace(/\0/g, " ").trim();
1381
+ if (s === "") {
1382
+ return 0;
1383
+ }
1384
+ return parseInt(s, 8);
1385
+ }
1386
+
1387
+ function readZip(bytes) {
1388
+ const directory = readZipCentralDirectory(bytes);
1389
+ const files = [];
1390
+ for (const entry of directory.entries) {
1391
+ if (entry.flags & (ZIP_ENCRYPTED_FLAG | ZIP_STRONG_ENCRYPTION_FLAG)) {
1392
+ throw new InstallerError(`encrypted zip entry ${entry.name}`);
1393
+ }
1394
+ if (entry.compressedSize === ZIP64_UINT32 || entry.uncompressedSize === ZIP64_UINT32) {
1395
+ throw new InstallerError(`unsupported ZIP64 entry ${entry.name}`);
1396
+ }
1397
+
1398
+ const local = readZipLocalHeader(bytes, entry, directory.offset);
1399
+ const raw = bytes.subarray(local.dataStart, local.dataEnd);
1400
+ let body;
1401
+ if (entry.method === 0) {
1402
+ body = Buffer.from(raw);
1403
+ } else if (entry.method === 8) {
1404
+ body = Buffer.from(inflateRawSync(raw));
1405
+ } else {
1406
+ throw new InstallerError(`unsupported zip method ${entry.method}`);
1407
+ }
1408
+ if (body.length !== entry.uncompressedSize) {
1409
+ throw new InstallerError(`zip size mismatch ${entry.name}`);
1410
+ }
1411
+ if (crc32(body) !== entry.crc32) {
1412
+ throw new InstallerError(`zip checksum mismatch ${entry.name}`);
1413
+ }
1414
+ if (entry.name.endsWith("/")) {
1415
+ throw new InstallerError(`unexpected archive file ${entry.name}`);
1416
+ }
1417
+ files.push({ name: entry.name, body });
1418
+ }
1419
+ return files;
1420
+ }
1421
+
1422
+ function readZipCentralDirectory(bytes) {
1423
+ const eocd = findZipEndOfCentralDirectory(bytes);
1424
+ const disk = bytes.readUInt16LE(eocd + 4);
1425
+ const directoryDisk = bytes.readUInt16LE(eocd + 6);
1426
+ const diskEntries = bytes.readUInt16LE(eocd + 8);
1427
+ const totalEntries = bytes.readUInt16LE(eocd + 10);
1428
+ const size = bytes.readUInt32LE(eocd + 12);
1429
+ const offset = bytes.readUInt32LE(eocd + 16);
1430
+ if (disk !== 0 || directoryDisk !== 0 || diskEntries !== totalEntries) {
1431
+ throw new InstallerError("multi-disk zip archive is not supported");
1432
+ }
1433
+ if (totalEntries === 0xffff || size === ZIP64_UINT32 || offset === ZIP64_UINT32) {
1434
+ throw new InstallerError("ZIP64 archive is not supported");
1435
+ }
1436
+ const end = offset + size;
1437
+ if (!Number.isSafeInteger(end) || end !== eocd) {
1438
+ throw new InstallerError("invalid zip central directory");
1439
+ }
1440
+
1441
+ const entries = [];
1442
+ let cursor = offset;
1443
+ for (let i = 0; i < totalEntries; i++) {
1444
+ if (cursor + ZIP_CENTRAL_HEADER_SIZE > end || bytes.readUInt32LE(cursor) !== 0x02014b50) {
1445
+ throw new InstallerError("invalid zip central directory");
1446
+ }
1447
+ const flags = bytes.readUInt16LE(cursor + 8);
1448
+ const method = bytes.readUInt16LE(cursor + 10);
1449
+ const crc = bytes.readUInt32LE(cursor + 16);
1450
+ const compressedSize = bytes.readUInt32LE(cursor + 20);
1451
+ const uncompressedSize = bytes.readUInt32LE(cursor + 24);
1452
+ const nameLen = bytes.readUInt16LE(cursor + 28);
1453
+ const extraLen = bytes.readUInt16LE(cursor + 30);
1454
+ const commentLen = bytes.readUInt16LE(cursor + 32);
1455
+ const localOffset = bytes.readUInt32LE(cursor + 42);
1456
+ const recordEnd = cursor + ZIP_CENTRAL_HEADER_SIZE + nameLen + extraLen + commentLen;
1457
+ if (!Number.isSafeInteger(recordEnd) || recordEnd > end) {
1458
+ throw new InstallerError("truncated zip central directory");
1459
+ }
1460
+ const name = bytes.subarray(cursor + ZIP_CENTRAL_HEADER_SIZE, cursor + ZIP_CENTRAL_HEADER_SIZE + nameLen).toString("utf8");
1461
+ if (localOffset === ZIP64_UINT32) {
1462
+ throw new InstallerError(`unsupported ZIP64 entry ${name}`);
1463
+ }
1464
+ entries.push({
1465
+ name,
1466
+ flags,
1467
+ method,
1468
+ crc32: crc,
1469
+ compressedSize,
1470
+ uncompressedSize,
1471
+ localOffset,
1472
+ });
1473
+ cursor = recordEnd;
1474
+ }
1475
+ if (cursor !== end) {
1476
+ throw new InstallerError("invalid zip central directory");
1477
+ }
1478
+ return { offset, entries };
1479
+ }
1480
+
1481
+ function findZipEndOfCentralDirectory(bytes) {
1482
+ const first = Math.max(0, bytes.length - ZIP_EOCD_SIZE - 0xffff);
1483
+ for (let offset = bytes.length - ZIP_EOCD_SIZE; offset >= first; offset--) {
1484
+ if (bytes.readUInt32LE(offset) !== 0x06054b50) {
1485
+ continue;
1486
+ }
1487
+ const commentLen = bytes.readUInt16LE(offset + 20);
1488
+ if (offset + ZIP_EOCD_SIZE + commentLen === bytes.length) {
1489
+ return offset;
1490
+ }
1491
+ }
1492
+ throw new InstallerError("missing zip end of central directory");
1493
+ }
1494
+
1495
+ function readZipLocalHeader(bytes, entry, centralOffset) {
1496
+ const offset = entry.localOffset;
1497
+ if (offset + ZIP_LOCAL_HEADER_SIZE > centralOffset || bytes.readUInt32LE(offset) !== 0x04034b50) {
1498
+ throw new InstallerError(`invalid zip local header ${entry.name}`);
1499
+ }
1500
+ const flags = bytes.readUInt16LE(offset + 6);
1501
+ const method = bytes.readUInt16LE(offset + 8);
1502
+ const nameLen = bytes.readUInt16LE(offset + 26);
1503
+ const extraLen = bytes.readUInt16LE(offset + 28);
1504
+ const nameStart = offset + ZIP_LOCAL_HEADER_SIZE;
1505
+ const dataStart = nameStart + nameLen + extraLen;
1506
+ const dataEnd = dataStart + entry.compressedSize;
1507
+ if (
1508
+ flags !== entry.flags ||
1509
+ method !== entry.method ||
1510
+ dataStart > centralOffset ||
1511
+ dataEnd > centralOffset ||
1512
+ !Number.isSafeInteger(dataEnd)
1513
+ ) {
1514
+ throw new InstallerError(`invalid zip local header ${entry.name}`);
1515
+ }
1516
+ const name = bytes.subarray(nameStart, nameStart + nameLen).toString("utf8");
1517
+ if (name !== entry.name) {
1518
+ throw new InstallerError(`invalid zip local header ${entry.name}`);
1519
+ }
1520
+ return { dataStart, dataEnd };
1521
+ }
1522
+
1523
+ function crc32(bytes) {
1524
+ let crc = 0xffffffff;
1525
+ for (let i = 0; i < bytes.length; i++) {
1526
+ crc ^= bytes[i];
1527
+ for (let bit = 0; bit < 8; bit++) {
1528
+ crc = (crc >>> 1) ^ (0xedb88320 & -(crc & 1));
1529
+ }
1530
+ }
1531
+ return (crc ^ 0xffffffff) >>> 0;
1532
+ }
1533
+
1534
+ function smoke(binPath, version, home, env) {
1535
+ const result = spawnSync(binPath, ["version", "--json"], {
1536
+ encoding: "utf8",
1537
+ timeout: SMOKE_TIMEOUT_MS,
1538
+ env: {
1539
+ ...env,
1540
+ WHOBOT_AGENT_CLI_HOME: home,
1541
+ },
1542
+ });
1543
+ if (result.error) {
1544
+ throw new InstallerError(`version smoke failed: ${result.error.message}`);
1545
+ }
1546
+ if (result.status !== 0) {
1547
+ throw new InstallerError(
1548
+ `version smoke exit ${result.status}: ${String(result.stderr || "").trim()}`,
1549
+ );
1550
+ }
1551
+ let parsed;
1552
+ try {
1553
+ parsed = JSON.parse(result.stdout);
1554
+ } catch {
1555
+ throw new InstallerError("version smoke did not emit JSON");
1556
+ }
1557
+ if (parsed.ok !== true) {
1558
+ throw new InstallerError("version smoke ok=false");
1559
+ }
1560
+ if (parsed.meta?.protocol !== 2) {
1561
+ throw new InstallerError(
1562
+ `protocol ${parsed.meta?.protocol} is not 2`,
1563
+ );
1564
+ }
1565
+ if (parsed.data?.version !== version) {
1566
+ throw new InstallerError(
1567
+ `installed version ${parsed.data?.version} does not match ${version}`,
1568
+ );
1569
+ }
1570
+ }
1571
+
1572
+ async function replaceAtomically(stagedPath, dest) {
1573
+ await fs.mkdir(path.dirname(dest), { recursive: true, mode: 0o755 });
1574
+ const tmp = `${dest}.tmp.${process.pid}.${Math.random().toString(16).slice(2)}`;
1575
+ try {
1576
+ await fs.copyFile(stagedPath, tmp);
1577
+ await fs.chmod(tmp, 0o755);
1578
+ const fd = openSync(tmp, "r+");
1579
+ try {
1580
+ fsyncSync(fd);
1581
+ } finally {
1582
+ closeSync(fd);
1583
+ }
1584
+ await fs.rename(tmp, dest);
1585
+ } catch (err) {
1586
+ await fs.unlink(tmp).catch(() => {});
1587
+ throw err;
1588
+ }
1589
+ }
1590
+
1591
+ function firstNonEmpty(...vals) {
1592
+ for (const v of vals) {
1593
+ if (v !== undefined && v !== null) {
1594
+ return v;
1595
+ }
1596
+ }
1597
+ return undefined;
1598
+ }
1599
+
1600
+ function envGet(env, names) {
1601
+ for (const n of names) {
1602
+ const v = env[n];
1603
+ if (v !== undefined && String(v).trim() !== "") {
1604
+ return String(v).trim();
1605
+ }
1606
+ }
1607
+ return "";
1608
+ }
1609
+
1610
+ function isLoopback(host) {
1611
+ const h = String(host).replace(/^\[|\]$/g, "").toLowerCase();
1612
+ return h === "localhost" || h === "127.0.0.1" || h === "::1" || h === "0:0:0:0:0:0:0:1";
1613
+ }
1614
+
1615
+ function assertURLAllowed(url) {
1616
+ if (url.protocol === "https:") {
1617
+ return;
1618
+ }
1619
+ if (url.protocol === "http:" && isLoopback(url.hostname)) {
1620
+ return;
1621
+ }
1622
+ throw new InstallerError(
1623
+ "HTTP download is only allowed for loopback test origins",
1624
+ );
1625
+ }
1626
+
1627
+ function matchesNoProxy(hostname, noProxy) {
1628
+ if (!noProxy) {
1629
+ return false;
1630
+ }
1631
+ const host = hostname.toLowerCase();
1632
+ for (const raw of noProxy.split(",")) {
1633
+ const item = raw.trim().toLowerCase();
1634
+ if (item === "" || item === "*") {
1635
+ return item === "*";
1636
+ }
1637
+ if (item === host) {
1638
+ return true;
1639
+ }
1640
+ if (item.startsWith(".") && (host.endsWith(item) || host === item.slice(1))) {
1641
+ return true;
1642
+ }
1643
+ if (!item.startsWith(".") && host.endsWith(`.${item}`)) {
1644
+ return true;
1645
+ }
1646
+ }
1647
+ return false;
1648
+ }
1649
+
1650
+ function proxyFor(url, env) {
1651
+ if (matchesNoProxy(url.hostname, envGet(env, ["NO_PROXY", "no_proxy"]))) {
1652
+ return null;
1653
+ }
1654
+ const raw =
1655
+ url.protocol === "https:"
1656
+ ? envGet(env, ["HTTPS_PROXY", "https_proxy", "HTTP_PROXY", "http_proxy"])
1657
+ : envGet(env, ["HTTP_PROXY", "http_proxy"]);
1658
+ if (!raw) {
1659
+ return null;
1660
+ }
1661
+ return new URL(raw.includes("://") ? raw : `http://${raw}`);
1662
+ }
1663
+
1664
+ async function httpGet(urlString, env, opts) {
1665
+ let url = new URL(urlString);
1666
+ assertURLAllowed(url);
1667
+ for (let hop = 0; hop < 6; hop++) {
1668
+ const res = await httpGetOnce(url, env, DOWNLOAD_TIMEOUT_MS);
1669
+ if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
1670
+ res.resume();
1671
+ const next = new URL(res.headers.location, url);
1672
+ if (url.protocol === "https:" && next.protocol !== "https:") {
1673
+ throw new InstallerError("refusing HTTPS to HTTP redirect");
1674
+ }
1675
+ assertURLAllowed(next);
1676
+ url = next;
1677
+ continue;
1678
+ }
1679
+ if (res.statusCode !== 200) {
1680
+ res.resume();
1681
+ throw new InstallerError(`download ${url.href} status ${res.statusCode}`);
1682
+ }
1683
+ return await readBody(res, opts.destFile, opts.expectedSize);
1684
+ }
1685
+ throw new InstallerError("too many redirects");
1686
+ }
1687
+
1688
+ function httpGetOnce(url, env, timeoutMs) {
1689
+ const proxy = proxyFor(url, env);
1690
+ if (url.protocol === "https:") {
1691
+ if (proxy) {
1692
+ return httpsGetViaProxy(url, proxy, timeoutMs);
1693
+ }
1694
+ return requestOnce(
1695
+ https,
1696
+ {
1697
+ hostname: url.hostname,
1698
+ port: url.port || 443,
1699
+ path: url.pathname + url.search,
1700
+ method: "GET",
1701
+ headers: { Host: url.host, Connection: "close" },
1702
+ servername: url.hostname,
1703
+ timeout: timeoutMs,
1704
+ },
1705
+ timeoutMs,
1706
+ );
1707
+ }
1708
+ if (proxy) {
1709
+ return requestOnce(
1710
+ http,
1711
+ {
1712
+ hostname: proxy.hostname,
1713
+ port: proxy.port || 80,
1714
+ path: url.href,
1715
+ method: "GET",
1716
+ headers: { Host: url.host, Connection: "close" },
1717
+ timeout: timeoutMs,
1718
+ },
1719
+ timeoutMs,
1720
+ );
1721
+ }
1722
+ return requestOnce(
1723
+ http,
1724
+ {
1725
+ hostname: url.hostname,
1726
+ port: url.port || 80,
1727
+ path: url.pathname + url.search,
1728
+ method: "GET",
1729
+ headers: { Host: url.host, Connection: "close" },
1730
+ timeout: timeoutMs,
1731
+ },
1732
+ timeoutMs,
1733
+ );
1734
+ }
1735
+
1736
+ function requestOnce(mod, options, timeoutMs) {
1737
+ return new Promise((resolve, reject) => {
1738
+ const req = mod.request(options, (res) => resolve(res));
1739
+ req.on("error", reject);
1740
+ req.setTimeout(timeoutMs, () => {
1741
+ req.destroy();
1742
+ reject(new InstallerError("download timeout"));
1743
+ });
1744
+ req.end();
1745
+ });
1746
+ }
1747
+
1748
+ function httpsGetViaProxy(url, proxy, timeoutMs) {
1749
+ return new Promise((resolve, reject) => {
1750
+ const req = http.request({
1751
+ hostname: proxy.hostname,
1752
+ port: proxy.port || 80,
1753
+ method: "CONNECT",
1754
+ path: `${url.hostname}:${url.port || 443}`,
1755
+ timeout: timeoutMs,
1756
+ });
1757
+ req.on("connect", (res, socket) => {
1758
+ if (res.statusCode !== 200) {
1759
+ socket.destroy();
1760
+ reject(new InstallerError(`proxy CONNECT status ${res.statusCode}`));
1761
+ return;
1762
+ }
1763
+ const tlsSock = tls.connect({
1764
+ socket,
1765
+ servername: url.hostname,
1766
+ timeout: timeoutMs,
1767
+ });
1768
+ tlsSock.once("error", reject);
1769
+ tlsSock.once("secureConnect", () => {
1770
+ const creq = https.request(
1771
+ {
1772
+ hostname: url.hostname,
1773
+ port: url.port || 443,
1774
+ path: url.pathname + url.search,
1775
+ method: "GET",
1776
+ headers: { Host: url.host, Connection: "close" },
1777
+ timeout: timeoutMs,
1778
+ createConnection: () => tlsSock,
1779
+ },
1780
+ (cres) => resolve(cres),
1781
+ );
1782
+ creq.on("error", reject);
1783
+ creq.end();
1784
+ });
1785
+ });
1786
+ req.on("error", reject);
1787
+ req.on("timeout", () => {
1788
+ req.destroy();
1789
+ reject(new InstallerError("proxy CONNECT timeout"));
1790
+ });
1791
+ req.end();
1792
+ });
1793
+ }
1794
+
1795
+ function readBody(res, destFile, expectedSize) {
1796
+ return new Promise((resolve, reject) => {
1797
+ const hash = createHash("sha256");
1798
+ let size = 0;
1799
+ const chunks = destFile ? null : [];
1800
+ const out = destFile ? createWriteStream(destFile) : null;
1801
+ let settled = false;
1802
+ const fail = (err) => {
1803
+ if (settled) {
1804
+ return;
1805
+ }
1806
+ settled = true;
1807
+ res.destroy();
1808
+ if (out) {
1809
+ out.destroy();
1810
+ }
1811
+ reject(err);
1812
+ };
1813
+ res.on("data", (chunk) => {
1814
+ size += chunk.length;
1815
+ if (expectedSize != null && size > expectedSize) {
1816
+ fail(new InstallerError("download exceeded manifest size"));
1817
+ return;
1818
+ }
1819
+ hash.update(chunk);
1820
+ if (out) {
1821
+ out.write(chunk);
1822
+ } else {
1823
+ chunks.push(chunk);
1824
+ }
1825
+ });
1826
+ res.on("error", fail);
1827
+ res.on("end", () => {
1828
+ const finish = () => {
1829
+ if (settled) {
1830
+ return;
1831
+ }
1832
+ if (expectedSize != null && size !== expectedSize) {
1833
+ fail(
1834
+ new InstallerError(
1835
+ `download size ${size} does not match manifest ${expectedSize}`,
1836
+ ),
1837
+ );
1838
+ return;
1839
+ }
1840
+ settled = true;
1841
+ resolve({
1842
+ sha256: hash.digest("hex"),
1843
+ size,
1844
+ body: chunks ? Buffer.concat(chunks) : null,
1845
+ });
1846
+ };
1847
+ if (out) {
1848
+ out.end(finish);
1849
+ out.on("error", fail);
1850
+ } else {
1851
+ finish();
1852
+ }
1853
+ });
1854
+ });
1855
+ }
1856
+
1857
+ function launchedAsCli() {
1858
+ if (typeof process.argv[1] !== "string") {
1859
+ return false;
1860
+ }
1861
+ try {
1862
+ return realpathSync(fileURLToPath(import.meta.url)) === realpathSync(process.argv[1]);
1863
+ } catch {
1864
+ return false;
1865
+ }
1866
+ }
1867
+
1868
+ if (launchedAsCli()) {
1869
+ runInstall(process.argv.slice(2), process.env)
1870
+ .then((dest) => {
1871
+ process.stdout.write(`${dest}\n`);
1872
+ process.exitCode = 0;
1873
+ })
1874
+ .catch((err) => {
1875
+ process.stderr.write(`${err && err.message ? err.message : err}\n`);
1876
+ process.exitCode = 1;
1877
+ });
1878
+ }