@swimmingliu/autovpn 1.6.0 → 1.6.1

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.
@@ -1,8 +1,12 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
1
3
  import { artifactLatest, artifactList } from '../artifacts/list.js';
2
4
  import { previewArtifact } from '../artifacts/preview.js';
3
5
  import { profilePayload, saveProfilePayload } from '../config/profile.js';
4
6
  import { startDetachedRetry as defaultStartDetachedRetry, startDetachedRun as defaultStartDetachedRun, stopManagedJob as defaultStopManagedJob } from '../jobs/commands.js';
5
7
  import { followLog as defaultFollowLog } from '../jobs/logs.js';
8
+ import { latestJobId, loadJob } from '../jobs/read.js';
9
+ import { resolveArtifactsRoot, resolveProfilePath } from '../runtime/paths.js';
6
10
  const DEPLOY_SECRET_KEYS = new Set([
7
11
  'cloudflare_api_token',
8
12
  'cloudflare_global_key',
@@ -99,10 +103,172 @@ function runEnv(options) {
99
103
  VPN_AUTOMATION_UPSTREAM_PROXY: proxyUrl
100
104
  };
101
105
  }
106
+ function parsePositiveNumber(value, fallback) {
107
+ const parsed = Number.parseFloat(String(value ?? ''));
108
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
109
+ }
110
+ function terminalStateTtlMs(env) {
111
+ return parsePositiveNumber(env.AUTOVPN_SERVER_TERMINAL_STATE_TTL_SECONDS, 600) * 1000;
112
+ }
113
+ function historyRetentionMs(env) {
114
+ const raw = String(env.AUTOVPN_SERVER_HISTORY_RETENTION_DAYS ?? '').trim();
115
+ if (raw) {
116
+ const parsed = Number.parseFloat(raw);
117
+ if (Number.isFinite(parsed) && parsed <= 0)
118
+ return 0;
119
+ if (Number.isFinite(parsed) && parsed > 0)
120
+ return parsed * 24 * 60 * 60 * 1000;
121
+ }
122
+ return 7 * 24 * 60 * 60 * 1000;
123
+ }
124
+ function parseTimeMs(value) {
125
+ const time = Date.parse(String(value ?? '').replace(/\+00:00$/, 'Z'));
126
+ return Number.isFinite(time) ? time : 0;
127
+ }
128
+ function jobTerminalAt(job) {
129
+ return parseTimeMs(job.finished_at) || parseTimeMs(job.created_at) || parseTimeMs(job.updated_at);
130
+ }
131
+ function latestJob(projectRoot, env) {
132
+ try {
133
+ return loadJob(projectRoot, latestJobId(projectRoot, env), env);
134
+ }
135
+ catch {
136
+ return undefined;
137
+ }
138
+ }
139
+ function terminalRunStateFromLatestJob(job, nowMs, ttlMs) {
140
+ const status = String(job?.status ?? '');
141
+ if (status === 'running' || status === 'stopping')
142
+ return status;
143
+ if (!['success', 'failed', 'stopped'].includes(status))
144
+ return undefined;
145
+ const finishedAt = jobTerminalAt(job ?? {});
146
+ if (!finishedAt || nowMs - finishedAt > ttlMs)
147
+ return undefined;
148
+ return status === 'stopped' ? 'idle' : status;
149
+ }
150
+ function terminalRunStateFromArtifact(artifact, nowMs, ttlMs) {
151
+ const status = String(artifact?.run_status ?? '');
152
+ if (!['success', 'failed'].includes(status))
153
+ return undefined;
154
+ const artifactDir = String(artifact?.artifact_dir ?? '');
155
+ let updatedAt = 0;
156
+ try {
157
+ updatedAt = artifactDir ? fs.statSync(artifactDir).mtimeMs : 0;
158
+ }
159
+ catch {
160
+ updatedAt = 0;
161
+ }
162
+ if (!updatedAt || nowMs - updatedAt > ttlMs)
163
+ return undefined;
164
+ return status;
165
+ }
166
+ function jobsRoot(projectRoot, env) {
167
+ return path.join(path.dirname(resolveProfilePath(projectRoot, env)), 'jobs');
168
+ }
169
+ function safeRmDir(dirPath) {
170
+ try {
171
+ fs.rmSync(dirPath, { recursive: true, force: true });
172
+ }
173
+ catch {
174
+ // Cleanup is best-effort; serve startup and state loading must continue.
175
+ }
176
+ }
177
+ function pruneArtifacts(projectRoot, env, cutoffMs, activeArtifactDirs) {
178
+ const root = resolveArtifactsRoot(projectRoot, env);
179
+ if (!fs.existsSync(root))
180
+ return;
181
+ for (const name of fs.readdirSync(root)) {
182
+ const artifactDir = path.join(root, name);
183
+ let stat;
184
+ try {
185
+ stat = fs.statSync(artifactDir);
186
+ }
187
+ catch {
188
+ continue;
189
+ }
190
+ if (!stat.isDirectory() || activeArtifactDirs.has(path.resolve(artifactDir)))
191
+ continue;
192
+ if (stat.mtimeMs < cutoffMs) {
193
+ safeRmDir(artifactDir);
194
+ }
195
+ }
196
+ }
197
+ function pruneJobs(projectRoot, env, cutoffMs) {
198
+ const root = jobsRoot(projectRoot, env);
199
+ const activeArtifactDirs = new Set();
200
+ const indexPath = path.join(root, 'index.json');
201
+ if (!fs.existsSync(indexPath))
202
+ return activeArtifactDirs;
203
+ let index;
204
+ try {
205
+ index = JSON.parse(fs.readFileSync(indexPath, 'utf8'));
206
+ }
207
+ catch {
208
+ return activeArtifactDirs;
209
+ }
210
+ const kept = [];
211
+ for (const item of (index.jobs ?? [])) {
212
+ const jobId = String(item.job_id ?? '');
213
+ let job;
214
+ try {
215
+ job = loadJob(projectRoot, jobId, env);
216
+ }
217
+ catch {
218
+ continue;
219
+ }
220
+ const status = String(job.status ?? '');
221
+ const artifactDir = String(job.artifact_dir ?? '');
222
+ if (artifactDir && ['running', 'stopping'].includes(status)) {
223
+ activeArtifactDirs.add(path.resolve(artifactDir));
224
+ }
225
+ const keep = ['running', 'stopping'].includes(status) || (jobTerminalAt(job) || parseTimeMs(job.created_at)) >= cutoffMs;
226
+ if (keep) {
227
+ kept.push({
228
+ job_id: job.job_id,
229
+ status: job.status,
230
+ kind: job.kind,
231
+ created_at: job.created_at,
232
+ job_file: job.job_file
233
+ });
234
+ }
235
+ else {
236
+ safeRmDir(path.dirname(String(job.job_file ?? '')));
237
+ }
238
+ }
239
+ const latestId = kept.some((item) => item.job_id === index.latest_job_id)
240
+ ? String(index.latest_job_id ?? '')
241
+ : String(kept.at(-1)?.job_id ?? '');
242
+ try {
243
+ fs.mkdirSync(path.dirname(indexPath), { recursive: true });
244
+ fs.writeFileSync(indexPath, `${JSON.stringify({ schema_version: 1, latest_job_id: latestId, jobs: kept }, null, 2)}\n`, 'utf8');
245
+ }
246
+ catch {
247
+ // Best-effort cleanup.
248
+ }
249
+ return activeArtifactDirs;
250
+ }
251
+ function createHistoryPruner(options) {
252
+ let lastPrunedAt = 0;
253
+ return () => {
254
+ const env = options.env ?? process.env;
255
+ const retention = historyRetentionMs(env);
256
+ if (retention <= 0)
257
+ return;
258
+ const nowMs = (options.now ?? (() => new Date()))().getTime();
259
+ if (lastPrunedAt && nowMs - lastPrunedAt < 24 * 60 * 60 * 1000)
260
+ return;
261
+ lastPrunedAt = nowMs;
262
+ const cutoffMs = nowMs - retention;
263
+ const activeArtifactDirs = pruneJobs(options.projectRoot, env, cutoffMs);
264
+ pruneArtifacts(options.projectRoot, env, cutoffMs, activeArtifactDirs);
265
+ };
266
+ }
102
267
  export function createServerRuntime(options) {
103
268
  let runState = 'idle';
104
269
  let activeJobId = '';
105
270
  let unsubscribeLogs;
271
+ const pruneHistory = createHistoryPruner(options);
106
272
  const subscribers = new Set();
107
273
  const startDetachedRun = options.startDetachedRun ?? defaultStartDetachedRun;
108
274
  const startDetachedRetry = options.startDetachedRetry ?? defaultStartDetachedRetry;
@@ -149,9 +315,34 @@ export function createServerRuntime(options) {
149
315
  }
150
316
  });
151
317
  }
318
+ function restoreRunState(artifact) {
319
+ if (runState !== 'idle') {
320
+ return;
321
+ }
322
+ const env = options.env ?? process.env;
323
+ const job = latestJob(options.projectRoot, env);
324
+ if (job && ['running', 'stopping'].includes(String(job.status ?? ''))) {
325
+ runState = String(job.status);
326
+ activeJobId = String(job.job_id ?? '');
327
+ if (activeJobId && !unsubscribeLogs) {
328
+ followJob(activeJobId);
329
+ }
330
+ return;
331
+ }
332
+ const nowMs = (options.now ?? (() => new Date()))().getTime();
333
+ const ttlMs = terminalStateTtlMs(env);
334
+ runState = terminalRunStateFromLatestJob(job, nowMs, ttlMs)
335
+ ?? terminalRunStateFromArtifact(artifact, nowMs, ttlMs)
336
+ ?? 'idle';
337
+ if (runState !== 'running' && runState !== 'stopping') {
338
+ activeJobId = '';
339
+ }
340
+ }
152
341
  return {
153
342
  async loadState() {
343
+ pruneHistory();
154
344
  const artifact = normalizeLatestArtifact(options.projectRoot, options.env ?? process.env);
345
+ restoreRunState(artifact);
155
346
  const retries = artifactList(options.projectRoot, options.env ?? process.env);
156
347
  return {
157
348
  profile: sanitizeProfileForServer(profilePayload(options.projectRoot, options.env)),
@@ -25,7 +25,6 @@ export function renderWebAdapterScript(options = {}) {
25
25
  '.server-login-brand{display:flex;align-items:center;gap:14px;}',
26
26
  '.server-login-logo{width:54px;height:54px;object-fit:contain;border-radius:18px;}',
27
27
  '.server-login-title{margin:0;font-size:28px;font-weight:850;letter-spacing:0;}',
28
- '.server-login-copy{margin:5px 0 0;color:var(--text-soft,#6d7794);line-height:1.5;}',
29
28
  '.server-login-form{display:grid;gap:14px;}',
30
29
  '.server-login-form input{width:100%;min-height:48px;border:1px solid var(--border,#dfe5f5);border-radius:14px;background:#fff;color:var(--text,#1e2746);padding:0 14px;outline:none;}',
31
30
  '.server-login-form input:focus{border-color:rgba(91,92,226,.45);box-shadow:0 0 0 4px rgba(91,92,226,.12);}',
@@ -47,6 +46,7 @@ export function renderWebAdapterScript(options = {}) {
47
46
 
48
47
  function showLoginPage(message = '', banned = false) {
49
48
  ensureLoginStyles();
49
+ document.title = 'AutoNetwork';
50
50
  let root = document.querySelector('[data-server-login]');
51
51
  if (!root) {
52
52
  root = document.createElement('section');
@@ -57,8 +57,7 @@ export function renderWebAdapterScript(options = {}) {
57
57
  '<div class="server-login-brand">',
58
58
  '<img class="server-login-logo" src="./assets/vpn-auto-logo-v2-minimal.svg" alt="" aria-hidden="true" />',
59
59
  '<div>',
60
- '<h1 class="server-login-title">AutoVPN</h1>',
61
- '<p class="server-login-copy">输入 serve 启动时打印的密码继续访问。</p>',
60
+ '<h1 class="server-login-title">AutoNetwork</h1>',
62
61
  '</div>',
63
62
  '</div>',
64
63
  '<form class="server-login-form" data-server-login-form>',
@@ -149,6 +148,7 @@ export function renderWebAdapterScript(options = {}) {
149
148
  }
150
149
 
151
150
  window.vpnAutomation = {
151
+ loadState: async () => state(),
152
152
  loadProfile: async () => {
153
153
  const payload = await state();
154
154
  return payload.profile || {};
@@ -159,17 +159,43 @@ async function bootstrap() {
159
159
  }
160
160
 
161
161
  state.isDemo = false;
162
- const loadedProfile = await window.vpnAutomation.loadProfile();
163
- state.profile = loadedProfile;
164
- state.savedProfile = structuredClone(loadedProfile);
162
+ const loadedState = window.vpnAutomation.loadState
163
+ ? await window.vpnAutomation.loadState()
164
+ : { profile: await window.vpnAutomation.loadProfile() };
165
+ hydrateInitialRuntimeState(loadedState);
165
166
  touchUpdate();
166
167
  await refreshQrCode();
167
- await hydrateRetryArtifacts();
168
- await hydrateLatestArtifact();
168
+ if (!loadedState?.retryArtifacts) {
169
+ await hydrateRetryArtifacts();
170
+ }
171
+ if (!loadedState?.artifact) {
172
+ await hydrateLatestArtifact();
173
+ }
169
174
  renderAll();
170
175
  state.unsubscribe = window.vpnAutomation.onPipelineEvent(handlePipelineEvent);
171
176
  }
172
177
 
178
+ function hydrateInitialRuntimeState(loadedState = {}) {
179
+ const loadedProfile = loadedState.profile ?? {};
180
+ state.profile = loadedProfile;
181
+ state.savedProfile = structuredClone(loadedProfile);
182
+ const nextRunState = String(loadedState.runState ?? '');
183
+ if (['idle', 'running', 'stopping', 'failed', 'success'].includes(nextRunState)) {
184
+ state.runState = nextRunState;
185
+ state.runResult = nextRunState === 'idle' ? state.runResult : nextRunState;
186
+ state.runStartedAt = nextRunState === 'running' ? Date.now() : null;
187
+ }
188
+ if (loadedState.artifact) {
189
+ hydrateArtifactState(loadedState.artifact);
190
+ }
191
+ if (Array.isArray(loadedState.retryArtifacts)) {
192
+ hydrateRetryArtifactState(loadedState.retryArtifacts);
193
+ }
194
+ if (loadedState.deployment) {
195
+ state.deployment = loadedState.deployment;
196
+ }
197
+ }
198
+
173
199
  function bindActions() {
174
200
  document.addEventListener('click', handleDocumentClick);
175
201
  document.addEventListener('input', handleDocumentInput);
@@ -264,6 +290,12 @@ function runTone() {
264
290
  if (state.runState === 'stopping') {
265
291
  return 'warning';
266
292
  }
293
+ if (state.runState === 'failed') {
294
+ return 'danger';
295
+ }
296
+ if (state.runState === 'success') {
297
+ return 'success';
298
+ }
267
299
  return 'neutral';
268
300
  }
269
301
 
@@ -882,7 +914,7 @@ async function exportLogs() {
882
914
  }
883
915
 
884
916
  async function runPipeline() {
885
- if (state.runState !== 'idle' || !state.profile) {
917
+ if (resolveRunControlState(state.runState).isBusy || !state.profile) {
886
918
  return;
887
919
  }
888
920
 
@@ -939,7 +971,7 @@ async function runPipeline() {
939
971
  }
940
972
 
941
973
  async function retryStage() {
942
- if (state.runState !== 'idle' || !state.profile || !state.selectedRetryArtifactDir || !state.selectedRetryStage) {
974
+ if (resolveRunControlState(state.runState).isBusy || !state.profile || !state.selectedRetryArtifactDir || !state.selectedRetryStage) {
943
975
  return;
944
976
  }
945
977
 
@@ -1065,10 +1097,10 @@ async function stopPipeline() {
1065
1097
 
1066
1098
  function finishRun(result = {}) {
1067
1099
  const messages = getMessages(state.language);
1068
- state.runState = 'idle';
1069
1100
  state.runStartedAt = null;
1070
1101
 
1071
1102
  if (result.stopped) {
1103
+ state.runState = 'idle';
1072
1104
  state.runResult = 'stopped';
1073
1105
  touchUpdate();
1074
1106
  renderAll();
@@ -1078,6 +1110,7 @@ function finishRun(result = {}) {
1078
1110
  }
1079
1111
 
1080
1112
  if (result.ok) {
1113
+ state.runState = 'success';
1081
1114
  state.runResult = 'success';
1082
1115
  touchUpdate();
1083
1116
  renderAll();
@@ -1086,6 +1119,7 @@ function finishRun(result = {}) {
1086
1119
  return;
1087
1120
  }
1088
1121
 
1122
+ state.runState = 'failed';
1089
1123
  state.runResult = 'failed';
1090
1124
  touchUpdate();
1091
1125
  renderAll();
@@ -1101,7 +1135,7 @@ function handlePipelineEvent(event) {
1101
1135
  if (event.type === 'server_state') {
1102
1136
  const nextRunState = String(event.run_state ?? '');
1103
1137
  if (['idle', 'running', 'stopping', 'failed', 'success'].includes(nextRunState)) {
1104
- state.runState = nextRunState === 'success' ? 'idle' : nextRunState;
1138
+ state.runState = nextRunState;
1105
1139
  if (['idle', 'failed', 'success'].includes(nextRunState)) {
1106
1140
  state.runStartedAt = null;
1107
1141
  }
@@ -1321,6 +1355,27 @@ async function hydrateArtifactPreview() {
1321
1355
  }
1322
1356
  }
1323
1357
 
1358
+ function hydrateArtifactState(result) {
1359
+ if (!result?.artifact_dir) {
1360
+ return;
1361
+ }
1362
+ state.artifactDir = result.artifact_dir;
1363
+ state.counts = normalizeCounts(result.counts ?? {});
1364
+ state.sourceCounts = normalizeSourceCounts(result.source_counts ?? {});
1365
+ state.outputFiles = result.outputFiles ?? [];
1366
+ state.nodeRows = result.nodeRows ?? [];
1367
+ state.retryContext = result.retry_context ?? {};
1368
+ state.deployment = result.deployment ?? state.deployment ?? {};
1369
+ if (result.run_status === 'success') {
1370
+ state.runResult = 'success';
1371
+ } else if (result.run_status === 'failed') {
1372
+ state.runResult = 'failed';
1373
+ }
1374
+ if (result.stage_status) {
1375
+ state.stageStatus = result.stage_status;
1376
+ }
1377
+ }
1378
+
1324
1379
  async function hydrateLatestArtifact() {
1325
1380
  if (!window.vpnAutomation?.latestArtifact) {
1326
1381
  return;
@@ -1331,23 +1386,26 @@ async function hydrateLatestArtifact() {
1331
1386
  if (!result?.ok || !result.artifact_dir) {
1332
1387
  return;
1333
1388
  }
1334
- state.artifactDir = result.artifact_dir;
1335
- state.counts = normalizeCounts(result.counts ?? {});
1336
- state.sourceCounts = normalizeSourceCounts(result.source_counts ?? {});
1337
- state.outputFiles = result.outputFiles ?? [];
1338
- state.nodeRows = result.nodeRows ?? [];
1339
- state.retryContext = result.retry_context ?? {};
1340
- state.deployment = result.deployment ?? {};
1341
- state.runResult = result.run_status === 'success' ? 'success' : state.runResult;
1342
- if (result.stage_status) {
1343
- state.stageStatus = result.stage_status;
1344
- }
1389
+ hydrateArtifactState(result);
1345
1390
  touchUpdate();
1346
1391
  } catch (error) {
1347
1392
  appendLog(formatMessage(getMessages(state.language).openFailed, { error: error.message }));
1348
1393
  }
1349
1394
  }
1350
1395
 
1396
+ function hydrateRetryArtifactState(items = []) {
1397
+ state.retryArtifacts = items;
1398
+ if (!state.selectedRetryArtifactDir) {
1399
+ state.selectedRetryArtifactDir = state.retryArtifacts[0]?.artifact_dir ?? '';
1400
+ }
1401
+ const selectedArtifact = state.retryArtifacts.find((item) => item.artifact_dir === state.selectedRetryArtifactDir) ?? state.retryArtifacts[0];
1402
+ const nextStage = selectedArtifact?.retryable_stages?.includes(state.selectedRetryStage)
1403
+ ? state.selectedRetryStage
1404
+ : resolveDefaultRetryStage(selectedArtifact);
1405
+ state.selectedRetryArtifactDir = selectedArtifact?.artifact_dir ?? '';
1406
+ state.selectedRetryStage = nextStage;
1407
+ }
1408
+
1351
1409
  async function hydrateRetryArtifacts() {
1352
1410
  if (!window.vpnAutomation?.artifactList) {
1353
1411
  return;
@@ -1358,16 +1416,7 @@ async function hydrateRetryArtifacts() {
1358
1416
  if (!result?.ok) {
1359
1417
  return;
1360
1418
  }
1361
- state.retryArtifacts = result.items ?? [];
1362
- if (!state.selectedRetryArtifactDir) {
1363
- state.selectedRetryArtifactDir = state.retryArtifacts[0]?.artifact_dir ?? '';
1364
- }
1365
- const selectedArtifact = state.retryArtifacts.find((item) => item.artifact_dir === state.selectedRetryArtifactDir) ?? state.retryArtifacts[0];
1366
- const nextStage = selectedArtifact?.retryable_stages?.includes(state.selectedRetryStage)
1367
- ? state.selectedRetryStage
1368
- : resolveDefaultRetryStage(selectedArtifact);
1369
- state.selectedRetryArtifactDir = selectedArtifact?.artifact_dir ?? '';
1370
- state.selectedRetryStage = nextStage;
1419
+ hydrateRetryArtifactState(result.items ?? []);
1371
1420
  touchUpdate();
1372
1421
  } catch (error) {
1373
1422
  appendLog(formatMessage(getMessages(state.language).openFailed, { error: error.message }));
@@ -5,7 +5,7 @@ const ZH_MESSAGES = {
5
5
  locale: 'zh-CN',
6
6
  appTitle: 'AutoVPN',
7
7
  sidebarTitle: 'AutoVPN',
8
- sidebarVersion: 'v.1.6.0',
8
+ sidebarVersion: 'v.1.6.1',
9
9
  brandSubtitle: '概览、运行、结果、订阅、日志、设置统一管理',
10
10
  languageLabel: '',
11
11
  saveButton: '保存配置',
@@ -58,7 +58,9 @@ const ZH_MESSAGES = {
58
58
  runStateLabels: {
59
59
  idle: '待运行',
60
60
  running: '运行中',
61
- stopping: '停止中'
61
+ stopping: '停止中',
62
+ success: '已完成',
63
+ failed: '失败'
62
64
  },
63
65
  runResultLabels: {
64
66
  idle: '未开始',
@@ -173,12 +173,19 @@ export function buildViewModel(state, messages, language) {
173
173
  currentStatusLabel: resolveCurrentStatusLabel(state, messages),
174
174
  currentTaskLabel: resolveCurrentTaskLabel(state, messages),
175
175
  runStateLabel: messages.runStateLabels[state.runState] ?? messages.runStateLabels.idle,
176
- runStateTone: state.runState === 'running' ? 'success' : state.runState === 'stopping' ? 'warning' : 'neutral',
176
+ runStateTone: runStateTone(state.runState),
177
177
  settingsDrawer: state.settingsDrawer ?? null,
178
178
  modalTransform: state.modalTransform ?? ''
179
179
  };
180
180
  }
181
181
 
182
+ function runStateTone(runState) {
183
+ if (runState === 'running' || runState === 'success') return 'success';
184
+ if (runState === 'stopping') return 'warning';
185
+ if (runState === 'failed') return 'danger';
186
+ return 'neutral';
187
+ }
188
+
182
189
  export function buildSidebarNav(messages, activePage) {
183
190
  return PAGE_ORDER.map((page) => `
184
191
  <button
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@swimmingliu/autovpn",
3
- "version": "1.6.0",
3
+ "version": "1.6.1",
4
4
  "description": "npm wrapper for the AutoVPN headless CLI",
5
5
  "type": "module",
6
6
  "repository": {