@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.
- package/README.md +102 -0
- package/bin/autovpn.mjs +5 -0
- package/dist/artifacts/list.js +99 -0
- package/dist/artifacts/preview.js +85 -0
- package/dist/backend/node-backend.js +194 -0
- package/dist/backend/python-backend.js +242 -0
- package/dist/backend/select-backend.js +12 -0
- package/dist/backend/types.js +1 -0
- package/dist/cli/commands/index.js +143 -0
- package/dist/cli/errors.js +7 -0
- package/dist/cli/global-options.js +29 -0
- package/dist/cli/main.js +231 -0
- package/dist/cli/native-commands.js +215 -0
- package/dist/cli/output.js +31 -0
- package/dist/config/profile.js +80 -0
- package/dist/doctor/checks.js +184 -0
- package/dist/events/schema.js +45 -0
- package/dist/jobs/commands.js +159 -0
- package/dist/jobs/logs.js +48 -0
- package/dist/jobs/process.js +75 -0
- package/dist/jobs/read.js +282 -0
- package/dist/jobs/store.js +115 -0
- package/dist/pipeline/availability.js +679 -0
- package/dist/pipeline/dedupe.js +104 -0
- package/dist/pipeline/deploy.js +1103 -0
- package/dist/pipeline/extract.js +259 -0
- package/dist/pipeline/obfuscate.js +203 -0
- package/dist/pipeline/orchestrator.js +1014 -0
- package/dist/pipeline/postprocess.js +214 -0
- package/dist/pipeline/proxy-runtime.js +228 -0
- package/dist/pipeline/render.js +91 -0
- package/dist/pipeline/speedtest.js +579 -0
- package/dist/runtime/env.js +35 -0
- package/dist/runtime/paths.js +66 -0
- package/dist/runtime/redaction.js +56 -0
- package/lib/cache.mjs +14 -0
- package/lib/errors.mjs +11 -0
- package/lib/install-python-cli.mjs +63 -0
- package/lib/runner.mjs +113 -0
- package/package.json +39 -0
|
@@ -0,0 +1,1103 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { spawn as defaultSpawn } from 'node:child_process';
|
|
3
|
+
import { access, mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { mergeProjectEnv } from '../runtime/env.js';
|
|
5
|
+
const PYTHON_DEPLOY_HELPER = `
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from vpn_automation.config.models import DeployConfig
|
|
10
|
+
from vpn_automation.config.runtime import load_runtime_env
|
|
11
|
+
from vpn_automation.integrations.cloudflare import deploy_pages_bundle, resolve_cloudflare_credentials
|
|
12
|
+
|
|
13
|
+
payload = json.load(sys.stdin)
|
|
14
|
+
deploy = DeployConfig(**payload["deploy"])
|
|
15
|
+
runtime_env = load_runtime_env(Path(payload["project_root"]))
|
|
16
|
+
credentials = resolve_cloudflare_credentials(deploy, runtime_env)
|
|
17
|
+
result = deploy_pages_bundle(Path(payload["bundle_dir"]), deploy, credentials)
|
|
18
|
+
json.dump(result, sys.stdout, ensure_ascii=False)
|
|
19
|
+
sys.stdout.write("\\n")
|
|
20
|
+
`;
|
|
21
|
+
const PYTHON_VERIFY_HELPER = `
|
|
22
|
+
import json
|
|
23
|
+
import sys
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from vpn_automation.config.models import DeployConfig
|
|
26
|
+
from vpn_automation.config.runtime import load_runtime_env
|
|
27
|
+
from vpn_automation.integrations.cloudflare import resolve_cloudflare_credentials
|
|
28
|
+
from vpn_automation.backend_resume import (
|
|
29
|
+
_cleanup_blocked_pages_project,
|
|
30
|
+
_default_verify,
|
|
31
|
+
_is_verify_success,
|
|
32
|
+
_merge_deploy_verification_target,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
payload = json.load(sys.stdin)
|
|
36
|
+
deploy = DeployConfig(**payload["deploy"])
|
|
37
|
+
deployment = payload.get("deployment", {})
|
|
38
|
+
runtime_env = load_runtime_env(Path(payload["project_root"]))
|
|
39
|
+
credentials = resolve_cloudflare_credentials(deploy, runtime_env)
|
|
40
|
+
target = _merge_deploy_verification_target(deploy, deployment)
|
|
41
|
+
verification = _default_verify(target, credentials)
|
|
42
|
+
result = dict(verification)
|
|
43
|
+
if _is_verify_success(verification):
|
|
44
|
+
result.update(_cleanup_blocked_pages_project(target, deployment, credentials))
|
|
45
|
+
json.dump(result, sys.stdout, ensure_ascii=False)
|
|
46
|
+
sys.stdout.write("\\n")
|
|
47
|
+
`;
|
|
48
|
+
const PAGES_PRODUCTION_BRANCH = 'main';
|
|
49
|
+
const FALLBACK_SUFFIX_PATTERN = /^(?<prefix>.+)-(?<suffix>\d+)$/;
|
|
50
|
+
const CLOUDFLARE_API_BASE_URL = 'https://api.cloudflare.com/client/v4';
|
|
51
|
+
const NETWORK_ERROR_MARKERS = [
|
|
52
|
+
'fetch failed',
|
|
53
|
+
'connectivity issue',
|
|
54
|
+
'und_err_socket',
|
|
55
|
+
'other side closed',
|
|
56
|
+
'econnreset',
|
|
57
|
+
'etimedout'
|
|
58
|
+
];
|
|
59
|
+
const BLOCKED_PAGES_MARKERS = [
|
|
60
|
+
'8000119',
|
|
61
|
+
'your pages project has been blocked',
|
|
62
|
+
'pages project has been blocked',
|
|
63
|
+
'contact abusereply@cloudflare.com'
|
|
64
|
+
];
|
|
65
|
+
const PAGES_SECRET_ENV_PREFIX = 'VPN_AUTOMATION_PAGES_SECRET_';
|
|
66
|
+
function clean(value) {
|
|
67
|
+
return value ? String(value).trim() : '';
|
|
68
|
+
}
|
|
69
|
+
function nonNegativeInt(value) {
|
|
70
|
+
const normalized = clean(value);
|
|
71
|
+
if (!/^[+-]?\d+$/.test(normalized)) {
|
|
72
|
+
return 0;
|
|
73
|
+
}
|
|
74
|
+
const parsed = Number(normalized);
|
|
75
|
+
return Number.isFinite(parsed) ? Math.max(0, Math.trunc(parsed)) : 0;
|
|
76
|
+
}
|
|
77
|
+
function getString(source, key) {
|
|
78
|
+
return clean(source[key]);
|
|
79
|
+
}
|
|
80
|
+
export function buildPagesDeployCommand(bundleDir, projectName) {
|
|
81
|
+
return ['npx', 'wrangler', 'pages', 'deploy', bundleDir, '--project-name', projectName, '--branch', PAGES_PRODUCTION_BRANCH];
|
|
82
|
+
}
|
|
83
|
+
export function derivePagesProjectUrl(projectName) {
|
|
84
|
+
return `https://${projectName}.pages.dev`;
|
|
85
|
+
}
|
|
86
|
+
export function buildSecretUrl(deploy) {
|
|
87
|
+
return `${getString(deploy, 'pages_project_url').replace(/\/+$/g, '')}/?${getString(deploy, 'secret_query')}`;
|
|
88
|
+
}
|
|
89
|
+
export function buildPagesProjectRootUrl(deploy) {
|
|
90
|
+
return getString(deploy, 'pages_project_url').replace(/\/+$/g, '');
|
|
91
|
+
}
|
|
92
|
+
export function buildCustomDomainRootUrl(deploy) {
|
|
93
|
+
const customDomain = getString(deploy, 'custom_domain').replace(/\/+$/g, '');
|
|
94
|
+
return customDomain ? `https://${customDomain}` : '';
|
|
95
|
+
}
|
|
96
|
+
export function rewriteUrlHost(url, hostname) {
|
|
97
|
+
try {
|
|
98
|
+
const parsed = new URL(clean(url));
|
|
99
|
+
if (!parsed.protocol || !parsed.host) {
|
|
100
|
+
return '';
|
|
101
|
+
}
|
|
102
|
+
return `${parsed.protocol}//${hostname}${parsed.pathname}${parsed.search}${parsed.hash}`;
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
return '';
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
export function buildCustomDomainSubscriptionUrl(deploy) {
|
|
109
|
+
const customDomain = getString(deploy, 'custom_domain');
|
|
110
|
+
if (!customDomain) {
|
|
111
|
+
return '';
|
|
112
|
+
}
|
|
113
|
+
const verifyUrl = getString(deploy, 'verify_subscription_url');
|
|
114
|
+
if (verifyUrl) {
|
|
115
|
+
return rewriteUrlHost(verifyUrl, customDomain) || verifyUrl;
|
|
116
|
+
}
|
|
117
|
+
const subscriptionUrl = getString(deploy, 'subscription_url');
|
|
118
|
+
return subscriptionUrl ? rewriteUrlHost(subscriptionUrl, customDomain) : '';
|
|
119
|
+
}
|
|
120
|
+
export function resolveVerifySubscriptionUrl(deploy) {
|
|
121
|
+
const verifySubscriptionUrl = getString(deploy, 'verify_subscription_url');
|
|
122
|
+
return verifySubscriptionUrl || getString(deploy, 'subscription_url');
|
|
123
|
+
}
|
|
124
|
+
export function resolveCustomDomainVerifySubscriptionUrl(deploy) {
|
|
125
|
+
return buildCustomDomainSubscriptionUrl(deploy).trim();
|
|
126
|
+
}
|
|
127
|
+
function pagesHostnameFromUrl(url) {
|
|
128
|
+
try {
|
|
129
|
+
return new URL(clean(url)).host.trim();
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
return '';
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
export function deriveCustomDomainDnsTarget(deploy) {
|
|
136
|
+
return pagesHostnameFromUrl(buildPagesProjectRootUrl(deploy));
|
|
137
|
+
}
|
|
138
|
+
function splitProjectSuffix(projectName, expectedPrefix = '') {
|
|
139
|
+
const normalized = clean(projectName);
|
|
140
|
+
if (!normalized) {
|
|
141
|
+
return { prefix: '', suffix: 0 };
|
|
142
|
+
}
|
|
143
|
+
const match = FALLBACK_SUFFIX_PATTERN.exec(normalized);
|
|
144
|
+
if (!match?.groups) {
|
|
145
|
+
return { prefix: normalized, suffix: 0 };
|
|
146
|
+
}
|
|
147
|
+
const prefix = match.groups.prefix;
|
|
148
|
+
const suffix = Number.parseInt(match.groups.suffix, 10);
|
|
149
|
+
if (expectedPrefix && prefix !== expectedPrefix) {
|
|
150
|
+
return { prefix: normalized, suffix: 0 };
|
|
151
|
+
}
|
|
152
|
+
return { prefix, suffix };
|
|
153
|
+
}
|
|
154
|
+
export function deriveFallbackProjectBaseName(configuredPrefix, currentProjectName) {
|
|
155
|
+
const explicit = clean(configuredPrefix);
|
|
156
|
+
if (explicit) {
|
|
157
|
+
return explicit;
|
|
158
|
+
}
|
|
159
|
+
const { prefix, suffix } = splitProjectSuffix(currentProjectName);
|
|
160
|
+
return prefix && suffix > 0 ? prefix : clean(currentProjectName);
|
|
161
|
+
}
|
|
162
|
+
export function generateFallbackProjectName(baseName, existingNames, options = {}) {
|
|
163
|
+
const normalized = clean(baseName);
|
|
164
|
+
if (!normalized) {
|
|
165
|
+
throw new Error('Fallback project base name is empty');
|
|
166
|
+
}
|
|
167
|
+
const existing = new Set(existingNames);
|
|
168
|
+
const current = splitProjectSuffix(options.currentProjectName ?? '', normalized);
|
|
169
|
+
const currentSuffix = current.prefix === normalized ? current.suffix : 0;
|
|
170
|
+
let maxExistingSuffix = 0;
|
|
171
|
+
for (const existingName of existing) {
|
|
172
|
+
const existing = splitProjectSuffix(existingName, normalized);
|
|
173
|
+
if (existing.prefix === normalized) {
|
|
174
|
+
maxExistingSuffix = Math.max(maxExistingSuffix, existing.suffix);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
let nextSuffix = Math.max(currentSuffix, maxExistingSuffix, nonNegativeInt(options.lastUsedSuffix)) + 1;
|
|
178
|
+
while (true) {
|
|
179
|
+
const width = Math.max(2, String(nextSuffix).length);
|
|
180
|
+
const candidate = `${normalized}-${String(nextSuffix).padStart(width, '0')}`;
|
|
181
|
+
if (!existing.has(candidate)) {
|
|
182
|
+
return { projectName: candidate, suffix: nextSuffix };
|
|
183
|
+
}
|
|
184
|
+
nextSuffix += 1;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
export function resolveLatestExistingProjectName(baseName, existingNames) {
|
|
188
|
+
const normalized = clean(baseName);
|
|
189
|
+
if (!normalized) {
|
|
190
|
+
return '';
|
|
191
|
+
}
|
|
192
|
+
const existing = new Set(existingNames);
|
|
193
|
+
if (existing.has(normalized)) {
|
|
194
|
+
return normalized;
|
|
195
|
+
}
|
|
196
|
+
let latestName = '';
|
|
197
|
+
let latestSuffix = 0;
|
|
198
|
+
for (const existingName of existing) {
|
|
199
|
+
const suffix = splitProjectSuffix(existingName, normalized);
|
|
200
|
+
if (suffix.prefix === normalized && suffix.suffix > latestSuffix) {
|
|
201
|
+
latestName = existingName;
|
|
202
|
+
latestSuffix = suffix.suffix;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return latestName;
|
|
206
|
+
}
|
|
207
|
+
export function resolveCloudflareCredentials(deploy, runtimeEnv, options = {}) {
|
|
208
|
+
const authMode = getString(deploy, 'cloudflare_auth_mode') || 'api_token';
|
|
209
|
+
const accountId = getString(deploy, 'account_id') || clean(runtimeEnv.CLOUDFLARE_ACCOUNT_ID);
|
|
210
|
+
if (authMode === 'global_key') {
|
|
211
|
+
const email = getString(deploy, 'cloudflare_email') || clean(runtimeEnv.CLOUDFLARE_EMAIL);
|
|
212
|
+
const globalApiKey = getString(deploy, 'cloudflare_global_key') || clean(runtimeEnv.CLOUDFLARE_API_KEY);
|
|
213
|
+
if (!email || !globalApiKey) {
|
|
214
|
+
throw new Error('Cloudflare global key credentials are incomplete');
|
|
215
|
+
}
|
|
216
|
+
return { auth_mode: 'global_key', api_token: '', account_id: accountId, email, global_api_key: globalApiKey };
|
|
217
|
+
}
|
|
218
|
+
const apiToken = getString(deploy, 'cloudflare_api_token') || clean(options.explicitApiToken) || clean(runtimeEnv.CLOUDFLARE_API_TOKEN);
|
|
219
|
+
if (!apiToken) {
|
|
220
|
+
throw new Error('Cloudflare API token is missing');
|
|
221
|
+
}
|
|
222
|
+
return { auth_mode: 'api_token', api_token: apiToken, account_id: accountId, email: '', global_api_key: '' };
|
|
223
|
+
}
|
|
224
|
+
export function buildWranglerAuthEnv(credentials) {
|
|
225
|
+
const env = { CI: '1' };
|
|
226
|
+
if (credentials.account_id) {
|
|
227
|
+
env.CLOUDFLARE_ACCOUNT_ID = credentials.account_id;
|
|
228
|
+
}
|
|
229
|
+
if (credentials.auth_mode === 'global_key') {
|
|
230
|
+
env.CLOUDFLARE_API_KEY = credentials.global_api_key;
|
|
231
|
+
env.CLOUDFLARE_EMAIL = credentials.email;
|
|
232
|
+
}
|
|
233
|
+
else {
|
|
234
|
+
env.CLOUDFLARE_API_TOKEN = credentials.api_token;
|
|
235
|
+
}
|
|
236
|
+
return env;
|
|
237
|
+
}
|
|
238
|
+
function buildProxyEnv(proxyUrl) {
|
|
239
|
+
return {
|
|
240
|
+
HTTP_PROXY: proxyUrl,
|
|
241
|
+
HTTPS_PROXY: proxyUrl,
|
|
242
|
+
ALL_PROXY: proxyUrl
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
function isTransientDeployFailure(stdout, stderr) {
|
|
246
|
+
const combined = `${stdout}\n${stderr}`.toLowerCase();
|
|
247
|
+
return NETWORK_ERROR_MARKERS.some((marker) => combined.includes(marker));
|
|
248
|
+
}
|
|
249
|
+
export function isBlockedPagesError(stdout, stderr = '') {
|
|
250
|
+
const combined = `${stdout}\n${stderr}`.toLowerCase();
|
|
251
|
+
return BLOCKED_PAGES_MARKERS.some((marker) => combined.includes(marker));
|
|
252
|
+
}
|
|
253
|
+
function resolveDeployProxyUrl(env) {
|
|
254
|
+
for (const key of [
|
|
255
|
+
'VPN_AUTOMATION_DEPLOY_PROXY',
|
|
256
|
+
'VPN_AUTOMATION_CLOUDFLARE_PROXY',
|
|
257
|
+
'VPN_AUTOMATION_UPSTREAM_PROXY',
|
|
258
|
+
'HTTPS_PROXY',
|
|
259
|
+
'https_proxy',
|
|
260
|
+
'HTTP_PROXY',
|
|
261
|
+
'http_proxy',
|
|
262
|
+
'ALL_PROXY',
|
|
263
|
+
'all_proxy'
|
|
264
|
+
]) {
|
|
265
|
+
const value = clean(env[key]);
|
|
266
|
+
if (!value) {
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
if (['off', 'none', 'false', '0'].includes(value.toLowerCase())) {
|
|
270
|
+
return '';
|
|
271
|
+
}
|
|
272
|
+
return value;
|
|
273
|
+
}
|
|
274
|
+
return '';
|
|
275
|
+
}
|
|
276
|
+
function resolvePagesSecretValue(secretName, runtimeEnv) {
|
|
277
|
+
const configuredDefault = secretName === 'ADMIN'
|
|
278
|
+
? clean(runtimeEnv.VPN_AUTOMATION_DEFAULT_PAGES_SECRET_ADMIN) || 'swimmingliu'
|
|
279
|
+
: '';
|
|
280
|
+
for (const key of [`${PAGES_SECRET_ENV_PREFIX}${secretName}`, secretName]) {
|
|
281
|
+
const value = clean(runtimeEnv[key]) || clean(process.env[key]);
|
|
282
|
+
if (value) {
|
|
283
|
+
return value;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
if (configuredDefault) {
|
|
287
|
+
return configuredDefault;
|
|
288
|
+
}
|
|
289
|
+
throw new Error(`Missing Pages secret value for ${secretName}; set ${PAGES_SECRET_ENV_PREFIX}${secretName} or ${secretName}`);
|
|
290
|
+
}
|
|
291
|
+
function cloneEnvVars(envVars, runtimeEnv) {
|
|
292
|
+
const cloned = {};
|
|
293
|
+
for (const [name, payload] of Object.entries(envVars)) {
|
|
294
|
+
const valueType = clean(payload.type) || 'plain_text';
|
|
295
|
+
if (valueType === 'secret_text') {
|
|
296
|
+
cloned[name] = { type: 'secret_text', value: resolvePagesSecretValue(name, runtimeEnv) };
|
|
297
|
+
continue;
|
|
298
|
+
}
|
|
299
|
+
cloned[name] = { type: valueType, value: payload.value ?? '' };
|
|
300
|
+
}
|
|
301
|
+
return cloned;
|
|
302
|
+
}
|
|
303
|
+
function cloneDeploymentConfig(config, runtimeEnv) {
|
|
304
|
+
const cloned = {};
|
|
305
|
+
if (config.env_vars && typeof config.env_vars === 'object') {
|
|
306
|
+
cloned.env_vars = cloneEnvVars(config.env_vars, runtimeEnv);
|
|
307
|
+
}
|
|
308
|
+
if (config.kv_namespaces) {
|
|
309
|
+
cloned.kv_namespaces = config.kv_namespaces;
|
|
310
|
+
}
|
|
311
|
+
for (const key of [
|
|
312
|
+
'compatibility_date',
|
|
313
|
+
'compatibility_flags',
|
|
314
|
+
'build_image_major_version',
|
|
315
|
+
'usage_model',
|
|
316
|
+
'fail_open',
|
|
317
|
+
'always_use_latest_compatibility_date'
|
|
318
|
+
]) {
|
|
319
|
+
if (Object.prototype.hasOwnProperty.call(config, key)) {
|
|
320
|
+
cloned[key] = config[key];
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
return cloned;
|
|
324
|
+
}
|
|
325
|
+
function cloneProjectDeploymentConfigs(projectPayload, runtimeEnv) {
|
|
326
|
+
const deploymentConfigs = projectPayload.deployment_configs;
|
|
327
|
+
if (!deploymentConfigs || typeof deploymentConfigs !== 'object') {
|
|
328
|
+
return {};
|
|
329
|
+
}
|
|
330
|
+
const cloned = {};
|
|
331
|
+
for (const [environmentName, config] of Object.entries(deploymentConfigs)) {
|
|
332
|
+
cloned[environmentName] = cloneDeploymentConfig(config, runtimeEnv);
|
|
333
|
+
}
|
|
334
|
+
return cloned;
|
|
335
|
+
}
|
|
336
|
+
function rewriteShareProjectSubValue(deploymentConfigs, options) {
|
|
337
|
+
const rewritten = {};
|
|
338
|
+
for (const [environmentName, config] of Object.entries(deploymentConfigs)) {
|
|
339
|
+
rewritten[environmentName] = { ...config };
|
|
340
|
+
}
|
|
341
|
+
for (const environmentName of ['preview', 'production']) {
|
|
342
|
+
const envConfig = { ...(rewritten[environmentName] ?? {}) };
|
|
343
|
+
const envVars = { ...(envConfig.env_vars ?? {}) };
|
|
344
|
+
envVars[options.envKey] = { type: 'plain_text', value: options.subValue };
|
|
345
|
+
envConfig.env_vars = envVars;
|
|
346
|
+
rewritten[environmentName] = envConfig;
|
|
347
|
+
}
|
|
348
|
+
return rewritten;
|
|
349
|
+
}
|
|
350
|
+
function buildShareProjectUpdatePayload(sourceProject, runtimeEnv, options) {
|
|
351
|
+
const clonedConfigs = cloneProjectDeploymentConfigs(sourceProject, runtimeEnv);
|
|
352
|
+
return {
|
|
353
|
+
deployment_configs: rewriteShareProjectSubValue(clonedConfigs, options)
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
async function fileExists(candidate) {
|
|
357
|
+
try {
|
|
358
|
+
await access(candidate);
|
|
359
|
+
return true;
|
|
360
|
+
}
|
|
361
|
+
catch {
|
|
362
|
+
return false;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
function expandHomePath(candidate) {
|
|
366
|
+
if (candidate === '~') {
|
|
367
|
+
return process.env.HOME || candidate;
|
|
368
|
+
}
|
|
369
|
+
if (candidate.startsWith('~/')) {
|
|
370
|
+
return process.env.HOME ? path.join(process.env.HOME, candidate.slice(2)) : candidate;
|
|
371
|
+
}
|
|
372
|
+
return candidate;
|
|
373
|
+
}
|
|
374
|
+
async function resolveShareProjectWorkerSourcePath(projectRoot, env) {
|
|
375
|
+
const candidates = [
|
|
376
|
+
clean(env.VPN_AUTOMATION_SHARE_WORKER_PATH),
|
|
377
|
+
path.join(projectRoot, 'electron', 'runtime', 'share-worker', 'vpn.js'),
|
|
378
|
+
path.join(projectRoot, 'templates', 'share-worker', 'vpn.js'),
|
|
379
|
+
path.join(path.dirname(projectRoot), 'cloudflarevpn', 'edgetunnel', 'vpn.js')
|
|
380
|
+
].filter(Boolean).map((candidate) => path.resolve(expandHomePath(candidate)));
|
|
381
|
+
const seen = new Set();
|
|
382
|
+
const uniqueCandidates = candidates.filter((candidate) => {
|
|
383
|
+
if (seen.has(candidate)) {
|
|
384
|
+
return false;
|
|
385
|
+
}
|
|
386
|
+
seen.add(candidate);
|
|
387
|
+
return true;
|
|
388
|
+
});
|
|
389
|
+
for (const candidate of uniqueCandidates) {
|
|
390
|
+
if (await fileExists(candidate)) {
|
|
391
|
+
return candidate;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
throw new Error(`share worker source not found; tried: ${uniqueCandidates.join(', ')}`);
|
|
395
|
+
}
|
|
396
|
+
function buildShareProjectBundleDir(projectRoot) {
|
|
397
|
+
return path.join(projectRoot, 'electron', 'runtime', 'share-worker', 'share_pages_bundle');
|
|
398
|
+
}
|
|
399
|
+
async function stageShareProjectWorkerBundle(projectRoot, env) {
|
|
400
|
+
const sourcePath = await resolveShareProjectWorkerSourcePath(projectRoot, env);
|
|
401
|
+
const bundleDir = buildShareProjectBundleDir(projectRoot);
|
|
402
|
+
const workerEntry = path.join(bundleDir, '_worker.js');
|
|
403
|
+
await mkdir(bundleDir, { recursive: true });
|
|
404
|
+
await writeFile(workerEntry, await readFile(sourcePath, 'utf8'), 'utf8');
|
|
405
|
+
return { sourcePath, bundleDir, workerEntry };
|
|
406
|
+
}
|
|
407
|
+
function runCommandWithSpawn(command, options, spawnImpl = defaultSpawn) {
|
|
408
|
+
const [executable, ...args] = command;
|
|
409
|
+
const child = spawnImpl(executable, args, {
|
|
410
|
+
cwd: options.cwd,
|
|
411
|
+
env: { ...process.env, ...options.env },
|
|
412
|
+
stdio: ['ignore', 'pipe', 'pipe']
|
|
413
|
+
});
|
|
414
|
+
let stdout = '';
|
|
415
|
+
let stderr = '';
|
|
416
|
+
child.stdout?.on('data', (chunk) => {
|
|
417
|
+
stdout += String(chunk);
|
|
418
|
+
});
|
|
419
|
+
child.stderr?.on('data', (chunk) => {
|
|
420
|
+
stderr += String(chunk);
|
|
421
|
+
});
|
|
422
|
+
return new Promise((resolve, reject) => {
|
|
423
|
+
child.on('error', reject);
|
|
424
|
+
child.on('close', (exitCode) => {
|
|
425
|
+
resolve({ returncode: exitCode ?? 1, stdout, stderr });
|
|
426
|
+
});
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
async function runPagesDeployAttempts(command, baseEnv, proxyUrl, options) {
|
|
430
|
+
const attempts = [
|
|
431
|
+
{ mode: 'direct', env: baseEnv },
|
|
432
|
+
{ mode: 'direct-retry', env: baseEnv }
|
|
433
|
+
];
|
|
434
|
+
if (proxyUrl) {
|
|
435
|
+
attempts.push({ mode: 'proxy', env: { ...baseEnv, ...buildProxyEnv(proxyUrl) } });
|
|
436
|
+
}
|
|
437
|
+
let result;
|
|
438
|
+
const attemptLog = [];
|
|
439
|
+
for (let index = 0; index < attempts.length; index += 1) {
|
|
440
|
+
const attempt = attempts[index];
|
|
441
|
+
result = await options.runCommand(command, { cwd: options.cwd, env: attempt.env });
|
|
442
|
+
attemptLog.push({ mode: attempt.mode, returncode: result.returncode });
|
|
443
|
+
if (result.returncode === 0) {
|
|
444
|
+
break;
|
|
445
|
+
}
|
|
446
|
+
if (!isTransientDeployFailure(result.stdout, result.stderr)) {
|
|
447
|
+
break;
|
|
448
|
+
}
|
|
449
|
+
if (index === attempts.length - 1) {
|
|
450
|
+
break;
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
if (!result) {
|
|
454
|
+
throw new Error('Wrangler deploy did not run');
|
|
455
|
+
}
|
|
456
|
+
return { result, attempts: attemptLog };
|
|
457
|
+
}
|
|
458
|
+
export function mergeDeployVerificationTarget(deploy, deployment) {
|
|
459
|
+
const merged = { ...deploy };
|
|
460
|
+
for (const key of ['project_name', 'pages_project_url', 'custom_domain']) {
|
|
461
|
+
if (Object.prototype.hasOwnProperty.call(deployment, key)) {
|
|
462
|
+
merged[key] = deployment[key];
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
return merged;
|
|
466
|
+
}
|
|
467
|
+
export function resolveCleanupBlockedProjectCandidates(deploy, deployment) {
|
|
468
|
+
const finalProject = getString(deploy, 'project_name');
|
|
469
|
+
const cleanupCandidates = [];
|
|
470
|
+
for (const key of ['cleanup_blocked_project', 'share_project_cleanup_blocked_project']) {
|
|
471
|
+
const candidate = getString(deployment, key);
|
|
472
|
+
if (!candidate || candidate === finalProject || cleanupCandidates.includes(candidate)) {
|
|
473
|
+
continue;
|
|
474
|
+
}
|
|
475
|
+
cleanupCandidates.push(candidate);
|
|
476
|
+
}
|
|
477
|
+
return cleanupCandidates;
|
|
478
|
+
}
|
|
479
|
+
export function buildNoopCleanupBlockedProjectResult(deployment) {
|
|
480
|
+
return {
|
|
481
|
+
cleanup_deleted: false,
|
|
482
|
+
cleanup_errors: deployment.cleanup_errors ?? []
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
function normalizeHostname(value) {
|
|
486
|
+
return clean(value).replace(/\.+$/g, '').toLowerCase();
|
|
487
|
+
}
|
|
488
|
+
function cloudflareHeaders(credentials) {
|
|
489
|
+
const headers = { 'Content-Type': 'application/json' };
|
|
490
|
+
if (credentials.auth_mode === 'global_key') {
|
|
491
|
+
headers['X-Auth-Email'] = credentials.email;
|
|
492
|
+
headers['X-Auth-Key'] = credentials.global_api_key;
|
|
493
|
+
return headers;
|
|
494
|
+
}
|
|
495
|
+
headers.Authorization = `Bearer ${credentials.api_token}`;
|
|
496
|
+
return headers;
|
|
497
|
+
}
|
|
498
|
+
function responseBodyText(response) {
|
|
499
|
+
return response.text().catch(() => '');
|
|
500
|
+
}
|
|
501
|
+
export class CloudflareHttpClient {
|
|
502
|
+
accountId;
|
|
503
|
+
headers;
|
|
504
|
+
fetchImpl;
|
|
505
|
+
constructor(credentials, options = {}) {
|
|
506
|
+
this.accountId = credentials.account_id;
|
|
507
|
+
this.headers = cloudflareHeaders(credentials);
|
|
508
|
+
this.fetchImpl = options.fetch ?? fetch;
|
|
509
|
+
}
|
|
510
|
+
async apiRequest(pathname, options = {}) {
|
|
511
|
+
const response = await this.fetchImpl(`${CLOUDFLARE_API_BASE_URL}${pathname}`, {
|
|
512
|
+
...options,
|
|
513
|
+
headers: { ...this.headers, ...options.headers },
|
|
514
|
+
signal: options.signal ?? AbortSignal.timeout(20_000)
|
|
515
|
+
});
|
|
516
|
+
if (!response.ok) {
|
|
517
|
+
const body = await responseBodyText(response);
|
|
518
|
+
throw new Error(`Cloudflare API request failed (${response.status}): ${body || response.statusText}`);
|
|
519
|
+
}
|
|
520
|
+
return response.json();
|
|
521
|
+
}
|
|
522
|
+
async apiResult(pathname, options = {}) {
|
|
523
|
+
const payload = await this.apiRequest(pathname, options);
|
|
524
|
+
if (payload && typeof payload === 'object' && 'result' in payload) {
|
|
525
|
+
return payload.result;
|
|
526
|
+
}
|
|
527
|
+
return payload;
|
|
528
|
+
}
|
|
529
|
+
async listAccounts() {
|
|
530
|
+
return (await this.apiResult('/accounts'));
|
|
531
|
+
}
|
|
532
|
+
async listPagesProjects() {
|
|
533
|
+
const accountId = await this.resolveAccountId();
|
|
534
|
+
return (await this.apiResult(`/accounts/${accountId}/pages/projects`));
|
|
535
|
+
}
|
|
536
|
+
async getPagesProject(projectName) {
|
|
537
|
+
const accountId = await this.resolveAccountId();
|
|
538
|
+
return (await this.apiResult(`/accounts/${accountId}/pages/projects/${projectName}`));
|
|
539
|
+
}
|
|
540
|
+
async createPagesProject(projectName) {
|
|
541
|
+
const accountId = await this.resolveAccountId();
|
|
542
|
+
return this.apiResult(`/accounts/${accountId}/pages/projects`, {
|
|
543
|
+
method: 'POST',
|
|
544
|
+
body: JSON.stringify({ name: projectName, production_branch: PAGES_PRODUCTION_BRANCH })
|
|
545
|
+
});
|
|
546
|
+
}
|
|
547
|
+
async updatePagesProject(projectName, payload) {
|
|
548
|
+
const accountId = await this.resolveAccountId();
|
|
549
|
+
return this.apiResult(`/accounts/${accountId}/pages/projects/${projectName}`, {
|
|
550
|
+
method: 'PATCH',
|
|
551
|
+
body: JSON.stringify(payload)
|
|
552
|
+
});
|
|
553
|
+
}
|
|
554
|
+
async copyPagesProjectConfig(sourceProjectName, targetProjectName, runtimeEnv) {
|
|
555
|
+
const sourceProject = await this.getPagesProject(sourceProjectName);
|
|
556
|
+
return this.updatePagesProject(targetProjectName, {
|
|
557
|
+
deployment_configs: cloneProjectDeploymentConfigs(sourceProject, runtimeEnv)
|
|
558
|
+
});
|
|
559
|
+
}
|
|
560
|
+
async listPagesDomains(projectName) {
|
|
561
|
+
const accountId = await this.resolveAccountId();
|
|
562
|
+
return (await this.apiResult(`/accounts/${accountId}/pages/projects/${projectName}/domains`));
|
|
563
|
+
}
|
|
564
|
+
async attachCustomDomain(projectName, domain) {
|
|
565
|
+
const accountId = await this.resolveAccountId();
|
|
566
|
+
return this.apiResult(`/accounts/${accountId}/pages/projects/${projectName}/domains`, {
|
|
567
|
+
method: 'POST',
|
|
568
|
+
body: JSON.stringify({ name: domain })
|
|
569
|
+
});
|
|
570
|
+
}
|
|
571
|
+
async detachCustomDomain(projectName, domain) {
|
|
572
|
+
const accountId = await this.resolveAccountId();
|
|
573
|
+
return this.apiRequest(`/accounts/${accountId}/pages/projects/${projectName}/domains/${encodeURIComponent(domain)}`, {
|
|
574
|
+
method: 'DELETE'
|
|
575
|
+
});
|
|
576
|
+
}
|
|
577
|
+
async resolveAccountId() {
|
|
578
|
+
if (this.accountId) {
|
|
579
|
+
return this.accountId;
|
|
580
|
+
}
|
|
581
|
+
const accounts = await this.listAccounts();
|
|
582
|
+
if (!accounts.length) {
|
|
583
|
+
throw new Error('No Cloudflare account available for the supplied credentials');
|
|
584
|
+
}
|
|
585
|
+
this.accountId = clean(accounts[0].id);
|
|
586
|
+
return this.accountId;
|
|
587
|
+
}
|
|
588
|
+
async listZones(name = '') {
|
|
589
|
+
const params = name ? `?name=${encodeURIComponent(name)}` : '';
|
|
590
|
+
return (await this.apiResult(`/zones${params}`));
|
|
591
|
+
}
|
|
592
|
+
async resolveZoneForHostname(hostname) {
|
|
593
|
+
const normalized = normalizeHostname(hostname);
|
|
594
|
+
const labels = normalized.split('.').filter(Boolean);
|
|
595
|
+
const startIndex = labels.length <= 2 ? 0 : 1;
|
|
596
|
+
for (let index = startIndex; index < labels.length - 1; index += 1) {
|
|
597
|
+
const candidate = labels.slice(index).join('.');
|
|
598
|
+
if (!candidate.includes('.')) {
|
|
599
|
+
continue;
|
|
600
|
+
}
|
|
601
|
+
const zones = await this.listZones(candidate);
|
|
602
|
+
for (const zone of zones) {
|
|
603
|
+
if (clean(zone.name).toLowerCase() === candidate) {
|
|
604
|
+
return zone;
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
throw new Error(`Cloudflare zone not found for hostname: ${hostname}`);
|
|
609
|
+
}
|
|
610
|
+
async listDnsRecords(zoneId, hostname) {
|
|
611
|
+
return (await this.apiResult(`/zones/${zoneId}/dns_records?name=${encodeURIComponent(hostname)}`));
|
|
612
|
+
}
|
|
613
|
+
async upsertSubdomainCname(hostname, target, proxied = true) {
|
|
614
|
+
const normalizedHost = normalizeHostname(hostname);
|
|
615
|
+
const normalizedTarget = normalizeHostname(target);
|
|
616
|
+
const zone = await this.resolveZoneForHostname(normalizedHost);
|
|
617
|
+
const zoneId = clean(zone.id);
|
|
618
|
+
const zoneName = normalizeHostname(clean(zone.name));
|
|
619
|
+
if (normalizedHost === zoneName) {
|
|
620
|
+
throw new Error('Apex custom domains require a different DNS strategy than CNAME');
|
|
621
|
+
}
|
|
622
|
+
const records = await this.listDnsRecords(zoneId, normalizedHost);
|
|
623
|
+
const conflicting = records.filter((record) => clean(record.type).toUpperCase() !== 'CNAME');
|
|
624
|
+
if (conflicting.length) {
|
|
625
|
+
throw new Error(`Conflicting non-CNAME DNS records exist for ${normalizedHost}`);
|
|
626
|
+
}
|
|
627
|
+
const cnameRecords = records.filter((record) => clean(record.type).toUpperCase() === 'CNAME');
|
|
628
|
+
const payload = {
|
|
629
|
+
type: 'CNAME',
|
|
630
|
+
name: normalizedHost,
|
|
631
|
+
content: normalizedTarget,
|
|
632
|
+
proxied
|
|
633
|
+
};
|
|
634
|
+
if (!cnameRecords.length) {
|
|
635
|
+
return this.apiResult(`/zones/${zoneId}/dns_records`, {
|
|
636
|
+
method: 'POST',
|
|
637
|
+
body: JSON.stringify(payload)
|
|
638
|
+
});
|
|
639
|
+
}
|
|
640
|
+
if (cnameRecords.length > 1) {
|
|
641
|
+
throw new Error(`Multiple CNAME records exist for ${normalizedHost}`);
|
|
642
|
+
}
|
|
643
|
+
const current = cnameRecords[0];
|
|
644
|
+
if (normalizeHostname(clean(current.content)) === normalizedTarget
|
|
645
|
+
&& Boolean(current.proxied) === proxied) {
|
|
646
|
+
return current;
|
|
647
|
+
}
|
|
648
|
+
return this.apiResult(`/zones/${zoneId}/dns_records/${clean(current.id)}`, {
|
|
649
|
+
method: 'PATCH',
|
|
650
|
+
body: JSON.stringify(payload)
|
|
651
|
+
});
|
|
652
|
+
}
|
|
653
|
+
async verifySubdomainCname(hostname, target) {
|
|
654
|
+
const normalizedHost = normalizeHostname(hostname);
|
|
655
|
+
const normalizedTarget = normalizeHostname(target);
|
|
656
|
+
const zone = await this.resolveZoneForHostname(normalizedHost);
|
|
657
|
+
const records = await this.listDnsRecords(clean(zone.id), normalizedHost);
|
|
658
|
+
return records.some((record) => (clean(record.type).toUpperCase() === 'CNAME'
|
|
659
|
+
&& normalizeHostname(clean(record.name)) === normalizedHost
|
|
660
|
+
&& normalizeHostname(clean(record.content)) === normalizedTarget));
|
|
661
|
+
}
|
|
662
|
+
async deletePagesProject(projectName) {
|
|
663
|
+
const accountId = await this.resolveAccountId();
|
|
664
|
+
return this.apiRequest(`/accounts/${accountId}/pages/projects/${projectName}`, { method: 'DELETE' });
|
|
665
|
+
}
|
|
666
|
+
async verifyUrl(url, expectedFragment = '') {
|
|
667
|
+
const response = await this.fetchImpl(url, {
|
|
668
|
+
method: 'GET',
|
|
669
|
+
signal: AbortSignal.timeout(30_000)
|
|
670
|
+
});
|
|
671
|
+
if (!response.ok) {
|
|
672
|
+
const body = await responseBodyText(response);
|
|
673
|
+
throw new Error(`URL verification failed (${response.status}): ${body || response.statusText}`);
|
|
674
|
+
}
|
|
675
|
+
if (!expectedFragment) {
|
|
676
|
+
return true;
|
|
677
|
+
}
|
|
678
|
+
return (await response.text()).includes(expectedFragment);
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
export async function defaultVerifyDeployment(deploy, client) {
|
|
682
|
+
const pagesDomainUrl = buildPagesProjectRootUrl(deploy);
|
|
683
|
+
const pagesDomainOk = pagesDomainUrl ? await client.verifyUrl(pagesDomainUrl) : false;
|
|
684
|
+
const secretOk = await client.verifyUrl(buildSecretUrl(deploy));
|
|
685
|
+
const subscriptionOk = await client.verifyUrl(resolveVerifySubscriptionUrl(deploy));
|
|
686
|
+
const customDomainUrl = buildCustomDomainRootUrl(deploy);
|
|
687
|
+
const customDomainOk = customDomainUrl ? await client.verifyUrl(customDomainUrl) : false;
|
|
688
|
+
const customDomainSubscriptionUrl = resolveCustomDomainVerifySubscriptionUrl(deploy);
|
|
689
|
+
const customDomainSubscriptionOk = customDomainSubscriptionUrl ? await client.verifyUrl(customDomainSubscriptionUrl) : false;
|
|
690
|
+
const customDomainDnsTarget = deriveCustomDomainDnsTarget(deploy);
|
|
691
|
+
const customDomainDnsOk = customDomainUrl && customDomainDnsTarget
|
|
692
|
+
? await client.verifySubdomainCname(getString(deploy, 'custom_domain'), customDomainDnsTarget)
|
|
693
|
+
: false;
|
|
694
|
+
return {
|
|
695
|
+
pages_domain_ok: pagesDomainOk,
|
|
696
|
+
secret_ok: secretOk,
|
|
697
|
+
subscription_ok: subscriptionOk,
|
|
698
|
+
custom_domain_ok: customDomainOk,
|
|
699
|
+
custom_domain_subscription_ok: customDomainSubscriptionOk,
|
|
700
|
+
custom_domain_dns_ok: customDomainDnsOk
|
|
701
|
+
};
|
|
702
|
+
}
|
|
703
|
+
export async function cleanupBlockedPagesProjects(deploy, deployment, client) {
|
|
704
|
+
const candidates = resolveCleanupBlockedProjectCandidates(deploy, deployment);
|
|
705
|
+
if (!candidates.length) {
|
|
706
|
+
return buildNoopCleanupBlockedProjectResult(deployment);
|
|
707
|
+
}
|
|
708
|
+
let deletedAny = false;
|
|
709
|
+
const errors = [];
|
|
710
|
+
for (const blockedProject of candidates) {
|
|
711
|
+
try {
|
|
712
|
+
await client.deletePagesProject(blockedProject);
|
|
713
|
+
deletedAny = true;
|
|
714
|
+
}
|
|
715
|
+
catch (error) {
|
|
716
|
+
errors.push(error instanceof Error ? error.message : String(error));
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
return { cleanup_deleted: deletedAny, cleanup_errors: errors };
|
|
720
|
+
}
|
|
721
|
+
export function selectPipelineStageBackend(stage, env = process.env) {
|
|
722
|
+
const stageKey = `AUTOVPN_STAGE_BACKEND_${stage.toUpperCase()}`;
|
|
723
|
+
const stageOverride = String(env[stageKey] ?? '').trim().toLowerCase();
|
|
724
|
+
const pipelineOverride = String(env.AUTOVPN_PIPELINE_BACKEND ?? '').trim().toLowerCase();
|
|
725
|
+
const selected = stageOverride || pipelineOverride || 'node';
|
|
726
|
+
return selected === 'python' ? 'python' : 'node';
|
|
727
|
+
}
|
|
728
|
+
async function defaultResolvePythonCli(env) {
|
|
729
|
+
// @ts-expect-error Phase 1 runner remains plain ESM JavaScript.
|
|
730
|
+
const runner = await import('../../lib/runner.mjs');
|
|
731
|
+
return runner.resolveOrInstallPythonCli({ env });
|
|
732
|
+
}
|
|
733
|
+
function pythonCommandFor(resolved) {
|
|
734
|
+
const command = resolved.command;
|
|
735
|
+
const name = path.basename(command).toLowerCase();
|
|
736
|
+
if (['autovpn', 'autovpn.exe'].includes(name)) {
|
|
737
|
+
if (!path.isAbsolute(command)) {
|
|
738
|
+
throw new Error('Python deploy/verify fallback requires an absolute AUTOVPN_PYTHON_CLI path; PATH autovpn cannot identify its Python interpreter safely');
|
|
739
|
+
}
|
|
740
|
+
const executable = process.platform === 'win32' ? 'python.exe' : 'python';
|
|
741
|
+
return path.join(path.dirname(command), executable);
|
|
742
|
+
}
|
|
743
|
+
return process.platform === 'win32' ? 'python.exe' : 'python3';
|
|
744
|
+
}
|
|
745
|
+
async function runPythonJsonHelper(code, payload, options, stageName) {
|
|
746
|
+
const cwd = options.cwd ?? process.cwd();
|
|
747
|
+
const env = mergeProjectEnv(cwd, options.env ?? process.env);
|
|
748
|
+
const resolved = options.resolvePythonCli ? await options.resolvePythonCli() : await defaultResolvePythonCli(env);
|
|
749
|
+
const child = (options.spawn ?? defaultSpawn)(pythonCommandFor(resolved), ['-c', code], {
|
|
750
|
+
cwd,
|
|
751
|
+
env,
|
|
752
|
+
stdio: ['pipe', 'pipe', 'pipe']
|
|
753
|
+
});
|
|
754
|
+
let stdout = '';
|
|
755
|
+
let stderr = '';
|
|
756
|
+
child.stdout?.on('data', (chunk) => {
|
|
757
|
+
stdout += String(chunk);
|
|
758
|
+
});
|
|
759
|
+
child.stderr?.on('data', (chunk) => {
|
|
760
|
+
stderr += String(chunk);
|
|
761
|
+
});
|
|
762
|
+
const completion = new Promise((resolve, reject) => {
|
|
763
|
+
child.on('error', reject);
|
|
764
|
+
child.on('close', (exitCode) => {
|
|
765
|
+
if (exitCode !== 0) {
|
|
766
|
+
reject(new Error(`Python ${stageName} backend failed with exit code ${exitCode}: ${stderr.trim()}`));
|
|
767
|
+
return;
|
|
768
|
+
}
|
|
769
|
+
try {
|
|
770
|
+
resolve(JSON.parse(stdout));
|
|
771
|
+
}
|
|
772
|
+
catch (error) {
|
|
773
|
+
reject(new Error(`Python ${stageName} backend returned invalid JSON: ${error instanceof Error ? error.message : String(error)}`));
|
|
774
|
+
}
|
|
775
|
+
});
|
|
776
|
+
});
|
|
777
|
+
child.stdin?.write(JSON.stringify(payload));
|
|
778
|
+
child.stdin?.end();
|
|
779
|
+
return completion;
|
|
780
|
+
}
|
|
781
|
+
function buildShareProjectNoopResult(deploy) {
|
|
782
|
+
return {
|
|
783
|
+
share_project_requested_name: getString(deploy, 'share_project_name'),
|
|
784
|
+
share_project_name: getString(deploy, 'share_project_name'),
|
|
785
|
+
share_project_fallback_used: false,
|
|
786
|
+
share_project_cleanup_blocked_project: '',
|
|
787
|
+
share_project_sub_value: '',
|
|
788
|
+
share_project_sync_ok: true,
|
|
789
|
+
share_project_sync_error: '',
|
|
790
|
+
share_project_fallback_candidate_names: [],
|
|
791
|
+
share_project_fallback_last_used_suffix: nonNegativeInt(deploy.share_project_fallback_last_used_suffix)
|
|
792
|
+
};
|
|
793
|
+
}
|
|
794
|
+
async function syncShareProjectSub(deploy, options) {
|
|
795
|
+
let requestedName = getString(deploy, 'share_project_name');
|
|
796
|
+
const envKey = getString(deploy, 'share_project_sub_env_key') || 'SUB';
|
|
797
|
+
const subValue = buildSecretUrl({
|
|
798
|
+
...deploy,
|
|
799
|
+
pages_project_url: options.pagesProjectUrl,
|
|
800
|
+
secret_query: getString(deploy, 'secret_query') || 'serect_key=swimmingliu'
|
|
801
|
+
});
|
|
802
|
+
const shareAutoFallback = deploy.share_project_auto_fallback ?? true;
|
|
803
|
+
let shareLastUsedSuffix = nonNegativeInt(deploy.share_project_fallback_last_used_suffix);
|
|
804
|
+
const result = {
|
|
805
|
+
share_project_requested_name: requestedName,
|
|
806
|
+
share_project_name: requestedName,
|
|
807
|
+
share_project_fallback_used: false,
|
|
808
|
+
share_project_cleanup_blocked_project: '',
|
|
809
|
+
share_project_sub_value: requestedName ? subValue : '',
|
|
810
|
+
share_project_sync_ok: true,
|
|
811
|
+
share_project_sync_error: '',
|
|
812
|
+
share_project_fallback_candidate_names: [],
|
|
813
|
+
share_project_fallback_last_used_suffix: shareLastUsedSuffix
|
|
814
|
+
};
|
|
815
|
+
if (!requestedName) {
|
|
816
|
+
return result;
|
|
817
|
+
}
|
|
818
|
+
let sourceProject;
|
|
819
|
+
try {
|
|
820
|
+
sourceProject = await options.client.getPagesProject(requestedName);
|
|
821
|
+
}
|
|
822
|
+
catch (error) {
|
|
823
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
824
|
+
if (!shareAutoFallback || !errorMessage.toLowerCase().includes('not found')) {
|
|
825
|
+
result.share_project_sync_ok = false;
|
|
826
|
+
result.share_project_sync_error = errorMessage;
|
|
827
|
+
return result;
|
|
828
|
+
}
|
|
829
|
+
const existingNames = new Set((await options.client.listPagesProjects()).map((project) => clean(project.name)).filter(Boolean));
|
|
830
|
+
const recoveredName = resolveLatestExistingProjectName(deriveFallbackProjectBaseName(getString(deploy, 'share_project_fallback_prefix'), requestedName), existingNames);
|
|
831
|
+
if (!recoveredName) {
|
|
832
|
+
result.share_project_sync_ok = false;
|
|
833
|
+
result.share_project_sync_error = errorMessage;
|
|
834
|
+
return result;
|
|
835
|
+
}
|
|
836
|
+
requestedName = recoveredName;
|
|
837
|
+
result.share_project_name = recoveredName;
|
|
838
|
+
sourceProject = await options.client.getPagesProject(requestedName);
|
|
839
|
+
}
|
|
840
|
+
const payload = buildShareProjectUpdatePayload(sourceProject, options.runtimeEnv, {
|
|
841
|
+
envKey,
|
|
842
|
+
subValue
|
|
843
|
+
});
|
|
844
|
+
const shareBundle = await stageShareProjectWorkerBundle(options.projectRoot, options.env);
|
|
845
|
+
result.share_project_source_path = shareBundle.sourcePath;
|
|
846
|
+
result.share_project_bundle_dir = shareBundle.bundleDir;
|
|
847
|
+
result.share_project_worker_entry = shareBundle.workerEntry;
|
|
848
|
+
const shareCustomDomains = typeof options.client.listPagesDomains === 'function'
|
|
849
|
+
? (await options.client.listPagesDomains(requestedName)).map((item) => clean(item.name)).filter(Boolean)
|
|
850
|
+
: [];
|
|
851
|
+
const deployShareProject = async (projectName) => runPagesDeployAttempts(buildPagesDeployCommand(shareBundle.bundleDir, projectName), buildWranglerAuthEnv(options.credentials), resolveDeployProxyUrl(options.env), {
|
|
852
|
+
cwd: shareBundle.bundleDir,
|
|
853
|
+
runCommand: options.runCommand
|
|
854
|
+
});
|
|
855
|
+
const fallbackShareProject = async () => {
|
|
856
|
+
const existingNames = new Set((await options.client.listPagesProjects()).map((project) => clean(project.name)).filter(Boolean));
|
|
857
|
+
const fallbackBaseName = deriveFallbackProjectBaseName(getString(deploy, 'share_project_fallback_prefix'), requestedName);
|
|
858
|
+
const fallback = generateFallbackProjectName(fallbackBaseName, existingNames, {
|
|
859
|
+
currentProjectName: requestedName,
|
|
860
|
+
lastUsedSuffix: shareLastUsedSuffix
|
|
861
|
+
});
|
|
862
|
+
shareLastUsedSuffix = fallback.suffix;
|
|
863
|
+
result.share_project_fallback_candidate_names = [fallback.projectName];
|
|
864
|
+
await options.client.createPagesProject(fallback.projectName);
|
|
865
|
+
await options.client.copyPagesProjectConfig(requestedName, fallback.projectName, options.runtimeEnv);
|
|
866
|
+
await options.client.updatePagesProject(fallback.projectName, payload);
|
|
867
|
+
for (const domain of shareCustomDomains) {
|
|
868
|
+
await ensureCustomDomainBound(options.client, fallback.projectName, domain, { previousProjectName: requestedName });
|
|
869
|
+
await options.client.upsertSubdomainCname(domain, `${fallback.projectName}.pages.dev`, false);
|
|
870
|
+
}
|
|
871
|
+
const fallbackDeploy = await deployShareProject(fallback.projectName);
|
|
872
|
+
if (fallbackDeploy.result.returncode !== 0) {
|
|
873
|
+
result.share_project_sync_ok = false;
|
|
874
|
+
result.share_project_sync_error = fallbackDeploy.result.stderr || fallbackDeploy.result.stdout;
|
|
875
|
+
result.share_project_redeploy_attempts = fallbackDeploy.attempts;
|
|
876
|
+
return result;
|
|
877
|
+
}
|
|
878
|
+
result.share_project_name = fallback.projectName;
|
|
879
|
+
result.share_project_fallback_used = true;
|
|
880
|
+
result.share_project_cleanup_blocked_project = requestedName;
|
|
881
|
+
result.share_project_sync_ok = true;
|
|
882
|
+
result.share_project_sync_error = '';
|
|
883
|
+
result.share_project_fallback_last_used_suffix = shareLastUsedSuffix;
|
|
884
|
+
result.share_project_redeploy_attempts = fallbackDeploy.attempts;
|
|
885
|
+
return result;
|
|
886
|
+
};
|
|
887
|
+
try {
|
|
888
|
+
await options.client.updatePagesProject(requestedName, payload);
|
|
889
|
+
const redeploy = await deployShareProject(requestedName);
|
|
890
|
+
if (redeploy.result.returncode !== 0) {
|
|
891
|
+
if (shareAutoFallback && isBlockedPagesError(redeploy.result.stdout, redeploy.result.stderr)) {
|
|
892
|
+
return fallbackShareProject();
|
|
893
|
+
}
|
|
894
|
+
result.share_project_sync_ok = false;
|
|
895
|
+
result.share_project_sync_error = redeploy.result.stderr || redeploy.result.stdout;
|
|
896
|
+
}
|
|
897
|
+
result.share_project_redeploy_attempts = redeploy.attempts;
|
|
898
|
+
return result;
|
|
899
|
+
}
|
|
900
|
+
catch (error) {
|
|
901
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
902
|
+
if (!shareAutoFallback || !isBlockedPagesError(errorMessage, '')) {
|
|
903
|
+
result.share_project_sync_ok = false;
|
|
904
|
+
result.share_project_sync_error = errorMessage;
|
|
905
|
+
return result;
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
return fallbackShareProject();
|
|
909
|
+
}
|
|
910
|
+
async function ensureCustomDomainBound(client, projectName, customDomain, options = {}) {
|
|
911
|
+
const normalizedDomain = clean(customDomain).toLowerCase();
|
|
912
|
+
if (!normalizedDomain) {
|
|
913
|
+
return;
|
|
914
|
+
}
|
|
915
|
+
const currentDomains = new Set((await client.listPagesDomains(projectName))
|
|
916
|
+
.map((item) => clean(item.name).toLowerCase())
|
|
917
|
+
.filter(Boolean));
|
|
918
|
+
if (currentDomains.has(normalizedDomain)) {
|
|
919
|
+
return;
|
|
920
|
+
}
|
|
921
|
+
try {
|
|
922
|
+
await client.attachCustomDomain(projectName, customDomain);
|
|
923
|
+
}
|
|
924
|
+
catch (error) {
|
|
925
|
+
const previousProjectName = clean(options.previousProjectName);
|
|
926
|
+
if (previousProjectName && previousProjectName !== projectName) {
|
|
927
|
+
await client.detachCustomDomain(previousProjectName, customDomain);
|
|
928
|
+
await client.attachCustomDomain(projectName, customDomain);
|
|
929
|
+
return;
|
|
930
|
+
}
|
|
931
|
+
throw error;
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
export async function deployPagesWithBackend(input, options = {}) {
|
|
935
|
+
if (selectPipelineStageBackend('deploy', options.env ?? process.env) !== 'python') {
|
|
936
|
+
const cwd = options.cwd ?? process.cwd();
|
|
937
|
+
const env = mergeProjectEnv(cwd, options.env ?? process.env);
|
|
938
|
+
const deploy = input.deploy;
|
|
939
|
+
const projectName = getString(deploy, 'project_name');
|
|
940
|
+
if (!projectName) {
|
|
941
|
+
throw new Error('Cloudflare Pages project name is missing');
|
|
942
|
+
}
|
|
943
|
+
const customDomain = getString(deploy, 'custom_domain');
|
|
944
|
+
const runtimeEnv = {
|
|
945
|
+
...env,
|
|
946
|
+
VPN_AUTOMATION_DEFAULT_PAGES_SECRET_ADMIN: getString(deploy, 'pages_secret_admin') || 'swimmingliu'
|
|
947
|
+
};
|
|
948
|
+
const credentials = resolveCloudflareCredentials(deploy, runtimeEnv);
|
|
949
|
+
const command = buildPagesDeployCommand(input.bundleDir, projectName);
|
|
950
|
+
const baseEnv = buildWranglerAuthEnv(credentials);
|
|
951
|
+
const runCommand = options.runCommand ?? ((commandToRun, runOptions) => runCommandWithSpawn(commandToRun, runOptions, options.spawn));
|
|
952
|
+
const proxyUrl = resolveDeployProxyUrl(env);
|
|
953
|
+
let activeCommand = command;
|
|
954
|
+
let finalProjectName = projectName;
|
|
955
|
+
let pagesProjectUrl = getString(deploy, 'pages_project_url') || derivePagesProjectUrl(projectName);
|
|
956
|
+
let fallbackUsed = false;
|
|
957
|
+
let cleanupBlockedProject = '';
|
|
958
|
+
let dnsError = '';
|
|
959
|
+
let dnsTarget = '';
|
|
960
|
+
let customDomainBound = false;
|
|
961
|
+
let fallbackLastUsedSuffix = nonNegativeInt(deploy.fallback_last_used_suffix);
|
|
962
|
+
const fallbackCandidateNames = [];
|
|
963
|
+
let shareSyncResult = buildShareProjectNoopResult(deploy);
|
|
964
|
+
let { result, attempts } = await runPagesDeployAttempts(command, baseEnv, proxyUrl, {
|
|
965
|
+
cwd: input.bundleDir,
|
|
966
|
+
runCommand
|
|
967
|
+
});
|
|
968
|
+
if (result.returncode !== 0
|
|
969
|
+
&& Boolean(deploy.auto_create_project_on_blocked ?? true)
|
|
970
|
+
&& isBlockedPagesError(result.stdout, result.stderr)) {
|
|
971
|
+
const client = options.cloudflareDeployClient ?? new CloudflareHttpClient(credentials, { fetch: options.fetch });
|
|
972
|
+
const existingNames = new Set((await client.listPagesProjects()).map((project) => clean(project.name)).filter(Boolean));
|
|
973
|
+
const fallbackBaseName = deriveFallbackProjectBaseName(getString(deploy, 'fallback_project_prefix'), projectName);
|
|
974
|
+
const fallback = generateFallbackProjectName(fallbackBaseName, existingNames, {
|
|
975
|
+
currentProjectName: projectName,
|
|
976
|
+
lastUsedSuffix: fallbackLastUsedSuffix
|
|
977
|
+
});
|
|
978
|
+
finalProjectName = fallback.projectName;
|
|
979
|
+
fallbackLastUsedSuffix = fallback.suffix;
|
|
980
|
+
fallbackCandidateNames.push(finalProjectName);
|
|
981
|
+
await client.createPagesProject(finalProjectName);
|
|
982
|
+
await client.copyPagesProjectConfig(projectName, finalProjectName, runtimeEnv);
|
|
983
|
+
if (customDomain) {
|
|
984
|
+
await ensureCustomDomainBound(client, finalProjectName, customDomain, { previousProjectName: projectName });
|
|
985
|
+
customDomainBound = true;
|
|
986
|
+
}
|
|
987
|
+
activeCommand = buildPagesDeployCommand(input.bundleDir, finalProjectName);
|
|
988
|
+
const fallbackDeploy = await runPagesDeployAttempts(activeCommand, baseEnv, proxyUrl, {
|
|
989
|
+
cwd: input.bundleDir,
|
|
990
|
+
runCommand
|
|
991
|
+
});
|
|
992
|
+
result = fallbackDeploy.result;
|
|
993
|
+
attempts = [
|
|
994
|
+
...attempts,
|
|
995
|
+
...fallbackDeploy.attempts.map((attempt) => ({
|
|
996
|
+
...attempt,
|
|
997
|
+
mode: `fallback-${String(attempt.mode)}`
|
|
998
|
+
}))
|
|
999
|
+
];
|
|
1000
|
+
pagesProjectUrl = derivePagesProjectUrl(finalProjectName);
|
|
1001
|
+
fallbackUsed = true;
|
|
1002
|
+
cleanupBlockedProject = projectName;
|
|
1003
|
+
}
|
|
1004
|
+
if (result.returncode === 0 && getString(deploy, 'share_project_name')) {
|
|
1005
|
+
const client = options.cloudflareDeployClient ?? new CloudflareHttpClient(credentials, { fetch: options.fetch });
|
|
1006
|
+
shareSyncResult = await syncShareProjectSub(deploy, {
|
|
1007
|
+
projectRoot: input.projectRoot,
|
|
1008
|
+
pagesProjectUrl,
|
|
1009
|
+
runtimeEnv,
|
|
1010
|
+
credentials,
|
|
1011
|
+
client,
|
|
1012
|
+
env,
|
|
1013
|
+
runCommand
|
|
1014
|
+
});
|
|
1015
|
+
}
|
|
1016
|
+
if (result.returncode === 0 && customDomain) {
|
|
1017
|
+
const client = options.cloudflareDeployClient ?? new CloudflareHttpClient(credentials, { fetch: options.fetch });
|
|
1018
|
+
if (!customDomainBound) {
|
|
1019
|
+
await ensureCustomDomainBound(client, finalProjectName, customDomain, {
|
|
1020
|
+
previousProjectName: fallbackUsed ? projectName : ''
|
|
1021
|
+
});
|
|
1022
|
+
}
|
|
1023
|
+
dnsTarget = pagesHostnameFromUrl(pagesProjectUrl) || `${finalProjectName}.pages.dev`;
|
|
1024
|
+
try {
|
|
1025
|
+
await client.upsertSubdomainCname(customDomain, dnsTarget, false);
|
|
1026
|
+
}
|
|
1027
|
+
catch (error) {
|
|
1028
|
+
dnsError = error instanceof Error ? error.message : String(error);
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
const shareSyncError = shareSyncResult.share_project_sync_ok === false
|
|
1032
|
+
? String(shareSyncResult.share_project_sync_error || 'share project sync failed')
|
|
1033
|
+
: '';
|
|
1034
|
+
const finalReturncode = (shareSyncError || dnsError) ? 1 : result.returncode;
|
|
1035
|
+
let finalStderr = result.stderr;
|
|
1036
|
+
if (dnsError) {
|
|
1037
|
+
finalStderr = `${finalStderr}\ncustom domain dns binding failed: ${dnsError}`.trim();
|
|
1038
|
+
}
|
|
1039
|
+
if (shareSyncError) {
|
|
1040
|
+
finalStderr = `${finalStderr}\nshare project sync failed: ${shareSyncError}`.trim();
|
|
1041
|
+
}
|
|
1042
|
+
return {
|
|
1043
|
+
command: activeCommand,
|
|
1044
|
+
returncode: finalReturncode,
|
|
1045
|
+
stdout: result.stdout,
|
|
1046
|
+
stderr: finalStderr,
|
|
1047
|
+
attempts,
|
|
1048
|
+
requested_project_name: projectName,
|
|
1049
|
+
fallback_candidate_names: fallbackCandidateNames,
|
|
1050
|
+
cleanup_blocked_project: cleanupBlockedProject === finalProjectName ? '' : cleanupBlockedProject,
|
|
1051
|
+
cleanup_deleted: false,
|
|
1052
|
+
cleanup_errors: [],
|
|
1053
|
+
project_name: finalProjectName,
|
|
1054
|
+
pages_project_url: pagesProjectUrl,
|
|
1055
|
+
custom_domain: customDomain,
|
|
1056
|
+
custom_domain_dns_name: customDomain,
|
|
1057
|
+
custom_domain_dns_target: dnsTarget,
|
|
1058
|
+
custom_domain_dns_proxied: false,
|
|
1059
|
+
custom_domain_dns_ok: Boolean(customDomain && dnsTarget && !dnsError),
|
|
1060
|
+
fallback_used: fallbackUsed,
|
|
1061
|
+
fallback_last_used_suffix: fallbackLastUsedSuffix,
|
|
1062
|
+
...shareSyncResult,
|
|
1063
|
+
bundle_dir: input.bundleDir,
|
|
1064
|
+
worker_entry: path.join(input.bundleDir, '_worker.js'),
|
|
1065
|
+
module_manifest_path: path.join(input.bundleDir, 'manifest.json')
|
|
1066
|
+
};
|
|
1067
|
+
}
|
|
1068
|
+
if (options.pythonDeploy) {
|
|
1069
|
+
return options.pythonDeploy(input);
|
|
1070
|
+
}
|
|
1071
|
+
return runPythonJsonHelper(PYTHON_DEPLOY_HELPER, input, options, 'deploy');
|
|
1072
|
+
}
|
|
1073
|
+
export async function verifyDeploymentWithBackend(input, options = {}) {
|
|
1074
|
+
if (selectPipelineStageBackend('verify', options.env ?? process.env) !== 'python') {
|
|
1075
|
+
const cwd = options.cwd ?? process.cwd();
|
|
1076
|
+
const env = mergeProjectEnv(cwd, options.env ?? process.env);
|
|
1077
|
+
const client = options.cloudflareClient ?? new CloudflareHttpClient(resolveCloudflareCredentials(input.deploy, env), { fetch: options.fetch });
|
|
1078
|
+
const target = mergeDeployVerificationTarget(input.deploy, input.deployment);
|
|
1079
|
+
const verification = await defaultVerifyDeployment(target, client);
|
|
1080
|
+
const result = { ...verification };
|
|
1081
|
+
if (isVerifySuccess(verification)) {
|
|
1082
|
+
Object.assign(result, await cleanupBlockedPagesProjects(target, input.deployment, client));
|
|
1083
|
+
}
|
|
1084
|
+
return result;
|
|
1085
|
+
}
|
|
1086
|
+
if (options.pythonVerify) {
|
|
1087
|
+
return options.pythonVerify(input);
|
|
1088
|
+
}
|
|
1089
|
+
return runPythonJsonHelper(PYTHON_VERIFY_HELPER, input, options, 'verify');
|
|
1090
|
+
}
|
|
1091
|
+
export function isVerifySuccess(verification) {
|
|
1092
|
+
const pagesDomainOk = verification.pages_domain_ok === undefined ? true : Boolean(verification.pages_domain_ok);
|
|
1093
|
+
if (!(pagesDomainOk && verification.secret_ok && verification.subscription_ok)) {
|
|
1094
|
+
return false;
|
|
1095
|
+
}
|
|
1096
|
+
if (verification.custom_domain_ok && verification.custom_domain_subscription_ok === false) {
|
|
1097
|
+
return false;
|
|
1098
|
+
}
|
|
1099
|
+
if (verification.custom_domain_ok && verification.custom_domain_dns_ok === false) {
|
|
1100
|
+
return false;
|
|
1101
|
+
}
|
|
1102
|
+
return true;
|
|
1103
|
+
}
|