@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,214 @@
1
+ import path from 'node:path';
2
+ import { spawn as defaultSpawn } from 'node:child_process';
3
+ import { mergeProjectEnv } from '../runtime/env.js';
4
+ const EMOJI_MAP = {
5
+ AE: '🇦🇪',
6
+ AR: '🇦🇷',
7
+ AU: '🇦🇺',
8
+ BE: '🇧🇪',
9
+ BR: '🇧🇷',
10
+ CA: '🇨🇦',
11
+ CH: '🇨🇭',
12
+ CL: '🇨🇱',
13
+ CN: '🇨🇳',
14
+ CO: '🇨🇴',
15
+ DE: '🇩🇪',
16
+ DK: '🇩🇰',
17
+ ES: '🇪🇸',
18
+ FR: '🇫🇷',
19
+ GB: '🇬🇧',
20
+ HK: '🇭🇰',
21
+ IN: '🇮🇳',
22
+ IT: '🇮🇹',
23
+ JP: '🇯🇵',
24
+ KR: '🇰🇷',
25
+ MX: '🇲🇽',
26
+ MY: '🇲🇾',
27
+ NL: '🇳🇱',
28
+ NO: '🇳🇴',
29
+ NZ: '🇳🇿',
30
+ PL: '🇵🇱',
31
+ PT: '🇵🇹',
32
+ RU: '🇷🇺',
33
+ SA: '🇸🇦',
34
+ SE: '🇸🇪',
35
+ SG: '🇸🇬',
36
+ TH: '🇹🇭',
37
+ TR: '🇹🇷',
38
+ TW: '🇹🇼',
39
+ US: '🇺🇸',
40
+ ZA: '🇿🇦'
41
+ };
42
+ const FALLBACK_COUNTRY_CODE = 'US';
43
+ const UNKNOWN_COUNTRY_CODE = 'ZZ';
44
+ const DEFAULT_FILTERS = {
45
+ excluded_country_codes: ['CN'],
46
+ per_country_limit: {}
47
+ };
48
+ const LEADING_EMOJI_AND_COUNTRY_PATTERN = /^(?:\S+\s+)?([A-Za-z]{2})\s+(.*)$/;
49
+ const PYTHON_POSTPROCESS_HELPER = `
50
+ import json
51
+ import sys
52
+ from vpn_automation.config.models import FilterConfig
53
+ from vpn_automation.pipeline.postprocess import decorate_link_with_country, select_links_by_country_limit
54
+
55
+ payload = json.load(sys.stdin)
56
+ filters = payload.get("filters")
57
+ ranked_links = payload.get("ranked_links") or []
58
+ filter_config = FilterConfig() if filters is None else FilterConfig(
59
+ excluded_country_codes=list(filters.get("excluded_country_codes") or []),
60
+ per_country_limit=dict(filters.get("per_country_limit") or {}),
61
+ )
62
+ selected = select_links_by_country_limit(
63
+ [(item["link"], {}, item.get("country_code") or item.get("countryCode") or "") for item in ranked_links],
64
+ filter_config,
65
+ )
66
+ country_by_link = {
67
+ item["link"]: item.get("country_code") or item.get("countryCode") or ""
68
+ for item in ranked_links
69
+ }
70
+ json.dump({"links": [decorate_link_with_country(link, country_by_link[link]) for link in selected]}, sys.stdout, ensure_ascii=False)
71
+ sys.stdout.write("\\n")
72
+ `;
73
+ export function parseVmessLink(link) {
74
+ const encoded = link.replace(/^vmess:\/\//, '');
75
+ const padded = encoded + '='.repeat((4 - (encoded.length % 4)) % 4);
76
+ return JSON.parse(Buffer.from(padded, 'base64url').toString('utf8'));
77
+ }
78
+ export function generateVmessLink(payload) {
79
+ const json = JSON.stringify(payload);
80
+ const encoded = Buffer.from(json, 'utf8').toString('base64').replaceAll('+', '-').replaceAll('/', '_');
81
+ return `vmess://${encoded}`;
82
+ }
83
+ export function normalizeCountryCode(countryCode) {
84
+ const normalized = String(countryCode || '').trim().toUpperCase();
85
+ if (normalized.length !== 2 || !/^[A-Z]{2}$/.test(normalized) || normalized === UNKNOWN_COUNTRY_CODE) {
86
+ return FALLBACK_COUNTRY_CODE;
87
+ }
88
+ return normalized;
89
+ }
90
+ export function countryToEmoji(countryCode) {
91
+ const normalized = normalizeCountryCode(countryCode);
92
+ return EMOJI_MAP[normalized] ?? EMOJI_MAP[FALLBACK_COUNTRY_CODE];
93
+ }
94
+ function resolveFilters(filters) {
95
+ return {
96
+ excluded_country_codes: filters?.excluded_country_codes ?? DEFAULT_FILTERS.excluded_country_codes,
97
+ per_country_limit: filters?.per_country_limit ?? DEFAULT_FILTERS.per_country_limit
98
+ };
99
+ }
100
+ function stripLeadingCountryPrefix(originalName) {
101
+ const name = String(originalName || '').trim();
102
+ if (!name) {
103
+ return '';
104
+ }
105
+ const match = name.match(LEADING_EMOJI_AND_COUNTRY_PATTERN);
106
+ if (!match) {
107
+ return name;
108
+ }
109
+ const remainder = String(match[2] ?? '').trim();
110
+ return remainder || name;
111
+ }
112
+ export function decorateNodeName(originalName, countryCode, emoji) {
113
+ const cleanedName = stripLeadingCountryPrefix(originalName);
114
+ return `${emoji} ${countryCode} ${cleanedName}`.trim();
115
+ }
116
+ export function decorateLinkWithCountry(link, countryCode) {
117
+ const payload = parseVmessLink(link);
118
+ const normalizedCountry = normalizeCountryCode(countryCode);
119
+ payload.ps = decorateNodeName(String(payload.ps ?? ''), normalizedCountry, countryToEmoji(normalizedCountry));
120
+ return generateVmessLink(payload);
121
+ }
122
+ export function selectLinksByCountryLimit(rankedLinks, filters = {}) {
123
+ const resolvedFilters = resolveFilters(filters);
124
+ const excluded = new Set(resolvedFilters.excluded_country_codes.map((country) => normalizeCountryCode(country)));
125
+ const limits = Object.fromEntries(Object.entries(resolvedFilters.per_country_limit).map(([country, limit]) => [normalizeCountryCode(country), Number(limit)]));
126
+ const counters = new Map();
127
+ const selected = [];
128
+ for (const item of rankedLinks) {
129
+ const country = normalizeCountryCode(String(item.country_code ?? item.countryCode ?? ''));
130
+ if (excluded.has(country)) {
131
+ continue;
132
+ }
133
+ if (country in limits) {
134
+ const current = counters.get(country) ?? 0;
135
+ if (current >= limits[country]) {
136
+ continue;
137
+ }
138
+ counters.set(country, current + 1);
139
+ }
140
+ selected.push(item.link);
141
+ }
142
+ return selected;
143
+ }
144
+ export function runPostprocess(input) {
145
+ const rankedLinks = input.ranked_links ?? [];
146
+ const selected = selectLinksByCountryLimit(rankedLinks, input.filters);
147
+ const countries = new Map(rankedLinks.map((item) => [item.link, String(item.country_code ?? item.countryCode ?? '')]));
148
+ return {
149
+ links: selected.map((link) => decorateLinkWithCountry(link, countries.get(link) ?? ''))
150
+ };
151
+ }
152
+ export function selectPipelineStageBackend(stage, env = process.env) {
153
+ const stageKey = `AUTOVPN_STAGE_BACKEND_${stage.toUpperCase()}`;
154
+ const stageOverride = String(env[stageKey] ?? '').trim().toLowerCase();
155
+ const pipelineOverride = String(env.AUTOVPN_PIPELINE_BACKEND ?? '').trim().toLowerCase();
156
+ const selected = stageOverride || pipelineOverride || 'node';
157
+ return selected === 'python' ? 'python' : 'node';
158
+ }
159
+ async function defaultResolvePythonCli(env) {
160
+ // @ts-expect-error Phase 1 runner remains plain ESM JavaScript.
161
+ const runner = await import('../../lib/runner.mjs');
162
+ return runner.resolveOrInstallPythonCli({ env });
163
+ }
164
+ function pythonCommandFor(resolved) {
165
+ const command = resolved.command;
166
+ const name = path.basename(command).toLowerCase();
167
+ if (['autovpn', 'autovpn.exe'].includes(name)) {
168
+ const executable = process.platform === 'win32' ? 'python.exe' : 'python';
169
+ return path.join(path.dirname(command), executable);
170
+ }
171
+ return process.platform === 'win32' ? 'python.exe' : 'python3';
172
+ }
173
+ async function postprocessWithPython(input, options) {
174
+ const env = mergeProjectEnv(options.cwd ?? process.cwd(), options.env ?? process.env);
175
+ const resolved = options.resolvePythonCli ? await options.resolvePythonCli() : await defaultResolvePythonCli(env);
176
+ const helperInput = { ...input, filters: resolveFilters(input.filters) };
177
+ const child = (options.spawn ?? defaultSpawn)(pythonCommandFor(resolved), ['-c', PYTHON_POSTPROCESS_HELPER], {
178
+ cwd: options.cwd ?? process.cwd(),
179
+ env,
180
+ stdio: ['pipe', 'pipe', 'pipe']
181
+ });
182
+ let stdout = '';
183
+ let stderr = '';
184
+ child.stdout?.on('data', (chunk) => {
185
+ stdout += String(chunk);
186
+ });
187
+ child.stderr?.on('data', (chunk) => {
188
+ stderr += String(chunk);
189
+ });
190
+ const completion = new Promise((resolve, reject) => {
191
+ child.on('error', reject);
192
+ child.on('close', (code) => {
193
+ if (code !== 0) {
194
+ reject(new Error(`Python postprocess backend failed with exit code ${code}: ${stderr.trim()}`));
195
+ return;
196
+ }
197
+ try {
198
+ resolve(JSON.parse(stdout));
199
+ }
200
+ catch (error) {
201
+ reject(new Error(`Python postprocess backend returned invalid JSON: ${error instanceof Error ? error.message : String(error)}`));
202
+ }
203
+ });
204
+ });
205
+ child.stdin?.write(JSON.stringify(helperInput));
206
+ child.stdin?.end();
207
+ return completion;
208
+ }
209
+ export async function postprocessLinksWithBackend(input, options = {}) {
210
+ if (selectPipelineStageBackend('postprocess', options.env ?? process.env) === 'python') {
211
+ return options.pythonPostprocess ? options.pythonPostprocess(input) : postprocessWithPython(input, options);
212
+ }
213
+ return runPostprocess(input);
214
+ }
@@ -0,0 +1,228 @@
1
+ import { spawn as defaultSpawn } from 'node:child_process';
2
+ import { mkdtemp, rm, writeFile } from 'node:fs/promises';
3
+ import net from 'node:net';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ export const PROXY_ENV_KEYS = [
7
+ 'HTTP_PROXY',
8
+ 'HTTPS_PROXY',
9
+ 'ALL_PROXY',
10
+ 'http_proxy',
11
+ 'https_proxy',
12
+ 'all_proxy',
13
+ 'NO_PROXY',
14
+ 'no_proxy'
15
+ ];
16
+ function padBase64(encoded) {
17
+ return encoded + '='.repeat((4 - (encoded.length % 4)) % 4);
18
+ }
19
+ export function parseVmessLink(link) {
20
+ const encoded = link.startsWith('vmess://') ? link.slice('vmess://'.length) : link;
21
+ return JSON.parse(Buffer.from(padBase64(encoded), 'base64url').toString('utf8'));
22
+ }
23
+ export function buildMihomoRuntimeConfig(payload, ports) {
24
+ const network = String(payload.net ?? 'ws');
25
+ const tlsEnabled = String(payload.tls ?? '').toLowerCase() === 'tls';
26
+ const proxyName = 'runtime-node';
27
+ const proxy = {
28
+ name: proxyName,
29
+ type: 'vmess',
30
+ server: payload.add,
31
+ port: Number(payload.port),
32
+ uuid: payload.id,
33
+ alterId: Number(String(payload.aid ?? '0') || 0),
34
+ cipher: payload.scy ?? 'auto',
35
+ udp: false,
36
+ network
37
+ };
38
+ if (tlsEnabled) {
39
+ proxy.tls = true;
40
+ proxy['skip-cert-verify'] = true;
41
+ proxy.servername = payload.sni || payload.host || payload.add;
42
+ }
43
+ if (network === 'ws') {
44
+ proxy['ws-opts'] = {
45
+ path: payload.path ?? '',
46
+ headers: { Host: payload.host || payload.add || '' }
47
+ };
48
+ }
49
+ return {
50
+ 'mixed-port': ports.mixedPort,
51
+ 'allow-lan': false,
52
+ mode: 'global',
53
+ 'log-level': 'silent',
54
+ ipv6: false,
55
+ 'external-controller': `127.0.0.1:${ports.controllerPort}`,
56
+ dns: { enable: false },
57
+ proxies: [proxy],
58
+ 'proxy-groups': [
59
+ {
60
+ name: 'GLOBAL',
61
+ type: 'select',
62
+ proxies: [proxyName]
63
+ }
64
+ ],
65
+ rules: ['MATCH,GLOBAL']
66
+ };
67
+ }
68
+ export function stripProxyEnv(env = process.env) {
69
+ const stripped = { ...env };
70
+ for (const key of PROXY_ENV_KEYS) {
71
+ delete stripped[key];
72
+ }
73
+ return stripped;
74
+ }
75
+ function requireFetch(fetchImpl) {
76
+ const selected = fetchImpl ?? globalThis.fetch?.bind(globalThis);
77
+ if (!selected) {
78
+ throw new Error('Node proxy runtime requires fetch support');
79
+ }
80
+ return selected;
81
+ }
82
+ function requireOkResponse(response, context) {
83
+ if (response.ok === false || Number(response.status ?? 200) >= 400) {
84
+ throw new Error(`${context} failed with status ${response.status ?? 0} ${response.statusText ?? ''}`.trim());
85
+ }
86
+ }
87
+ export async function selectMihomoProxy(controllerUrl, proxyName, _timeoutSeconds, options = {}) {
88
+ if (!controllerUrl) {
89
+ return;
90
+ }
91
+ const fetchImpl = requireFetch(options.fetch);
92
+ const response = await fetchImpl(`${controllerUrl}/proxies/GLOBAL`, {
93
+ method: 'PUT',
94
+ headers: { 'content-type': 'application/json' },
95
+ body: JSON.stringify({ name: proxyName })
96
+ });
97
+ requireOkResponse(response, 'mihomo proxy selection');
98
+ }
99
+ export async function probeMihomoProxyDelay(controllerUrl, proxyName, probeUrl, timeoutSeconds, options = {}) {
100
+ const fetchImpl = requireFetch(options.fetch);
101
+ const url = new URL(`${controllerUrl}/proxies/${encodeURIComponent(proxyName)}/delay`);
102
+ url.searchParams.set('timeout', String(Math.trunc(timeoutSeconds * 1000)));
103
+ url.searchParams.set('url', probeUrl);
104
+ const response = await fetchImpl(url.toString(), { method: 'GET' });
105
+ requireOkResponse(response, 'mihomo proxy delay probe');
106
+ const payload = await response.json?.();
107
+ const delay = Number(payload?.delay ?? -1);
108
+ if (!Number.isFinite(delay) || delay < 0) {
109
+ throw new Error(`mihomo returned invalid delay payload: ${JSON.stringify(payload)}`);
110
+ }
111
+ return Math.trunc(delay);
112
+ }
113
+ async function findFreePort() {
114
+ return await new Promise((resolve, reject) => {
115
+ const server = net.createServer();
116
+ server.once('error', reject);
117
+ server.listen(0, '127.0.0.1', () => {
118
+ const address = server.address();
119
+ server.close((error) => {
120
+ if (error) {
121
+ reject(error);
122
+ return;
123
+ }
124
+ if (typeof address === 'object' && address?.port) {
125
+ resolve(address.port);
126
+ return;
127
+ }
128
+ reject(new Error('failed to allocate a free local port'));
129
+ });
130
+ });
131
+ });
132
+ }
133
+ async function canConnectToPort(port) {
134
+ return await new Promise((resolve) => {
135
+ const socket = net.createConnection({ host: '127.0.0.1', port });
136
+ socket.once('connect', () => {
137
+ socket.destroy();
138
+ resolve(true);
139
+ });
140
+ socket.once('error', () => {
141
+ socket.destroy();
142
+ resolve(false);
143
+ });
144
+ });
145
+ }
146
+ function sleep(ms) {
147
+ return new Promise((resolve) => {
148
+ setTimeout(resolve, ms);
149
+ });
150
+ }
151
+ async function defaultWaitForPort(port, timeoutSeconds) {
152
+ const deadline = Date.now() + Math.max(timeoutSeconds, 0.1) * 1000;
153
+ while (Date.now() < deadline) {
154
+ if (await canConnectToPort(port)) {
155
+ return;
156
+ }
157
+ await sleep(100);
158
+ }
159
+ throw new Error(`proxy port ${port} did not open in time`);
160
+ }
161
+ async function closeChildProcess(process) {
162
+ if (process.exitCode !== null || process.killed) {
163
+ return;
164
+ }
165
+ await new Promise((resolve) => {
166
+ const timer = setTimeout(() => {
167
+ if (process.exitCode === null && !process.killed) {
168
+ process.kill('SIGKILL');
169
+ }
170
+ resolve();
171
+ }, 2000);
172
+ process.once('close', () => {
173
+ clearTimeout(timer);
174
+ resolve();
175
+ });
176
+ process.kill('SIGTERM');
177
+ });
178
+ }
179
+ export async function openMihomoRuntime(link, options = {}) {
180
+ const startupWaitSeconds = Number(options.startupWaitSeconds ?? 1);
181
+ const mixedPort = Number.isInteger(options.mixedPort) && Number(options.mixedPort) > 0
182
+ ? Number(options.mixedPort)
183
+ : await findFreePort();
184
+ const controllerPort = Number.isInteger(options.controllerPort) && Number(options.controllerPort) > 0
185
+ ? Number(options.controllerPort)
186
+ : await findFreePort();
187
+ const proxyName = 'runtime-node';
188
+ const controllerUrl = `http://127.0.0.1:${controllerPort}`;
189
+ const payload = parseVmessLink(link);
190
+ const config = buildMihomoRuntimeConfig(payload, { mixedPort, controllerPort });
191
+ const tempDir = await mkdtemp(path.join(os.tmpdir(), 'autovpn-mihomo-'));
192
+ const configPath = path.join(tempDir, 'config.json');
193
+ await writeFile(configPath, JSON.stringify(config), 'utf8');
194
+ const child = (options.spawn ?? defaultSpawn)(options.runtimePath || 'mihomo', ['-f', configPath], {
195
+ stdio: ['ignore', 'pipe', 'pipe'],
196
+ env: stripProxyEnv(options.env ?? process.env)
197
+ });
198
+ let closed = false;
199
+ const close = async () => {
200
+ if (closed) {
201
+ return;
202
+ }
203
+ closed = true;
204
+ await closeChildProcess(child);
205
+ await rm(tempDir, { recursive: true, force: true });
206
+ };
207
+ try {
208
+ const waitForPort = options.waitForPort ?? defaultWaitForPort;
209
+ await waitForPort(mixedPort, startupWaitSeconds + 4);
210
+ await waitForPort(controllerPort, startupWaitSeconds + 4);
211
+ await (options.selectProxy ?? ((url, name, timeoutSeconds) => selectMihomoProxy(url, name, timeoutSeconds)))(controllerUrl, proxyName, startupWaitSeconds + 4);
212
+ }
213
+ catch (error) {
214
+ await close();
215
+ throw error;
216
+ }
217
+ return {
218
+ process: child,
219
+ proxies: {
220
+ http: `http://127.0.0.1:${mixedPort}`,
221
+ https: `http://127.0.0.1:${mixedPort}`
222
+ },
223
+ configPath,
224
+ controllerUrl,
225
+ proxyName,
226
+ close
227
+ };
228
+ }
@@ -0,0 +1,91 @@
1
+ import path from 'node:path';
2
+ import { spawn as defaultSpawn } from 'node:child_process';
3
+ import { mergeProjectEnv } from '../runtime/env.js';
4
+ export const MAIN_DATA_PLACEHOLDER = '__MAIN_DATA__';
5
+ const PYTHON_RENDER_HELPER = `
6
+ import json
7
+ import sys
8
+ from vpn_automation.pipeline.render import replace_main_data
9
+
10
+ payload = json.load(sys.stdin)
11
+ json.dump(
12
+ {"rendered_source": replace_main_data(payload["template"], list(payload.get("links") or []))},
13
+ sys.stdout,
14
+ ensure_ascii=False,
15
+ )
16
+ sys.stdout.write("\\n")
17
+ `;
18
+ export function replaceMainData(template, links) {
19
+ const occurrences = template.split(MAIN_DATA_PLACEHOLDER).length - 1;
20
+ if (occurrences !== 1) {
21
+ throw new Error('Template must contain exactly one MainData placeholder');
22
+ }
23
+ return template.replace(MAIN_DATA_PLACEHOLDER, links.join('\n'));
24
+ }
25
+ export function runRender(input) {
26
+ return {
27
+ rendered_source: replaceMainData(input.template, input.links ?? [])
28
+ };
29
+ }
30
+ export function selectPipelineStageBackend(stage, env = process.env) {
31
+ const stageKey = `AUTOVPN_STAGE_BACKEND_${stage.toUpperCase()}`;
32
+ const stageOverride = String(env[stageKey] ?? '').trim().toLowerCase();
33
+ const pipelineOverride = String(env.AUTOVPN_PIPELINE_BACKEND ?? '').trim().toLowerCase();
34
+ const selected = stageOverride || pipelineOverride || 'node';
35
+ return selected === 'python' ? 'python' : 'node';
36
+ }
37
+ async function defaultResolvePythonCli(env) {
38
+ // @ts-expect-error Phase 1 runner remains plain ESM JavaScript.
39
+ const runner = await import('../../lib/runner.mjs');
40
+ return runner.resolveOrInstallPythonCli({ env });
41
+ }
42
+ function pythonCommandFor(resolved) {
43
+ const command = resolved.command;
44
+ const name = path.basename(command).toLowerCase();
45
+ if (['autovpn', 'autovpn.exe'].includes(name)) {
46
+ const executable = process.platform === 'win32' ? 'python.exe' : 'python';
47
+ return path.join(path.dirname(command), executable);
48
+ }
49
+ return process.platform === 'win32' ? 'python.exe' : 'python3';
50
+ }
51
+ async function renderWithPython(input, options) {
52
+ const env = mergeProjectEnv(options.cwd ?? process.cwd(), options.env ?? process.env);
53
+ const resolved = options.resolvePythonCli ? await options.resolvePythonCli() : await defaultResolvePythonCli(env);
54
+ const child = (options.spawn ?? defaultSpawn)(pythonCommandFor(resolved), ['-c', PYTHON_RENDER_HELPER], {
55
+ cwd: options.cwd ?? process.cwd(),
56
+ env,
57
+ stdio: ['pipe', 'pipe', 'pipe']
58
+ });
59
+ let stdout = '';
60
+ let stderr = '';
61
+ child.stdout?.on('data', (chunk) => {
62
+ stdout += String(chunk);
63
+ });
64
+ child.stderr?.on('data', (chunk) => {
65
+ stderr += String(chunk);
66
+ });
67
+ const completion = new Promise((resolve, reject) => {
68
+ child.on('error', reject);
69
+ child.on('close', (code) => {
70
+ if (code !== 0) {
71
+ reject(new Error(`Python render backend failed with exit code ${code}: ${stderr.trim()}`));
72
+ return;
73
+ }
74
+ try {
75
+ resolve(JSON.parse(stdout));
76
+ }
77
+ catch (error) {
78
+ reject(new Error(`Python render backend returned invalid JSON: ${error instanceof Error ? error.message : String(error)}`));
79
+ }
80
+ });
81
+ });
82
+ child.stdin?.write(JSON.stringify(input));
83
+ child.stdin?.end();
84
+ return completion;
85
+ }
86
+ export async function renderMainDataWithBackend(input, options = {}) {
87
+ if (selectPipelineStageBackend('render', options.env ?? process.env) === 'python') {
88
+ return options.pythonRender ? options.pythonRender(input) : renderWithPython(input, options);
89
+ }
90
+ return runRender(input);
91
+ }