@swimmingliu/autovpn 1.4.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.
Files changed (40) hide show
  1. package/README.md +102 -0
  2. package/bin/autovpn.mjs +5 -0
  3. package/dist/artifacts/list.js +99 -0
  4. package/dist/artifacts/preview.js +85 -0
  5. package/dist/backend/node-backend.js +194 -0
  6. package/dist/backend/python-backend.js +242 -0
  7. package/dist/backend/select-backend.js +12 -0
  8. package/dist/backend/types.js +1 -0
  9. package/dist/cli/commands/index.js +143 -0
  10. package/dist/cli/errors.js +7 -0
  11. package/dist/cli/global-options.js +29 -0
  12. package/dist/cli/main.js +231 -0
  13. package/dist/cli/native-commands.js +215 -0
  14. package/dist/cli/output.js +31 -0
  15. package/dist/config/profile.js +80 -0
  16. package/dist/doctor/checks.js +184 -0
  17. package/dist/events/schema.js +45 -0
  18. package/dist/jobs/commands.js +159 -0
  19. package/dist/jobs/logs.js +48 -0
  20. package/dist/jobs/process.js +75 -0
  21. package/dist/jobs/read.js +282 -0
  22. package/dist/jobs/store.js +115 -0
  23. package/dist/pipeline/availability.js +679 -0
  24. package/dist/pipeline/dedupe.js +104 -0
  25. package/dist/pipeline/deploy.js +1103 -0
  26. package/dist/pipeline/extract.js +259 -0
  27. package/dist/pipeline/obfuscate.js +203 -0
  28. package/dist/pipeline/orchestrator.js +1014 -0
  29. package/dist/pipeline/postprocess.js +214 -0
  30. package/dist/pipeline/proxy-runtime.js +228 -0
  31. package/dist/pipeline/render.js +91 -0
  32. package/dist/pipeline/speedtest.js +579 -0
  33. package/dist/runtime/env.js +35 -0
  34. package/dist/runtime/paths.js +66 -0
  35. package/dist/runtime/redaction.js +56 -0
  36. package/lib/cache.mjs +14 -0
  37. package/lib/errors.mjs +11 -0
  38. package/lib/install-python-cli.mjs +63 -0
  39. package/lib/runner.mjs +113 -0
  40. package/package.json +39 -0
@@ -0,0 +1,679 @@
1
+ import path from 'node:path';
2
+ import { spawn as defaultSpawn } from 'node:child_process';
3
+ import http from 'node:http';
4
+ import net from 'node:net';
5
+ import tls from 'node:tls';
6
+ import { mergeProjectEnv } from '../runtime/env.js';
7
+ import { openMihomoRuntime as defaultOpenMihomoRuntime } from './proxy-runtime.js';
8
+ const PROVIDER_TARGETS = [
9
+ { name: 'gemini', url: 'https://gemini.google.com', allowed_hosts: ['gemini.google.com'], negative_phrases: [] },
10
+ { name: 'chatgpt_ios', url: 'https://ios.chat.openai.com/', allowed_hosts: ['ios.chat.openai.com'], negative_phrases: [] },
11
+ { name: 'chatgpt_web', url: 'https://api.openai.com/compliance/cookie_requirements', allowed_hosts: ['api.openai.com'], negative_phrases: [] },
12
+ { name: 'claude', url: 'https://claude.ai/cdn-cgi/trace', allowed_hosts: ['claude.ai'], negative_phrases: [] }
13
+ ];
14
+ const CHALLENGE_PHRASES = [
15
+ 'just a moment',
16
+ 'checking your browser',
17
+ 'verify you are human',
18
+ 'enable javascript and cookies'
19
+ ];
20
+ const PYTHON_AVAILABILITY_HELPER = `
21
+ import json
22
+ import sys
23
+ from vpn_automation.config.models import AvailabilityTargetConfig, SpeedTestConfig
24
+ from vpn_automation.pipeline.availability import ProviderTarget, check_link_availability_batch
25
+ from vpn_automation.pipeline.speedtest import SpeedTestResult
26
+
27
+ payload = json.load(sys.stdin)
28
+ config = SpeedTestConfig(**payload["config"])
29
+ results = [SpeedTestResult(**item) for item in payload.get("results", [])]
30
+ raw_targets = payload.get("targets", None)
31
+ targets = None
32
+ if isinstance(raw_targets, list):
33
+ targets = tuple(
34
+ ProviderTarget(
35
+ name=str(item["name"]),
36
+ url=str(item["url"]),
37
+ allowed_hosts=tuple(item.get("allowed_hosts") or []),
38
+ negative_phrases=tuple(item.get("negative_phrases") or []),
39
+ )
40
+ for item in raw_targets
41
+ )
42
+ elif isinstance(raw_targets, dict):
43
+ targets = {name: AvailabilityTargetConfig(**value) for name, value in raw_targets.items()}
44
+ output = [
45
+ item.to_dict()
46
+ for item in check_link_availability_batch(
47
+ results,
48
+ config,
49
+ runtime_path=payload.get("runtime_path", ""),
50
+ targets=targets,
51
+ )
52
+ ]
53
+ json.dump(output, sys.stdout, ensure_ascii=False)
54
+ sys.stdout.write("\\n")
55
+ `;
56
+ function providerResultWithDefaults(result) {
57
+ return {
58
+ provider: result.provider,
59
+ passed: result.passed,
60
+ reason: result.reason,
61
+ status_code: result.status_code ?? 0,
62
+ final_url: result.final_url ?? '',
63
+ matched_phrase: result.matched_phrase ?? ''
64
+ };
65
+ }
66
+ function hostIsAllowed(hostname, allowedHosts) {
67
+ const host = hostname.toLowerCase();
68
+ return allowedHosts.some((allowed) => host === allowed || host.endsWith(`.${allowed}`));
69
+ }
70
+ function hostnameFor(url) {
71
+ try {
72
+ return new URL(url).hostname;
73
+ }
74
+ catch {
75
+ return '';
76
+ }
77
+ }
78
+ export function normalizeProviderTargets(targets) {
79
+ if (targets == null) {
80
+ return PROVIDER_TARGETS.map((target) => ({ ...target, allowed_hosts: [...target.allowed_hosts], negative_phrases: [...target.negative_phrases] }));
81
+ }
82
+ if (Array.isArray(targets)) {
83
+ return targets.map((target) => ({
84
+ name: String(target.name),
85
+ url: String(target.url),
86
+ allowed_hosts: (target.allowed_hosts ?? []).map((host) => String(host)),
87
+ negative_phrases: (target.negative_phrases ?? []).map((phrase) => String(phrase))
88
+ }));
89
+ }
90
+ const normalized = [];
91
+ for (const [name, config] of Object.entries(targets)) {
92
+ if (config.enabled === false) {
93
+ continue;
94
+ }
95
+ const url = String(config.url ?? '').trim();
96
+ if (!url) {
97
+ continue;
98
+ }
99
+ let allowedHosts = (config.allowed_hosts ?? [])
100
+ .map((host) => String(host).trim().toLowerCase())
101
+ .filter(Boolean);
102
+ if (allowedHosts.length === 0) {
103
+ const host = hostnameFor(url).toLowerCase();
104
+ allowedHosts = host ? [host] : [];
105
+ }
106
+ normalized.push({
107
+ name: String(name),
108
+ url,
109
+ allowed_hosts: allowedHosts,
110
+ negative_phrases: []
111
+ });
112
+ }
113
+ return normalized;
114
+ }
115
+ export function evaluateProviderResponse(target, response) {
116
+ const finalUrl = response.final_url;
117
+ const statusCode = Number(response.status_code || 0);
118
+ const host = hostnameFor(finalUrl);
119
+ if (!host || !hostIsAllowed(host, target.allowed_hosts)) {
120
+ return {
121
+ provider: target.name,
122
+ passed: false,
123
+ reason: 'unexpected_host',
124
+ status_code: statusCode,
125
+ final_url: finalUrl,
126
+ matched_phrase: ''
127
+ };
128
+ }
129
+ if (statusCode >= 400) {
130
+ return {
131
+ provider: target.name,
132
+ passed: false,
133
+ reason: 'http_error',
134
+ status_code: statusCode,
135
+ final_url: finalUrl,
136
+ matched_phrase: ''
137
+ };
138
+ }
139
+ const content = `${response.title}\n${response.body}`.toLowerCase();
140
+ for (const phrase of CHALLENGE_PHRASES) {
141
+ if (content.includes(phrase)) {
142
+ return {
143
+ provider: target.name,
144
+ passed: false,
145
+ reason: 'challenge_page',
146
+ status_code: statusCode,
147
+ final_url: finalUrl,
148
+ matched_phrase: phrase
149
+ };
150
+ }
151
+ }
152
+ for (const phrase of target.negative_phrases) {
153
+ if (content.includes(String(phrase).toLowerCase())) {
154
+ return {
155
+ provider: target.name,
156
+ passed: false,
157
+ reason: 'negative_phrase',
158
+ status_code: statusCode,
159
+ final_url: finalUrl,
160
+ matched_phrase: phrase
161
+ };
162
+ }
163
+ }
164
+ return {
165
+ provider: target.name,
166
+ passed: true,
167
+ reason: 'ok',
168
+ status_code: statusCode,
169
+ final_url: finalUrl,
170
+ matched_phrase: ''
171
+ };
172
+ }
173
+ export function availabilityResultToDict(result) {
174
+ const providerResults = Object.fromEntries(Object.entries(result.provider_results).map(([name, provider]) => [name, providerResultWithDefaults(provider)]));
175
+ return {
176
+ link: result.speed_result.link,
177
+ reachable: result.speed_result.reachable,
178
+ average_download_mb_s: result.speed_result.average_download_mb_s,
179
+ latency_ms: result.speed_result.latency_ms,
180
+ all_passed: Object.values(providerResults).every((provider) => provider.passed),
181
+ provider_results: providerResults
182
+ };
183
+ }
184
+ function buildRuntimeErrorResult(speedResult, reason, targets) {
185
+ return availabilityResultToDict({
186
+ speed_result: speedResult,
187
+ provider_results: Object.fromEntries(targets.map((target) => [target.name, {
188
+ provider: target.name,
189
+ passed: false,
190
+ reason: 'runtime_error',
191
+ final_url: target.url,
192
+ matched_phrase: reason,
193
+ status_code: 0
194
+ }]))
195
+ });
196
+ }
197
+ function emitEvent(callback, eventType, payload) {
198
+ if (callback) {
199
+ callback(eventType, payload);
200
+ }
201
+ }
202
+ function numberOrDefault(value, fallback) {
203
+ const parsed = Number(value);
204
+ return Number.isFinite(parsed) ? parsed : fallback;
205
+ }
206
+ async function fetchWithTimeout(fetchImpl, url, timeoutMs) {
207
+ const controller = new AbortController();
208
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
209
+ try {
210
+ return await fetchImpl(url, { signal: controller.signal });
211
+ }
212
+ finally {
213
+ clearTimeout(timer);
214
+ }
215
+ }
216
+ function extractTitle(html) {
217
+ return /<title[^>]*>([\s\S]*?)<\/title>/i.exec(html)?.[1]?.trim() ?? '';
218
+ }
219
+ async function checkLinkAvailabilityDirect(speedResult, config, options) {
220
+ const fetchImpl = options.fetch ?? globalThis.fetch?.bind(globalThis);
221
+ if (!fetchImpl) {
222
+ throw new Error('Node availability backend requires fetch support');
223
+ }
224
+ const timeoutMs = Math.max(1, numberOrDefault(config.timeout_seconds, 20)) * 1000;
225
+ const providerResults = {};
226
+ for (const target of options.targets) {
227
+ try {
228
+ const response = await fetchWithTimeout(fetchImpl, target.url, timeoutMs);
229
+ const body = await response.text();
230
+ providerResults[target.name] = evaluateProviderResponse(target, {
231
+ final_url: String(response.url ?? target.url),
232
+ status_code: Number(response.status ?? 200),
233
+ title: extractTitle(body),
234
+ body
235
+ });
236
+ }
237
+ catch (error) {
238
+ providerResults[target.name] = {
239
+ provider: target.name,
240
+ passed: false,
241
+ reason: 'runtime_error',
242
+ status_code: 0,
243
+ final_url: target.url,
244
+ matched_phrase: error instanceof Error ? error.message : String(error)
245
+ };
246
+ }
247
+ }
248
+ return {
249
+ speed_result: speedResult,
250
+ provider_results: providerResults
251
+ };
252
+ }
253
+ function socketConnect(host, port, timeoutMs) {
254
+ return new Promise((resolve, reject) => {
255
+ const socket = net.createConnection({ host, port });
256
+ const timer = setTimeout(() => {
257
+ socket.destroy();
258
+ reject(new Error(`proxy connection timed out after ${timeoutMs}ms`));
259
+ }, timeoutMs);
260
+ socket.once('connect', () => {
261
+ clearTimeout(timer);
262
+ resolve(socket);
263
+ });
264
+ socket.once('error', (error) => {
265
+ clearTimeout(timer);
266
+ reject(error);
267
+ });
268
+ });
269
+ }
270
+ function splitHttpHeaders(buffer) {
271
+ const marker = buffer.indexOf('\r\n\r\n');
272
+ if (marker < 0) {
273
+ return undefined;
274
+ }
275
+ return {
276
+ head: buffer.subarray(0, marker),
277
+ body: buffer.subarray(marker + 4)
278
+ };
279
+ }
280
+ function parseHttpStatus(head) {
281
+ const firstLine = head.toString('latin1').split('\r\n')[0] ?? '';
282
+ const match = /^HTTP\/\d(?:\.\d)?\s+(\d+)/i.exec(firstLine);
283
+ if (!match) {
284
+ throw new Error(`invalid HTTP response: ${firstLine}`);
285
+ }
286
+ return Number(match[1]);
287
+ }
288
+ function parseHttpHeaders(head) {
289
+ const headers = {};
290
+ for (const line of head.toString('latin1').split('\r\n').slice(1)) {
291
+ const separator = line.indexOf(':');
292
+ if (separator < 0) {
293
+ continue;
294
+ }
295
+ const key = line.slice(0, separator).trim().toLowerCase();
296
+ const value = line.slice(separator + 1).trim();
297
+ const existing = headers[key];
298
+ if (Array.isArray(existing)) {
299
+ existing.push(value);
300
+ }
301
+ else if (existing) {
302
+ headers[key] = [existing, value];
303
+ }
304
+ else {
305
+ headers[key] = value;
306
+ }
307
+ }
308
+ return headers;
309
+ }
310
+ function headerValue(headers, name) {
311
+ const value = headers[name.toLowerCase()];
312
+ return Array.isArray(value) ? (value[0] ?? '') : (value ?? '');
313
+ }
314
+ function redirectTarget(current, response) {
315
+ if (response.status_code < 300 || response.status_code >= 400) {
316
+ return undefined;
317
+ }
318
+ const location = headerValue(response.headers, 'location');
319
+ return location ? new URL(location, current) : undefined;
320
+ }
321
+ function fetchHttpUrlViaHttpProxy(target, proxy, timeoutMs) {
322
+ return new Promise((resolve, reject) => {
323
+ const request = http.request({
324
+ hostname: proxy.hostname,
325
+ port: Number(proxy.port || 80),
326
+ method: 'GET',
327
+ path: target.toString(),
328
+ headers: {
329
+ Host: target.host,
330
+ Connection: 'close'
331
+ },
332
+ agent: false,
333
+ timeout: timeoutMs
334
+ }, (response) => {
335
+ const chunks = [];
336
+ response.on('data', (chunk) => chunks.push(chunk));
337
+ response.once('end', () => {
338
+ resolve({
339
+ final_url: target.toString(),
340
+ status_code: Number(response.statusCode ?? 0),
341
+ body: Buffer.concat(chunks).toString('utf8'),
342
+ headers: response.headers
343
+ });
344
+ });
345
+ });
346
+ request.once('timeout', () => {
347
+ request.destroy(new Error(`proxy fetch timed out after ${timeoutMs}ms`));
348
+ });
349
+ request.once('error', reject);
350
+ request.end();
351
+ });
352
+ }
353
+ function readHttpResponse(socket, timeoutMs) {
354
+ return new Promise((resolve, reject) => {
355
+ let buffered = Buffer.alloc(0);
356
+ let headersParsed = false;
357
+ let status = 0;
358
+ let headers = {};
359
+ const chunks = [];
360
+ const timer = setTimeout(() => {
361
+ cleanup();
362
+ socket.destroy();
363
+ reject(new Error(`proxy fetch timed out after ${timeoutMs}ms`));
364
+ }, timeoutMs);
365
+ const cleanup = () => {
366
+ clearTimeout(timer);
367
+ socket.off('data', onData);
368
+ socket.off('end', onEnd);
369
+ socket.off('error', onError);
370
+ };
371
+ const onError = (error) => {
372
+ cleanup();
373
+ reject(error);
374
+ };
375
+ const onEnd = () => {
376
+ cleanup();
377
+ if (!headersParsed) {
378
+ reject(new Error('proxy response ended before HTTP headers were received'));
379
+ return;
380
+ }
381
+ resolve({ status, headers, body: Buffer.concat(chunks).toString('utf8') });
382
+ };
383
+ const onData = (chunk) => {
384
+ if (!headersParsed) {
385
+ buffered = Buffer.concat([buffered, chunk]);
386
+ const split = splitHttpHeaders(buffered);
387
+ if (!split) {
388
+ return;
389
+ }
390
+ status = parseHttpStatus(split.head);
391
+ headers = parseHttpHeaders(split.head);
392
+ headersParsed = true;
393
+ if (split.body.byteLength > 0) {
394
+ chunks.push(split.body);
395
+ }
396
+ return;
397
+ }
398
+ chunks.push(chunk);
399
+ };
400
+ socket.on('data', onData);
401
+ socket.once('end', onEnd);
402
+ socket.once('error', onError);
403
+ });
404
+ }
405
+ function readConnectResponse(socket, timeoutMs) {
406
+ return new Promise((resolve, reject) => {
407
+ let buffered = Buffer.alloc(0);
408
+ const timer = setTimeout(() => {
409
+ cleanup();
410
+ socket.destroy();
411
+ reject(new Error(`proxy CONNECT timed out after ${timeoutMs}ms`));
412
+ }, timeoutMs);
413
+ const cleanup = () => {
414
+ clearTimeout(timer);
415
+ socket.off('data', onData);
416
+ socket.off('error', onError);
417
+ };
418
+ const onError = (error) => {
419
+ cleanup();
420
+ reject(error);
421
+ };
422
+ const onData = (chunk) => {
423
+ buffered = Buffer.concat([buffered, chunk]);
424
+ const split = splitHttpHeaders(buffered);
425
+ if (!split) {
426
+ return;
427
+ }
428
+ const status = parseHttpStatus(split.head);
429
+ cleanup();
430
+ if (status < 200 || status >= 300) {
431
+ socket.destroy();
432
+ reject(new Error(`proxy CONNECT failed with status ${status}`));
433
+ return;
434
+ }
435
+ resolve();
436
+ };
437
+ socket.on('data', onData);
438
+ socket.once('error', onError);
439
+ });
440
+ }
441
+ function waitForSecureConnect(socket, timeoutMs) {
442
+ return new Promise((resolve, reject) => {
443
+ const timer = setTimeout(() => {
444
+ cleanup();
445
+ socket.destroy();
446
+ reject(new Error(`proxy TLS handshake timed out after ${timeoutMs}ms`));
447
+ }, timeoutMs);
448
+ const cleanup = () => {
449
+ clearTimeout(timer);
450
+ socket.off('secureConnect', onSecureConnect);
451
+ socket.off('error', onError);
452
+ };
453
+ const onSecureConnect = () => {
454
+ cleanup();
455
+ resolve();
456
+ };
457
+ const onError = (error) => {
458
+ cleanup();
459
+ reject(error);
460
+ };
461
+ socket.once('secureConnect', onSecureConnect);
462
+ socket.once('error', onError);
463
+ });
464
+ }
465
+ async function fetchUrlViaHttpProxyTarget(target, proxy, timeoutMs, redirectsRemaining) {
466
+ if (target.protocol === 'http:') {
467
+ const response = await fetchHttpUrlViaHttpProxy(target, proxy, timeoutMs);
468
+ const next = redirectTarget(target, response);
469
+ if (next) {
470
+ if (redirectsRemaining <= 0) {
471
+ throw new Error(`too many redirects while fetching ${target.toString()}`);
472
+ }
473
+ return fetchUrlViaHttpProxyTarget(next, proxy, timeoutMs, redirectsRemaining - 1);
474
+ }
475
+ return {
476
+ final_url: response.final_url,
477
+ status_code: response.status_code,
478
+ body: response.body
479
+ };
480
+ }
481
+ if (target.protocol !== 'https:') {
482
+ throw new Error(`unsupported availability URL protocol: ${target.protocol}`);
483
+ }
484
+ const proxyPort = Number(proxy.port || 80);
485
+ const socket = await socketConnect(proxy.hostname, proxyPort, timeoutMs);
486
+ const targetPort = Number(target.port || 443);
487
+ socket.write([
488
+ `CONNECT ${target.hostname}:${targetPort} HTTP/1.1`,
489
+ `Host: ${target.hostname}:${targetPort}`,
490
+ 'Connection: keep-alive',
491
+ '',
492
+ ''
493
+ ].join('\r\n'));
494
+ await readConnectResponse(socket, timeoutMs);
495
+ const secureSocket = tls.connect({
496
+ socket,
497
+ servername: target.hostname,
498
+ rejectUnauthorized: true
499
+ });
500
+ await waitForSecureConnect(secureSocket, timeoutMs);
501
+ secureSocket.write([
502
+ `GET ${target.pathname || '/'}${target.search} HTTP/1.1`,
503
+ `Host: ${target.host}`,
504
+ 'Connection: close',
505
+ '',
506
+ ''
507
+ ].join('\r\n'));
508
+ const response = await readHttpResponse(secureSocket, timeoutMs);
509
+ const proxiedResponse = {
510
+ final_url: target.toString(),
511
+ status_code: response.status,
512
+ body: response.body,
513
+ headers: response.headers
514
+ };
515
+ const next = redirectTarget(target, proxiedResponse);
516
+ if (next) {
517
+ if (redirectsRemaining <= 0) {
518
+ throw new Error(`too many redirects while fetching ${target.toString()}`);
519
+ }
520
+ return fetchUrlViaHttpProxyTarget(next, proxy, timeoutMs, redirectsRemaining - 1);
521
+ }
522
+ return {
523
+ final_url: target.toString(),
524
+ status_code: response.status,
525
+ body: response.body
526
+ };
527
+ }
528
+ export async function fetchUrlViaHttpProxy(url, proxyUrl, timeoutSeconds) {
529
+ const timeoutMs = Math.max(1, timeoutSeconds) * 1000;
530
+ return fetchUrlViaHttpProxyTarget(new URL(url), new URL(proxyUrl), timeoutMs, 5);
531
+ }
532
+ async function checkLinkAvailabilityMihomo(speedResult, config, options) {
533
+ const openRuntime = options.openMihomoRuntime ?? defaultOpenMihomoRuntime;
534
+ const fetchViaProxy = options.fetchUrlViaHttpProxy ?? fetchUrlViaHttpProxy;
535
+ let runtime;
536
+ try {
537
+ runtime = await openRuntime(speedResult.link, {
538
+ runtimePath: options.runtimePath,
539
+ startupWaitSeconds: numberOrDefault(config.startup_wait_seconds, 1),
540
+ env: options.env
541
+ });
542
+ const providerResults = {};
543
+ const timeoutSeconds = Math.max(1, numberOrDefault(config.timeout_seconds, 20));
544
+ for (const target of options.targets) {
545
+ try {
546
+ const response = await fetchViaProxy(target.url, runtime.proxies.http, timeoutSeconds);
547
+ providerResults[target.name] = evaluateProviderResponse(target, {
548
+ final_url: response.final_url,
549
+ status_code: response.status_code,
550
+ title: extractTitle(response.body),
551
+ body: response.body
552
+ });
553
+ }
554
+ catch (error) {
555
+ providerResults[target.name] = {
556
+ provider: target.name,
557
+ passed: false,
558
+ reason: 'runtime_error',
559
+ status_code: 0,
560
+ final_url: target.url,
561
+ matched_phrase: error instanceof Error ? error.message : String(error)
562
+ };
563
+ }
564
+ }
565
+ return {
566
+ speed_result: speedResult,
567
+ provider_results: providerResults
568
+ };
569
+ }
570
+ finally {
571
+ await runtime?.close();
572
+ }
573
+ }
574
+ async function checkBatchInNode(input, options) {
575
+ if (input.results.length === 0) {
576
+ return [];
577
+ }
578
+ const targets = normalizeProviderTargets(input.targets);
579
+ const useMihomoRuntime = String((options.env ?? process.env).AUTOVPN_AVAILABILITY_RUNTIME ?? '').trim().toLowerCase() === 'mihomo';
580
+ const checkLinkAvailability = options.checkLinkAvailability ?? ((speedResult, config) => (useMihomoRuntime
581
+ ? checkLinkAvailabilityMihomo(speedResult, config, {
582
+ ...options,
583
+ targets,
584
+ runtimePath: input.runtime_path ?? ''
585
+ })
586
+ : checkLinkAvailabilityDirect(speedResult, config, { targets, fetch: options.fetch })));
587
+ const output = [];
588
+ for (let index = 0; index < input.results.length; index += 1) {
589
+ const speedResult = input.results[index];
590
+ let availability;
591
+ try {
592
+ availability = availabilityResultToDict(await checkLinkAvailability(speedResult, input.config, {
593
+ runtime_path: input.runtime_path ?? '',
594
+ targets
595
+ }));
596
+ }
597
+ catch (error) {
598
+ availability = buildRuntimeErrorResult(speedResult, error instanceof Error ? error.message : String(error), targets);
599
+ }
600
+ output.push(availability);
601
+ const completed = index + 1;
602
+ if (options.progressCallback) {
603
+ const statuses = Object.entries(availability.provider_results)
604
+ .map(([name, provider]) => `${name}=${provider.passed ? 'ok' : provider.reason}`)
605
+ .join(' ');
606
+ options.progressCallback(`[availability] ${completed}/${input.results.length} ${statuses}`);
607
+ }
608
+ emitEvent(options.eventCallback, 'availability_link_result', {
609
+ completed,
610
+ total: input.results.length,
611
+ link: availability.link,
612
+ all_passed: availability.all_passed,
613
+ provider_results: availability.provider_results
614
+ });
615
+ }
616
+ return output;
617
+ }
618
+ export function selectPipelineStageBackend(stage, env = process.env) {
619
+ const stageKey = `AUTOVPN_STAGE_BACKEND_${stage.toUpperCase()}`;
620
+ const stageOverride = String(env[stageKey] ?? '').trim().toLowerCase();
621
+ const pipelineOverride = String(env.AUTOVPN_PIPELINE_BACKEND ?? '').trim().toLowerCase();
622
+ const selected = stageOverride || pipelineOverride || 'node';
623
+ return selected === 'python' ? 'python' : 'node';
624
+ }
625
+ async function defaultResolvePythonCli(env) {
626
+ // @ts-expect-error Phase 1 runner remains plain ESM JavaScript.
627
+ const runner = await import('../../lib/runner.mjs');
628
+ return runner.resolveOrInstallPythonCli({ env });
629
+ }
630
+ function pythonCommandFor(resolved) {
631
+ const command = resolved.command;
632
+ const name = path.basename(command).toLowerCase();
633
+ if (['autovpn', 'autovpn.exe'].includes(name)) {
634
+ const executable = process.platform === 'win32' ? 'python.exe' : 'python';
635
+ return path.join(path.dirname(command), executable);
636
+ }
637
+ return process.platform === 'win32' ? 'python.exe' : 'python3';
638
+ }
639
+ async function availabilityWithPython(input, options) {
640
+ const env = mergeProjectEnv(options.cwd ?? process.cwd(), options.env ?? process.env);
641
+ const resolved = options.resolvePythonCli ? await options.resolvePythonCli() : await defaultResolvePythonCli(env);
642
+ const child = (options.spawn ?? defaultSpawn)(pythonCommandFor(resolved), ['-c', PYTHON_AVAILABILITY_HELPER], {
643
+ cwd: options.cwd ?? process.cwd(),
644
+ env,
645
+ stdio: ['pipe', 'pipe', 'pipe']
646
+ });
647
+ let stdout = '';
648
+ let stderr = '';
649
+ child.stdout?.on('data', (chunk) => {
650
+ stdout += String(chunk);
651
+ });
652
+ child.stderr?.on('data', (chunk) => {
653
+ stderr += String(chunk);
654
+ });
655
+ const completion = new Promise((resolve, reject) => {
656
+ child.on('error', reject);
657
+ child.on('close', (code) => {
658
+ if (code !== 0) {
659
+ reject(new Error(`Python availability backend failed with exit code ${code}: ${stderr.trim()}`));
660
+ return;
661
+ }
662
+ try {
663
+ resolve(JSON.parse(stdout));
664
+ }
665
+ catch (error) {
666
+ reject(new Error(`Python availability backend returned invalid JSON: ${error instanceof Error ? error.message : String(error)}`));
667
+ }
668
+ });
669
+ });
670
+ child.stdin?.write(JSON.stringify(input));
671
+ child.stdin?.end();
672
+ return completion;
673
+ }
674
+ export async function checkLinkAvailabilityBatchWithBackend(input, options = {}) {
675
+ if (selectPipelineStageBackend('availability', options.env ?? process.env) === 'python') {
676
+ return options.pythonAvailability ? options.pythonAvailability(input) : availabilityWithPython(input, options);
677
+ }
678
+ return checkBatchInNode(input, options);
679
+ }