@swimmingliu/autovpn 1.8.0 → 2.0.2

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.
@@ -0,0 +1,388 @@
1
+ /**
2
+ * The account-pool interchange format.
3
+ *
4
+ * CSV was chosen over JSON because the pool is maintained by hand in a
5
+ * spreadsheet as often as it is generated, and over the legacy INI-like file
6
+ * because that format repeats the official site on every row and has no place
7
+ * for an expiry date or a status. One row is one account of one brand.
8
+ *
9
+ * brand,account,password,official_site,expires_at,status,note
10
+ *
11
+ * `brand`, `account` and `password` are required. The rest may be left empty:
12
+ * `official_site` falls back to the brand's site, `expires_at` to "unknown",
13
+ * `status` to `active`. Column order is fixed by the header, and a file may
14
+ * omit trailing optional columns entirely.
15
+ */
16
+ import { brandFromSite, brandSite, normalizeBrand } from './brands.js';
17
+ export const CSV_COLUMNS = ['brand', 'account', 'password', 'official_site', 'expires_at', 'status', 'note'];
18
+ export const ACCOUNT_STATUSES = ['active', 'disabled', 'expired', 'invalid'];
19
+ /** Statuses that take an account out of rotation without erasing its history. */
20
+ export const RETIRED_STATUSES = ['disabled', 'expired', 'invalid'];
21
+ /**
22
+ * RFC 4180 reader: quoted fields, escaped quotes, embedded newlines, CRLF.
23
+ *
24
+ * A spreadsheet exports `password` values containing commas and quotes, so a
25
+ * naive `split(',')` would silently corrupt exactly the rows that matter most.
26
+ * Returns rows of raw strings; blank lines are dropped, not turned into rows.
27
+ */
28
+ export function parseCsv(text) {
29
+ return parseCsvRows(text).map((row) => row.cells);
30
+ }
31
+ /**
32
+ * RFC 4180 reader that also remembers where each row started.
33
+ *
34
+ * A reported line number must match what the operator sees in an editor, so a
35
+ * blank separator line or a quoted multi-line note shifts the numbers rather
36
+ * than being silently collapsed (the old reader reported row indices).
37
+ */
38
+ export function parseCsvRows(text) {
39
+ const source = text.replace(/^/, '');
40
+ const rows = [];
41
+ let row = [];
42
+ let field = '';
43
+ let quoted = false;
44
+ let index = 0;
45
+ let line = 1;
46
+ let rowStart = 1;
47
+ const endField = () => {
48
+ row.push(field);
49
+ field = '';
50
+ };
51
+ const endRow = () => {
52
+ endField();
53
+ if (row.some((cell) => cell.trim() !== ''))
54
+ rows.push({ line: rowStart, cells: row });
55
+ row = [];
56
+ };
57
+ while (index < source.length) {
58
+ const char = source[index];
59
+ if (quoted) {
60
+ if (char === '"') {
61
+ if (source[index + 1] === '"') {
62
+ field += '"';
63
+ index += 2;
64
+ continue;
65
+ }
66
+ quoted = false;
67
+ index += 1;
68
+ continue;
69
+ }
70
+ // A quoted field may contain newlines; the physical line counter still
71
+ // has to advance or every later row reports the wrong line.
72
+ if (char === '\n')
73
+ line += 1;
74
+ field += char;
75
+ index += 1;
76
+ continue;
77
+ }
78
+ if (char === '"' && field === '') {
79
+ quoted = true;
80
+ index += 1;
81
+ continue;
82
+ }
83
+ if (char === ',') {
84
+ endField();
85
+ index += 1;
86
+ continue;
87
+ }
88
+ if (char === '\r' || char === '\n') {
89
+ endRow();
90
+ index += char === '\r' && source[index + 1] === '\n' ? 2 : 1;
91
+ line += 1;
92
+ rowStart = line;
93
+ continue;
94
+ }
95
+ field += char;
96
+ index += 1;
97
+ }
98
+ endRow();
99
+ return rows;
100
+ }
101
+ /** Quote only when the value needs it, so exported files stay readable. */
102
+ export function csvCell(value) {
103
+ const text = String(value ?? '');
104
+ return /[",\r\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
105
+ }
106
+ export function stringifyCsv(records) {
107
+ const lines = [CSV_COLUMNS.join(',')];
108
+ for (const record of records) {
109
+ lines.push(CSV_COLUMNS.map((column) => csvCell(record[column])).join(','));
110
+ }
111
+ return `${lines.join('\n')}\n`;
112
+ }
113
+ const HEADER_ALIASES = {
114
+ brand: 'brand',
115
+ 品牌: 'brand',
116
+ 厂商: 'brand',
117
+ account: 'account',
118
+ username: 'account',
119
+ user: 'account',
120
+ 账号: 'account',
121
+ 帐号: 'account',
122
+ password: 'password',
123
+ pass: 'password',
124
+ 密码: 'password',
125
+ official_site: 'official_site',
126
+ site: 'official_site',
127
+ url: 'official_site',
128
+ 官网: 'official_site',
129
+ expires_at: 'expires_at',
130
+ expiry: 'expires_at',
131
+ expires: 'expires_at',
132
+ 到期: 'expires_at',
133
+ 到期时间: 'expires_at',
134
+ status: 'status',
135
+ 状态: 'status',
136
+ note: 'note',
137
+ remark: 'note',
138
+ 备注: 'note'
139
+ };
140
+ const STATUS_ALIASES = {
141
+ active: 'active',
142
+ enabled: 'active',
143
+ ok: 'active',
144
+ 正常: 'active',
145
+ 可用: 'active',
146
+ disabled: 'disabled',
147
+ paused: 'disabled',
148
+ 停用: 'disabled',
149
+ 禁用: 'disabled',
150
+ expired: 'expired',
151
+ 过期: 'expired',
152
+ 到期: 'expired',
153
+ invalid: 'invalid',
154
+ revoked: 'invalid',
155
+ bad: 'invalid',
156
+ 失效: 'invalid',
157
+ 密码错误: 'invalid'
158
+ };
159
+ export function normalizeStatus(value) {
160
+ const raw = String(value ?? '').trim();
161
+ if (!raw)
162
+ return 'active';
163
+ return STATUS_ALIASES[raw.toLowerCase()] ?? null;
164
+ }
165
+ /**
166
+ * Accept the date spellings a spreadsheet produces and emit one ISO date.
167
+ *
168
+ * `2026/1/5`, `2026-01-05` and `2026-01-05 00:00:00` are the same day; an
169
+ * unparseable value is an issue on that row rather than a silent "no expiry",
170
+ * because a dropped expiry date keeps a dead account in rotation forever.
171
+ */
172
+ export function normalizeExpiry(value) {
173
+ const raw = String(value ?? '').trim();
174
+ if (!raw)
175
+ return '';
176
+ const match = /^(\d{4})[-/.](\d{1,2})[-/.](\d{1,2})(?:[ T].*)?$/.exec(raw);
177
+ if (!match)
178
+ return null;
179
+ const year = Number(match[1]);
180
+ const month = Number(match[2]);
181
+ const day = Number(match[3]);
182
+ if (month < 1 || month > 12 || day < 1 || day > 31)
183
+ return null;
184
+ const date = new Date(Date.UTC(year, month - 1, day));
185
+ if (date.getUTCFullYear() !== year || date.getUTCMonth() !== month - 1 || date.getUTCDate() !== day)
186
+ return null;
187
+ const pad = (part) => String(part).padStart(2, '0');
188
+ return `${year}-${pad(month)}-${pad(day)}`;
189
+ }
190
+ /**
191
+ * Parse a pool CSV into validated records.
192
+ *
193
+ * Bad rows are reported and skipped rather than aborting the file: a 143-row
194
+ * export with one malformed line should still import the other 142, and the
195
+ * caller decides whether the issues are fatal.
196
+ */
197
+ export function parseAccountCsv(text) {
198
+ const rows = parseCsvRows(text);
199
+ const issues = [];
200
+ if (rows.length === 0) {
201
+ return { records: [], issues: [{ line: 1, message: 'file is empty' }] };
202
+ }
203
+ const headerCells = rows[0].cells;
204
+ const header = headerCells.map((cell) => HEADER_ALIASES[cell.trim().toLowerCase()] ?? null);
205
+ if (!header.includes('brand') || !header.includes('account') || !header.includes('password')) {
206
+ // Never echo the raw first row: when the file has no header line at all,
207
+ // row 1 is a real account row and one of its cells is the password. Report
208
+ // which known columns were recognised instead, and which are missing.
209
+ const recognised = [...new Set(header.filter((value) => Boolean(value)))];
210
+ const missing = ['brand', 'account', 'password'].filter((column) => !header.includes(column));
211
+ const seenText = recognised.length > 0 ? `recognised columns: ${recognised.join(', ')}` : 'no recognised columns; the first line must be a header row';
212
+ return {
213
+ records: [],
214
+ issues: [{ line: 1, message: `header is missing ${missing.join(', ')} (${seenText})` }]
215
+ };
216
+ }
217
+ const records = [];
218
+ for (let rowIndex = 1; rowIndex < rows.length; rowIndex += 1) {
219
+ const { line, cells } = rows[rowIndex];
220
+ // A row wider than the header almost always means an unquoted comma inside
221
+ // a field: taking the first cells silently stores a truncated password,
222
+ // and the vendor then rejects the login and retires a good account.
223
+ const expectedWidth = headerCells.length;
224
+ if (cells.length > expectedWidth) {
225
+ issues.push({
226
+ line,
227
+ message: `row has ${cells.length} cells but the header has ${expectedWidth}; quote any value containing a comma`
228
+ });
229
+ continue;
230
+ }
231
+ const value = (column) => {
232
+ const position = header.indexOf(column);
233
+ return position === -1 ? '' : String(cells[position] ?? '').trim();
234
+ };
235
+ const brandRaw = value('brand');
236
+ const brand = normalizeBrand(brandRaw);
237
+ if (!brand) {
238
+ issues.push({ line, message: `unknown brand: ${brandRaw || '(empty)'}` });
239
+ continue;
240
+ }
241
+ const account = value('account');
242
+ const password = value('password');
243
+ if (!account || !password) {
244
+ issues.push({ line, message: 'account and password are required' });
245
+ continue;
246
+ }
247
+ const status = normalizeStatus(value('status'));
248
+ if (!status) {
249
+ issues.push({ line, message: `unknown status: ${value('status')}` });
250
+ continue;
251
+ }
252
+ const expiresAt = normalizeExpiry(value('expires_at'));
253
+ if (expiresAt === null) {
254
+ issues.push({ line, message: `unparseable expires_at: ${value('expires_at')}` });
255
+ continue;
256
+ }
257
+ const siteRaw = value('official_site');
258
+ // A site we do not recognise is allowed (vendors rotate domains), but one
259
+ // we do recognise that belongs to another brand is a data error: the row
260
+ // would log in to the wrong vendor with a credential it has never issued.
261
+ const siteBrand = brandFromSite(siteRaw);
262
+ if (siteBrand && siteBrand !== brand) {
263
+ issues.push({ line, message: `official_site belongs to ${siteBrand}, not ${brand}` });
264
+ continue;
265
+ }
266
+ records.push({
267
+ brand,
268
+ account,
269
+ password,
270
+ official_site: siteRaw || brandSite(brand),
271
+ expires_at: expiresAt,
272
+ status,
273
+ note: value('note')
274
+ });
275
+ }
276
+ return { records, issues };
277
+ }
278
+ /**
279
+ * Reader for the original hand-maintained file:
280
+ *
281
+ * [雷霆加速器 #2]
282
+ * 官网:https://…
283
+ * 账号: …
284
+ * 密码: …
285
+ *
286
+ * Kept so the existing 143 accounts can be seeded once without a manual
287
+ * conversion step. Every row it produces still goes through the same
288
+ * validation as a CSV row.
289
+ */
290
+ export function parseLegacyAccountFile(text) {
291
+ const issues = [];
292
+ const records = [];
293
+ let current = null;
294
+ const flush = () => {
295
+ if (!current)
296
+ return;
297
+ const { line, heading, fields } = current;
298
+ const brand = normalizeBrand(heading);
299
+ if (!brand) {
300
+ issues.push({ line, message: `unknown brand: ${heading}` });
301
+ current = null;
302
+ return;
303
+ }
304
+ const account = fields.get('account') ?? '';
305
+ const password = fields.get('password') ?? '';
306
+ if (!account || !password) {
307
+ issues.push({ line, message: 'account and password are required' });
308
+ current = null;
309
+ return;
310
+ }
311
+ const expiresAt = normalizeExpiry(fields.get('expires_at') ?? '');
312
+ if (expiresAt === null) {
313
+ issues.push({ line, message: `unparseable expiry in section ${heading}` });
314
+ current = null;
315
+ return;
316
+ }
317
+ const status = normalizeStatus(fields.get('status') ?? '');
318
+ if (!status) {
319
+ issues.push({ line, message: `unknown status in section ${heading}` });
320
+ current = null;
321
+ return;
322
+ }
323
+ const siteRaw = fields.get('official_site') ?? '';
324
+ // Same guard as the CSV path: a section whose 官网 was copied from another
325
+ // brand would log in to the wrong vendor with a credential it never issued.
326
+ const siteBrand = brandFromSite(siteRaw);
327
+ if (siteBrand && siteBrand !== brand) {
328
+ issues.push({ line, message: `official_site belongs to ${siteBrand}, not ${brand}` });
329
+ current = null;
330
+ return;
331
+ }
332
+ records.push({
333
+ brand,
334
+ account,
335
+ password,
336
+ official_site: siteRaw || brandSite(brand),
337
+ expires_at: expiresAt,
338
+ status,
339
+ note: fields.get('note') ?? ''
340
+ });
341
+ current = null;
342
+ };
343
+ const lines = text.split(/\r?\n/);
344
+ for (let index = 0; index < lines.length; index += 1) {
345
+ const line = lines[index].trim();
346
+ if (!line || line.startsWith('#') || line.startsWith(';'))
347
+ continue;
348
+ const heading = /^\[(.+)\]$/.exec(line);
349
+ if (heading) {
350
+ flush();
351
+ current = { line: index + 1, heading: heading[1].trim(), fields: new Map() };
352
+ continue;
353
+ }
354
+ const pair = /^([^::]+)[::](.*)$/.exec(line);
355
+ if (!pair) {
356
+ issues.push({ line: index + 1, message: 'expected key: value' });
357
+ continue;
358
+ }
359
+ if (!current) {
360
+ issues.push({ line: index + 1, message: 'value outside a [brand] section' });
361
+ continue;
362
+ }
363
+ const key = HEADER_ALIASES[pair[1].trim().toLowerCase()];
364
+ if (!key) {
365
+ issues.push({ line: index + 1, message: `unknown field: ${pair[1].trim()}` });
366
+ continue;
367
+ }
368
+ current.fields.set(key, pair[2].trim());
369
+ }
370
+ flush();
371
+ return { records, issues };
372
+ }
373
+ /**
374
+ * Pick the reader from the file's first meaningful line, not its extension.
375
+ *
376
+ * The legacy reader accepts `#` / `;` comments, so a legacy file with a header
377
+ * comment must not be misdetected as CSV and fail its header check.
378
+ */
379
+ export function parseAccountFile(text, format = 'auto') {
380
+ if (format === 'csv')
381
+ return parseAccountCsv(text);
382
+ if (format === 'legacy')
383
+ return parseLegacyAccountFile(text);
384
+ const first = text.replace(/^/, '').split(/\r?\n/)
385
+ .map((line) => line.trim())
386
+ .find((line) => line !== '' && !line.startsWith('#') && !line.startsWith(';')) ?? '';
387
+ return /^\[.+\]$/.test(first) ? parseLegacyAccountFile(text) : parseAccountCsv(text);
388
+ }
@@ -181,6 +181,31 @@ export async function createAutoVpnServer(options) {
181
181
  writeJson(response, 200, await options.runtime.saveProfile?.(body) ?? { ok: false, error: 'profile_save_unavailable' }, 'config');
182
182
  return;
183
183
  }
184
+ if (request.method === 'GET' && url.pathname === '/api/accounts') {
185
+ writeJson(response, 200, await options.runtime.loadAccounts?.() ?? { ok: false, error: 'accounts_unavailable' });
186
+ return;
187
+ }
188
+ if (request.method === 'POST' && url.pathname === '/api/accounts/import') {
189
+ const body = await readJsonBody(request);
190
+ const result = await options.runtime.importAccounts?.({
191
+ content: String(body.content ?? ''),
192
+ format: String(body.format ?? 'auto')
193
+ }) ?? { ok: false, error: 'accounts_unavailable' };
194
+ writeJson(response, result.ok === false ? 400 : 200, result);
195
+ return;
196
+ }
197
+ if (request.method === 'POST' && url.pathname === '/api/accounts/status') {
198
+ const body = await readJsonBody(request);
199
+ const result = await options.runtime.setAccountStatus?.({
200
+ id: Number(body.id ?? 0),
201
+ brand: String(body.brand ?? ''),
202
+ account: String(body.account ?? ''),
203
+ status: String(body.status ?? ''),
204
+ reason: String(body.reason ?? '')
205
+ }) ?? { ok: false, error: 'accounts_unavailable' };
206
+ writeJson(response, result.ok === false ? 400 : 200, result);
207
+ return;
208
+ }
184
209
  if (request.method === 'POST' && url.pathname === '/api/qr') {
185
210
  const body = await readJsonBody(request);
186
211
  const text = String(body.text ?? '');
@@ -6,6 +6,9 @@ import { profilePayload, saveProfilePayload } from '../config/profile.js';
6
6
  import { startDetachedRetry as defaultStartDetachedRetry, startDetachedRun as defaultStartDetachedRun, stopManagedJob as defaultStopManagedJob } from '../jobs/commands.js';
7
7
  import { followLog as defaultFollowLog } from '../jobs/logs.js';
8
8
  import { latestJobId, loadJob } from '../jobs/read.js';
9
+ import { AccountStore } from '../pool/account-store.js';
10
+ import { normalizeBrand } from '../pool/brands.js';
11
+ import { parseAccountFile } from '../pool/csv.js';
9
12
  import { resolveArtifactsRoot, resolveProfilePath } from '../runtime/paths.js';
10
13
  const DEPLOY_SECRET_KEYS = new Set([
11
14
  'cloudflare_api_token',
@@ -25,6 +28,14 @@ export function sanitizeProfileForServer(profile) {
25
28
  safe.deploy[key] = redactIfSet(key, value);
26
29
  }
27
30
  }
31
+ // Credential sources carry a real account password; `/api/state` must not
32
+ // echo it back to the client. The renderer round-trips `<redacted>` through
33
+ // `preserveRedactedSecrets` on save, so the stored value is untouched.
34
+ for (const source of Object.values((safe.sources ?? {}))) {
35
+ if (source && typeof source === 'object' && String(source.password ?? '').trim()) {
36
+ source.password = '<redacted>';
37
+ }
38
+ }
28
39
  return safe;
29
40
  }
30
41
  function preserveRedactedSecrets(incoming, current) {
@@ -33,7 +44,7 @@ function preserveRedactedSecrets(incoming, current) {
33
44
  for (const [name, source] of Object.entries((merged.sources ?? {}))) {
34
45
  const currentSource = currentSources[name] ?? {};
35
46
  if (source && typeof source === 'object') {
36
- for (const key of ['url', 'key']) {
47
+ for (const key of ['url', 'key', 'password']) {
37
48
  if (source[key] === '<redacted>') {
38
49
  source[key] = currentSource[key] ?? '';
39
50
  }
@@ -142,7 +153,21 @@ function latestJobEvents(job, maxEvents = 1000) {
142
153
  return [];
143
154
  try {
144
155
  const lines = fs.readFileSync(eventLog, 'utf8').split(/\r?\n/).filter(Boolean);
145
- return lines.slice(-maxEvents).map((line) => parseJsonLine(line)).filter(Boolean);
156
+ const events = lines.map((line) => parseJsonLine(line)).filter(Boolean);
157
+ const priorSteps = new Map();
158
+ // APK/domain steps occur early. Preserve their latest states even when
159
+ // subsequent node logs exceed the bounded text-history window.
160
+ for (const event of events.slice(0, -maxEvents)) {
161
+ if (!event || typeof event !== 'object' || !('type' in event))
162
+ continue;
163
+ if (event.type === 'extract_substage' && 'source_name' in event && typeof event.source_name === 'string'
164
+ && 'platform' in event && typeof event.platform === 'string' && 'substage' in event && typeof event.substage === 'string') {
165
+ priorSteps.set([event.source_name, event.platform, event.substage].join(':'), event);
166
+ }
167
+ if (event.type === 'stage' && 'stage' in event && typeof event.stage === 'string')
168
+ priorSteps.set(`stage:${event.stage}`, event);
169
+ }
170
+ return [...priorSteps.values(), ...events.slice(-maxEvents)];
146
171
  }
147
172
  catch {
148
173
  return [];
@@ -375,6 +400,8 @@ export function createServerRuntime(options) {
375
400
  runState,
376
401
  artifact,
377
402
  retryArtifacts: Array.isArray(retries.items) ? retries.items : [],
403
+ activity: Array.isArray(retries.activity) ? retries.activity : [],
404
+ activityUnavailable: Number(retries.activityUnavailable ?? 0),
378
405
  deployment: (artifact?.deployment ?? {}),
379
406
  logEvents: latestJobEvents(job)
380
407
  };
@@ -387,6 +414,53 @@ export function createServerRuntime(options) {
387
414
  profile: sanitizeProfileForServer(saveProfilePayload(options.projectRoot, merged, options.env))
388
415
  };
389
416
  },
417
+ async loadAccounts() {
418
+ const store = new AccountStore({ env: options.env, now: options.now });
419
+ try {
420
+ return { ok: true, stats: store.stats(), accounts: store.list({ status: 'all', includeDeleted: true }) };
421
+ }
422
+ finally {
423
+ store.close();
424
+ }
425
+ },
426
+ async importAccounts(input = {}) {
427
+ const content = String(input.content ?? '');
428
+ const format = input.format === 'csv' || input.format === 'legacy' ? input.format : 'auto';
429
+ const parsed = parseAccountFile(content, format);
430
+ if (parsed.records.length === 0) {
431
+ return { ok: false, imported: 0, issues: parsed.issues };
432
+ }
433
+ const store = new AccountStore({ env: options.env, now: options.now });
434
+ try {
435
+ const outcome = store.importRecords(parsed.records);
436
+ return { ok: true, parsed: parsed.records.length, ...outcome, issues: parsed.issues, stats: store.stats() };
437
+ }
438
+ finally {
439
+ store.close();
440
+ }
441
+ },
442
+ async setAccountStatus(input = {}) {
443
+ const id = Number(input.id);
444
+ const brand = normalizeBrand(input.brand);
445
+ const account = String(input.account ?? '').trim();
446
+ const byId = Number.isInteger(id) && id > 0;
447
+ if (!byId && (!brand || !account))
448
+ return { ok: false, error: 'id_or_brand_and_account_required' };
449
+ const store = new AccountStore({ env: options.env, now: options.now });
450
+ try {
451
+ const status = String(input.status ?? '');
452
+ const reason = String(input.reason ?? 'disabled by operator');
453
+ const ok = status === 'active'
454
+ ? (byId ? store.enableById(id) : store.enable(brand, account))
455
+ : status === 'disabled'
456
+ ? (byId ? store.disableById(id, reason) : store.disable(brand, account, reason))
457
+ : false;
458
+ return ok ? { ok, ...(byId ? { id } : { brand }), status } : { ok: false, error: 'account_not_found_or_status_invalid' };
459
+ }
460
+ finally {
461
+ store.close();
462
+ }
463
+ },
390
464
  async startRun(runOptions = {}) {
391
465
  if (runState === 'running' || runState === 'stopping') {
392
466
  return { ok: false, error: 'run_already_active' };
@@ -1,3 +1,4 @@
1
+ import { accountsTemplate } from '../cli/accounts-commands.js';
1
2
  export function renderWebAdapterScript(options = {}) {
2
3
  return `
3
4
  (() => {
@@ -138,7 +139,12 @@ export function renderWebAdapterScript(options = {}) {
138
139
  return request(path, requestOptions);
139
140
  }
140
141
  if (!response.ok) {
141
- throw new Error(payload.error || 'request_failed');
142
+ // The import endpoint answers 400 with the parsed issues (line + reason).
143
+ // Throwing that away would leave the operator with a bare request_failed
144
+ // and no way to find the bad rows, so carry the payload on the error.
145
+ const error = new Error(payload.error || 'request_failed');
146
+ error.payload = payload;
147
+ throw error;
142
148
  }
143
149
  return payload;
144
150
  }
@@ -157,6 +163,16 @@ export function renderWebAdapterScript(options = {}) {
157
163
  method: 'POST',
158
164
  body: JSON.stringify(profile || {})
159
165
  }),
166
+ loadAccounts: async () => request('/api/accounts'),
167
+ importAccounts: async (content, format = 'auto') => request('/api/accounts/import', {
168
+ method: 'POST',
169
+ body: JSON.stringify({ content: String(content || ''), format })
170
+ }),
171
+ setAccountStatus: async (payload) => request('/api/accounts/status', {
172
+ method: 'POST',
173
+ body: JSON.stringify(payload || {})
174
+ }),
175
+ accountsTemplate: async () => ({ ok: true, content: ${JSON.stringify(accountsTemplate())} }),
160
176
  runPipeline: async (options = {}) => request('/api/runs', {
161
177
  method: 'POST',
162
178
  body: JSON.stringify({
@@ -185,7 +201,7 @@ export function renderWebAdapterScript(options = {}) {
185
201
  },
186
202
  artifactList: async () => {
187
203
  const payload = await state();
188
- return { ok: true, items: payload.retryArtifacts || [] };
204
+ return { ok: true, items: payload.retryArtifacts || [], activity: payload.activity || [], activityUnavailable: Number(payload.activityUnavailable || 0) };
189
205
  },
190
206
  retryStage: async (options = {}) => request('/api/runs/retry-stage', {
191
207
  method: 'POST',