@swimmingliu/autovpn 1.4.1 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +32 -0
- package/dist/cli/commands/index.js +5 -1
- package/dist/cli/main.js +30 -0
- package/dist/cli/native-commands.js +1 -1
- package/dist/cli/output.js +1 -0
- package/dist/doctor/checks.js +47 -23
- package/dist/pipeline/deploy.js +15 -7
- package/dist/runtime/managed-tools.js +269 -0
- package/dist/server/http.js +195 -0
- package/dist/server/options.js +34 -0
- package/dist/server/runtime.js +151 -0
- package/dist/server/web-adapter.js +87 -0
- package/dist/web/renderer/app.js +1332 -0
- package/dist/web/renderer/assets/VPN-logo.png +0 -0
- package/dist/web/renderer/assets/vpn-auto-logo-v2-minimal.svg +41 -0
- package/dist/web/renderer/i18n.js +137 -0
- package/dist/web/renderer/index.html +48 -0
- package/dist/web/renderer/state.js +81 -0
- package/dist/web/renderer/styles.css +2010 -0
- package/dist/web/renderer/views.js +1258 -0
- package/package.json +4 -4
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import http from 'node:http';
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { fileURLToPath, URL } from 'node:url';
|
|
5
|
+
import { renderWebAdapterScript } from './web-adapter.js';
|
|
6
|
+
import { redactText } from '../runtime/redaction.js';
|
|
7
|
+
const SENSITIVE_KEYS = new Set([
|
|
8
|
+
'key',
|
|
9
|
+
'token',
|
|
10
|
+
'api_token',
|
|
11
|
+
'cloudflare_api_token',
|
|
12
|
+
'cloudflare_global_key',
|
|
13
|
+
'subscription_url',
|
|
14
|
+
'verify_subscription_url',
|
|
15
|
+
'secret_query',
|
|
16
|
+
'pages_secret_admin',
|
|
17
|
+
'share_project_sub_value'
|
|
18
|
+
]);
|
|
19
|
+
function redactPayload(value, parentKey = '') {
|
|
20
|
+
if (typeof value === 'string') {
|
|
21
|
+
if (SENSITIVE_KEYS.has(parentKey.toLowerCase())) {
|
|
22
|
+
return value ? '<redacted>' : '';
|
|
23
|
+
}
|
|
24
|
+
return redactText(value);
|
|
25
|
+
}
|
|
26
|
+
if (value === null || ['number', 'boolean'].includes(typeof value)) {
|
|
27
|
+
return value;
|
|
28
|
+
}
|
|
29
|
+
if (Array.isArray(value)) {
|
|
30
|
+
return value.map((item) => redactPayload(item, parentKey));
|
|
31
|
+
}
|
|
32
|
+
if (typeof value === 'object') {
|
|
33
|
+
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, redactPayload(item, key)]));
|
|
34
|
+
}
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
function writeJson(response, statusCode, payload) {
|
|
38
|
+
response.writeHead(statusCode, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
39
|
+
response.end(JSON.stringify(redactPayload(payload)));
|
|
40
|
+
}
|
|
41
|
+
function contentType(filePath) {
|
|
42
|
+
if (filePath.endsWith('.html'))
|
|
43
|
+
return 'text/html; charset=utf-8';
|
|
44
|
+
if (filePath.endsWith('.js'))
|
|
45
|
+
return 'text/javascript; charset=utf-8';
|
|
46
|
+
if (filePath.endsWith('.css'))
|
|
47
|
+
return 'text/css; charset=utf-8';
|
|
48
|
+
if (filePath.endsWith('.svg'))
|
|
49
|
+
return 'image/svg+xml';
|
|
50
|
+
if (filePath.endsWith('.png'))
|
|
51
|
+
return 'image/png';
|
|
52
|
+
return 'application/octet-stream';
|
|
53
|
+
}
|
|
54
|
+
function rendererRoot() {
|
|
55
|
+
return path.resolve(fileURLToPath(new URL('../web/renderer', import.meta.url)));
|
|
56
|
+
}
|
|
57
|
+
async function writeStaticFile(response, statusCode, filePath, body) {
|
|
58
|
+
response.writeHead(statusCode, { 'Content-Type': contentType(filePath) });
|
|
59
|
+
response.end(body ?? await fs.readFile(filePath));
|
|
60
|
+
}
|
|
61
|
+
async function serveRendererIndex(response) {
|
|
62
|
+
const indexPath = path.join(rendererRoot(), 'index.html');
|
|
63
|
+
const html = await fs.readFile(indexPath, 'utf8');
|
|
64
|
+
const injected = html.replace('<script type="module" src="./app.js"></script>', '<script src="/web-adapter.js"></script>\n <script type="module" src="./app.js"></script>');
|
|
65
|
+
await writeStaticFile(response, 200, indexPath, injected);
|
|
66
|
+
}
|
|
67
|
+
async function serveRendererAsset(url, response) {
|
|
68
|
+
if (url.pathname === '/') {
|
|
69
|
+
await serveRendererIndex(response);
|
|
70
|
+
return true;
|
|
71
|
+
}
|
|
72
|
+
if (url.pathname === '/web-adapter.js') {
|
|
73
|
+
await writeStaticFile(response, 200, 'web-adapter.js', renderWebAdapterScript());
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
const decodedPath = decodeURIComponent(url.pathname.replace(/^\/+/, ''));
|
|
77
|
+
if (!decodedPath || decodedPath.includes('..') || path.isAbsolute(decodedPath)) {
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
const filePath = path.join(rendererRoot(), decodedPath);
|
|
81
|
+
const relative = path.relative(rendererRoot(), filePath);
|
|
82
|
+
if (relative.startsWith('..') || path.isAbsolute(relative)) {
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
try {
|
|
86
|
+
await writeStaticFile(response, 200, filePath);
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
function isAuthorized(request, url, auth) {
|
|
94
|
+
if (!auth.enabled) {
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
const authorization = request.headers.authorization ?? '';
|
|
98
|
+
if (authorization === `Bearer ${auth.token}`) {
|
|
99
|
+
return true;
|
|
100
|
+
}
|
|
101
|
+
return url.searchParams.get('token') === auth.token;
|
|
102
|
+
}
|
|
103
|
+
async function readJsonBody(request) {
|
|
104
|
+
const chunks = [];
|
|
105
|
+
let size = 0;
|
|
106
|
+
for await (const chunk of request) {
|
|
107
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk));
|
|
108
|
+
size += buffer.length;
|
|
109
|
+
if (size > 1024 * 1024) {
|
|
110
|
+
throw new Error('request_body_too_large');
|
|
111
|
+
}
|
|
112
|
+
chunks.push(buffer);
|
|
113
|
+
}
|
|
114
|
+
const text = Buffer.concat(chunks).toString('utf8').trim();
|
|
115
|
+
if (!text) {
|
|
116
|
+
return {};
|
|
117
|
+
}
|
|
118
|
+
const parsed = JSON.parse(text);
|
|
119
|
+
return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed) ? parsed : {};
|
|
120
|
+
}
|
|
121
|
+
export async function createAutoVpnServer(options) {
|
|
122
|
+
const server = http.createServer(async (request, response) => {
|
|
123
|
+
const url = new URL(request.url ?? '/', `http://${request.headers.host ?? `${options.host}:${options.port}`}`);
|
|
124
|
+
if (url.pathname.startsWith('/api/') && !isAuthorized(request, url, options.auth)) {
|
|
125
|
+
writeJson(response, 401, { ok: false, error: 'unauthorized' });
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
try {
|
|
129
|
+
if (request.method === 'GET' && url.pathname === '/api/health') {
|
|
130
|
+
writeJson(response, 200, {
|
|
131
|
+
status: 'ok',
|
|
132
|
+
version: options.version ?? '',
|
|
133
|
+
backend: options.backendKind ?? '',
|
|
134
|
+
projectRoot: options.projectRoot
|
|
135
|
+
});
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
if (request.method === 'GET' && url.pathname === '/api/state') {
|
|
139
|
+
writeJson(response, 200, await options.runtime.loadState());
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
if (request.method === 'GET' && url.pathname === '/api/events') {
|
|
143
|
+
response.writeHead(200, {
|
|
144
|
+
'Content-Type': 'text/event-stream; charset=utf-8',
|
|
145
|
+
'Cache-Control': 'no-cache, no-transform',
|
|
146
|
+
Connection: 'keep-alive'
|
|
147
|
+
});
|
|
148
|
+
response.flushHeaders();
|
|
149
|
+
const unsubscribe = options.runtime.subscribe?.((event) => {
|
|
150
|
+
response.write(`data: ${JSON.stringify(redactPayload(event))}\n\n`);
|
|
151
|
+
}) ?? (() => { });
|
|
152
|
+
request.on('close', unsubscribe);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
if (request.method === 'POST' && url.pathname === '/api/runs') {
|
|
156
|
+
const body = await readJsonBody(request);
|
|
157
|
+
writeJson(response, 202, await options.runtime.startRun?.({
|
|
158
|
+
skipDeploy: Boolean(body.skipDeploy),
|
|
159
|
+
skipVerify: Boolean(body.skipVerify),
|
|
160
|
+
resumeLatest: Boolean(body.resumeLatest)
|
|
161
|
+
}) ?? { ok: false, error: 'run_unavailable' });
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
if (request.method === 'POST' && url.pathname === '/api/runs/current/stop') {
|
|
165
|
+
writeJson(response, 200, await options.runtime.stopRun?.() ?? { ok: true, requested: false });
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
if (request.method === 'GET' && !url.pathname.startsWith('/api/')) {
|
|
169
|
+
if (await serveRendererAsset(url, response)) {
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
writeJson(response, 404, { ok: false, error: 'not_found' });
|
|
174
|
+
}
|
|
175
|
+
catch (error) {
|
|
176
|
+
writeJson(response, 500, { ok: false, error: error instanceof Error ? error.message : String(error) });
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
await new Promise((resolve) => server.listen(options.port, options.host, resolve));
|
|
180
|
+
const address = server.address();
|
|
181
|
+
const port = typeof address === 'object' && address ? address.port : options.port;
|
|
182
|
+
const originHost = options.host === '0.0.0.0' ? '127.0.0.1' : options.host;
|
|
183
|
+
return {
|
|
184
|
+
origin: `http://${originHost}:${port}`,
|
|
185
|
+
close: () => new Promise((resolve, reject) => {
|
|
186
|
+
server.close((error) => {
|
|
187
|
+
if (error) {
|
|
188
|
+
reject(error);
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
resolve();
|
|
192
|
+
});
|
|
193
|
+
})
|
|
194
|
+
};
|
|
195
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { CliUsageError } from '../cli/errors.js';
|
|
4
|
+
import { readOptionValue, resolveProjectRoot } from '../runtime/paths.js';
|
|
5
|
+
function hasFlag(argv, flag) {
|
|
6
|
+
return argv.includes(flag);
|
|
7
|
+
}
|
|
8
|
+
function isLoopbackHost(host) {
|
|
9
|
+
return host === '127.0.0.1' || host === 'localhost' || host === '::1';
|
|
10
|
+
}
|
|
11
|
+
function defaultRandomToken() {
|
|
12
|
+
return crypto.randomBytes(18).toString('base64url');
|
|
13
|
+
}
|
|
14
|
+
export function parseServeOptions(argv, context) {
|
|
15
|
+
const host = readOptionValue(argv, '--host') ?? context.env.AUTOVPN_SERVER_HOST ?? '127.0.0.1';
|
|
16
|
+
const portText = readOptionValue(argv, '--port') ?? context.env.AUTOVPN_SERVER_PORT ?? '8765';
|
|
17
|
+
const port = Number(portText);
|
|
18
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
19
|
+
throw new CliUsageError('serve --port must be an integer from 1 to 65535');
|
|
20
|
+
}
|
|
21
|
+
const token = readOptionValue(argv, '--token') ?? context.env.AUTOVPN_SERVER_TOKEN ?? '';
|
|
22
|
+
const noAuth = hasFlag(argv, '--no-auth');
|
|
23
|
+
if (!isLoopbackHost(host) && !token && !noAuth) {
|
|
24
|
+
throw new CliUsageError('serve requires --token or --no-auth when binding to non-loopback host');
|
|
25
|
+
}
|
|
26
|
+
return {
|
|
27
|
+
host,
|
|
28
|
+
port,
|
|
29
|
+
projectRoot: path.resolve(resolveProjectRoot(argv, context.cwd)),
|
|
30
|
+
auth: noAuth
|
|
31
|
+
? { enabled: false, token: '' }
|
|
32
|
+
: { enabled: true, token: token || (context.randomToken ?? defaultRandomToken)() }
|
|
33
|
+
};
|
|
34
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { artifactLatest, artifactList } from '../artifacts/list.js';
|
|
2
|
+
import { previewArtifact } from '../artifacts/preview.js';
|
|
3
|
+
import { profilePayload } from '../config/profile.js';
|
|
4
|
+
import { startDetachedRun as defaultStartDetachedRun, stopManagedJob as defaultStopManagedJob } from '../jobs/commands.js';
|
|
5
|
+
import { followLog as defaultFollowLog } from '../jobs/logs.js';
|
|
6
|
+
const DEPLOY_SECRET_KEYS = new Set([
|
|
7
|
+
'cloudflare_api_token',
|
|
8
|
+
'cloudflare_global_key',
|
|
9
|
+
'pages_secret_admin',
|
|
10
|
+
'subscription_url',
|
|
11
|
+
'verify_subscription_url',
|
|
12
|
+
'secret_query',
|
|
13
|
+
'share_project_sub_value'
|
|
14
|
+
]);
|
|
15
|
+
function redactIfSet(value) {
|
|
16
|
+
return String(value ?? '').trim() ? '<redacted>' : '';
|
|
17
|
+
}
|
|
18
|
+
export function sanitizeProfileForServer(profile) {
|
|
19
|
+
const safe = structuredClone(profile ?? {});
|
|
20
|
+
for (const source of Object.values((safe.sources ?? {}))) {
|
|
21
|
+
if (source && typeof source === 'object') {
|
|
22
|
+
source.url = redactIfSet(source.url);
|
|
23
|
+
source.key = redactIfSet(source.key);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
for (const [key, value] of Object.entries((safe.deploy ?? {}))) {
|
|
27
|
+
if (DEPLOY_SECRET_KEYS.has(key)) {
|
|
28
|
+
safe.deploy[key] = redactIfSet(value);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return safe;
|
|
32
|
+
}
|
|
33
|
+
function normalizeLatestArtifact(projectRoot, env) {
|
|
34
|
+
const latest = artifactLatest(projectRoot, env);
|
|
35
|
+
if (!latest.ok || !latest.artifact_dir) {
|
|
36
|
+
return undefined;
|
|
37
|
+
}
|
|
38
|
+
const preview = previewArtifact(String(latest.artifact_dir));
|
|
39
|
+
return {
|
|
40
|
+
...latest,
|
|
41
|
+
...preview,
|
|
42
|
+
outputFiles: (preview.files ?? []),
|
|
43
|
+
nodeRows: []
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
function parseJsonLine(line) {
|
|
47
|
+
const trimmed = line.trim();
|
|
48
|
+
if (!trimmed) {
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
try {
|
|
52
|
+
return JSON.parse(trimmed);
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return { type: 'log', message: trimmed };
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
export function createServerRuntime(options) {
|
|
59
|
+
let runState = 'idle';
|
|
60
|
+
let activeJobId = '';
|
|
61
|
+
let unsubscribeLogs;
|
|
62
|
+
const subscribers = new Set();
|
|
63
|
+
const startDetachedRun = options.startDetachedRun ?? defaultStartDetachedRun;
|
|
64
|
+
const stopManagedJob = options.stopManagedJob ?? defaultStopManagedJob;
|
|
65
|
+
const followLog = options.followLog ?? defaultFollowLog;
|
|
66
|
+
function publish(event) {
|
|
67
|
+
for (const subscriber of subscribers) {
|
|
68
|
+
subscriber(event);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
function followJob(jobId) {
|
|
72
|
+
let cancelled = false;
|
|
73
|
+
unsubscribeLogs?.();
|
|
74
|
+
unsubscribeLogs = () => {
|
|
75
|
+
cancelled = true;
|
|
76
|
+
};
|
|
77
|
+
queueMicrotask(async () => {
|
|
78
|
+
try {
|
|
79
|
+
for await (const chunk of followLog(options.projectRoot, jobId, ['logs', '--format', 'jsonl', '--follow'], { env: options.env })) {
|
|
80
|
+
if (cancelled) {
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
for (const line of String(chunk).split(/\r?\n/)) {
|
|
84
|
+
const event = parseJsonLine(line);
|
|
85
|
+
if (event) {
|
|
86
|
+
publish(event);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
if (!cancelled && runState !== 'stopping') {
|
|
91
|
+
runState = 'success';
|
|
92
|
+
publish({ type: 'server_state', run_state: runState });
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
catch (error) {
|
|
96
|
+
if (!cancelled) {
|
|
97
|
+
runState = 'failed';
|
|
98
|
+
publish({ type: 'error', message: error instanceof Error ? error.message : String(error) });
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
return {
|
|
104
|
+
async loadState() {
|
|
105
|
+
const artifact = normalizeLatestArtifact(options.projectRoot, options.env ?? process.env);
|
|
106
|
+
const retries = artifactList(options.projectRoot, options.env ?? process.env);
|
|
107
|
+
return {
|
|
108
|
+
profile: sanitizeProfileForServer(profilePayload(options.projectRoot, options.env)),
|
|
109
|
+
runState,
|
|
110
|
+
artifact,
|
|
111
|
+
retryArtifacts: Array.isArray(retries.items) ? retries.items : [],
|
|
112
|
+
deployment: (artifact?.deployment ?? {})
|
|
113
|
+
};
|
|
114
|
+
},
|
|
115
|
+
async startRun(runOptions = {}) {
|
|
116
|
+
if (runState === 'running' || runState === 'stopping') {
|
|
117
|
+
return { ok: false, error: 'run_already_active' };
|
|
118
|
+
}
|
|
119
|
+
runState = 'running';
|
|
120
|
+
const job = await startDetachedRun({
|
|
121
|
+
projectRoot: options.projectRoot,
|
|
122
|
+
skipDeploy: Boolean(runOptions.skipDeploy),
|
|
123
|
+
skipVerify: Boolean(runOptions.skipVerify),
|
|
124
|
+
resumeLatest: Boolean(runOptions.resumeLatest),
|
|
125
|
+
outputFormat: 'jsonl'
|
|
126
|
+
}, {
|
|
127
|
+
env: options.env,
|
|
128
|
+
cwd: options.projectRoot
|
|
129
|
+
});
|
|
130
|
+
activeJobId = String(job.job_id ?? '');
|
|
131
|
+
followJob(activeJobId);
|
|
132
|
+
return { ok: true, runId: activeJobId, job_id: activeJobId, status: job.status ?? 'running' };
|
|
133
|
+
},
|
|
134
|
+
async stopRun() {
|
|
135
|
+
if (runState !== 'running' || !activeJobId) {
|
|
136
|
+
return { ok: true, requested: false };
|
|
137
|
+
}
|
|
138
|
+
runState = 'stopping';
|
|
139
|
+
publish({ type: 'server_state', run_state: runState });
|
|
140
|
+
const stopped = await stopManagedJob(options.projectRoot, activeJobId, { env: options.env });
|
|
141
|
+
unsubscribeLogs?.();
|
|
142
|
+
runState = 'idle';
|
|
143
|
+
publish({ type: 'server_state', run_state: 'idle' });
|
|
144
|
+
return { ok: true, requested: true, job_id: stopped.job_id ?? activeJobId, status: stopped.status ?? 'stopped' };
|
|
145
|
+
},
|
|
146
|
+
subscribe(handler) {
|
|
147
|
+
subscribers.add(handler);
|
|
148
|
+
return () => subscribers.delete(handler);
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
export function renderWebAdapterScript() {
|
|
2
|
+
return `
|
|
3
|
+
(() => {
|
|
4
|
+
const params = new URLSearchParams(window.location.search);
|
|
5
|
+
const token = params.get('token') || window.localStorage.getItem('autovpn.server.token') || '';
|
|
6
|
+
if (token) {
|
|
7
|
+
window.localStorage.setItem('autovpn.server.token', token);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function withToken(path) {
|
|
11
|
+
if (!token) return path;
|
|
12
|
+
const url = new URL(path, window.location.origin);
|
|
13
|
+
url.searchParams.set('token', token);
|
|
14
|
+
return url.pathname + url.search;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function request(path, options = {}) {
|
|
18
|
+
const headers = { ...(options.headers || {}) };
|
|
19
|
+
if (token) headers.Authorization = 'Bearer ' + token;
|
|
20
|
+
if (options.body && !headers['Content-Type']) headers['Content-Type'] = 'application/json';
|
|
21
|
+
const response = await fetch(path, { ...options, headers });
|
|
22
|
+
const payload = await response.json().catch(() => ({}));
|
|
23
|
+
if (!response.ok) {
|
|
24
|
+
throw new Error(payload.error || 'request_failed');
|
|
25
|
+
}
|
|
26
|
+
return payload;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function state() {
|
|
30
|
+
return request('/api/state');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
window.vpnAutomation = {
|
|
34
|
+
loadProfile: async () => {
|
|
35
|
+
const payload = await state();
|
|
36
|
+
return payload.profile || {};
|
|
37
|
+
},
|
|
38
|
+
saveProfile: async () => ({ ok: false, error: 'profile_save_unavailable_in_server_mode' }),
|
|
39
|
+
runPipeline: async (options = {}) => request('/api/runs', {
|
|
40
|
+
method: 'POST',
|
|
41
|
+
body: JSON.stringify({
|
|
42
|
+
skipDeploy: Boolean(options.skipDeploy),
|
|
43
|
+
skipVerify: Boolean(options.skipVerify),
|
|
44
|
+
resumeLatest: Boolean(options.resumeLatest)
|
|
45
|
+
})
|
|
46
|
+
}),
|
|
47
|
+
stopPipeline: async () => request('/api/runs/current/stop', { method: 'POST' }),
|
|
48
|
+
openUrl: async (url) => {
|
|
49
|
+
if (url) window.open(url, '_blank', 'noopener,noreferrer');
|
|
50
|
+
return { ok: true };
|
|
51
|
+
},
|
|
52
|
+
openPath: async () => ({ ok: false, error: 'open_path_unavailable_in_browser' }),
|
|
53
|
+
generateQr: async () => ({ ok: false, dataUrl: '' }),
|
|
54
|
+
previewArtifact: async () => {
|
|
55
|
+
const payload = await state();
|
|
56
|
+
return payload.artifact ? { ok: true, ...payload.artifact } : { ok: false };
|
|
57
|
+
},
|
|
58
|
+
latestArtifact: async () => {
|
|
59
|
+
const payload = await state();
|
|
60
|
+
return payload.artifact ? { ok: true, ...payload.artifact } : { ok: false };
|
|
61
|
+
},
|
|
62
|
+
artifactList: async () => {
|
|
63
|
+
const payload = await state();
|
|
64
|
+
return { ok: true, items: payload.retryArtifacts || [] };
|
|
65
|
+
},
|
|
66
|
+
retryStage: async () => ({ ok: false, error: 'retry_stage_unavailable_in_server_mode' }),
|
|
67
|
+
copyText: async (text) => {
|
|
68
|
+
await navigator.clipboard.writeText(String(text || ''));
|
|
69
|
+
return { ok: true };
|
|
70
|
+
},
|
|
71
|
+
exportLogs: async () => ({ ok: false, error: 'export_logs_unavailable_in_browser' }),
|
|
72
|
+
onPipelineEvent: (callback) => {
|
|
73
|
+
const events = new EventSource(withToken('/api/events'));
|
|
74
|
+
events.onmessage = (event) => {
|
|
75
|
+
try {
|
|
76
|
+
callback(JSON.parse(event.data));
|
|
77
|
+
} catch {
|
|
78
|
+
callback({ type: 'log', message: event.data });
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
events.onerror = () => {};
|
|
82
|
+
return () => events.close();
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
})();
|
|
86
|
+
`;
|
|
87
|
+
}
|