@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.
- package/dist/artifacts/list.js +48 -6
- package/dist/cli/accounts-commands.js +304 -0
- package/dist/cli/commands/index.js +6 -0
- package/dist/cli/native-commands.js +10 -0
- package/dist/cli/output.js +1 -0
- package/dist/config/profile.js +119 -5
- package/dist/events/schema.js +2 -0
- package/dist/pipeline/apk-discovery.js +504 -0
- package/dist/pipeline/brand-cache.js +126 -0
- package/dist/pipeline/brand-protocol.js +210 -0
- package/dist/pipeline/credential-source.js +1406 -0
- package/dist/pipeline/extract.js +55 -2
- package/dist/pipeline/orchestrator.js +88 -9
- package/dist/pool/account-store.js +484 -0
- package/dist/pool/brands.js +110 -0
- package/dist/pool/csv.js +388 -0
- package/dist/server/http.js +25 -0
- package/dist/server/runtime.js +76 -2
- package/dist/server/web-adapter.js +18 -2
- package/dist/web/renderer/THIRD_PARTY_NOTICES.txt +345 -0
- package/dist/web/renderer/analytics.bundle.css +2 -0
- package/dist/web/renderer/analytics.bundle.js +66 -0
- package/dist/web/renderer/app.js +385 -80
- package/dist/web/renderer/appearance.js +76 -0
- package/dist/web/renderer/assets/vpn-auto-logo-v3.png +0 -0
- package/dist/web/renderer/i18n.js +1 -1
- package/dist/web/renderer/icons.js +38 -0
- package/dist/web/renderer/index.html +24 -2
- package/dist/web/renderer/state.js +0 -1
- package/dist/web/renderer/styles.css +584 -48
- package/dist/web/renderer/tables.js +208 -0
- package/dist/web/renderer/views.js +483 -255
- package/dist/web/renderer/workspace.css +381 -0
- package/package.json +4 -3
package/dist/pool/csv.js
ADDED
|
@@ -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
|
+
}
|
package/dist/server/http.js
CHANGED
|
@@ -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 ?? '');
|
package/dist/server/runtime.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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',
|