@swimmingliu/autovpn 2.0.2 → 2.1.0

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.
@@ -12,11 +12,29 @@ import { BRANDS, brandLabel, normalizeBrand } from '../pool/brands.js';
12
12
  import { CSV_COLUMNS, RETIRED_STATUSES, parseAccountFile, stringifyCsv } from '../pool/csv.js';
13
13
  import { CliUsageError } from './errors.js';
14
14
  import { readOptionValue } from './global-options.js';
15
- export const ACCOUNTS_SUBCOMMANDS = ['import', 'list', 'stats', 'snapshot', 'disable', 'enable', 'expire', 'export', 'template'];
15
+ export const ACCOUNTS_SUBCOMMANDS = ['import', 'list', 'stats', 'snapshot', 'disable', 'enable', 'expire', 'export', 'template', 'refresh-expiry'];
16
16
  /** Options that consume the next argv slot, so positionals can be found. */
17
17
  const VALUE_OPTIONS = new Set([
18
- '--project-root', '--format', '--output', '--brand', '--status', '--reason', '--date', '--file', '--limit', '--query', '--id'
18
+ '--project-root', '--format', '--output', '--brand', '--status', '--reason', '--date', '--file', '--limit', '--query', '--id',
19
+ '--concurrency'
19
20
  ]);
21
+ /**
22
+ * Resolve the pipeline worker lazily.
23
+ *
24
+ * The worker is owned by the pipeline module; a build that predates it (or a
25
+ * stripped install) must still let the rest of `accounts` work, so the command
26
+ * reports `refresh_expiry_unavailable` instead of failing at import time.
27
+ */
28
+ async function loadRefreshAccountExpiry() {
29
+ try {
30
+ const module = await import('../pipeline/credential-source.js');
31
+ const worker = module.refreshAccountExpiry;
32
+ return typeof worker === 'function' ? worker : null;
33
+ }
34
+ catch {
35
+ return null;
36
+ }
37
+ }
20
38
  export function accountsPositionals(argv) {
21
39
  const out = [];
22
40
  for (let index = 1; index < argv.length; index += 1) {
@@ -63,6 +81,19 @@ export function validateAccountsCommand(argv) {
63
81
  if (subcommand === 'import' && !positionals[0] && readOptionValue(argv, '--file') === undefined && !argv.includes('--stdin')) {
64
82
  throw new CliUsageError('accounts import requires a file path or --stdin');
65
83
  }
84
+ if (subcommand === 'refresh-expiry') {
85
+ for (const option of ['--limit', '--concurrency']) {
86
+ const value = readOptionValue(argv, option);
87
+ if (value === undefined)
88
+ continue;
89
+ if (!/^\d+$/.test(value)) {
90
+ throw new CliUsageError(`accounts refresh-expiry ${option} must be a non-negative integer`);
91
+ }
92
+ }
93
+ if (Number(readOptionValue(argv, '--concurrency') ?? 3) < 1) {
94
+ throw new CliUsageError('accounts refresh-expiry --concurrency must be a positive integer');
95
+ }
96
+ }
66
97
  if (['disable', 'enable', 'expire'].includes(subcommand)) {
67
98
  const id = readOptionValue(argv, '--id');
68
99
  if (id !== undefined) {
@@ -82,24 +113,38 @@ function renderStatsHuman(stats) {
82
113
  const lines = [
83
114
  `account pool: ${stats.total} total, ${stats.active} active`,
84
115
  ` retired: ${stats.disabled} disabled, ${stats.expired} expired, ${stats.invalid} invalid`,
85
- ` expiring within 7 days: ${stats.expiring_soon}`
116
+ ` expiring within 7 days: ${stats.expiring_soon}`,
117
+ ` expiry unknown: ${stats.expiry_unknown}`
86
118
  ];
87
119
  for (const brand of stats.by_brand) {
88
120
  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}`);
121
+ ` disabled=${brand.disabled} expired=${brand.expired} invalid=${brand.invalid} unknown=${brand.expiry_unknown}`);
90
122
  }
91
123
  lines.push(` db: ${stats.db_path}`);
92
124
  return `${lines.join('\n')}\n`;
93
125
  }
94
- function renderListHuman(rows) {
126
+ function renderListHuman(rows, hiddenRetired = 0) {
127
+ const footer = hiddenRetired > 0 ? `${hiddenRetired} retired row(s) hidden; use --all to show them\n` : '';
95
128
  if (rows.length === 0)
96
- return 'account pool: no matching accounts\n';
129
+ return footer || 'account pool: no matching accounts\n';
97
130
  const lines = ['brand account status expires uses ok/fail note'];
98
131
  for (const row of rows) {
99
132
  lines.push(`${row.brand.padEnd(9)} ${row.account_masked.padEnd(14)} ${row.status.padEnd(9)} ` +
100
133
  `${(row.expires_at || '-').padEnd(11)} ${String(row.use_count).padStart(4)} ` +
101
134
  `${row.success_count}/${row.fail_count}`.padEnd(8) + ` ${row.status_reason || row.note}`);
102
135
  }
136
+ // A sweep that retires an expired row hides it from the default view without
137
+ // a trace, which reads as "the pool lost accounts". Say how many are hidden
138
+ // and how to see them. English, like the rest of this table and the command's
139
+ // operator messages.
140
+ return `${lines.join('\n')}\n${footer}`;
141
+ }
142
+ function renderRefreshLine(event) {
143
+ return `${event.brand.padEnd(9)} ${event.account_masked.padEnd(14)} ${(event.expires_at || '-').padEnd(11)} ${event.status}${event.error ? ` ${event.error}` : ''}`;
144
+ }
145
+ function renderRefreshHuman(rows, result) {
146
+ const lines = rows.map(renderRefreshLine);
147
+ lines.push(`checked=${result.checked} updated=${result.updated} expired=${result.expired} failed=${result.failed}`);
103
148
  return `${lines.join('\n')}\n`;
104
149
  }
105
150
  export function accountsTemplate() {
@@ -114,6 +159,58 @@ export function accountsTemplate() {
114
159
  }));
115
160
  return stringifyCsv(example);
116
161
  }
162
+ /**
163
+ * `accounts refresh-expiry`: log in to pool accounts to learn their real
164
+ * membership date.
165
+ *
166
+ * The worker (brand-specific login, membership-date parsing, retries) lives in
167
+ * the pipeline; this function parses the flags, supplies the store and renders
168
+ * the result. Without the worker — an install whose dist predates it — the
169
+ * command refuses instead of pretending it checked anything.
170
+ */
171
+ async function runRefreshExpiry(argv, context) {
172
+ const worker = context.refreshExpiry === null
173
+ ? null
174
+ : context.refreshExpiry ?? await loadRefreshAccountExpiry();
175
+ if (!worker) {
176
+ context.io.writeStdout(jsonLine({ ok: false, error: 'refresh_expiry_unavailable' }));
177
+ context.io.writeStderr('accounts refresh-expiry: the installed build has no expiry refresh worker\n');
178
+ return 1;
179
+ }
180
+ const limit = Number(readOptionValue(argv, '--limit') ?? 0);
181
+ const concurrency = Number(readOptionValue(argv, '--concurrency') ?? 3);
182
+ const store = context.store ?? new AccountStore({ env: context.env, now: context.now });
183
+ const ownsStore = !context.store;
184
+ try {
185
+ let streamed = 0;
186
+ const result = await worker({
187
+ brand: readOptionValue(argv, '--brand'),
188
+ limit: Number.isFinite(limit) ? Math.max(0, Math.trunc(limit)) : 0,
189
+ concurrency: Number.isFinite(concurrency) ? Math.max(1, Math.trunc(concurrency)) : 3,
190
+ env: context.env,
191
+ store,
192
+ now: context.now,
193
+ // A refresh logs in to every account, which takes minutes per brand; the
194
+ // human view prints each outcome as it lands instead of going silent.
195
+ onProgress: wantsHuman(argv)
196
+ ? (event) => {
197
+ context.io.writeStdout(`${renderRefreshLine(event)}\n`);
198
+ streamed += 1;
199
+ }
200
+ : undefined
201
+ });
202
+ // Any result the worker did not stream (or a worker version that never
203
+ // calls onProgress) is still printed once, so human and JSON output cover
204
+ // exactly the same accounts.
205
+ const remaining = result.results.slice(streamed);
206
+ context.io.writeStdout(wantsHuman(argv) ? renderRefreshHuman(remaining, result) : jsonLine(result));
207
+ return result.failed > 0 ? 1 : 0;
208
+ }
209
+ finally {
210
+ if (ownsStore)
211
+ store.close();
212
+ }
213
+ }
117
214
  export async function runAccountsCommand(argv, context) {
118
215
  const subcommand = argv[1];
119
216
  // The template is a static document: it must work before the pool exists and
@@ -122,6 +219,9 @@ export async function runAccountsCommand(argv, context) {
122
219
  context.io.writeStdout(accountsTemplate());
123
220
  return 0;
124
221
  }
222
+ if (subcommand === 'refresh-expiry') {
223
+ return await runRefreshExpiry(argv, context);
224
+ }
125
225
  const dryRun = subcommand === 'import' && argv.includes('--dry-run');
126
226
  // A preview must not write: the store's getters sweep expired rows, so even
127
227
  // opening it read-write would mutate the pool before showing the plan.
@@ -164,7 +264,23 @@ export async function runAccountsCommand(argv, context) {
164
264
  query: readOptionValue(argv, '--query'),
165
265
  limit
166
266
  });
167
- context.io.writeStdout(wantsHuman(argv) ? renderListHuman(rows) : jsonLine({ ok: true, count: rows.length, accounts: rows }));
267
+ if (wantsHuman(argv)) {
268
+ // The default view hides every soft-deleted row, so an expiry retirement
269
+ // would look like an account that vanished. Count what the filter hid;
270
+ // `--all` and a retired-status filter already show those rows.
271
+ const hiddenRetired = includeDeleted
272
+ ? 0
273
+ : store.list({
274
+ brand: readOptionValue(argv, '--brand'),
275
+ status: 'all',
276
+ includeDeleted: true,
277
+ query: readOptionValue(argv, '--query')
278
+ }).filter((row) => row.deleted).length;
279
+ context.io.writeStdout(renderListHuman(rows, hiddenRetired));
280
+ }
281
+ else {
282
+ context.io.writeStdout(jsonLine({ ok: true, count: rows.length, accounts: rows }));
283
+ }
168
284
  return 0;
169
285
  }
170
286
  if (subcommand === 'import') {
@@ -18,7 +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
+ accounts import|list|stats|snapshot|disable|enable|expire|refresh-expiry|export|template
22
22
  doctor
23
23
  artifacts latest|list|preview
24
24
  run
@@ -87,19 +87,21 @@ export function normalizeOfficialSources(sources) {
87
87
  mode: 'credential',
88
88
  official_site: String(config.official_site ?? '').trim() || brandSite(brand),
89
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
90
+ // A round is now one cursor step over the fixed area list (one region by
91
+ // default), not a full sweep of every region. 200 / 50 preserves roughly
92
+ // the work the old 4 full sweeps did and matches the early-stop rule:
93
+ // 50 consecutive rounds with no genuinely new node.
94
+ max_iterations: wasCredential ? positiveInteger(config.max_iterations, 200) : 200,
95
+ plateau_limit: wasCredential ? positiveInteger(config.plateau_limit, 50) : 50,
96
+ areas_per_round: Math.min(64, positiveInteger(config.areas_per_round, 8)),
97
+ area_concurrency: Math.min(16, positiveInteger(config.area_concurrency, 4))
94
98
  };
95
99
  if (!wasCredential) {
96
100
  // 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
101
+ // cursor flow at the documented scheduled-run default instead. A floor
100
102
  // 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
+ normalized.max_iterations = 200;
104
+ normalized.plateau_limit = 50;
103
105
  delete normalized.min_iterations;
104
106
  }
105
107
  for (const field of OBSOLETE_SOURCE_FIELDS)
@@ -111,6 +113,33 @@ export function normalizeOfficialSources(sources) {
111
113
  function state(value) {
112
114
  return String(value ?? '').trim() ? 'set' : 'missing';
113
115
  }
116
+ /**
117
+ * Crawl egress defaults, surfaced so the settings drawer can render them.
118
+ *
119
+ * `proxy` is the product default: the crawl goes through the upstream proxy and
120
+ * drops to direct only when the proxy is not listening. `auto` starts direct
121
+ * and pins the proxy on the first blocked response; `direct` never proxies.
122
+ * The pipeline reads the same values from the environment, which the
123
+ * orchestrator and the Electron backend env build from this section.
124
+ */
125
+ export function normalizeNetworkSection(value) {
126
+ const raw = (value && typeof value === 'object' ? value : {});
127
+ const mode = String(raw.proxy_mode ?? 'proxy').trim().toLowerCase();
128
+ return {
129
+ ...raw,
130
+ proxy_mode: ['proxy', 'direct', 'auto'].includes(mode) ? mode : 'proxy',
131
+ proxy_url: String(raw.proxy_url ?? 'http://127.0.0.1:7897').trim() || 'http://127.0.0.1:7897'
132
+ };
133
+ }
134
+ /** Map the profile's [network] section onto the env the pipeline reads. */
135
+ export function networkEnv(section, base = {}) {
136
+ const network = normalizeNetworkSection(section);
137
+ return {
138
+ ...base,
139
+ VPN_AUTOMATION_PROXY_MODE: network.proxy_mode,
140
+ VPN_AUTOMATION_UPSTREAM_PROXY: network.proxy_url
141
+ };
142
+ }
114
143
  function readProfile(profilePath) {
115
144
  if (!fs.existsSync(profilePath)) {
116
145
  return {};
@@ -178,6 +207,7 @@ export function profilePayload(projectRoot, env = process.env) {
178
207
  return {
179
208
  ...raw,
180
209
  sources: normalizeOfficialSources((raw.sources ?? {})),
210
+ network: normalizeNetworkSection(raw.network),
181
211
  paths,
182
212
  workspace: paths
183
213
  };
@@ -189,6 +219,7 @@ export function saveProfilePayload(projectRoot, payload, env = process.env) {
189
219
  delete persisted.paths;
190
220
  delete persisted.workspace;
191
221
  persisted.sources = normalizeOfficialSources((persisted.sources ?? {}));
222
+ persisted.network = normalizeNetworkSection(persisted.network);
192
223
  fs.writeFileSync(profilePath, stringify(persisted), 'utf8');
193
224
  return profilePayload(projectRoot, env);
194
225
  }