@swimmingliu/autovpn 1.4.1 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -24,6 +24,33 @@ autovpn run --project-root . --skip-deploy --skip-verify --output jsonl
24
24
  autovpn artifacts latest --project-root .
25
25
  ```
26
26
 
27
+ ## Server Web UI
28
+
29
+ `autovpn serve` starts a single-user HTTP service that serves the AutoVPN Web UI
30
+ from the same renderer used by the desktop app.
31
+
32
+ ```bash
33
+ autovpn serve --project-root .
34
+ ```
35
+
36
+ Defaults:
37
+
38
+ - host: `127.0.0.1`
39
+ - port: `8765`
40
+ - auth: token-protected
41
+
42
+ For server deployment, bind explicitly and provide a token:
43
+
44
+ ```bash
45
+ export AUTOVPN_SERVER_TOKEN="$(openssl rand -base64 24)"
46
+ autovpn serve --project-root /opt/autovpn --host 0.0.0.0 --port 8765 \
47
+ --token "$AUTOVPN_SERVER_TOKEN"
48
+ ```
49
+
50
+ The server refuses non-loopback binds unless `--token` or `--no-auth` is
51
+ provided. Prefer SSH forwarding, a private reverse proxy, or your own HTTPS
52
+ terminator for internet-facing deployments.
53
+
27
54
  ## Runtime Shape
28
55
 
29
56
  The CLI is currently Node-first with explicit Python rollback:
@@ -47,6 +74,8 @@ Current Node backend notes:
47
74
  - Deploy and verify can be rolled back with `AUTOVPN_STAGE_BACKEND_DEPLOY=python` and `AUTOVPN_STAGE_BACKEND_VERIFY=python`.
48
75
  - `AUTOVPN_NO_PYTHON=1` disables implicit Python backend resolution and default Python runtime stage fallback. Use it as a v3 readiness gate. Empty offline runs now complete in Node, and Node has direct HTTP speedtest and availability runtimes. Node also has opt-in Mihomo-backed paths: set `AUTOVPN_SPEEDTEST_RUNTIME=mihomo` for controller delay probing and candidate downloads through the local Mihomo proxy, and set `AUTOVPN_AVAILABILITY_RUNTIME=mihomo` to check provider availability through the same per-node proxy runtime.
49
76
  - Project `.env` is loaded before resolving profile and artifact paths. Explicit process environment values still win over `.env`.
77
+ - `autovpn serve` is Node-native and exposes the browser UI plus `/api/health`,
78
+ `/api/state`, `/api/runs`, `/api/runs/current/stop`, and `/api/events`.
50
79
 
51
80
  Fallback flags for migrated commands:
52
81
 
@@ -96,6 +125,9 @@ For Python-backed commands, the wrapper forwards argv, stdin, stdout, stderr, an
96
125
  - `AUTOVPN_DOCTOR_BACKEND`
97
126
  - `AUTOVPN_PROFILE_BACKEND`
98
127
  - `AUTOVPN_ARTIFACTS_BACKEND`
128
+ - `AUTOVPN_SERVER_HOST`
129
+ - `AUTOVPN_SERVER_PORT`
130
+ - `AUTOVPN_SERVER_TOKEN`
99
131
  - `VPN_AUTOMATION_RUNTIME_ROOT`
100
132
  - `VPN_AUTOMATION_PROFILE_PATH`
101
133
  - `VPN_AUTOMATION_ARTIFACTS_ROOT`
@@ -10,7 +10,8 @@ const TOP_LEVEL_COMMANDS = new Set([
10
10
  'jobs',
11
11
  'status',
12
12
  'logs',
13
- 'stop'
13
+ 'stop',
14
+ 'serve'
14
15
  ]);
15
16
  const JOBS_SUBCOMMANDS = new Set(['list', 'status', 'logs', 'stop', 'resume', 'retry']);
16
17
  const RESUME_SUBCOMMANDS = new Set(['pipeline', 'speedtest']);
@@ -82,6 +83,9 @@ export function validateCommand(argv) {
82
83
  validateChoice('doctor', '--output', readOptionValue(argv, '--output'), ['human', 'json']);
83
84
  return;
84
85
  }
86
+ if (command === 'serve') {
87
+ return;
88
+ }
85
89
  if (command === 'profile') {
86
90
  const subcommand = argv[1] ?? '';
87
91
  if (!PROFILE_SUBCOMMANDS.has(subcommand)) {
package/dist/cli/main.js CHANGED
@@ -9,6 +9,9 @@ import { selectBackend } from '../backend/select-backend.js';
9
9
  import { loadJob } from '../jobs/read.js';
10
10
  import { readOptionValue, resolveProjectRoot } from '../runtime/paths.js';
11
11
  import { redactText } from '../runtime/redaction.js';
12
+ import { createAutoVpnServer } from '../server/http.js';
13
+ import { parseServeOptions } from '../server/options.js';
14
+ import { createServerRuntime } from '../server/runtime.js';
12
15
  async function defaultReadPackageVersion() {
13
16
  // @ts-expect-error The Phase 1 runner is plain ESM JavaScript.
14
17
  const runner = await import('../../lib/runner.mjs');
@@ -190,6 +193,33 @@ export async function runCliShell(argv, options = {}) {
190
193
  validateCommand(normalizedArgv);
191
194
  const createBackend = options.createBackend ?? defaultCreateBackend;
192
195
  const backend = createBackend({ env, cwd, runForwarder });
196
+ if (normalizedArgv[0] === 'serve') {
197
+ const serveOptions = parseServeOptions(normalizedArgv, { cwd, env });
198
+ const serverFactory = options.createServer ?? createAutoVpnServer;
199
+ const runtime = createServerRuntime({
200
+ projectRoot: serveOptions.projectRoot,
201
+ env
202
+ });
203
+ const server = await serverFactory({
204
+ ...serveOptions,
205
+ runtime,
206
+ version: await resolvePackageVersion(options),
207
+ backendKind: String(backend.kind ?? '')
208
+ });
209
+ io.writeStdout(`AutoVPN server listening on ${server.origin}\n`);
210
+ if (serveOptions.auth.enabled) {
211
+ io.writeStdout(`Open ${server.origin}/?token=${encodeURIComponent(serveOptions.auth.token)}\n`);
212
+ }
213
+ else {
214
+ io.writeStderr('autovpn: warning: server authentication is disabled\n');
215
+ }
216
+ if (options.serveExitAfterStart) {
217
+ await server.close();
218
+ return 0;
219
+ }
220
+ await new Promise(() => { });
221
+ return 0;
222
+ }
193
223
  let pythonFallbackBackend;
194
224
  const runExplicitPythonFallback = (fallbackArgv) => {
195
225
  pythonFallbackBackend ??= createBackend({
@@ -70,7 +70,7 @@ export async function runNativeCommand(argv, context) {
70
70
  return context.pythonFallback(argv);
71
71
  if (readOptionValue(argv, '--output') !== 'json')
72
72
  return undefined;
73
- const result = runDoctor(projectRoot, argv, context.env);
73
+ const result = await runDoctor(projectRoot, argv, context.env);
74
74
  context.io.writeStdout(jsonLine(result.payload));
75
75
  return result.code;
76
76
  }
@@ -27,5 +27,6 @@ Commands:
27
27
  status
28
28
  logs
29
29
  stop
30
+ serve
30
31
  `;
31
32
  }
@@ -5,6 +5,9 @@ import { spawnSync } from 'node:child_process';
5
5
  import { parse } from '@iarna/toml';
6
6
  import { profileSummary } from '../config/profile.js';
7
7
  import { resolveArtifactsRoot, resolveProfilePath } from '../runtime/paths.js';
8
+ import { normalizeManagedToolCommandForSpawn, resolveManagedNpmTool } from '../runtime/managed-tools.js';
9
+ const JAVASCRIPT_OBFUSCATOR_VERSION = '5.4.3';
10
+ const WRANGLER_VERSION = '4.106.0';
8
11
  function check(name, status, message, details = {}) {
9
12
  return { name, status, message, details };
10
13
  }
@@ -21,19 +24,27 @@ function pathWritable(targetPath) {
21
24
  return false;
22
25
  }
23
26
  }
24
- function commandPath(name, env) {
27
+ function commandPath(name, env, platform = process.platform) {
25
28
  const pathValue = String(env.PATH ?? process.env.PATH ?? '');
29
+ const extensions = platform === 'win32'
30
+ ? String(env.PATHEXT ?? process.env.PATHEXT ?? '.COM;.EXE;.BAT;.CMD')
31
+ .split(';')
32
+ .filter(Boolean)
33
+ : [''];
26
34
  for (const dir of pathValue.split(path.delimiter).filter(Boolean)) {
27
- const candidate = path.join(dir, name);
28
- if (fs.existsSync(candidate)) {
29
- return candidate;
35
+ for (const extension of extensions) {
36
+ const candidate = path.join(dir, `${name}${extension}`);
37
+ if (fs.existsSync(candidate)) {
38
+ return candidate;
39
+ }
30
40
  }
31
41
  }
32
42
  return '';
33
43
  }
34
44
  function safeRun(command, env) {
35
45
  try {
36
- const result = spawnSync(command[0], command.slice(1), {
46
+ const { executable, args } = normalizeManagedToolCommandForSpawn(command);
47
+ const result = spawnSync(executable, args, {
37
48
  encoding: 'utf8',
38
49
  env: { ...process.env, ...env },
39
50
  timeout: 5000
@@ -110,8 +121,8 @@ function checkProxyRuntime(env) {
110
121
  checks.push(check('proxy_environment', 'pass', 'Proxy environment inspected', { configured_keys: configuredKeys }));
111
122
  return checks;
112
123
  }
113
- function checkNodeTools(projectRoot, env) {
114
- const missing = ['node', 'npm', 'npx'].filter((name) => !commandPath(name, env));
124
+ async function checkNodeTools(projectRoot, env, options = {}) {
125
+ const missing = ['node', 'npm'].filter((name) => !commandPath(name, env, options.platform));
115
126
  const checks = [
116
127
  check('node_binaries', missing.length ? 'fail' : 'pass', missing.length ? 'Node.js command line tools are missing' : 'Node.js command line tools are available', { missing })
117
128
  ];
@@ -120,17 +131,22 @@ function checkNodeTools(projectRoot, env) {
120
131
  path.join(projectRoot, 'electron', 'runtime', 'node-vendor', 'node_modules', 'playwright')
121
132
  ].some((candidate) => fs.existsSync(candidate));
122
133
  checks.push(check('playwright', hasPlaywright ? 'pass' : 'warn', hasPlaywright ? 'Playwright package is installed' : 'Playwright package was not found; run npx playwright install --with-deps chromium-headless-shell'));
123
- const npx = commandPath('npx', env);
124
- if (!npx) {
125
- checks.push(check('javascript_obfuscator', 'fail', 'npx is missing'));
134
+ try {
135
+ const obfuscator = await (options.resolveManagedNpmTool ?? resolveManagedNpmTool)({
136
+ packageName: 'javascript-obfuscator',
137
+ binaryName: 'javascript-obfuscator',
138
+ version: JAVASCRIPT_OBFUSCATOR_VERSION,
139
+ projectRoot,
140
+ installMissing: false
141
+ });
142
+ checks.push(check('javascript_obfuscator', 'pass', 'javascript-obfuscator is available', { source: obfuscator.source, version: obfuscator.version, path: obfuscator.command }));
126
143
  }
127
- else {
128
- const result = safeRun([npx, 'javascript-obfuscator', '--version'], env);
129
- checks.push(check('javascript_obfuscator', result.ok ? 'pass' : 'fail', result.ok ? 'javascript-obfuscator is available' : 'javascript-obfuscator is not available', { result: result.message }));
144
+ catch (error) {
145
+ checks.push(check('javascript_obfuscator', 'fail', error instanceof Error ? error.message : String(error)));
130
146
  }
131
147
  return checks;
132
148
  }
133
- function checkCloudflare(summary, deploy, env) {
149
+ async function checkCloudflare(summary, deploy, env, projectRoot, options = {}) {
134
150
  const checks = [];
135
151
  const deploySummary = summary.deploy ?? {};
136
152
  const hasCredentials = deploySummary.cloudflare_api_token === 'set'
@@ -143,17 +159,23 @@ function checkCloudflare(summary, deploy, env) {
143
159
  const pagesProjectUrl = String(deploySummary.pages_project_url ?? '');
144
160
  const hasDeployUrl = Boolean(String(deploySummary.project_name ?? '').trim() && URL.canParse(pagesProjectUrl));
145
161
  checks.push(check('deploy_urls', hasDeployUrl ? 'pass' : (deploy ? 'fail' : 'warn'), hasDeployUrl ? 'Deploy URL settings are internally consistent' : 'Deploy URL settings are incomplete', { has_project_name: Boolean(String(deploySummary.project_name ?? '').trim()), has_pages_url: URL.canParse(pagesProjectUrl) }));
146
- const npx = commandPath('npx', env);
147
- if (!npx) {
148
- checks.push(check('wrangler', deploy ? 'fail' : 'warn', 'npx is missing'));
162
+ try {
163
+ const wrangler = await (options.resolveManagedNpmTool ?? resolveManagedNpmTool)({
164
+ packageName: 'wrangler',
165
+ binaryName: 'wrangler',
166
+ version: WRANGLER_VERSION,
167
+ projectRoot,
168
+ installMissing: false
169
+ });
170
+ const result = (options.safeRun ?? safeRun)([wrangler.command, 'pages', 'deploy', '--help'], env);
171
+ checks.push(check('wrangler', result.ok ? 'pass' : (deploy ? 'fail' : 'warn'), result.ok ? 'Wrangler Pages deploy command is available' : 'Wrangler Pages deploy command is not available', { source: wrangler.source, version: wrangler.version, path: wrangler.command, result: result.message, deploy_required: deploy }));
149
172
  }
150
- else {
151
- const result = safeRun([npx, 'wrangler', 'pages', 'deploy', '--help'], env);
152
- checks.push(check('wrangler', result.ok ? 'pass' : (deploy ? 'fail' : 'warn'), result.ok ? 'Wrangler Pages deploy command is available' : 'Wrangler Pages deploy command is not available', { result: result.message, deploy_required: deploy }));
173
+ catch (error) {
174
+ checks.push(check('wrangler', deploy ? 'fail' : 'warn', error instanceof Error ? error.message : String(error), { deploy_required: deploy }));
153
175
  }
154
176
  return checks;
155
177
  }
156
- export function runDoctor(projectRoot, argv, env = process.env) {
178
+ export async function runDoctor(projectRoot, argv, env = process.env, options = {}) {
157
179
  const deploy = argv.includes('--deploy');
158
180
  const strict = argv.includes('--strict');
159
181
  const profilePath = resolveProfilePath(projectRoot, env);
@@ -162,6 +184,8 @@ export function runDoctor(projectRoot, argv, env = process.env) {
162
184
  const summary = profileSummary(projectRoot, env);
163
185
  const sourceValues = Object.values((summary.sources ?? {}));
164
186
  const configuredSources = sourceValues.filter((source) => source.enabled && source.url === 'set' && source.key === 'set');
187
+ const nodeToolChecks = await checkNodeTools(projectRoot, env, options);
188
+ const cloudflareChecks = await checkCloudflare(summary, deploy, env, projectRoot, options);
165
189
  const checks = [
166
190
  check('node_version', 'pass', `Node ${process.versions.node}`, { required: '>=20' }),
167
191
  check('project_root', 'pass', 'Project root resolved', { path: projectRoot }),
@@ -174,8 +198,8 @@ export function runDoctor(projectRoot, argv, env = process.env) {
174
198
  : check('sources', 'warn', 'No enabled source has both URL and key configured', { configured_count: 0, key_state: 'missing' }),
175
199
  checkSpeedTestConfig(profile),
176
200
  ...checkProxyRuntime(env),
177
- ...checkNodeTools(projectRoot, env),
178
- ...checkCloudflare(summary, deploy, env)
201
+ ...nodeToolChecks,
202
+ ...cloudflareChecks
179
203
  ];
180
204
  const hasFailures = checks.some((item) => item.status === 'fail');
181
205
  const hasWarnings = checks.some((item) => item.status === 'warn');
@@ -2,6 +2,7 @@ import path from 'node:path';
2
2
  import { spawn as defaultSpawn } from 'node:child_process';
3
3
  import { access, mkdir, readFile, writeFile } from 'node:fs/promises';
4
4
  import { mergeProjectEnv } from '../runtime/env.js';
5
+ import { normalizeManagedToolCommandForSpawn, resolveManagedNpmTool } from '../runtime/managed-tools.js';
5
6
  const PYTHON_DEPLOY_HELPER = `
6
7
  import json
7
8
  import sys
@@ -77,8 +78,8 @@ function nonNegativeInt(value) {
77
78
  function getString(source, key) {
78
79
  return clean(source[key]);
79
80
  }
80
- export function buildPagesDeployCommand(bundleDir, projectName) {
81
- return ['npx', 'wrangler', 'pages', 'deploy', bundleDir, '--project-name', projectName, '--branch', PAGES_PRODUCTION_BRANCH];
81
+ export function buildPagesDeployCommand(wranglerExecutable, bundleDir, projectName) {
82
+ return [wranglerExecutable, 'pages', 'deploy', bundleDir, '--project-name', projectName, '--branch', PAGES_PRODUCTION_BRANCH];
82
83
  }
83
84
  export function derivePagesProjectUrl(projectName) {
84
85
  return `https://${projectName}.pages.dev`;
@@ -405,7 +406,7 @@ async function stageShareProjectWorkerBundle(projectRoot, env) {
405
406
  return { sourcePath, bundleDir, workerEntry };
406
407
  }
407
408
  function runCommandWithSpawn(command, options, spawnImpl = defaultSpawn) {
408
- const [executable, ...args] = command;
409
+ const { executable, args } = normalizeManagedToolCommandForSpawn(command);
409
410
  const child = spawnImpl(executable, args, {
410
411
  cwd: options.cwd,
411
412
  env: { ...process.env, ...options.env },
@@ -848,7 +849,7 @@ async function syncShareProjectSub(deploy, options) {
848
849
  const shareCustomDomains = typeof options.client.listPagesDomains === 'function'
849
850
  ? (await options.client.listPagesDomains(requestedName)).map((item) => clean(item.name)).filter(Boolean)
850
851
  : [];
851
- const deployShareProject = async (projectName) => runPagesDeployAttempts(buildPagesDeployCommand(shareBundle.bundleDir, projectName), buildWranglerAuthEnv(options.credentials), resolveDeployProxyUrl(options.env), {
852
+ const deployShareProject = async (projectName) => runPagesDeployAttempts(buildPagesDeployCommand(options.wranglerExecutable, shareBundle.bundleDir, projectName), buildWranglerAuthEnv(options.credentials), resolveDeployProxyUrl(options.env), {
852
853
  cwd: shareBundle.bundleDir,
853
854
  runCommand: options.runCommand
854
855
  });
@@ -946,9 +947,15 @@ export async function deployPagesWithBackend(input, options = {}) {
946
947
  VPN_AUTOMATION_DEFAULT_PAGES_SECRET_ADMIN: getString(deploy, 'pages_secret_admin') || 'swimmingliu'
947
948
  };
948
949
  const credentials = resolveCloudflareCredentials(deploy, runtimeEnv);
949
- const command = buildPagesDeployCommand(input.bundleDir, projectName);
950
950
  const baseEnv = buildWranglerAuthEnv(credentials);
951
951
  const runCommand = options.runCommand ?? ((commandToRun, runOptions) => runCommandWithSpawn(commandToRun, runOptions, options.spawn));
952
+ const wrangler = await (options.resolveManagedNpmTool ?? resolveManagedNpmTool)({
953
+ packageName: 'wrangler',
954
+ binaryName: 'wrangler',
955
+ version: '4.106.0',
956
+ projectRoot: input.projectRoot
957
+ });
958
+ const command = buildPagesDeployCommand(wrangler.command, input.bundleDir, projectName);
952
959
  const proxyUrl = resolveDeployProxyUrl(env);
953
960
  let activeCommand = command;
954
961
  let finalProjectName = projectName;
@@ -984,7 +991,7 @@ export async function deployPagesWithBackend(input, options = {}) {
984
991
  await ensureCustomDomainBound(client, finalProjectName, customDomain, { previousProjectName: projectName });
985
992
  customDomainBound = true;
986
993
  }
987
- activeCommand = buildPagesDeployCommand(input.bundleDir, finalProjectName);
994
+ activeCommand = buildPagesDeployCommand(wrangler.command, input.bundleDir, finalProjectName);
988
995
  const fallbackDeploy = await runPagesDeployAttempts(activeCommand, baseEnv, proxyUrl, {
989
996
  cwd: input.bundleDir,
990
997
  runCommand
@@ -1010,7 +1017,8 @@ export async function deployPagesWithBackend(input, options = {}) {
1010
1017
  credentials,
1011
1018
  client,
1012
1019
  env,
1013
- runCommand
1020
+ runCommand,
1021
+ wranglerExecutable: wrangler.command
1014
1022
  });
1015
1023
  }
1016
1024
  if (result.returncode === 0 && customDomain) {
@@ -0,0 +1,269 @@
1
+ import { spawn as defaultSpawn } from 'node:child_process';
2
+ import path from 'node:path';
3
+ import { access, mkdir } from 'node:fs/promises';
4
+ import { constants } from 'node:fs';
5
+ import { resolveUserRuntimeRoot } from './paths.js';
6
+ export class ManagedToolError extends Error {
7
+ code;
8
+ constructor(message, code = 'MANAGED_TOOL_ERROR') {
9
+ super(message);
10
+ this.name = 'ManagedToolError';
11
+ this.code = code;
12
+ }
13
+ }
14
+ export async function resolveManagedNpmTool(options) {
15
+ const packageName = validatePackageName(options.packageName);
16
+ const binaryName = validateSinglePathPart(options.binaryName, 'binaryName');
17
+ const version = validateSinglePathPart(options.version, 'version');
18
+ const toolsRoot = path.resolve(options.toolsRoot ?? path.join(resolveUserRuntimeRoot(), 'tools'));
19
+ const projectRoot = path.resolve(options.projectRoot ?? process.cwd());
20
+ const timeoutMs = validateTimeoutMs(options.timeoutMs ?? 120000);
21
+ const platform = options.platform ?? process.platform;
22
+ const runCommand = options.runCommand ??
23
+ ((command, commandOptions) => runCommandWithSpawn(command, { ...commandOptions, timeoutMs }));
24
+ const managed = managedToolPaths(toolsRoot, packageName, version, binaryName);
25
+ const allowProjectFallback = options.allowProjectFallback ?? true;
26
+ const installMissing = options.installMissing ?? true;
27
+ const managedBinary = await firstExecutable(resolveManagedNpmToolBinaryCandidates(managed.binDir, binaryName, platform));
28
+ if (managedBinary) {
29
+ const verifiedVersion = await verifyToolVersion(managedBinary, managed.installDir, runCommand, 'managed');
30
+ return {
31
+ command: managedBinary,
32
+ args: [],
33
+ source: 'managed',
34
+ packageName,
35
+ version: verifiedVersion,
36
+ requestedVersion: version
37
+ };
38
+ }
39
+ if (allowProjectFallback) {
40
+ const projectBinDir = path.join(projectRoot, 'node_modules', '.bin');
41
+ const projectBinary = await firstExecutable(resolveManagedNpmToolBinaryCandidates(projectBinDir, binaryName, platform));
42
+ if (projectBinary) {
43
+ const verifiedVersion = await verifyToolVersion(projectBinary, projectRoot, runCommand, 'project');
44
+ return {
45
+ command: projectBinary,
46
+ args: [],
47
+ source: 'project',
48
+ packageName,
49
+ version: verifiedVersion,
50
+ requestedVersion: version
51
+ };
52
+ }
53
+ }
54
+ if (!installMissing) {
55
+ throw new ManagedToolError(`Managed npm tool ${packageName}@${version} is not installed`, 'MANAGED_TOOL_MISSING');
56
+ }
57
+ await installManagedTool(packageName, version, managed.installDir, runCommand);
58
+ const installedBinary = await firstExecutable(resolveManagedNpmToolBinaryCandidates(managed.binDir, binaryName, platform));
59
+ if (!installedBinary) {
60
+ throw new ManagedToolError(`npm install completed but ${binaryName} was not found at the managed tool path`, 'MANAGED_TOOL_BINARY_MISSING');
61
+ }
62
+ const verifiedVersion = await verifyToolVersion(installedBinary, managed.installDir, runCommand, 'managed');
63
+ return {
64
+ command: installedBinary,
65
+ args: [],
66
+ source: 'managed',
67
+ packageName,
68
+ version: verifiedVersion,
69
+ requestedVersion: version
70
+ };
71
+ }
72
+ export function resolveManagedNpmToolBinaryCandidates(binDir, binaryName, platform = process.platform) {
73
+ const safeBinaryName = validateSinglePathPart(binaryName, 'binaryName');
74
+ const primary = path.join(binDir, safeBinaryName);
75
+ if (platform === 'win32' && !safeBinaryName.toLowerCase().endsWith('.cmd')) {
76
+ return [primary, `${primary}.cmd`];
77
+ }
78
+ return [primary];
79
+ }
80
+ export function normalizeManagedToolCommandForSpawn(command, platform = process.platform) {
81
+ const [executable, ...args] = command;
82
+ if (platform === 'win32' && /\.(?:cmd|bat)$/i.test(executable)) {
83
+ return {
84
+ executable: 'cmd.exe',
85
+ args: ['/d', '/s', '/c', [executable, ...args].map(quoteWindowsCmdArgument).join(' ')]
86
+ };
87
+ }
88
+ return { executable, args };
89
+ }
90
+ function managedToolPaths(toolsRoot, packageName, version, binaryName) {
91
+ const installDir = path.join(toolsRoot, 'npm', packageName, version);
92
+ return {
93
+ installDir,
94
+ binDir: path.join(installDir, 'node_modules', '.bin')
95
+ };
96
+ }
97
+ async function installManagedTool(packageName, version, installDir, runCommand) {
98
+ await mkdir(installDir, { recursive: true });
99
+ let result;
100
+ try {
101
+ result = await runCommand(['npm', 'install', '--no-save', '--no-audit', '--no-fund', `${packageName}@${version}`], {
102
+ cwd: installDir,
103
+ env: { NPM_CONFIG_YES: 'true' }
104
+ });
105
+ }
106
+ catch (error) {
107
+ throw new ManagedToolError(`npm install failed for ${packageName}@${version}: ${safeErrorMessage(error)}`, 'MANAGED_TOOL_INSTALL_FAILED');
108
+ }
109
+ if (result.returncode !== 0) {
110
+ throw new ManagedToolError(`npm install failed for ${packageName}@${version}: ${safeCommandMessage(result)}`, 'MANAGED_TOOL_INSTALL_FAILED');
111
+ }
112
+ }
113
+ async function verifyToolVersion(binaryPath, cwd, runCommand, source) {
114
+ let result;
115
+ try {
116
+ result = await runCommand([binaryPath, '--version'], { cwd, env: {} });
117
+ }
118
+ catch (error) {
119
+ throw new ManagedToolError(`Managed npm tool ${source} verification failed: ${safeErrorMessage(error)}`, 'MANAGED_TOOL_VERSION_FAILED');
120
+ }
121
+ if (result.returncode !== 0) {
122
+ throw new ManagedToolError(`Managed npm tool ${source} verification failed: ${safeCommandMessage(result)}`, 'MANAGED_TOOL_VERSION_FAILED');
123
+ }
124
+ return firstOutputLine(result.stdout || result.stderr) || 'unknown';
125
+ }
126
+ async function isExecutable(filePath) {
127
+ try {
128
+ await access(filePath, constants.X_OK);
129
+ return true;
130
+ }
131
+ catch {
132
+ return false;
133
+ }
134
+ }
135
+ async function firstExecutable(candidates) {
136
+ for (const candidate of candidates) {
137
+ if (await isExecutable(candidate)) {
138
+ return candidate;
139
+ }
140
+ }
141
+ return undefined;
142
+ }
143
+ function runCommandWithSpawn(command, options, spawnImpl = defaultSpawn) {
144
+ const { executable, args } = normalizeManagedToolCommandForSpawn(command);
145
+ const child = spawnImpl(executable, args, {
146
+ cwd: options.cwd,
147
+ env: { ...process.env, ...options.env },
148
+ detached: process.platform !== 'win32',
149
+ stdio: ['ignore', 'pipe', 'pipe']
150
+ });
151
+ let stdout = '';
152
+ let stderr = '';
153
+ child.stdout?.on('data', (chunk) => {
154
+ stdout += String(chunk);
155
+ });
156
+ child.stderr?.on('data', (chunk) => {
157
+ stderr += String(chunk);
158
+ });
159
+ return new Promise((resolve, reject) => {
160
+ let settled = false;
161
+ const timeout = setTimeout(() => {
162
+ if (settled) {
163
+ return;
164
+ }
165
+ settled = true;
166
+ killTimedOutChild(child);
167
+ child.stdout?.destroy();
168
+ child.stderr?.destroy();
169
+ resolve({
170
+ returncode: 1,
171
+ stdout,
172
+ stderr: `${stderr}${stderr ? '\n' : ''}command timed out after ${options.timeoutMs}ms`
173
+ });
174
+ }, options.timeoutMs);
175
+ child.on('error', (error) => {
176
+ if (settled) {
177
+ return;
178
+ }
179
+ settled = true;
180
+ clearTimeout(timeout);
181
+ reject(error);
182
+ });
183
+ child.on('close', (exitCode) => {
184
+ if (settled) {
185
+ return;
186
+ }
187
+ settled = true;
188
+ clearTimeout(timeout);
189
+ resolve({ returncode: exitCode ?? 1, stdout, stderr });
190
+ });
191
+ });
192
+ }
193
+ function quoteWindowsCmdArgument(value) {
194
+ const escaped = value.replace(/(["^&|<>()%!])/g, '^$1');
195
+ return `"${escaped}"`;
196
+ }
197
+ function firstOutputLine(value) {
198
+ return value.trim().split(/\r?\n/)[0]?.trim() ?? '';
199
+ }
200
+ function killTimedOutChild(child) {
201
+ if (process.platform !== 'win32' && child.pid) {
202
+ try {
203
+ process.kill(-child.pid, 'SIGKILL');
204
+ return;
205
+ }
206
+ catch {
207
+ // Fall back to killing the direct child below.
208
+ }
209
+ }
210
+ child.kill('SIGKILL');
211
+ }
212
+ function safeCommandMessage(result) {
213
+ const output = [result.stderr, result.stdout].filter((value) => value.trim()).join('\n').trim();
214
+ const message = output || `exit code ${result.returncode}`;
215
+ return truncate(message.replace(/\s+/g, ' '), 640);
216
+ }
217
+ function safeErrorMessage(error) {
218
+ if (error instanceof Error) {
219
+ return truncate(error.message.replace(/\s+/g, ' '), 640);
220
+ }
221
+ return truncate(String(error).replace(/\s+/g, ' '), 640);
222
+ }
223
+ function truncate(value, maxLength) {
224
+ if (value.length <= maxLength) {
225
+ return value;
226
+ }
227
+ return `${value.slice(0, maxLength)}...`;
228
+ }
229
+ function validatePackageName(value) {
230
+ const packageName = requireNonEmpty(value, 'packageName');
231
+ if (path.isAbsolute(packageName) || packageName.includes('\\')) {
232
+ throw new ManagedToolError('packageName contains unsafe path characters', 'MANAGED_TOOL_INVALID_OPTIONS');
233
+ }
234
+ const parts = packageName.split('/');
235
+ const scoped = packageName.startsWith('@');
236
+ if ((scoped && parts.length !== 2) || (!scoped && parts.length !== 1)) {
237
+ throw new ManagedToolError('packageName contains unsafe path segments', 'MANAGED_TOOL_INVALID_OPTIONS');
238
+ }
239
+ for (const part of parts) {
240
+ rejectUnsafePathPart(part, 'packageName');
241
+ }
242
+ return packageName;
243
+ }
244
+ function validateSinglePathPart(value, name) {
245
+ const part = requireNonEmpty(value, name);
246
+ if (path.isAbsolute(part) || part.includes('/') || part.includes('\\')) {
247
+ throw new ManagedToolError(`${name} contains unsafe path characters`, 'MANAGED_TOOL_INVALID_OPTIONS');
248
+ }
249
+ rejectUnsafePathPart(part, name);
250
+ return part;
251
+ }
252
+ function rejectUnsafePathPart(part, name) {
253
+ if (!part || part === '.' || part === '..') {
254
+ throw new ManagedToolError(`${name} contains unsafe path segments`, 'MANAGED_TOOL_INVALID_OPTIONS');
255
+ }
256
+ }
257
+ function validateTimeoutMs(value) {
258
+ if (!Number.isFinite(value) || value <= 0) {
259
+ throw new ManagedToolError('timeoutMs must be a positive number', 'MANAGED_TOOL_INVALID_OPTIONS');
260
+ }
261
+ return value;
262
+ }
263
+ function requireNonEmpty(value, name) {
264
+ const trimmed = String(value ?? '').trim();
265
+ if (!trimmed) {
266
+ throw new ManagedToolError(`${name} is required`, 'MANAGED_TOOL_INVALID_OPTIONS');
267
+ }
268
+ return trimmed;
269
+ }