@swimmingliu/autovpn 1.4.2 → 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 CHANGED
@@ -24,6 +24,33 @@ autovpn run --project-root . --skip-deploy --skip-verify --output jsonl
24
24
  autovpn artifacts latest --project-root .
25
25
  ```
26
26
 
27
+ ## Server Web UI
28
+
29
+ `autovpn serve` starts a single-user HTTP service that serves the AutoVPN Web UI
30
+ from the same renderer used by the desktop app.
31
+
32
+ ```bash
33
+ autovpn serve --project-root .
34
+ ```
35
+
36
+ Defaults:
37
+
38
+ - host: `127.0.0.1`
39
+ - port: `8765`
40
+ - auth: token-protected
41
+
42
+ For server deployment, bind explicitly and provide a token:
43
+
44
+ ```bash
45
+ export AUTOVPN_SERVER_TOKEN="$(openssl rand -base64 24)"
46
+ autovpn serve --project-root /opt/autovpn --host 0.0.0.0 --port 8765 \
47
+ --token "$AUTOVPN_SERVER_TOKEN"
48
+ ```
49
+
50
+ The server refuses non-loopback binds unless `--token` or `--no-auth` is
51
+ provided. Prefer SSH forwarding, a private reverse proxy, or your own HTTPS
52
+ terminator for internet-facing deployments.
53
+
27
54
  ## Runtime Shape
28
55
 
29
56
  The CLI is currently Node-first with explicit Python rollback:
@@ -47,6 +74,8 @@ Current Node backend notes:
47
74
  - Deploy and verify can be rolled back with `AUTOVPN_STAGE_BACKEND_DEPLOY=python` and `AUTOVPN_STAGE_BACKEND_VERIFY=python`.
48
75
  - `AUTOVPN_NO_PYTHON=1` disables implicit Python backend resolution and default Python runtime stage fallback. Use it as a v3 readiness gate. Empty offline runs now complete in Node, and Node has direct HTTP speedtest and availability runtimes. Node also has opt-in Mihomo-backed paths: set `AUTOVPN_SPEEDTEST_RUNTIME=mihomo` for controller delay probing and candidate downloads through the local Mihomo proxy, and set `AUTOVPN_AVAILABILITY_RUNTIME=mihomo` to check provider availability through the same per-node proxy runtime.
49
76
  - Project `.env` is loaded before resolving profile and artifact paths. Explicit process environment values still win over `.env`.
77
+ - `autovpn serve` is Node-native and exposes the browser UI plus `/api/health`,
78
+ `/api/state`, `/api/runs`, `/api/runs/current/stop`, and `/api/events`.
50
79
 
51
80
  Fallback flags for migrated commands:
52
81
 
@@ -96,6 +125,9 @@ For Python-backed commands, the wrapper forwards argv, stdin, stdout, stderr, an
96
125
  - `AUTOVPN_DOCTOR_BACKEND`
97
126
  - `AUTOVPN_PROFILE_BACKEND`
98
127
  - `AUTOVPN_ARTIFACTS_BACKEND`
128
+ - `AUTOVPN_SERVER_HOST`
129
+ - `AUTOVPN_SERVER_PORT`
130
+ - `AUTOVPN_SERVER_TOKEN`
99
131
  - `VPN_AUTOMATION_RUNTIME_ROOT`
100
132
  - `VPN_AUTOMATION_PROFILE_PATH`
101
133
  - `VPN_AUTOMATION_ARTIFACTS_ROOT`
@@ -10,7 +10,8 @@ const TOP_LEVEL_COMMANDS = new Set([
10
10
  'jobs',
11
11
  'status',
12
12
  'logs',
13
- 'stop'
13
+ 'stop',
14
+ 'serve'
14
15
  ]);
15
16
  const JOBS_SUBCOMMANDS = new Set(['list', 'status', 'logs', 'stop', 'resume', 'retry']);
16
17
  const RESUME_SUBCOMMANDS = new Set(['pipeline', 'speedtest']);
@@ -82,6 +83,9 @@ export function validateCommand(argv) {
82
83
  validateChoice('doctor', '--output', readOptionValue(argv, '--output'), ['human', 'json']);
83
84
  return;
84
85
  }
86
+ if (command === 'serve') {
87
+ return;
88
+ }
85
89
  if (command === 'profile') {
86
90
  const subcommand = argv[1] ?? '';
87
91
  if (!PROFILE_SUBCOMMANDS.has(subcommand)) {
package/dist/cli/main.js CHANGED
@@ -9,6 +9,9 @@ import { selectBackend } from '../backend/select-backend.js';
9
9
  import { loadJob } from '../jobs/read.js';
10
10
  import { readOptionValue, resolveProjectRoot } from '../runtime/paths.js';
11
11
  import { redactText } from '../runtime/redaction.js';
12
+ import { createAutoVpnServer } from '../server/http.js';
13
+ import { parseServeOptions } from '../server/options.js';
14
+ import { createServerRuntime } from '../server/runtime.js';
12
15
  async function defaultReadPackageVersion() {
13
16
  // @ts-expect-error The Phase 1 runner is plain ESM JavaScript.
14
17
  const runner = await import('../../lib/runner.mjs');
@@ -190,6 +193,33 @@ export async function runCliShell(argv, options = {}) {
190
193
  validateCommand(normalizedArgv);
191
194
  const createBackend = options.createBackend ?? defaultCreateBackend;
192
195
  const backend = createBackend({ env, cwd, runForwarder });
196
+ if (normalizedArgv[0] === 'serve') {
197
+ const serveOptions = parseServeOptions(normalizedArgv, { cwd, env });
198
+ const serverFactory = options.createServer ?? createAutoVpnServer;
199
+ const runtime = createServerRuntime({
200
+ projectRoot: serveOptions.projectRoot,
201
+ env
202
+ });
203
+ const server = await serverFactory({
204
+ ...serveOptions,
205
+ runtime,
206
+ version: await resolvePackageVersion(options),
207
+ backendKind: String(backend.kind ?? '')
208
+ });
209
+ io.writeStdout(`AutoVPN server listening on ${server.origin}\n`);
210
+ if (serveOptions.auth.enabled) {
211
+ io.writeStdout(`Open ${server.origin}/?token=${encodeURIComponent(serveOptions.auth.token)}\n`);
212
+ }
213
+ else {
214
+ io.writeStderr('autovpn: warning: server authentication is disabled\n');
215
+ }
216
+ if (options.serveExitAfterStart) {
217
+ await server.close();
218
+ return 0;
219
+ }
220
+ await new Promise(() => { });
221
+ return 0;
222
+ }
193
223
  let pythonFallbackBackend;
194
224
  const runExplicitPythonFallback = (fallbackArgv) => {
195
225
  pythonFallbackBackend ??= createBackend({
@@ -27,5 +27,6 @@ Commands:
27
27
  status
28
28
  logs
29
29
  stop
30
+ serve
30
31
  `;
31
32
  }
@@ -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
+ }