@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.
@@ -2,11 +2,24 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { safeDeployment, redactText } from '../runtime/redaction.js';
4
4
  import { resolveArtifactsRoot } from '../runtime/paths.js';
5
+ function isReport(value) {
6
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
7
+ }
5
8
  function loadJson(filePath) {
6
9
  if (!fs.existsSync(filePath)) {
7
10
  return {};
8
11
  }
9
- return JSON.parse(fs.readFileSync(filePath, 'utf8'));
12
+ // Reports are external, crash-interruptible files. Invalid metadata must
13
+ // stay unknown rather than preventing the whole workspace from loading.
14
+ try {
15
+ const value = JSON.parse(fs.readFileSync(filePath, 'utf8'));
16
+ if (isReport(value))
17
+ return value;
18
+ }
19
+ catch {
20
+ return { error: 'Artifact report is unreadable', metadata_unreadable: true };
21
+ }
22
+ return { error: 'Artifact report is invalid', metadata_unreadable: true };
10
23
  }
11
24
  function countNonEmptyLines(filePath) {
12
25
  if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) {
@@ -108,13 +121,13 @@ function isArtifactDir(artifactDir) {
108
121
  export function artifactList(projectRoot, env = process.env) {
109
122
  const artifactsRoot = resolveArtifactsRoot(projectRoot, env);
110
123
  if (!fs.existsSync(artifactsRoot)) {
111
- return { ok: true, items: [] };
124
+ return { ok: true, items: [], activity: [] };
112
125
  }
113
- const items = fs.readdirSync(artifactsRoot)
126
+ const directories = fs.readdirSync(artifactsRoot)
114
127
  .map((name) => path.join(artifactsRoot, name))
115
128
  .filter((item) => fs.statSync(item).isDirectory() && isArtifactDir(item))
116
- .sort((left, right) => fs.statSync(right).mtimeMs - fs.statSync(left).mtimeMs)
117
- .slice(0, 20)
129
+ .sort((left, right) => fs.statSync(right).mtimeMs - fs.statSync(left).mtimeMs);
130
+ const items = directories.slice(0, 20)
118
131
  .map((artifactDir) => {
119
132
  const report = loadJson(path.join(artifactDir, 'pipeline_report.json'));
120
133
  const stageStatus = (report.stage_status ?? {});
@@ -130,5 +143,34 @@ export function artifactList(projectRoot, env = process.env) {
130
143
  updated_at: new Date(fs.statSync(artifactDir).mtimeMs).toISOString().replace('Z', '')
131
144
  };
132
145
  });
133
- return { ok: true, items };
146
+ // Retry choices stay bounded; the calendar aggregates every retained run,
147
+ // otherwise a busy day would silently erase most of the year's history.
148
+ const days = new Map();
149
+ let activityUnavailable = 0;
150
+ for (const directory of directories) {
151
+ try {
152
+ // Final output is authoritative. Availability is an intermediate stage
153
+ // and can contain nodes that postprocessing later removes or rejects.
154
+ const finalFileCount = countNonEmptyLines(path.join(directory, 'vpn_node_emoji.txt'));
155
+ const report = loadJson(path.join(directory, 'pipeline_report.json'));
156
+ const reported = isReport(report.counts) ? report.counts.final_links : undefined;
157
+ const value = finalFileCount ?? (typeof reported === 'number' && Number.isFinite(reported) && reported >= 0 ? reported : undefined);
158
+ if (value === undefined) {
159
+ activityUnavailable += 1;
160
+ continue;
161
+ }
162
+ const stamp = path.basename(directory).slice(0, 8);
163
+ const date = stamp.slice(0, 4) + '-' + stamp.slice(4, 6) + '-' + stamp.slice(6, 8);
164
+ const day = days.get(date) ?? { date, nodes: 0, runs: 0 };
165
+ day.nodes += Math.floor(value);
166
+ day.runs += 1;
167
+ days.set(date, day);
168
+ }
169
+ catch {
170
+ // One unreadable old report must not prevent profile/state loading.
171
+ // Preserve the missing-data count instead of claiming a zero output.
172
+ activityUnavailable += 1;
173
+ }
174
+ }
175
+ return { ok: true, items, activityUnavailable, activity: [...days.values()].sort((left, right) => left.date.localeCompare(right.date)) };
134
176
  }
@@ -0,0 +1,304 @@
1
+ /**
2
+ * `autovpn accounts …` — the pool's operator surface.
3
+ *
4
+ * Every listing is masked. Only `export` writes real credentials, and it says
5
+ * so on stderr, so a terminal scrollback or a pasted log never carries them by
6
+ * accident.
7
+ */
8
+ import fs from 'node:fs';
9
+ import path from 'node:path';
10
+ import { AccountStore } from '../pool/account-store.js';
11
+ import { BRANDS, brandLabel, normalizeBrand } from '../pool/brands.js';
12
+ import { CSV_COLUMNS, RETIRED_STATUSES, parseAccountFile, stringifyCsv } from '../pool/csv.js';
13
+ import { CliUsageError } from './errors.js';
14
+ import { readOptionValue } from './global-options.js';
15
+ export const ACCOUNTS_SUBCOMMANDS = ['import', 'list', 'stats', 'snapshot', 'disable', 'enable', 'expire', 'export', 'template'];
16
+ /** Options that consume the next argv slot, so positionals can be found. */
17
+ const VALUE_OPTIONS = new Set([
18
+ '--project-root', '--format', '--output', '--brand', '--status', '--reason', '--date', '--file', '--limit', '--query', '--id'
19
+ ]);
20
+ export function accountsPositionals(argv) {
21
+ const out = [];
22
+ for (let index = 1; index < argv.length; index += 1) {
23
+ const value = argv[index];
24
+ if (VALUE_OPTIONS.has(value)) {
25
+ index += 1;
26
+ continue;
27
+ }
28
+ if (value.startsWith('--'))
29
+ continue;
30
+ out.push(value);
31
+ }
32
+ // argv[1] is the subcommand itself.
33
+ return out.slice(1);
34
+ }
35
+ function jsonLine(payload) {
36
+ return `${JSON.stringify(payload)}\n`;
37
+ }
38
+ function wantsHuman(argv) {
39
+ return readOptionValue(argv, '--output') === 'human';
40
+ }
41
+ export function validateAccountsCommand(argv) {
42
+ const subcommand = argv[1] ?? '';
43
+ if (!ACCOUNTS_SUBCOMMANDS.includes(subcommand)) {
44
+ throw new CliUsageError(`accounts subcommand must be one of: ${ACCOUNTS_SUBCOMMANDS.join(', ')}`);
45
+ }
46
+ const format = readOptionValue(argv, '--format');
47
+ if (format !== undefined && !['auto', 'csv', 'legacy'].includes(format)) {
48
+ throw new CliUsageError('accounts import --format must be one of: auto, csv, legacy');
49
+ }
50
+ const output = readOptionValue(argv, '--output');
51
+ if (output !== undefined && !['human', 'jsonl', 'json'].includes(output)) {
52
+ throw new CliUsageError('accounts --output must be one of: human, jsonl');
53
+ }
54
+ const status = readOptionValue(argv, '--status');
55
+ if (status !== undefined && !['active', 'disabled', 'expired', 'invalid', 'all'].includes(status)) {
56
+ throw new CliUsageError('accounts --status must be one of: active, disabled, expired, invalid, all');
57
+ }
58
+ const brand = readOptionValue(argv, '--brand');
59
+ if (brand !== undefined && !normalizeBrand(brand)) {
60
+ throw new CliUsageError(`accounts --brand must be one of: ${BRANDS.map((item) => item.key).join(', ')}`);
61
+ }
62
+ const positionals = accountsPositionals(argv);
63
+ if (subcommand === 'import' && !positionals[0] && readOptionValue(argv, '--file') === undefined && !argv.includes('--stdin')) {
64
+ throw new CliUsageError('accounts import requires a file path or --stdin');
65
+ }
66
+ if (['disable', 'enable', 'expire'].includes(subcommand)) {
67
+ const id = readOptionValue(argv, '--id');
68
+ if (id !== undefined) {
69
+ if (!/^\d+$/.test(id) || Number(id) < 1)
70
+ throw new CliUsageError('accounts --id must be a positive integer');
71
+ return;
72
+ }
73
+ if (!positionals[0] || !positionals[1]) {
74
+ throw new CliUsageError(`accounts ${subcommand} requires <brand> <account> or --id`);
75
+ }
76
+ if (!normalizeBrand(positionals[0])) {
77
+ throw new CliUsageError(`unknown brand: ${positionals[0]}`);
78
+ }
79
+ }
80
+ }
81
+ function renderStatsHuman(stats) {
82
+ const lines = [
83
+ `account pool: ${stats.total} total, ${stats.active} active`,
84
+ ` retired: ${stats.disabled} disabled, ${stats.expired} expired, ${stats.invalid} invalid`,
85
+ ` expiring within 7 days: ${stats.expiring_soon}`
86
+ ];
87
+ for (const brand of stats.by_brand) {
88
+ lines.push(` ${brandLabel(brand.brand).padEnd(8)} total=${String(brand.total).padStart(3)} active=${String(brand.active).padStart(3)}` +
89
+ ` disabled=${brand.disabled} expired=${brand.expired} invalid=${brand.invalid}`);
90
+ }
91
+ lines.push(` db: ${stats.db_path}`);
92
+ return `${lines.join('\n')}\n`;
93
+ }
94
+ function renderListHuman(rows) {
95
+ if (rows.length === 0)
96
+ return 'account pool: no matching accounts\n';
97
+ const lines = ['brand account status expires uses ok/fail note'];
98
+ for (const row of rows) {
99
+ lines.push(`${row.brand.padEnd(9)} ${row.account_masked.padEnd(14)} ${row.status.padEnd(9)} ` +
100
+ `${(row.expires_at || '-').padEnd(11)} ${String(row.use_count).padStart(4)} ` +
101
+ `${row.success_count}/${row.fail_count}`.padEnd(8) + ` ${row.status_reason || row.note}`);
102
+ }
103
+ return `${lines.join('\n')}\n`;
104
+ }
105
+ export function accountsTemplate() {
106
+ const example = BRANDS.map((brand) => ({
107
+ brand: brand.key,
108
+ account: 'ACCOUNT',
109
+ password: 'PASSWORD',
110
+ official_site: brand.official_site,
111
+ expires_at: '',
112
+ status: 'active',
113
+ note: ''
114
+ }));
115
+ return stringifyCsv(example);
116
+ }
117
+ export async function runAccountsCommand(argv, context) {
118
+ const subcommand = argv[1];
119
+ // The template is a static document: it must work before the pool exists and
120
+ // on a host where the database path is not writable, so it never opens a store.
121
+ if (subcommand === 'template') {
122
+ context.io.writeStdout(accountsTemplate());
123
+ return 0;
124
+ }
125
+ const dryRun = subcommand === 'import' && argv.includes('--dry-run');
126
+ // A preview must not write: the store's getters sweep expired rows, so even
127
+ // opening it read-write would mutate the pool before showing the plan.
128
+ const store = context.store ?? new AccountStore({ env: context.env, now: context.now, readOnly: dryRun });
129
+ const ownsStore = !context.store;
130
+ try {
131
+ if (subcommand === 'stats') {
132
+ const stats = store.stats();
133
+ context.io.writeStdout(wantsHuman(argv) ? renderStatsHuman(stats) : jsonLine(stats));
134
+ return 0;
135
+ }
136
+ if (subcommand === 'snapshot') {
137
+ const accounts = store.list({ status: 'all', includeDeleted: true });
138
+ const payload = { ok: true, stats: store.stats(), accounts };
139
+ context.io.writeStdout(jsonLine(payload));
140
+ return 0;
141
+ }
142
+ if (subcommand === 'list') {
143
+ const limitRaw = readOptionValue(argv, '--limit');
144
+ let limit;
145
+ if (limitRaw !== undefined) {
146
+ const parsed = Number(limitRaw);
147
+ if (!Number.isFinite(parsed) || parsed < 1) {
148
+ throw new CliUsageError('accounts list --limit must be a positive integer');
149
+ }
150
+ limit = Math.trunc(parsed);
151
+ }
152
+ const statusRaw = readOptionValue(argv, '--status');
153
+ // Every retired row is soft-deleted, so filtering by a retired status
154
+ // without including deleted rows would always answer with nothing.
155
+ const retiredFilter = statusRaw !== undefined && RETIRED_STATUSES.includes(statusRaw);
156
+ const includeDeleted = argv.includes('--all') || retiredFilter;
157
+ // `--all` means "including retired rows", so it must not be combined with
158
+ // the active-only default: that pair returned nothing at all.
159
+ const status = (statusRaw ?? (includeDeleted ? 'all' : 'active'));
160
+ const rows = store.list({
161
+ brand: readOptionValue(argv, '--brand'),
162
+ status,
163
+ includeDeleted,
164
+ query: readOptionValue(argv, '--query'),
165
+ limit
166
+ });
167
+ context.io.writeStdout(wantsHuman(argv) ? renderListHuman(rows) : jsonLine({ ok: true, count: rows.length, accounts: rows }));
168
+ return 0;
169
+ }
170
+ if (subcommand === 'import') {
171
+ const positionals = accountsPositionals(argv);
172
+ const target = readOptionValue(argv, '--file') ?? positionals[0];
173
+ let text;
174
+ let fileName;
175
+ if (argv.includes('--stdin')) {
176
+ if (!context.readStdin)
177
+ throw new CliUsageError('accounts import --stdin is unavailable in this context');
178
+ text = await context.readStdin();
179
+ fileName = 'upload.csv';
180
+ }
181
+ else {
182
+ const filePath = path.resolve(context.cwd, target);
183
+ if (!fs.existsSync(filePath)) {
184
+ throw new CliUsageError(`accounts import: file not found: ${filePath}`);
185
+ }
186
+ text = fs.readFileSync(filePath, 'utf8');
187
+ fileName = path.basename(filePath);
188
+ }
189
+ const format = (readOptionValue(argv, '--format') ?? 'auto');
190
+ const parsed = parseAccountFile(text, format);
191
+ if (parsed.records.length === 0) {
192
+ context.io.writeStdout(jsonLine({ ok: false, imported: 0, issues: parsed.issues }));
193
+ context.io.writeStderr(`accounts import: no usable rows in ${fileName}\n`);
194
+ return 1;
195
+ }
196
+ const outcome = dryRun
197
+ ? { ...store.importRecords(parsed.records), dry_run: true }
198
+ : store.importRecords(parsed.records);
199
+ const payload = {
200
+ ok: true,
201
+ dry_run: dryRun,
202
+ file: fileName,
203
+ parsed: parsed.records.length,
204
+ ...outcome,
205
+ issues: parsed.issues,
206
+ stats: store.stats()
207
+ };
208
+ if (wantsHuman(argv)) {
209
+ const lines = [
210
+ `${dryRun ? 'would import' : 'imported'} ${parsed.records.length} rows from ${fileName}`,
211
+ ` new=${outcome.inserted} updated=${outcome.updated} rotated=${outcome.rotated} revived=${outcome.revived} unchanged=${outcome.unchanged} skipped=${outcome.skipped}`
212
+ ];
213
+ for (const issue of parsed.issues)
214
+ lines.push(` line ${issue.line}: ${issue.message}`);
215
+ context.io.writeStdout(`${lines.join('\n')}\n`);
216
+ }
217
+ else {
218
+ context.io.writeStdout(jsonLine(payload));
219
+ }
220
+ return parsed.issues.length > 0 && !argv.includes('--allow-issues') ? 1 : 0;
221
+ }
222
+ if (subcommand === 'export') {
223
+ const includeDeleted = argv.includes('--all');
224
+ const records = store.exportRecords(includeDeleted);
225
+ const text = stringifyCsv(records);
226
+ const target = readOptionValue(argv, '--file') ?? accountsPositionals(argv)[0];
227
+ if (target) {
228
+ const filePath = path.resolve(context.cwd, target);
229
+ // `mode` only applies when the file is created. An existing backup.csv
230
+ // from an editor or `touch` stays world-readable unless it is tightened
231
+ // here, and this file is the one artifact that holds plaintext credentials.
232
+ const existed = fs.existsSync(filePath);
233
+ fs.writeFileSync(filePath, text, { mode: 0o600 });
234
+ if (existed) {
235
+ try {
236
+ fs.chmodSync(filePath, 0o600);
237
+ }
238
+ catch {
239
+ // A filesystem without POSIX modes still gets the file written.
240
+ }
241
+ }
242
+ context.io.writeStdout(jsonLine({ ok: true, file: filePath, rows: records.length }));
243
+ }
244
+ else {
245
+ context.io.writeStdout(text);
246
+ }
247
+ context.io.writeStderr('accounts export: output contains plaintext credentials\n');
248
+ return 0;
249
+ }
250
+ const [brandRaw, account] = accountsPositionals(argv);
251
+ const idRaw = readOptionValue(argv, '--id');
252
+ const id = idRaw === undefined ? null : Number(idRaw);
253
+ if (subcommand === 'disable') {
254
+ const ok = id === null
255
+ ? store.disable(brandRaw, account, readOptionValue(argv, '--reason') ?? 'disabled by operator')
256
+ : store.disableById(id, readOptionValue(argv, '--reason') ?? 'disabled by operator');
257
+ context.io.writeStdout(jsonLine({ ok, ...(id === null ? { brand: normalizeBrand(brandRaw) } : { id }), action: 'disabled' }));
258
+ return ok ? 0 : 1;
259
+ }
260
+ if (subcommand === 'enable') {
261
+ const ok = id === null ? store.enable(brandRaw, account) : store.enableById(id);
262
+ if (ok === null) {
263
+ // The row exists but its membership date has passed: reporting success
264
+ // would be undone by the next read, so say why and exit non-zero.
265
+ context.io.writeStdout(jsonLine({
266
+ ok: false,
267
+ error: 'membership_expired',
268
+ ...(id === null ? { brand: normalizeBrand(brandRaw) } : { id }),
269
+ action: 'enabled'
270
+ }));
271
+ context.io.writeStderr('accounts enable: the membership date has passed; set a new one with `accounts expire --date` first\n');
272
+ return 1;
273
+ }
274
+ context.io.writeStdout(jsonLine({ ok, ...(id === null ? { brand: normalizeBrand(brandRaw) } : { id }), action: 'enabled' }));
275
+ return ok ? 0 : 1;
276
+ }
277
+ if (subcommand === 'expire') {
278
+ const date = readOptionValue(argv, '--date');
279
+ // Without --date the old code wrote '' and reported success, which erased
280
+ // a known expiry and left the row permanently un-expirable. `--clear` is
281
+ // the explicit way to say "no date known".
282
+ if (date === undefined && !argv.includes('--clear')) {
283
+ throw new CliUsageError('accounts expire requires --date YYYY-MM-DD (or --clear)');
284
+ }
285
+ const target = argv.includes('--clear') ? '' : String(date);
286
+ const ok = id === null
287
+ ? store.setExpiry(brandRaw, account, target)
288
+ : store.setExpiryById(id, target);
289
+ if (ok === null) {
290
+ context.io.writeStdout(jsonLine({ ok: false, error: 'invalid_date', ...(id === null ? { brand: normalizeBrand(brandRaw) } : { id }), action: 'expiry_set' }));
291
+ context.io.writeStderr(`accounts expire: --date must be YYYY-MM-DD, got ${target}\n`);
292
+ return 1;
293
+ }
294
+ context.io.writeStdout(jsonLine({ ok, ...(id === null ? { brand: normalizeBrand(brandRaw) } : { id }), expires_at: target, action: 'expiry_set' }));
295
+ return ok ? 0 : 1;
296
+ }
297
+ throw new CliUsageError(`unknown accounts subcommand: ${subcommand}`);
298
+ }
299
+ finally {
300
+ if (ownsStore)
301
+ store.close();
302
+ }
303
+ }
304
+ export { CSV_COLUMNS };
@@ -1,7 +1,9 @@
1
+ import { validateAccountsCommand } from '../accounts-commands.js';
1
2
  import { CliUsageError } from '../errors.js';
2
3
  import { readOptionValue } from '../global-options.js';
3
4
  const TOP_LEVEL_COMMANDS = new Set([
4
5
  'profile',
6
+ 'accounts',
5
7
  'doctor',
6
8
  'artifacts',
7
9
  'run',
@@ -90,6 +92,10 @@ export function validateCommand(argv) {
90
92
  if (command === 'serve') {
91
93
  return;
92
94
  }
95
+ if (command === 'accounts') {
96
+ validateAccountsCommand(argv);
97
+ return;
98
+ }
93
99
  if (command === 'profile') {
94
100
  const subcommand = argv[1] ?? '';
95
101
  if (!PROFILE_SUBCOMMANDS.has(subcommand)) {
@@ -8,6 +8,7 @@ import { publicStartedPayload, startDetachedResume, startDetachedRetry, startDet
8
8
  import { followLog } from '../jobs/logs.js';
9
9
  import { latestJobId, listJobs, loadJob, publicJobPayload, singleActiveJobId, tailLog } from '../jobs/read.js';
10
10
  import { readOptionValue, resolveProjectRoot } from '../runtime/paths.js';
11
+ import { runAccountsCommand } from './accounts-commands.js';
11
12
  function jsonLine(payload) {
12
13
  return `${JSON.stringify(payload)}\n`;
13
14
  }
@@ -94,6 +95,15 @@ export async function runNativeCommand(argv, context) {
94
95
  return 0;
95
96
  }
96
97
  }
98
+ if (command === 'accounts') {
99
+ return await runAccountsCommand(argv, {
100
+ cwd: context.cwd,
101
+ env: context.env,
102
+ io: context.io,
103
+ readStdin: context.readStdin,
104
+ now: context.now ? () => new Date(context.now()) : undefined
105
+ });
106
+ }
97
107
  if (command === 'artifacts') {
98
108
  const subcommand = argv[1];
99
109
  if (subcommand === 'latest') {
@@ -18,6 +18,7 @@ Usage:
18
18
 
19
19
  Commands:
20
20
  profile show|save|summary
21
+ accounts import|list|stats|snapshot|disable|enable|expire|export|template
21
22
  doctor
22
23
  artifacts latest|list|preview
23
24
  run
@@ -1,8 +1,113 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { parse, stringify } from '@iarna/toml';
4
+ import { brandSite, normalizeBrand } from '../pool/brands.js';
4
5
  import { resolveArtifactsRoot, resolveProfilePath } from '../runtime/paths.js';
5
- const DEFAULT_SOURCE_ORDER = ['leiting', 'heidong', 'mifeng', 'xuanfeng-area', 'xuanfeng-all-area'];
6
+ /**
7
+ * One entry per vendor.
8
+ *
9
+ * `xuanfeng-area` / `xuanfeng-all-area` were two profile rows for one brand's
10
+ * two route tables. The pipeline now fetches every table `/api/eversion`
11
+ * advertises and merges them, so a second row would only duplicate the work.
12
+ * Old profiles keep working: `normalizeBrand` maps both names to `xuanfeng`.
13
+ */
14
+ const DEFAULT_SOURCE_ORDER = ['leiting', 'heidong', 'mifeng', 'xuanfeng'];
15
+ /** Profile rows that name the same vendor twice, folded into one. */
16
+ const LEGACY_SOURCE_ALIASES = {
17
+ 'xuanfeng-area': 'xuanfeng',
18
+ 'xuanfeng-all-area': 'xuanfeng',
19
+ xuanfeng1: 'xuanfeng',
20
+ xuanfeng2: 'xuanfeng'
21
+ };
22
+ /**
23
+ * Fold legacy split rows into their canonical name.
24
+ *
25
+ * A row spelled with the canonical name always wins: an alias
26
+ * (`xuanfeng-area`, `leiting_ios`) is the older shape, and letting it win would
27
+ * overwrite the configured site or round budget with a stale copy. Two aliases
28
+ * collapse to the first one listed. `enabled` is merged with OR either way, so
29
+ * a disabled duplicate can never switch off a live source.
30
+ */
31
+ export function collapseLegacySources(sources) {
32
+ const out = {};
33
+ for (const [name, config] of Object.entries(sources ?? {})) {
34
+ const canonical = LEGACY_SOURCE_ALIASES[name] ?? name;
35
+ const existing = out[canonical];
36
+ if (!existing) {
37
+ out[canonical] = { ...config };
38
+ continue;
39
+ }
40
+ out[canonical] = {
41
+ ...existing,
42
+ ...(canonical === name ? config : {}),
43
+ enabled: Boolean(existing.enabled ?? true) || Boolean(config.enabled ?? true)
44
+ };
45
+ }
46
+ return out;
47
+ }
48
+ const OFFICIAL_SOURCE_KEYS = new Set(['leiting', 'heidong', 'mifeng', 'xuanfeng']);
49
+ /**
50
+ * Fields the drawer no longer edits.
51
+ *
52
+ * `min_iterations` and `platforms` are deliberately absent: both are still read
53
+ * by the round policy and by `resolveCredentialPlatforms`, so a pinned debug
54
+ * profile that sets them must keep working. Dropping them here would silently
55
+ * change how many rounds run.
56
+ */
57
+ const OBSOLETE_SOURCE_FIELDS = [
58
+ 'url', 'key', 'account', 'password', 'platform',
59
+ 'use_random_area', 'area_min', 'area_max',
60
+ 'resume_from_iteration', 'failure_limit'
61
+ ];
62
+ function positiveInteger(value, fallback) {
63
+ const parsed = Number(value);
64
+ return Number.isFinite(parsed) && parsed >= 1 ? Math.trunc(parsed) : fallback;
65
+ }
66
+ /**
67
+ * Migrate the four vendor rows to the final input contract: official site only.
68
+ *
69
+ * Credentials live in the account pool and the control-plane key is discovered
70
+ * from the APK. Both client channels run for every row, so a stored `platform`
71
+ * is no longer a choice. URL-mode round counts are not carried over: 3000 URL
72
+ * iterations meant 3000 requests, while 3000 credential rounds would mean
73
+ * 3000 requests per route per platform.
74
+ */
75
+ export function normalizeOfficialSources(sources) {
76
+ const collapsed = collapseLegacySources(sources);
77
+ const out = {};
78
+ for (const [name, config] of Object.entries(collapsed)) {
79
+ const brand = normalizeBrand(name);
80
+ if (!brand || !OFFICIAL_SOURCE_KEYS.has(brand) || !config || typeof config !== 'object') {
81
+ out[name] = config;
82
+ continue;
83
+ }
84
+ const wasCredential = config.mode === 'credential';
85
+ const normalized = {
86
+ ...config,
87
+ mode: 'credential',
88
+ official_site: String(config.official_site ?? '').trim() || brandSite(brand),
89
+ enabled: config.enabled !== false,
90
+ // 4 / 2 is the documented scheduled-run default for every row, so a
91
+ // credential row that never set rounds behaves like the shipped one.
92
+ max_iterations: wasCredential ? positiveInteger(config.max_iterations, 4) : 4,
93
+ plateau_limit: wasCredential ? positiveInteger(config.plateau_limit, 2) : 2
94
+ };
95
+ if (!wasCredential) {
96
+ // A legacy URL row's caps describe a different operation; start the
97
+ // route-loop flow at the documented scheduled-run default instead.
98
+ // 20 dry rounds meant 20 URL fetches; here each round is one request per
99
+ // route per platform, so the stored value would idle for minutes. A floor
100
+ // carried over from URL mode is the same mismatch and is dropped with it.
101
+ normalized.max_iterations = 4;
102
+ normalized.plateau_limit = 2;
103
+ delete normalized.min_iterations;
104
+ }
105
+ for (const field of OBSOLETE_SOURCE_FIELDS)
106
+ delete normalized[field];
107
+ out[brand] = normalized;
108
+ }
109
+ return out;
110
+ }
6
111
  function state(value) {
7
112
  return String(value ?? '').trim() ? 'set' : 'missing';
8
113
  }
@@ -16,19 +121,25 @@ export function profileSummary(projectRoot, env = process.env) {
16
121
  const profilePath = resolveProfilePath(projectRoot, env);
17
122
  const stateRoot = path.dirname(profilePath);
18
123
  const payload = readProfile(profilePath);
19
- const rawSources = (payload.sources ?? {});
124
+ const rawSources = normalizeOfficialSources((payload.sources ?? {}));
20
125
  const sourceNames = [
21
126
  ...DEFAULT_SOURCE_ORDER,
22
127
  ...Object.keys(rawSources).filter((name) => !DEFAULT_SOURCE_ORDER.includes(name)).sort()
23
128
  ];
24
129
  const sources = Object.fromEntries(sourceNames.map((name) => {
25
130
  const config = rawSources[name] ?? {};
131
+ const mode = config?.mode === 'credential' ? 'credential' : 'url';
26
132
  return [
27
133
  name,
28
134
  {
29
135
  enabled: Boolean(config?.enabled ?? true),
30
- url: state(config?.url),
31
- key: state(config?.key)
136
+ mode,
137
+ url: mode === 'url' ? state(config?.url) : state(config?.official_site ?? config?.apk_url),
138
+ // A credential source has no control-plane key in the profile; the key
139
+ // is discovered from the APK, so the check must not require one.
140
+ key: mode === 'url' ? state(config?.key) : state(config?.key ?? 'discovered'),
141
+ account: mode === 'credential' ? 'pool' : state(config?.account),
142
+ platform: mode === 'credential' ? 'android+ios' : String(config?.platform ?? 'android')
32
143
  }
33
144
  ];
34
145
  }));
@@ -63,8 +174,10 @@ export function profilePayload(projectRoot, env = process.env) {
63
174
  state_root: path.dirname(profilePath),
64
175
  profile_path: profilePath
65
176
  };
177
+ const raw = readProfile(profilePath);
66
178
  return {
67
- ...readProfile(profilePath),
179
+ ...raw,
180
+ sources: normalizeOfficialSources((raw.sources ?? {})),
68
181
  paths,
69
182
  workspace: paths
70
183
  };
@@ -75,6 +188,7 @@ export function saveProfilePayload(projectRoot, payload, env = process.env) {
75
188
  const persisted = { ...payload };
76
189
  delete persisted.paths;
77
190
  delete persisted.workspace;
191
+ persisted.sources = normalizeOfficialSources((persisted.sources ?? {}));
78
192
  fs.writeFileSync(profilePath, stringify(persisted), 'utf8');
79
193
  return profilePayload(projectRoot, env);
80
194
  }
@@ -10,6 +10,8 @@ const KNOWN_EVENT_TYPES = new Set([
10
10
  'extract_iteration',
11
11
  'extract_source_completed',
12
12
  'extract_source_failed',
13
+ 'extract_platform_started',
14
+ 'extract_platform_completed',
13
15
  'speedtest_runtime',
14
16
  'speedtest_probe_result',
15
17
  'speedtest_selected',