@swimmingliu/autovpn 1.6.6 → 1.6.7
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 +7 -54
- package/dist/backend/select-backend.js +0 -3
- package/dist/cli/commands/index.js +4 -0
- package/dist/cli/main.js +7 -25
- package/dist/cli/native-commands.js +1 -0
- package/dist/jobs/commands.js +13 -0
- package/dist/jobs/process.js +92 -10
- package/dist/pipeline/availability.js +0 -96
- package/dist/pipeline/dedupe.js +1 -69
- package/dist/pipeline/deploy.js +138 -251
- package/dist/pipeline/extract.js +0 -80
- package/dist/pipeline/obfuscate.js +1 -80
- package/dist/pipeline/orchestrator.js +105 -127
- package/dist/pipeline/postprocess.js +1 -85
- package/dist/pipeline/proxy-runtime.js +5 -0
- package/dist/pipeline/render.js +1 -73
- package/dist/pipeline/speedtest.js +89 -100
- package/dist/runtime/paths.js +11 -2
- package/dist/web/renderer/i18n.js +1 -1
- package/package.json +3 -4
- package/dist/backend/python-backend.js +0 -242
- package/lib/cache.mjs +0 -14
- package/lib/errors.mjs +0 -11
- package/lib/install-python-cli.mjs +0 -63
- package/lib/runner.mjs +0 -113
|
@@ -1,27 +1,7 @@
|
|
|
1
|
-
import path from 'node:path';
|
|
2
|
-
import { spawn as defaultSpawn } from 'node:child_process';
|
|
3
1
|
import net from 'node:net';
|
|
4
2
|
import tls from 'node:tls';
|
|
5
|
-
import { mergeProjectEnv } from '../runtime/env.js';
|
|
6
3
|
import { openMihomoRuntime as defaultOpenMihomoRuntime, probeMihomoProxyDelay as defaultProbeMihomoProxyDelay } from './proxy-runtime.js';
|
|
7
|
-
const
|
|
8
|
-
import json
|
|
9
|
-
import sys
|
|
10
|
-
from vpn_automation.config.models import SpeedTestConfig
|
|
11
|
-
from vpn_automation.pipeline.speedtest import speedtest_links
|
|
12
|
-
|
|
13
|
-
payload = json.load(sys.stdin)
|
|
14
|
-
output = [
|
|
15
|
-
item.__dict__
|
|
16
|
-
for item in speedtest_links(
|
|
17
|
-
payload.get("links", []),
|
|
18
|
-
SpeedTestConfig(**payload["config"]),
|
|
19
|
-
runtime_path=payload.get("runtime_path", ""),
|
|
20
|
-
)
|
|
21
|
-
]
|
|
22
|
-
json.dump(output, sys.stdout, ensure_ascii=False)
|
|
23
|
-
sys.stdout.write("\\n")
|
|
24
|
-
`;
|
|
4
|
+
const PROBE_MAX_ATTEMPTS = 2;
|
|
25
5
|
export function aggregateSpeedMeasurements(values) {
|
|
26
6
|
if (values.length === 0) {
|
|
27
7
|
return 0;
|
|
@@ -94,9 +74,27 @@ function defaultNow() {
|
|
|
94
74
|
}
|
|
95
75
|
async function fetchWithTimeout(fetchImpl, url, timeoutMs) {
|
|
96
76
|
const controller = new AbortController();
|
|
97
|
-
|
|
77
|
+
let internallyTimedOut = false;
|
|
78
|
+
const timer = setTimeout(() => {
|
|
79
|
+
internallyTimedOut = true;
|
|
80
|
+
controller.abort();
|
|
81
|
+
}, timeoutMs);
|
|
98
82
|
try {
|
|
99
|
-
|
|
83
|
+
const response = await fetchImpl(url, { signal: controller.signal });
|
|
84
|
+
if (internallyTimedOut) {
|
|
85
|
+
const error = new Error(`request timed out after ${timeoutMs}ms`);
|
|
86
|
+
error.code = 'AUTOVPN_INTERNAL_TIMEOUT';
|
|
87
|
+
throw error;
|
|
88
|
+
}
|
|
89
|
+
return response;
|
|
90
|
+
}
|
|
91
|
+
catch (error) {
|
|
92
|
+
if (internallyTimedOut && !(error instanceof Error && error.code === 'AUTOVPN_INTERNAL_TIMEOUT')) {
|
|
93
|
+
const timeoutError = new Error(`request timed out after ${timeoutMs}ms`, { cause: error });
|
|
94
|
+
timeoutError.code = 'AUTOVPN_INTERNAL_TIMEOUT';
|
|
95
|
+
throw timeoutError;
|
|
96
|
+
}
|
|
97
|
+
throw error;
|
|
100
98
|
}
|
|
101
99
|
finally {
|
|
102
100
|
clearTimeout(timer);
|
|
@@ -122,6 +120,7 @@ async function readResponseBytes(response, maxBytes, timeoutMs) {
|
|
|
122
120
|
if (response.body?.getReader) {
|
|
123
121
|
const reader = response.body.getReader();
|
|
124
122
|
let timedOut = false;
|
|
123
|
+
let reachedCap = false;
|
|
125
124
|
try {
|
|
126
125
|
return await withBodyTimeout(async () => {
|
|
127
126
|
let total = 0;
|
|
@@ -131,6 +130,9 @@ async function readResponseBytes(response, maxBytes, timeoutMs) {
|
|
|
131
130
|
break;
|
|
132
131
|
}
|
|
133
132
|
total += Math.min(value.byteLength, maxBytes - total);
|
|
133
|
+
if (total >= maxBytes) {
|
|
134
|
+
reachedCap = true;
|
|
135
|
+
}
|
|
134
136
|
}
|
|
135
137
|
return total;
|
|
136
138
|
}, timeoutMs);
|
|
@@ -140,16 +142,13 @@ async function readResponseBytes(response, maxBytes, timeoutMs) {
|
|
|
140
142
|
throw error;
|
|
141
143
|
}
|
|
142
144
|
finally {
|
|
143
|
-
if (timedOut) {
|
|
145
|
+
if (timedOut || reachedCap) {
|
|
144
146
|
await reader.cancel().catch(() => { });
|
|
145
147
|
}
|
|
146
148
|
reader.releaseLock();
|
|
147
149
|
}
|
|
148
150
|
}
|
|
149
|
-
|
|
150
|
-
return await withBodyTimeout(async () => Math.min((await response.arrayBuffer()).byteLength, maxBytes), timeoutMs);
|
|
151
|
-
}
|
|
152
|
-
return 0;
|
|
151
|
+
throw new Error('streaming response body required for bounded speed test downloads');
|
|
153
152
|
}
|
|
154
153
|
function requireOkResponse(response, allowedStatuses) {
|
|
155
154
|
const status = Number(response.status ?? 200);
|
|
@@ -157,6 +156,36 @@ function requireOkResponse(response, allowedStatuses) {
|
|
|
157
156
|
throw new Error(`unexpected status ${status}`);
|
|
158
157
|
}
|
|
159
158
|
}
|
|
159
|
+
function isTransientProbeError(error) {
|
|
160
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
161
|
+
const code = typeof error === 'object' && error !== null && 'code' in error
|
|
162
|
+
? String(error.code ?? '')
|
|
163
|
+
: '';
|
|
164
|
+
const statusMatch = /(?:unexpected status|failed with status)\s+(\d{3})\b/i.exec(message);
|
|
165
|
+
if (statusMatch) {
|
|
166
|
+
const status = Number(statusMatch[1]);
|
|
167
|
+
return status >= 500 && status <= 599;
|
|
168
|
+
}
|
|
169
|
+
if (['AUTOVPN_INTERNAL_TIMEOUT', 'ECONNRESET', 'ETIMEDOUT', 'EPIPE', 'ECONNREFUSED', 'UND_ERR_CONNECT_TIMEOUT', 'UND_ERR_HEADERS_TIMEOUT', 'UND_ERR_BODY_TIMEOUT'].includes(code.toUpperCase())) {
|
|
170
|
+
return true;
|
|
171
|
+
}
|
|
172
|
+
return error instanceof TypeError && message.trim().toLowerCase() === 'fetch failed';
|
|
173
|
+
}
|
|
174
|
+
async function retryTransientProbe(operation) {
|
|
175
|
+
let lastError;
|
|
176
|
+
for (let attempt = 1; attempt <= PROBE_MAX_ATTEMPTS; attempt += 1) {
|
|
177
|
+
try {
|
|
178
|
+
return await operation();
|
|
179
|
+
}
|
|
180
|
+
catch (error) {
|
|
181
|
+
lastError = error;
|
|
182
|
+
if (attempt >= PROBE_MAX_ATTEMPTS || !isTransientProbeError(error)) {
|
|
183
|
+
throw error;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
throw lastError;
|
|
188
|
+
}
|
|
160
189
|
function socketConnect(host, port, timeoutMs) {
|
|
161
190
|
return new Promise((resolve, reject) => {
|
|
162
191
|
const socket = net.createConnection({ host, port });
|
|
@@ -346,12 +375,14 @@ async function probeLinksDirect(links, config, options) {
|
|
|
346
375
|
const now = options.now ?? defaultNow;
|
|
347
376
|
const timeoutMs = Math.max(1, Number(config.timeout_seconds)) * 1000;
|
|
348
377
|
return mapWithConcurrency(links, config.concurrency, async (link) => {
|
|
349
|
-
const started = now();
|
|
350
378
|
try {
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
379
|
+
return await retryTransientProbe(async () => {
|
|
380
|
+
const started = now();
|
|
381
|
+
const response = await fetchWithTimeout(fetchImpl, config.probe_url, timeoutMs);
|
|
382
|
+
const elapsed = Math.max(now() - started, 1);
|
|
383
|
+
requireOkResponse(response, new Set([200, 204]));
|
|
384
|
+
return { link, reachable: true, latency_ms: Math.max(Math.round(elapsed), 1), error: '' };
|
|
385
|
+
});
|
|
355
386
|
}
|
|
356
387
|
catch (error) {
|
|
357
388
|
return { link, reachable: false, latency_ms: 0, error: error instanceof Error ? error.message : String(error) };
|
|
@@ -362,22 +393,26 @@ async function probeLinksMihomo(links, config, runtimePath, options) {
|
|
|
362
393
|
const openRuntime = options.openMihomoRuntime ?? defaultOpenMihomoRuntime;
|
|
363
394
|
const probeDelay = options.probeMihomoProxyDelay ?? defaultProbeMihomoProxyDelay;
|
|
364
395
|
return mapWithConcurrency(links, config.concurrency, async (link) => {
|
|
365
|
-
let runtime;
|
|
366
396
|
try {
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
397
|
+
return await retryTransientProbe(async () => {
|
|
398
|
+
let runtime;
|
|
399
|
+
try {
|
|
400
|
+
runtime = await openRuntime(link, {
|
|
401
|
+
runtimePath,
|
|
402
|
+
startupWaitSeconds: config.startup_wait_seconds,
|
|
403
|
+
env: options.env
|
|
404
|
+
});
|
|
405
|
+
const latencyMs = await probeDelay(runtime.controllerUrl, runtime.proxyName, config.probe_url, config.timeout_seconds);
|
|
406
|
+
return { link, reachable: true, latency_ms: latencyMs, error: '' };
|
|
407
|
+
}
|
|
408
|
+
finally {
|
|
409
|
+
await runtime?.close();
|
|
410
|
+
}
|
|
371
411
|
});
|
|
372
|
-
const latencyMs = await probeDelay(runtime.controllerUrl, runtime.proxyName, config.probe_url, config.timeout_seconds);
|
|
373
|
-
return { link, reachable: true, latency_ms: latencyMs, error: '' };
|
|
374
412
|
}
|
|
375
413
|
catch (error) {
|
|
376
414
|
return { link, reachable: false, latency_ms: 0, error: error instanceof Error ? error.message : String(error) };
|
|
377
415
|
}
|
|
378
|
-
finally {
|
|
379
|
-
await runtime?.close();
|
|
380
|
-
}
|
|
381
416
|
});
|
|
382
417
|
}
|
|
383
418
|
async function testLinkDirect(link, config, options) {
|
|
@@ -495,6 +530,17 @@ async function speedtestInNode(input, options) {
|
|
|
495
530
|
urls: [...config.urls]
|
|
496
531
|
});
|
|
497
532
|
const probes = await probeLinks(input.links, config, { runtime_path: runtimePath });
|
|
533
|
+
for (let index = 0; index < probes.length; index += 1) {
|
|
534
|
+
const probe = probes[index];
|
|
535
|
+
emitEvent(options.eventCallback, 'speedtest_probe_result', {
|
|
536
|
+
completed: index + 1,
|
|
537
|
+
total: input.links.length,
|
|
538
|
+
link: probe.link,
|
|
539
|
+
reachable: probe.reachable,
|
|
540
|
+
latency_ms: probe.latency_ms,
|
|
541
|
+
error: probe.error ?? ''
|
|
542
|
+
});
|
|
543
|
+
}
|
|
498
544
|
const candidateLinks = selectSpeedtestCandidates(probes, config.max_download_candidates);
|
|
499
545
|
const probeByLink = new Map(probes.map((probe) => [probe.link, probe]));
|
|
500
546
|
const candidateSet = new Set(candidateLinks);
|
|
@@ -533,64 +579,7 @@ async function speedtestInNode(input, options) {
|
|
|
533
579
|
results.push(...testedResults);
|
|
534
580
|
return results;
|
|
535
581
|
}
|
|
536
|
-
export function selectPipelineStageBackend(stage, env = process.env) {
|
|
537
|
-
void stage;
|
|
538
|
-
void env;
|
|
539
|
-
return 'node';
|
|
540
|
-
}
|
|
541
|
-
async function defaultResolvePythonCli(env) {
|
|
542
|
-
// @ts-expect-error Phase 1 runner remains plain ESM JavaScript.
|
|
543
|
-
const runner = await import('../../lib/runner.mjs');
|
|
544
|
-
return runner.resolveOrInstallPythonCli({ env });
|
|
545
|
-
}
|
|
546
|
-
function pythonCommandFor(resolved) {
|
|
547
|
-
const command = resolved.command;
|
|
548
|
-
const name = path.basename(command).toLowerCase();
|
|
549
|
-
if (['autovpn', 'autovpn.exe'].includes(name)) {
|
|
550
|
-
const executable = process.platform === 'win32' ? 'python.exe' : 'python';
|
|
551
|
-
return path.join(path.dirname(command), executable);
|
|
552
|
-
}
|
|
553
|
-
return process.platform === 'win32' ? 'python.exe' : 'python3';
|
|
554
|
-
}
|
|
555
|
-
async function speedtestWithPython(input, options) {
|
|
556
|
-
const env = mergeProjectEnv(options.cwd ?? process.cwd(), options.env ?? process.env);
|
|
557
|
-
const resolved = options.resolvePythonCli ? await options.resolvePythonCli() : await defaultResolvePythonCli(env);
|
|
558
|
-
const child = (options.spawn ?? defaultSpawn)(pythonCommandFor(resolved), ['-c', PYTHON_SPEEDTEST_HELPER], {
|
|
559
|
-
cwd: options.cwd ?? process.cwd(),
|
|
560
|
-
env,
|
|
561
|
-
stdio: ['pipe', 'pipe', 'pipe']
|
|
562
|
-
});
|
|
563
|
-
let stdout = '';
|
|
564
|
-
let stderr = '';
|
|
565
|
-
child.stdout?.on('data', (chunk) => {
|
|
566
|
-
stdout += String(chunk);
|
|
567
|
-
});
|
|
568
|
-
child.stderr?.on('data', (chunk) => {
|
|
569
|
-
stderr += String(chunk);
|
|
570
|
-
});
|
|
571
|
-
const completion = new Promise((resolve, reject) => {
|
|
572
|
-
child.on('error', reject);
|
|
573
|
-
child.on('close', (code) => {
|
|
574
|
-
if (code !== 0) {
|
|
575
|
-
reject(new Error(`Python speedtest backend failed with exit code ${code}: ${stderr.trim()}`));
|
|
576
|
-
return;
|
|
577
|
-
}
|
|
578
|
-
try {
|
|
579
|
-
resolve(JSON.parse(stdout));
|
|
580
|
-
}
|
|
581
|
-
catch (error) {
|
|
582
|
-
reject(new Error(`Python speedtest backend returned invalid JSON: ${error instanceof Error ? error.message : String(error)}`));
|
|
583
|
-
}
|
|
584
|
-
});
|
|
585
|
-
});
|
|
586
|
-
child.stdin?.write(JSON.stringify(input));
|
|
587
|
-
child.stdin?.end();
|
|
588
|
-
return completion;
|
|
589
|
-
}
|
|
590
582
|
export async function speedtestLinksWithBackend(input, options = {}) {
|
|
591
|
-
if (selectPipelineStageBackend('speedtest', options.env ?? process.env) === 'python') {
|
|
592
|
-
return options.pythonSpeedtest ? options.pythonSpeedtest(input) : speedtestWithPython(input, options);
|
|
593
|
-
}
|
|
594
583
|
return speedtestInNode(input, options);
|
|
595
584
|
}
|
|
596
585
|
export async function probeSpeedtestLinksInNode(input, options = {}) {
|
package/dist/runtime/paths.js
CHANGED
|
@@ -5,8 +5,17 @@ export function resolveRuntimeRoot(candidate) {
|
|
|
5
5
|
const resolved = fs.existsSync(absolute) ? fs.realpathSync(absolute) : absolute;
|
|
6
6
|
let current = fs.existsSync(resolved) && fs.statSync(resolved).isFile() ? path.dirname(resolved) : resolved;
|
|
7
7
|
while (true) {
|
|
8
|
-
|
|
9
|
-
|
|
8
|
+
const packagePath = path.join(current, 'package.json');
|
|
9
|
+
if (fs.existsSync(packagePath)) {
|
|
10
|
+
try {
|
|
11
|
+
const manifest = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
|
|
12
|
+
if (manifest.name === 'vpn-subscription-automation') {
|
|
13
|
+
return current;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
// Continue walking when an unrelated package manifest is invalid.
|
|
18
|
+
}
|
|
10
19
|
}
|
|
11
20
|
const parent = path.dirname(current);
|
|
12
21
|
if (parent === current) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@swimmingliu/autovpn",
|
|
3
|
-
"version": "1.6.
|
|
3
|
+
"version": "1.6.7",
|
|
4
4
|
"description": "npm wrapper for the AutoVPN headless CLI",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"repository": {
|
|
@@ -14,16 +14,15 @@
|
|
|
14
14
|
"files": [
|
|
15
15
|
"bin/",
|
|
16
16
|
"dist/",
|
|
17
|
-
"lib/",
|
|
18
17
|
"README.md"
|
|
19
18
|
],
|
|
20
19
|
"publishConfig": {
|
|
21
20
|
"access": "public"
|
|
22
21
|
},
|
|
23
22
|
"scripts": {
|
|
24
|
-
"build": "tsc -p tsconfig.json && node scripts/copy-web-assets.mjs",
|
|
23
|
+
"build": "node -e \"require('fs').rmSync('dist', { recursive: true, force: true })\" && tsc -p tsconfig.json && node scripts/copy-web-assets.mjs",
|
|
25
24
|
"prepack": "npm run build",
|
|
26
|
-
"test": "npm run build && node --test test/*.test.mjs test/doctor/*.test.mjs test/
|
|
25
|
+
"test": "npm run build && node --test test/*.test.mjs test/doctor/*.test.mjs test/jobs/*.test.mjs test/pipeline/*.test.mjs test/runtime/*.test.mjs test/server/*.test.mjs"
|
|
27
26
|
},
|
|
28
27
|
"engines": {
|
|
29
28
|
"node": ">=22.5.0"
|
|
@@ -1,242 +0,0 @@
|
|
|
1
|
-
import { spawn as defaultSpawn } from 'node:child_process';
|
|
2
|
-
import { parseEventLine } from '../events/schema.js';
|
|
3
|
-
import { mergeProjectEnv } from '../runtime/env.js';
|
|
4
|
-
function pushOption(argv, name, value) {
|
|
5
|
-
if (value !== undefined && value !== '') {
|
|
6
|
-
argv.push(name, String(value));
|
|
7
|
-
}
|
|
8
|
-
}
|
|
9
|
-
function runArgs(options) {
|
|
10
|
-
const argv = ['run', '--project-root', options.projectRoot, '--output', options.output ?? 'jsonl'];
|
|
11
|
-
if (options.resumeLatest)
|
|
12
|
-
argv.push('--resume-latest');
|
|
13
|
-
if (options.skipDeploy)
|
|
14
|
-
argv.push('--skip-deploy');
|
|
15
|
-
if (options.skipVerify)
|
|
16
|
-
argv.push('--skip-verify');
|
|
17
|
-
pushOption(argv, '--event-log', options.eventLog);
|
|
18
|
-
pushOption(argv, '--human-log', options.humanLog);
|
|
19
|
-
return argv;
|
|
20
|
-
}
|
|
21
|
-
function retryArgs(options) {
|
|
22
|
-
const argv = [
|
|
23
|
-
'retry-stage',
|
|
24
|
-
'--project-root',
|
|
25
|
-
options.projectRoot,
|
|
26
|
-
'--artifact-dir',
|
|
27
|
-
options.artifactDir,
|
|
28
|
-
'--stage',
|
|
29
|
-
options.stage,
|
|
30
|
-
'--output',
|
|
31
|
-
options.output ?? 'jsonl'
|
|
32
|
-
];
|
|
33
|
-
pushOption(argv, '--event-log', options.eventLog);
|
|
34
|
-
pushOption(argv, '--human-log', options.humanLog);
|
|
35
|
-
return argv;
|
|
36
|
-
}
|
|
37
|
-
function resumeArgs(options) {
|
|
38
|
-
const argv = [
|
|
39
|
-
'resume',
|
|
40
|
-
options.mode,
|
|
41
|
-
'--project-root',
|
|
42
|
-
options.projectRoot,
|
|
43
|
-
'--session',
|
|
44
|
-
options.session,
|
|
45
|
-
'--output',
|
|
46
|
-
options.output ?? 'jsonl'
|
|
47
|
-
];
|
|
48
|
-
pushOption(argv, '--event-log', options.eventLog);
|
|
49
|
-
pushOption(argv, '--human-log', options.humanLog);
|
|
50
|
-
return argv;
|
|
51
|
-
}
|
|
52
|
-
async function* splitLines(stream) {
|
|
53
|
-
const queue = [];
|
|
54
|
-
let ended = false;
|
|
55
|
-
let pendingResolve;
|
|
56
|
-
let buffer = '';
|
|
57
|
-
stream.on('data', (chunk) => {
|
|
58
|
-
buffer += String(chunk);
|
|
59
|
-
const parts = buffer.split(/\r?\n/);
|
|
60
|
-
buffer = parts.pop() ?? '';
|
|
61
|
-
queue.push(...parts.filter((line) => line.trim()));
|
|
62
|
-
pendingResolve?.();
|
|
63
|
-
pendingResolve = undefined;
|
|
64
|
-
});
|
|
65
|
-
stream.on('end', () => {
|
|
66
|
-
if (buffer.trim())
|
|
67
|
-
queue.push(buffer);
|
|
68
|
-
ended = true;
|
|
69
|
-
pendingResolve?.();
|
|
70
|
-
pendingResolve = undefined;
|
|
71
|
-
});
|
|
72
|
-
while (!ended || queue.length) {
|
|
73
|
-
if (!queue.length) {
|
|
74
|
-
await new Promise((resolve) => {
|
|
75
|
-
pendingResolve = resolve;
|
|
76
|
-
});
|
|
77
|
-
continue;
|
|
78
|
-
}
|
|
79
|
-
yield queue.shift();
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
function waitForClose(child) {
|
|
83
|
-
return new Promise((resolve, reject) => {
|
|
84
|
-
child.once('error', reject);
|
|
85
|
-
child.once('close', (code, signal) => {
|
|
86
|
-
resolve({ code, signal });
|
|
87
|
-
});
|
|
88
|
-
});
|
|
89
|
-
}
|
|
90
|
-
function errorSummary(stderr) {
|
|
91
|
-
const summary = stderr.trim().split(/\r?\n/).filter(Boolean).slice(-3).join('\n');
|
|
92
|
-
return summary ? `: ${summary}` : '';
|
|
93
|
-
}
|
|
94
|
-
function parseJsonPayload(stdout) {
|
|
95
|
-
const line = stdout.split(/\r?\n/).find((candidate) => candidate.trim());
|
|
96
|
-
if (!line) {
|
|
97
|
-
throw new Error('Python backend returned empty JSON payload');
|
|
98
|
-
}
|
|
99
|
-
const payload = JSON.parse(line);
|
|
100
|
-
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
|
|
101
|
-
throw new Error('Python backend JSON payload must be an object');
|
|
102
|
-
}
|
|
103
|
-
return payload;
|
|
104
|
-
}
|
|
105
|
-
function projectRootFromArgv(argv) {
|
|
106
|
-
const index = argv.indexOf('--project-root');
|
|
107
|
-
if (index >= 0 && argv[index + 1]) {
|
|
108
|
-
return argv[index + 1];
|
|
109
|
-
}
|
|
110
|
-
const inline = argv.find((item) => item.startsWith('--project-root='));
|
|
111
|
-
if (inline) {
|
|
112
|
-
return inline.slice('--project-root='.length) || undefined;
|
|
113
|
-
}
|
|
114
|
-
return undefined;
|
|
115
|
-
}
|
|
116
|
-
export class PythonBackend {
|
|
117
|
-
kind = 'python';
|
|
118
|
-
env;
|
|
119
|
-
cwd;
|
|
120
|
-
runForwarder;
|
|
121
|
-
resolvePythonCli;
|
|
122
|
-
spawn;
|
|
123
|
-
constructor(options = {}) {
|
|
124
|
-
this.env = options.env ?? process.env;
|
|
125
|
-
this.cwd = options.cwd ?? process.cwd();
|
|
126
|
-
this.runForwarder = options.runForwarder;
|
|
127
|
-
this.resolvePythonCli = options.resolvePythonCli;
|
|
128
|
-
this.spawn = options.spawn ?? defaultSpawn;
|
|
129
|
-
}
|
|
130
|
-
async executeCli(argv) {
|
|
131
|
-
const env = this.envForArgv(argv);
|
|
132
|
-
const forwarderOptions = { env, cwd: this.cwd };
|
|
133
|
-
if (this.runForwarder) {
|
|
134
|
-
return this.runForwarder(argv, forwarderOptions);
|
|
135
|
-
}
|
|
136
|
-
// @ts-expect-error Phase 1 runner remains plain ESM JavaScript.
|
|
137
|
-
const runner = await import('../../lib/runner.mjs');
|
|
138
|
-
return Number(await runner.runForwarder(argv, forwarderOptions));
|
|
139
|
-
}
|
|
140
|
-
run(options) {
|
|
141
|
-
return this.streamEvents(runArgs(options));
|
|
142
|
-
}
|
|
143
|
-
retryStage(options) {
|
|
144
|
-
return this.streamEvents(retryArgs(options));
|
|
145
|
-
}
|
|
146
|
-
resume(options) {
|
|
147
|
-
return this.streamEvents(resumeArgs(options));
|
|
148
|
-
}
|
|
149
|
-
async startDetached(options) {
|
|
150
|
-
const argv = runArgs(options);
|
|
151
|
-
argv.push('--detach');
|
|
152
|
-
argv.push('--json');
|
|
153
|
-
return this.captureJson(argv);
|
|
154
|
-
}
|
|
155
|
-
async stopJob(jobId, options = {}) {
|
|
156
|
-
const argv = jobId === 'latest'
|
|
157
|
-
? ['stop']
|
|
158
|
-
: ['jobs', 'stop', jobId];
|
|
159
|
-
pushOption(argv, '--project-root', options.projectRoot);
|
|
160
|
-
pushOption(argv, '--timeout', options.timeout);
|
|
161
|
-
return this.captureJson(argv);
|
|
162
|
-
}
|
|
163
|
-
async readJob(jobId, options = {}) {
|
|
164
|
-
const argv = jobId === 'latest'
|
|
165
|
-
? ['status', '--json']
|
|
166
|
-
: ['jobs', 'status', jobId, '--json'];
|
|
167
|
-
pushOption(argv, '--project-root', options.projectRoot);
|
|
168
|
-
return this.captureJson(argv);
|
|
169
|
-
}
|
|
170
|
-
async *readLogs(options) {
|
|
171
|
-
const argv = options.jobId
|
|
172
|
-
? ['jobs', 'logs', options.jobId, '--project-root', options.projectRoot]
|
|
173
|
-
: ['logs', '--project-root', options.projectRoot];
|
|
174
|
-
if (options.format)
|
|
175
|
-
argv.push('--format', options.format);
|
|
176
|
-
pushOption(argv, '--tail', options.tail);
|
|
177
|
-
if (options.follow)
|
|
178
|
-
argv.push('--follow');
|
|
179
|
-
yield* this.streamLines(argv);
|
|
180
|
-
}
|
|
181
|
-
async *streamEvents(argv) {
|
|
182
|
-
for await (const line of this.streamLines(argv)) {
|
|
183
|
-
yield parseEventLine(line);
|
|
184
|
-
}
|
|
185
|
-
}
|
|
186
|
-
async *streamLines(argv) {
|
|
187
|
-
const env = this.envForArgv(argv);
|
|
188
|
-
const resolved = this.resolvePythonCli ? this.resolvePythonCli() : await this.defaultResolvePythonCli(env);
|
|
189
|
-
const child = this.spawn(resolved.command, [...resolved.args, ...argv], {
|
|
190
|
-
cwd: this.cwd,
|
|
191
|
-
env,
|
|
192
|
-
stdio: ['ignore', 'pipe', 'pipe']
|
|
193
|
-
});
|
|
194
|
-
let stderr = '';
|
|
195
|
-
child.stderr?.on('data', (chunk) => {
|
|
196
|
-
stderr += String(chunk);
|
|
197
|
-
});
|
|
198
|
-
const closePromise = waitForClose(child);
|
|
199
|
-
const stdout = child.stdout;
|
|
200
|
-
if (!stdout) {
|
|
201
|
-
throw new Error('Python backend did not expose stdout for event streaming');
|
|
202
|
-
}
|
|
203
|
-
for await (const line of splitLines(stdout)) {
|
|
204
|
-
yield line;
|
|
205
|
-
}
|
|
206
|
-
const { code, signal } = await closePromise;
|
|
207
|
-
if (code !== 0) {
|
|
208
|
-
throw new Error(`Python backend exited with ${signal ? `signal ${signal}` : `code ${code}`}${errorSummary(stderr)}`);
|
|
209
|
-
}
|
|
210
|
-
}
|
|
211
|
-
async captureJson(argv) {
|
|
212
|
-
const env = this.envForArgv(argv);
|
|
213
|
-
const resolved = this.resolvePythonCli ? this.resolvePythonCli() : await this.defaultResolvePythonCli(env);
|
|
214
|
-
const child = this.spawn(resolved.command, [...resolved.args, ...argv], {
|
|
215
|
-
cwd: this.cwd,
|
|
216
|
-
env,
|
|
217
|
-
stdio: ['ignore', 'pipe', 'pipe']
|
|
218
|
-
});
|
|
219
|
-
let stdout = '';
|
|
220
|
-
let stderr = '';
|
|
221
|
-
child.stdout?.on('data', (chunk) => {
|
|
222
|
-
stdout += String(chunk);
|
|
223
|
-
});
|
|
224
|
-
child.stderr?.on('data', (chunk) => {
|
|
225
|
-
stderr += String(chunk);
|
|
226
|
-
});
|
|
227
|
-
const { code, signal } = await waitForClose(child);
|
|
228
|
-
if (code !== 0) {
|
|
229
|
-
throw new Error(`Python backend exited with ${signal ? `signal ${signal}` : `code ${code}`}${errorSummary(stderr)}`);
|
|
230
|
-
}
|
|
231
|
-
return parseJsonPayload(stdout);
|
|
232
|
-
}
|
|
233
|
-
envForArgv(argv) {
|
|
234
|
-
const projectRoot = projectRootFromArgv(argv);
|
|
235
|
-
return projectRoot ? mergeProjectEnv(projectRoot, this.env) : this.env;
|
|
236
|
-
}
|
|
237
|
-
async defaultResolvePythonCli(env = this.env) {
|
|
238
|
-
// @ts-expect-error Phase 1 runner remains plain ESM JavaScript.
|
|
239
|
-
const runner = await import('../../lib/runner.mjs');
|
|
240
|
-
return runner.resolveOrInstallPythonCli({ env });
|
|
241
|
-
}
|
|
242
|
-
}
|
package/lib/cache.mjs
DELETED
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
import os from 'node:os';
|
|
2
|
-
import path from 'node:path';
|
|
3
|
-
|
|
4
|
-
export function getCacheDir(env = process.env) {
|
|
5
|
-
const override = String(env.AUTOVPN_CACHE_DIR ?? '').trim();
|
|
6
|
-
if (override) {
|
|
7
|
-
return override;
|
|
8
|
-
}
|
|
9
|
-
return path.join(os.homedir(), '.cache', 'autovpn', 'npm-wrapper');
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
export function pythonBinName(platform = process.platform) {
|
|
13
|
-
return platform === 'win32' ? 'Scripts' : 'bin';
|
|
14
|
-
}
|
package/lib/errors.mjs
DELETED
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
export class WrapperError extends Error {
|
|
2
|
-
constructor(message, options = {}) {
|
|
3
|
-
super(message);
|
|
4
|
-
this.name = 'WrapperError';
|
|
5
|
-
this.cause = options.cause;
|
|
6
|
-
}
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
export function isEnabled(value) {
|
|
10
|
-
return ['1', 'true', 'yes', 'on'].includes(String(value ?? '').trim().toLowerCase());
|
|
11
|
-
}
|
|
@@ -1,63 +0,0 @@
|
|
|
1
|
-
import fs from 'node:fs';
|
|
2
|
-
import path from 'node:path';
|
|
3
|
-
import { spawnSync as defaultSpawnSync } from 'node:child_process';
|
|
4
|
-
|
|
5
|
-
import { getCacheDir, pythonBinName } from './cache.mjs';
|
|
6
|
-
import { isEnabled, WrapperError } from './errors.mjs';
|
|
7
|
-
|
|
8
|
-
export function buildPipInstallArgs({ env = process.env, packageVersion }) {
|
|
9
|
-
const packageSpec = String(env.AUTOVPN_WHEEL_URL ?? '').trim()
|
|
10
|
-
|| `${String(env.AUTOVPN_PYTHON_PACKAGE ?? 'vpn-subscription-automation').trim()}==${packageVersion}`;
|
|
11
|
-
const args = ['-m', 'pip', 'install'];
|
|
12
|
-
if (env.AUTOVPN_PIP_INDEX_URL) {
|
|
13
|
-
args.push('--index-url', String(env.AUTOVPN_PIP_INDEX_URL));
|
|
14
|
-
}
|
|
15
|
-
if (env.AUTOVPN_PIP_EXTRA_INDEX_URL) {
|
|
16
|
-
args.push('--extra-index-url', String(env.AUTOVPN_PIP_EXTRA_INDEX_URL));
|
|
17
|
-
}
|
|
18
|
-
args.push(packageSpec);
|
|
19
|
-
return args;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
export function installPythonCli({
|
|
23
|
-
env = process.env,
|
|
24
|
-
packageVersion,
|
|
25
|
-
spawnSync = defaultSpawnSync,
|
|
26
|
-
cacheDir = getCacheDir(env),
|
|
27
|
-
platform = process.platform
|
|
28
|
-
} = {}) {
|
|
29
|
-
if (isEnabled(env.AUTOVPN_NO_INSTALL)) {
|
|
30
|
-
throw new WrapperError('No compatible Python autovpn CLI found and AUTOVPN_NO_INSTALL is enabled.');
|
|
31
|
-
}
|
|
32
|
-
if (!packageVersion) {
|
|
33
|
-
throw new WrapperError('Cannot install Python AutoVPN backend without an npm package version.');
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
const venvDir = path.join(cacheDir, `python-${packageVersion}`);
|
|
37
|
-
const executable = platform === 'win32' ? 'autovpn.exe' : 'autovpn';
|
|
38
|
-
const cliPath = path.join(venvDir, pythonBinName(platform), executable);
|
|
39
|
-
const forceInstall = isEnabled(env.AUTOVPN_FORCE_INSTALL);
|
|
40
|
-
|
|
41
|
-
if (!forceInstall && fs.existsSync(cliPath)) {
|
|
42
|
-
return { command: cliPath, args: [], source: 'managed-venv' };
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
fs.mkdirSync(cacheDir, { recursive: true });
|
|
46
|
-
const pythonCommand = String(env.PYTHON ?? env.PYTHON3 ?? 'python3');
|
|
47
|
-
const venvResult = spawnSync(pythonCommand, ['-m', 'venv', venvDir], { stdio: 'inherit', env });
|
|
48
|
-
if (venvResult.status !== 0) {
|
|
49
|
-
throw new WrapperError(`Failed to create AutoVPN Python backend venv with ${pythonCommand}.`);
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
const pythonPath = path.join(venvDir, pythonBinName(platform), platform === 'win32' ? 'python.exe' : 'python');
|
|
53
|
-
const installResult = spawnSync(pythonPath, buildPipInstallArgs({ env, packageVersion }), { stdio: 'inherit', env });
|
|
54
|
-
if (installResult.status !== 0) {
|
|
55
|
-
throw new WrapperError('Failed to install AutoVPN Python backend into the wrapper-managed venv.');
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
if (!fs.existsSync(cliPath)) {
|
|
59
|
-
throw new WrapperError(`Installed Python backend did not provide autovpn at ${cliPath}.`);
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
return { command: cliPath, args: [], source: 'managed-venv' };
|
|
63
|
-
}
|