@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/artifacts/list.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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') {
|
package/dist/cli/output.js
CHANGED
package/dist/config/profile.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
31
|
-
|
|
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
|
-
...
|
|
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
|
}
|
package/dist/events/schema.js
CHANGED
|
@@ -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',
|