@carlelieser/nexus-cli 0.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.
@@ -0,0 +1,1247 @@
1
+ #!/usr/bin/env node
2
+ import yargs from "yargs";
3
+ import { hideBin } from "yargs/helpers";
4
+ import ora from "ora";
5
+ import { platform, homedir, tmpdir } from "node:os";
6
+ import { join, basename, dirname } from "node:path";
7
+ import { mkdtemp, rm, readFile, mkdir, rename, access, writeFile } from "node:fs/promises";
8
+ import { Camoufox } from "camoufox-js";
9
+ import { createWriteStream } from "node:fs";
10
+ import { Readable } from "node:stream";
11
+ import { pipeline } from "node:stream/promises";
12
+ class NexusError extends Error {
13
+ constructor(message, options) {
14
+ super(message, options);
15
+ this.name = new.target.name;
16
+ }
17
+ }
18
+ class AuthError extends NexusError {
19
+ kind = "auth";
20
+ }
21
+ class ScrapeError extends NexusError {
22
+ kind = "scrape";
23
+ }
24
+ class DownloadError extends NexusError {
25
+ kind = "download";
26
+ }
27
+ class NetworkError extends NexusError {
28
+ kind = "network";
29
+ }
30
+ class ThrottleError extends NexusError {
31
+ kind = "throttle";
32
+ }
33
+ class CancelError extends NexusError {
34
+ kind = "cancel";
35
+ }
36
+ function isCancel(e) {
37
+ return e instanceof CancelError || e instanceof Error && e.name === "AbortError";
38
+ }
39
+ function isNexusError(e) {
40
+ return e instanceof NexusError;
41
+ }
42
+ function summarize(results) {
43
+ return {
44
+ results,
45
+ succeeded: results.filter((r) => r.ok).length,
46
+ failed: results.filter((r) => !r.ok).length
47
+ };
48
+ }
49
+ const DEFAULT_BACKOFF = {
50
+ baseDelayMs: 2e3,
51
+ maxDelayMs: 6e4,
52
+ relaxAfter: 5
53
+ };
54
+ class BackoffPolicy {
55
+ delayMs = 0;
56
+ concurrency;
57
+ cleanStreak = 0;
58
+ cfg;
59
+ constructor(cfg) {
60
+ this.cfg = cfg;
61
+ this.concurrency = cfg.maxConcurrency;
62
+ }
63
+ /** Current inter-mod delay to wait before starting the next member. */
64
+ get currentDelayMs() {
65
+ return this.delayMs;
66
+ }
67
+ /** Current effective concurrency. */
68
+ get currentConcurrency() {
69
+ return this.concurrency;
70
+ }
71
+ /** Record a throttle signal (429 / Cloudflare / repeated timeout). */
72
+ onThrottle() {
73
+ this.cleanStreak = 0;
74
+ this.delayMs = this.delayMs === 0 ? this.cfg.baseDelayMs : Math.min(this.delayMs * 2, this.cfg.maxDelayMs);
75
+ this.concurrency = Math.max(1, this.concurrency - 1);
76
+ }
77
+ /** Record a clean success; may relax pacing after enough in a row. */
78
+ onSuccess() {
79
+ this.cleanStreak += 1;
80
+ if (this.cleanStreak < this.cfg.relaxAfter) return;
81
+ this.cleanStreak = 0;
82
+ if (this.concurrency < this.cfg.maxConcurrency) {
83
+ this.concurrency += 1;
84
+ } else if (this.delayMs > 0) {
85
+ this.delayMs = Math.floor(this.delayMs / 2);
86
+ if (this.delayMs < this.cfg.baseDelayMs / 2) this.delayMs = 0;
87
+ }
88
+ }
89
+ }
90
+ const defaultSleep$1 = (ms) => new Promise((r) => setTimeout(r, ms));
91
+ function isThrottle(e) {
92
+ if (e instanceof ThrottleError) return true;
93
+ const msg = (e instanceof Error ? e.message : String(e)).toLowerCase();
94
+ return msg.includes("429") || msg.includes("too many requests") || msg.includes("cloudflare") || msg.includes("just a moment") || msg.includes("timeout");
95
+ }
96
+ async function withRetry(fn, opts) {
97
+ const sleep = opts.sleep ?? defaultSleep$1;
98
+ let lastErr;
99
+ for (let attempt = 1; attempt <= opts.attempts; attempt++) {
100
+ opts.signal?.throwIfAborted();
101
+ try {
102
+ return await fn();
103
+ } catch (e) {
104
+ if (isCancel(e) || opts.signal?.aborted) throw e;
105
+ lastErr = e;
106
+ if (attempt === opts.attempts) break;
107
+ await sleep(opts.baseDelayMs * 2 ** (attempt - 1));
108
+ }
109
+ }
110
+ throw lastErr;
111
+ }
112
+ const defaultSleep = (ms) => new Promise((r) => setTimeout(r, ms));
113
+ async function downloadCollection(deps, session, params) {
114
+ const sleep = params.sleep ?? defaultSleep;
115
+ params.signal?.throwIfAborted();
116
+ const req = deps.site.collectionMembersQuery(params.game, params.ref);
117
+ const json = await session.postJson(req.url, req.body, req.headers);
118
+ const all = deps.site.parseCollectionMembers(json);
119
+ const members = params.includeOptional ? all : all.filter((m) => !m.optional);
120
+ params.onResolved?.(members);
121
+ const policy = new BackoffPolicy({
122
+ maxConcurrency: params.concurrency,
123
+ ...DEFAULT_BACKOFF
124
+ });
125
+ const results = [];
126
+ for (let i = 0; i < members.length; i++) {
127
+ params.signal?.throwIfAborted();
128
+ const member = members[i];
129
+ if (policy.currentDelayMs > 0) {
130
+ await sleep(policy.currentDelayMs);
131
+ }
132
+ params.onStart?.(member, i + 1, members.length);
133
+ const result = await runOne(deps, session, params, member);
134
+ results.push(result);
135
+ if (result.ok) policy.onSuccess();
136
+ else if (result.throttled) policy.onThrottle();
137
+ params.onProgress?.(result);
138
+ }
139
+ return summarize(results);
140
+ }
141
+ async function runOne(deps, session, params, member) {
142
+ if (params.dryRun) {
143
+ return {
144
+ modId: member.modId,
145
+ ok: true,
146
+ files: [member.name ?? `mod ${member.modId} file ${member.fileId}`]
147
+ };
148
+ }
149
+ const target = {
150
+ url: deps.site.fileDownloadUrl(member.game, member.modId, member.fileId),
151
+ fileId: member.fileId,
152
+ category: member.optional ? "optional" : "main",
153
+ ...member.name ? { fileName: member.name } : {}
154
+ };
155
+ try {
156
+ const path = await withRetry(
157
+ () => deps.downloader.fetch(target, params.outDir, session, params.onFileProgress, params.signal),
158
+ {
159
+ attempts: params.retryAttempts,
160
+ baseDelayMs: params.retryBaseDelayMs,
161
+ ...params.sleep ? { sleep: params.sleep } : {},
162
+ ...params.signal ? { signal: params.signal } : {}
163
+ }
164
+ );
165
+ return { modId: member.modId, ok: true, files: [path] };
166
+ } catch (e) {
167
+ if (isCancel(e)) throw e;
168
+ const message = e instanceof Error ? e.message : String(e);
169
+ return {
170
+ modId: member.modId,
171
+ ok: false,
172
+ files: [],
173
+ error: message,
174
+ throttled: isThrottle(e)
175
+ };
176
+ }
177
+ }
178
+ async function downloadMod(deps, session, params) {
179
+ params.signal?.throwIfAborted();
180
+ const url = deps.site.modFilesUrl(params.game, params.modId);
181
+ const landed = await session.goto(url);
182
+ if (deps.site.isAuthRedirect(landed)) {
183
+ throw new AuthError("session expired or not authenticated");
184
+ }
185
+ const html = await session.html();
186
+ const all = deps.site.parseDownloadTargets(html);
187
+ const main = all.filter((t) => t.category === "main");
188
+ if (main.length === 0) {
189
+ throw new ScrapeError(`mod ${params.modId} has no main files`);
190
+ }
191
+ if (params.dryRun) {
192
+ return {
193
+ modId: params.modId,
194
+ ok: true,
195
+ files: main.map((t) => t.fileName ?? `file-${t.fileId}`)
196
+ };
197
+ }
198
+ const files = [];
199
+ for (const target of main) {
200
+ params.signal?.throwIfAborted();
201
+ const path = await withRetry(
202
+ () => deps.downloader.fetch(target, params.outDir, session, params.onFileProgress, params.signal),
203
+ {
204
+ attempts: params.retryAttempts,
205
+ baseDelayMs: params.retryBaseDelayMs,
206
+ ...params.sleep ? { sleep: params.sleep } : {},
207
+ ...params.signal ? { signal: params.signal } : {}
208
+ }
209
+ );
210
+ files.push(path);
211
+ }
212
+ return { modId: params.modId, ok: true, files };
213
+ }
214
+ async function restoreSession(deps, headful) {
215
+ const saved = await deps.store.load();
216
+ if (!saved) {
217
+ throw new AuthError("no saved session — run `nexus import --from chrome` first");
218
+ }
219
+ const session = await deps.browser.launch({ headful });
220
+ await session.setCookies(saved.cookies);
221
+ if (!await session.isLoggedIn()) {
222
+ await session.close();
223
+ throw new AuthError("session expired — run `nexus import --from chrome` again");
224
+ }
225
+ return session;
226
+ }
227
+ const APP = "nexus-cli";
228
+ function configDir() {
229
+ const xdg = process.env.XDG_CONFIG_HOME;
230
+ if (xdg) return join(xdg, APP);
231
+ switch (platform()) {
232
+ case "win32":
233
+ return join(process.env.APPDATA ?? join(homedir(), "AppData", "Roaming"), APP);
234
+ case "darwin":
235
+ return join(homedir(), "Library", "Application Support", APP);
236
+ default:
237
+ return join(homedir(), ".config", APP);
238
+ }
239
+ }
240
+ function sessionFile() {
241
+ return join(configDir(), "session.json");
242
+ }
243
+ function defaultOutDir(game) {
244
+ return join(process.cwd(), "downloads", game);
245
+ }
246
+ const MOD = /nexusmods\.com\/(?:games\/)?([^/]+)\/mods\/(\d+)/i;
247
+ const COLLECTION = /nexusmods\.com\/games\/([^/]+)\/collections\/([^/?#]+)/i;
248
+ function parseNexusUrl(input) {
249
+ const collection = COLLECTION.exec(input);
250
+ if (collection) {
251
+ return { game: collection[1], collection: collection[2] };
252
+ }
253
+ const mod = MOD.exec(input);
254
+ if (mod) {
255
+ return { game: mod[1], modId: Number(mod[2]) };
256
+ }
257
+ return null;
258
+ }
259
+ const out = {
260
+ info(msg) {
261
+ process.stdout.write(`${msg}
262
+ `);
263
+ },
264
+ success(msg) {
265
+ process.stdout.write(`✓ ${msg}
266
+ `);
267
+ },
268
+ warn(msg) {
269
+ process.stderr.write(`! ${msg}
270
+ `);
271
+ },
272
+ error(e, verbose) {
273
+ if (verbose && e instanceof Error && e.stack) {
274
+ process.stderr.write(`${e.stack}
275
+ `);
276
+ return;
277
+ }
278
+ const prefix = isNexusError(e) ? `${e.kind} error` : "error";
279
+ const msg = e instanceof Error ? e.message : String(e);
280
+ process.stderr.write(`✗ ${prefix}: ${msg}
281
+ `);
282
+ }
283
+ };
284
+ const ACCOUNT_URL = "https://www.nexusmods.com/users/myaccount";
285
+ const SIGN_IN_HOST$1 = "users.nexusmods.com";
286
+ const NEXUS_DOMAIN = ".nexusmods.com";
287
+ const CHALLENGE_TIMEOUT_MS = 25e3;
288
+ const RESOLVE_TIMEOUT_MS = 6e4;
289
+ function isChallenge(response) {
290
+ return response?.headers()["cf-mitigated"] === "challenge";
291
+ }
292
+ class CamoufoxBrowser {
293
+ async launch(opts) {
294
+ const userDataDir = await mkdtemp(join(tmpdir(), "nexus-camoufox-"));
295
+ const context = await Camoufox({
296
+ headless: !opts.headful,
297
+ user_data_dir: userDataDir,
298
+ humanize: true,
299
+ // Pin locale to match the imported session's origin. geoip is left OFF:
300
+ // when it resolved to a different region than the session cookies, the
301
+ // fingerprint/cookie mismatch triggered a hard Cloudflare challenge.
302
+ locale: "en-US"
303
+ });
304
+ const page = context.pages()[0] ?? await context.newPage();
305
+ return new CamoufoxSession(context, page, userDataDir);
306
+ }
307
+ }
308
+ class CamoufoxSession {
309
+ constructor(context, page, userDataDir) {
310
+ this.context = context;
311
+ this.page = page;
312
+ this.userDataDir = userDataDir;
313
+ }
314
+ async goto(url) {
315
+ await this.navigate(url);
316
+ return this.page.url();
317
+ }
318
+ /**
319
+ * Navigate to `url` and, if Cloudflare intercepts with a challenge, wait for
320
+ * Camoufox to auto-solve it. Returns the response for the real page.
321
+ *
322
+ * A challenge is identified by the `cf-mitigated: challenge` response header —
323
+ * a protocol signal, not page markup. When it's absent the response is the
324
+ * real page and we return at once; when present, Camoufox's auto-solve fires
325
+ * its own navigation to the real page, which we wait for.
326
+ */
327
+ async navigate(url) {
328
+ let response;
329
+ try {
330
+ response = await this.page.goto(url, { waitUntil: "commit" });
331
+ } catch (e) {
332
+ throw new NetworkError(`failed to load ${url}`, { cause: e });
333
+ }
334
+ const deadline = Date.now() + CHALLENGE_TIMEOUT_MS;
335
+ while (isChallenge(response) && Date.now() < deadline) {
336
+ response = await this.page.waitForNavigation({ waitUntil: "commit", timeout: deadline - Date.now() }).catch(() => response);
337
+ }
338
+ await this.page.waitForLoadState("domcontentloaded", { timeout: 1e4 }).catch(() => void 0);
339
+ return response;
340
+ }
341
+ async setCookies(cookies) {
342
+ await this.context.addCookies(
343
+ cookies.map((c) => ({
344
+ name: c.name,
345
+ value: c.value,
346
+ domain: c.domain || NEXUS_DOMAIN,
347
+ path: c.path || "/",
348
+ ...c.expires !== void 0 ? { expires: c.expires } : {},
349
+ ...c.httpOnly !== void 0 ? { httpOnly: c.httpOnly } : {},
350
+ ...c.secure !== void 0 ? { secure: c.secure } : {},
351
+ ...c.sameSite ? { sameSite: c.sameSite } : {}
352
+ }))
353
+ );
354
+ }
355
+ async isLoggedIn() {
356
+ try {
357
+ await this.navigate(ACCOUNT_URL);
358
+ } catch {
359
+ return false;
360
+ }
361
+ if (new URL(this.page.url()).host === SIGN_IN_HOST$1) return false;
362
+ const url = this.page.url();
363
+ return url.includes("/users/") || url.includes("/settings");
364
+ }
365
+ async html() {
366
+ for (let attempt = 0; attempt < 3; attempt++) {
367
+ try {
368
+ await this.page.waitForLoadState("domcontentloaded", { timeout: 1e4 });
369
+ return await this.page.content();
370
+ } catch {
371
+ await this.page.waitForTimeout(500);
372
+ }
373
+ }
374
+ return this.page.content();
375
+ }
376
+ async postJson(url, body, headers = {}) {
377
+ return this.page.evaluate(
378
+ async ({ url: url2, body: body2, headers: headers2 }) => {
379
+ const res = await fetch(url2, {
380
+ method: "POST",
381
+ credentials: "include",
382
+ headers: { "content-type": "application/json", ...headers2 },
383
+ body: JSON.stringify(body2)
384
+ });
385
+ if (!res.ok) throw new Error(`HTTP ${res.status} from ${url2}`);
386
+ return res.json();
387
+ },
388
+ { url, body, headers }
389
+ );
390
+ }
391
+ async resolveUsername() {
392
+ await this.navigate(ACCOUNT_URL).catch(() => void 0);
393
+ return this.page.evaluate(() => {
394
+ const el = document.querySelector("[data-username]") ?? document.querySelector("#login-name, .username");
395
+ const ds = el?.dataset?.username;
396
+ if (ds) return ds;
397
+ const text = el?.textContent?.trim();
398
+ return text && text.length > 0 ? text : null;
399
+ }).catch(() => null);
400
+ }
401
+ async resolveDownloadUrl(filePageUrl) {
402
+ try {
403
+ await this.navigate(filePageUrl);
404
+ const slowButton = this.page.getByRole("button", { name: /slow download/i });
405
+ await slowButton.waitFor({ state: "visible", timeout: 3e4 });
406
+ const respPromise = this.page.waitForResponse((r) => /GenerateDownloadUrl/i.test(r.url()), {
407
+ timeout: RESOLVE_TIMEOUT_MS
408
+ });
409
+ this.page.once("download", (d) => void d.cancel().catch(() => void 0));
410
+ await slowButton.click();
411
+ const resp = await respPromise;
412
+ if (!resp.ok()) {
413
+ throw new NetworkError(`download resolver returned HTTP ${resp.status()}`);
414
+ }
415
+ const cdnUrl = (await resp.json()).url;
416
+ if (!cdnUrl) throw new NetworkError("resolver returned no download URL");
417
+ const cookieHeader = (await this.context.cookies()).map((c) => `${c.name}=${c.value}`).join("; ");
418
+ const userAgent = await this.page.evaluate(() => navigator.userAgent);
419
+ return { cdnUrl, cookieHeader, userAgent };
420
+ } catch (e) {
421
+ if (e instanceof NetworkError) throw e;
422
+ throw new NetworkError(`failed to resolve download for ${filePageUrl}`, {
423
+ cause: e
424
+ });
425
+ }
426
+ }
427
+ async close() {
428
+ await this.context.close().catch(() => void 0);
429
+ await rm(this.userDataDir, { recursive: true, force: true }).catch(() => void 0);
430
+ }
431
+ }
432
+ const CHROMIUM_VARIANTS = [
433
+ "chromium",
434
+ "brave",
435
+ "edge",
436
+ "opera",
437
+ "opera-gx",
438
+ "vivaldi",
439
+ "arc",
440
+ "whale"
441
+ ];
442
+ function isChromiumVariant(key) {
443
+ return CHROMIUM_VARIANTS.includes(key);
444
+ }
445
+ const DISPLAY = {
446
+ chrome: "Chrome",
447
+ chromium: "Chromium",
448
+ brave: "Brave",
449
+ edge: "Edge",
450
+ opera: "Opera",
451
+ "opera-gx": "Opera GX",
452
+ vivaldi: "Vivaldi",
453
+ arc: "Arc",
454
+ whale: "Whale",
455
+ firefox: "Firefox",
456
+ safari: "Safari"
457
+ };
458
+ class BrowserCookieSource {
459
+ browser;
460
+ key;
461
+ constructor(from) {
462
+ this.key = from.toLowerCase();
463
+ if (!(this.key in DISPLAY)) {
464
+ throw new AuthError(
465
+ `unsupported browser '${from}' (supported: ${Object.keys(DISPLAY).join(", ")})`
466
+ );
467
+ }
468
+ this.browser = DISPLAY[this.key];
469
+ }
470
+ async read(domainSuffix) {
471
+ const strategy = await strategyFor(this.key);
472
+ let found;
473
+ try {
474
+ found = await strategy.queryCookies("%", domainSuffix);
475
+ } catch (e) {
476
+ throw new AuthError(`could not read ${this.browser} cookies`, { cause: e });
477
+ }
478
+ return found.map(toCookie);
479
+ }
480
+ }
481
+ async function strategyFor(key) {
482
+ process.env.DOTENV_CONFIG_QUIET = "true";
483
+ const gc = await import("@mherod/get-cookie");
484
+ if (key === "chrome") return new gc.ChromeCookieQueryStrategy();
485
+ if (key === "firefox") return new gc.FirefoxCookieQueryStrategy();
486
+ if (key === "safari") return new gc.SafariCookieQueryStrategy();
487
+ if (isChromiumVariant(key)) return new gc.ChromiumCookieQueryStrategy(key);
488
+ throw new AuthError(`unsupported browser '${key}'`);
489
+ }
490
+ function toCookie(c) {
491
+ const cookie = {
492
+ name: c.name,
493
+ value: String(c.value),
494
+ domain: c.domain,
495
+ path: c.meta?.path ?? "/"
496
+ };
497
+ if (c.meta?.secure !== void 0) cookie.secure = c.meta.secure;
498
+ if (c.meta?.httpOnly !== void 0) cookie.httpOnly = c.meta.httpOnly;
499
+ const expires = expiryToUnix(c.expiry);
500
+ if (expires !== void 0) cookie.expires = expires;
501
+ return cookie;
502
+ }
503
+ function expiryToUnix(expiry) {
504
+ if (expiry === void 0 || expiry === "Infinity") return void 0;
505
+ if (expiry instanceof Date) return Math.floor(expiry.getTime() / 1e3);
506
+ return expiry > 1e12 ? Math.floor(expiry / 1e3) : expiry;
507
+ }
508
+ class FileCookieSource {
509
+ constructor(path) {
510
+ this.path = path;
511
+ this.browser = `file ${path}`;
512
+ }
513
+ browser;
514
+ async read(domainSuffix) {
515
+ let raw;
516
+ try {
517
+ raw = await readFile(this.path, "utf8");
518
+ } catch (e) {
519
+ throw new AuthError(`could not read cookie file ${this.path}`, { cause: e });
520
+ }
521
+ const cookies = looksLikeJson(raw) ? parseJson(raw, this.path) : parseNetscape(raw);
522
+ return cookies.filter((c) => hostMatches(c.domain, domainSuffix));
523
+ }
524
+ }
525
+ function looksLikeJson(raw) {
526
+ return /^\s*[[{]/.test(raw);
527
+ }
528
+ function parseJson(raw, path) {
529
+ let data;
530
+ try {
531
+ data = JSON.parse(raw);
532
+ } catch (e) {
533
+ throw new AuthError(`cookie file ${path} is not valid JSON`, { cause: e });
534
+ }
535
+ const arr = Array.isArray(data) ? data : Array.isArray(data.cookies) ? data.cookies : null;
536
+ if (!arr) {
537
+ throw new AuthError(`cookie file ${path} is not a JSON cookie array`);
538
+ }
539
+ const cookies = [];
540
+ for (const entry of arr) {
541
+ const c = entry;
542
+ if (!c.name || c.value === void 0 || !c.domain) continue;
543
+ const expires = c.expirationDate ?? c.expires;
544
+ cookies.push({
545
+ name: c.name,
546
+ value: c.value,
547
+ domain: c.domain,
548
+ path: c.path ?? "/",
549
+ secure: Boolean(c.secure),
550
+ httpOnly: Boolean(c.httpOnly),
551
+ sameSite: sameSiteOf(c.sameSite),
552
+ ...typeof expires === "number" && expires > 0 ? { expires: Math.floor(expires) } : {}
553
+ });
554
+ }
555
+ return cookies;
556
+ }
557
+ function parseNetscape(raw) {
558
+ const cookies = [];
559
+ for (const line of raw.split("\n")) {
560
+ const trimmed = line.trim();
561
+ if (!trimmed || trimmed.startsWith("#") && !trimmed.startsWith("#HttpOnly_")) continue;
562
+ const f = trimmed.split(" ");
563
+ if (f.length < 7) continue;
564
+ let domain = f[0];
565
+ let httpOnly = false;
566
+ if (domain.startsWith("#HttpOnly_")) {
567
+ domain = domain.slice("#HttpOnly_".length);
568
+ httpOnly = true;
569
+ }
570
+ const expires = Number(f[4]);
571
+ const cookie = {
572
+ name: f[5],
573
+ value: f[6],
574
+ domain,
575
+ path: f[2] ?? "/",
576
+ secure: f[3].toUpperCase() === "TRUE",
577
+ httpOnly,
578
+ sameSite: "Lax"
579
+ };
580
+ if (Number.isFinite(expires) && expires > 0) cookie.expires = expires;
581
+ cookies.push(cookie);
582
+ }
583
+ return cookies;
584
+ }
585
+ function sameSiteOf(v) {
586
+ switch (v?.toLowerCase()) {
587
+ case "strict":
588
+ return "Strict";
589
+ case "no_restriction":
590
+ case "none":
591
+ return "None";
592
+ default:
593
+ return "Lax";
594
+ }
595
+ }
596
+ function hostMatches(domain, suffix) {
597
+ const host = domain.replace(/^\./, "").toLowerCase();
598
+ const s = suffix.toLowerCase();
599
+ return host === s || host.endsWith(`.${s}`);
600
+ }
601
+ class BrowserDownloader {
602
+ async fetch(target, outDir, session, onProgress, signal) {
603
+ signal?.throwIfAborted();
604
+ await mkdir(outDir, { recursive: true });
605
+ const resolved = await session.resolveDownloadUrl(target.url);
606
+ const name = filenameFrom(resolved.cdnUrl) ?? fallbackName(target);
607
+ const finalPath = join(outDir, name);
608
+ if (await exists(finalPath)) return finalPath;
609
+ const partPath = `${finalPath}.part`;
610
+ let res;
611
+ try {
612
+ res = await fetch(resolved.cdnUrl, {
613
+ headers: {
614
+ cookie: resolved.cookieHeader,
615
+ "user-agent": resolved.userAgent
616
+ },
617
+ ...signal ? { signal } : {}
618
+ });
619
+ } catch (e) {
620
+ if (signal?.aborted) throw e;
621
+ throw new NetworkError(`failed to fetch file ${target.fileId}`, { cause: e });
622
+ }
623
+ if (!res.ok || !res.body) {
624
+ throw new DownloadError(`download for file ${target.fileId} returned HTTP ${res.status}`);
625
+ }
626
+ const totalBytes = Number(res.headers.get("content-length") ?? 0);
627
+ let receivedBytes = 0;
628
+ const source = Readable.fromWeb(res.body);
629
+ source.on("data", (chunk) => {
630
+ receivedBytes += chunk.length;
631
+ onProgress?.({ receivedBytes, totalBytes });
632
+ });
633
+ try {
634
+ await pipeline(source, createWriteStream(partPath), signal ? { signal } : {});
635
+ } catch (e) {
636
+ await rm(partPath, { force: true });
637
+ if (signal?.aborted) throw e;
638
+ throw new DownloadError(`failed writing file ${target.fileId}`, { cause: e });
639
+ }
640
+ await rename(partPath, finalPath);
641
+ return finalPath;
642
+ }
643
+ }
644
+ function filenameFrom(cdnUrl) {
645
+ try {
646
+ const path = new URL(cdnUrl).pathname;
647
+ const base = decodeURIComponent(basename(path));
648
+ return base.length > 0 ? sanitize(base) : null;
649
+ } catch {
650
+ return null;
651
+ }
652
+ }
653
+ function fallbackName(target) {
654
+ if (target.fileName) {
655
+ const base = sanitize(target.fileName);
656
+ return /\.[a-z0-9]{2,4}$/i.test(base) ? base : `${base}-${target.fileId}`;
657
+ }
658
+ return `file-${target.fileId}`;
659
+ }
660
+ function sanitize(name) {
661
+ return name.replace(/[/\\?%*:|"<>]/g, "-").replace(/\s+/g, " ").trim();
662
+ }
663
+ async function exists(p) {
664
+ try {
665
+ await access(p);
666
+ return true;
667
+ } catch {
668
+ return false;
669
+ }
670
+ }
671
+ const BASE = "https://www.nexusmods.com";
672
+ const GRAPHQL_URL = "https://api-router.nexusmods.com/graphql";
673
+ const SIGN_IN_HOST = "users.nexusmods.com";
674
+ const COLLECTION_QUERY = `
675
+ query CollectionRevisionMods($slug: String!, $viewAdultContent: Boolean = true) {
676
+ collectionRevision(slug: $slug, viewAdultContent: $viewAdultContent) {
677
+ modFiles {
678
+ fileId
679
+ optional
680
+ file {
681
+ name
682
+ sizeInBytes
683
+ mod {
684
+ modId
685
+ name
686
+ game { domainName }
687
+ }
688
+ }
689
+ }
690
+ }
691
+ }`;
692
+ class NexusWebAdapter {
693
+ modFilesUrl(game, modId) {
694
+ return `${BASE}/${game}/mods/${modId}?tab=files`;
695
+ }
696
+ collectionUrl(game, ref) {
697
+ return `${BASE}/games/${game}/collections/${ref}/mods`;
698
+ }
699
+ collectionMembersQuery(_game, ref) {
700
+ return {
701
+ url: GRAPHQL_URL,
702
+ body: {
703
+ operationName: "CollectionRevisionMods",
704
+ query: COLLECTION_QUERY,
705
+ variables: { slug: ref, viewAdultContent: true }
706
+ },
707
+ headers: { "x-graphql-operationname": "CollectionRevisionMods" }
708
+ };
709
+ }
710
+ parseCollectionMembers(json) {
711
+ const modFiles = json?.data?.collectionRevision?.modFiles;
712
+ if (!Array.isArray(modFiles)) {
713
+ throw new ScrapeError("unexpected collection response shape");
714
+ }
715
+ const members = [];
716
+ for (const entry of modFiles) {
717
+ const mod = entry.file?.mod;
718
+ const modId = mod?.modId;
719
+ const game = mod?.game?.domainName;
720
+ const fileId = entry.fileId;
721
+ if (!modId || !game || !fileId) continue;
722
+ const sizeBytes = Number(entry.file?.sizeInBytes);
723
+ members.push({
724
+ game,
725
+ modId,
726
+ fileId,
727
+ optional: Boolean(entry.optional),
728
+ ...entry.file?.name ? { name: entry.file.name } : {},
729
+ ...Number.isFinite(sizeBytes) && sizeBytes > 0 ? { sizeBytes } : {}
730
+ });
731
+ }
732
+ if (members.length === 0) {
733
+ throw new ScrapeError("collection has no downloadable files");
734
+ }
735
+ return members;
736
+ }
737
+ fileDownloadUrl(game, modId, fileId) {
738
+ return `${BASE}/${game}/mods/${modId}?tab=files&file_id=${fileId}`;
739
+ }
740
+ parseDownloadTargets(html) {
741
+ const base = modBaseUrl(html);
742
+ const targets = [];
743
+ for (const segment of segmentByCategory(html)) {
744
+ const tagRe = /<dt[^>]*id="file-expander-header-(\d+)"[^>]*>/gi;
745
+ let m;
746
+ while ((m = tagRe.exec(segment.body)) !== null) {
747
+ const fileId = Number(m[1]);
748
+ if (!Number.isFinite(fileId)) continue;
749
+ const tag = m[0];
750
+ const name = (/data-name="([^"]*)"/i.exec(tag)?.[1] ?? "").trim();
751
+ const version = (/data-version="([^"]*)"/i.exec(tag)?.[1] ?? "").trim();
752
+ const fileName = name ? version ? `${name} ${version}` : name : void 0;
753
+ targets.push({
754
+ url: downloadUrl(base, fileId),
755
+ fileId,
756
+ category: segment.category,
757
+ ...fileName ? { fileName } : {}
758
+ });
759
+ }
760
+ }
761
+ return targets;
762
+ }
763
+ isAuthRedirect(landedUrl) {
764
+ try {
765
+ return new URL(landedUrl).host === SIGN_IN_HOST;
766
+ } catch {
767
+ return false;
768
+ }
769
+ }
770
+ }
771
+ function segmentByCategory(html) {
772
+ const markerRe = (
773
+ // 1) Live container ids: id="file-container-main-files"
774
+ // 2) Legacy text headers: <h3>Main files</h3> / "Old versions"
775
+ /id="file-container-(main|optional|old|miscellaneous)-files"|<(?:h[1-6]|div|dt)[^>]*>\s*((?:main|optional|old|miscellaneous)[^<]*?files?|old versions?)\s*<\/(?:h[1-6]|div|dt)>/gi
776
+ );
777
+ const marks = [];
778
+ let m;
779
+ while ((m = markerRe.exec(html)) !== null) {
780
+ const category = m[1] ? categoryOf(m[1]) : categoryOf(m[2] ?? "");
781
+ marks.push({ index: m.index, category });
782
+ }
783
+ if (marks.length === 0) {
784
+ return [{ category: "unknown", body: html }];
785
+ }
786
+ const segments = [];
787
+ for (let i = 0; i < marks.length; i++) {
788
+ const start = marks[i].index;
789
+ const end = i + 1 < marks.length ? marks[i + 1].index : html.length;
790
+ segments.push({ category: marks[i].category, body: html.slice(start, end) });
791
+ }
792
+ return segments;
793
+ }
794
+ function categoryOf(label) {
795
+ const h = label.toLowerCase();
796
+ if (h.startsWith("main")) return "main";
797
+ if (h.startsWith("optional")) return "optional";
798
+ if (h.startsWith("old")) return "old";
799
+ if (h.startsWith("misc")) return "miscellaneous";
800
+ return "unknown";
801
+ }
802
+ function modBaseUrl(html) {
803
+ const og = /property="og:url"\s+content="([^"]+)"/i.exec(html);
804
+ if (og?.[1]) return og[1].replace(/[?#].*$/, "");
805
+ const path = /\/[a-z0-9]+\/mods\/\d+/i.exec(html);
806
+ return path ? `${BASE}${path[0]}` : BASE;
807
+ }
808
+ function downloadUrl(base, fileId) {
809
+ return `${base}?tab=files&file_id=${fileId}`;
810
+ }
811
+ class FileSessionStore {
812
+ constructor(path = sessionFile()) {
813
+ this.path = path;
814
+ }
815
+ async save(s) {
816
+ await mkdir(dirname(this.path), { recursive: true });
817
+ await writeFile(this.path, JSON.stringify(s, null, 2), { mode: 384 });
818
+ }
819
+ async load() {
820
+ let raw;
821
+ try {
822
+ raw = await readFile(this.path, "utf8");
823
+ } catch (e) {
824
+ if (e.code === "ENOENT") return null;
825
+ throw e;
826
+ }
827
+ const parsed = JSON.parse(raw);
828
+ if (!parsed.username || !parsed.cookies) return null;
829
+ return parsed;
830
+ }
831
+ async clear() {
832
+ await rm(this.path, { force: true });
833
+ }
834
+ }
835
+ function buildDeps() {
836
+ return {
837
+ browser: new CamoufoxBrowser(),
838
+ store: new FileSessionStore(),
839
+ site: new NexusWebAdapter(),
840
+ downloader: new BrowserDownloader()
841
+ };
842
+ }
843
+ const NEXUS_COOKIE_DOMAIN = "nexusmods.com";
844
+ function cookieSourceFor(browser) {
845
+ return new BrowserCookieSource(browser);
846
+ }
847
+ function fileCookieSource(path) {
848
+ return new FileCookieSource(path);
849
+ }
850
+ const RETRY_ATTEMPTS = 3;
851
+ const RETRY_BASE_DELAY_MS = 1e3;
852
+ const downloadCommand = {
853
+ command: "download [target]",
854
+ describe: "Download a mod or a collection",
855
+ builder: (y) => y.positional("target", {
856
+ type: "string",
857
+ describe: "A nexusmods.com mod or collection URL (or use --game with --mod/--collection)"
858
+ }).option("game", {
859
+ type: "string",
860
+ describe: "Nexus game domain (e.g. skyrimspecialedition)"
861
+ }).option("mod", { type: "number", describe: "Numeric mod id" }).option("collection", { type: "string", describe: "Collection slug or id" }).option("out", { type: "string", describe: "Output directory" }).option("concurrency", { type: "number", default: 2 }).option("dry-run", {
862
+ type: "boolean",
863
+ default: false,
864
+ describe: "List what would be downloaded without fetching"
865
+ }).option("optional", {
866
+ type: "boolean",
867
+ default: false,
868
+ describe: "For collections, also download files marked optional"
869
+ }).option("headful", {
870
+ type: "boolean",
871
+ default: false,
872
+ describe: "Show the browser window (useful for debugging)"
873
+ }).conflicts("mod", "collection").check((argv) => {
874
+ if (typeof argv.target === "string") {
875
+ const ref = parseNexusUrl(argv.target);
876
+ if (!ref) {
877
+ throw new Error(`not a recognised Nexus mod or collection URL: ${argv.target}`);
878
+ }
879
+ argv.game = ref.game;
880
+ if ("modId" in ref) argv.mod = ref.modId;
881
+ else argv.collection = ref.collection;
882
+ }
883
+ if (argv.game === void 0) {
884
+ throw new Error("provide a Nexus URL, or --game with --mod/--collection");
885
+ }
886
+ if (argv.mod === void 0 && argv.collection === void 0) {
887
+ throw new Error("provide a Nexus URL, or --mod / --collection");
888
+ }
889
+ return true;
890
+ }),
891
+ handler: async (raw) => {
892
+ const argv = raw;
893
+ const outDir = argv.out ?? defaultOutDir(argv.game);
894
+ const deps = buildDeps();
895
+ const spinner = ora({ text: "Restoring session…", discardStdin: false }).start();
896
+ const controller = new AbortController();
897
+ const onSigint = () => {
898
+ if (controller.signal.aborted) {
899
+ spinner.stop();
900
+ out.warn("forced quit");
901
+ process.exit(130);
902
+ }
903
+ controller.abort(new CancelError("cancelled by user"));
904
+ spinner.text = "Cancelling… (press Ctrl+C again to force quit)";
905
+ };
906
+ process.on("SIGINT", onSigint);
907
+ let session;
908
+ try {
909
+ session = await restoreSession(deps, argv.headful);
910
+ } catch (e) {
911
+ spinner.stop();
912
+ process.removeListener("SIGINT", onSigint);
913
+ if (isCancel(e) || controller.signal.aborted) {
914
+ out.warn("cancelled");
915
+ process.exitCode = 130;
916
+ return;
917
+ }
918
+ out.error(e, argv.verbose);
919
+ process.exitCode = e instanceof AuthError ? 2 : 1;
920
+ return;
921
+ }
922
+ const runStart = Date.now();
923
+ const progress = new FileProgress();
924
+ const global = argv.collection !== void 0 ? new GlobalProgress() : null;
925
+ const ticker = setInterval(() => {
926
+ if (argv["dry-run"]) return;
927
+ spinner.text = composeStatus(progress, global, Date.now() - runStart);
928
+ }, 1e3);
929
+ if (typeof ticker.unref === "function") ticker.unref();
930
+ const signal = controller.signal;
931
+ try {
932
+ const report = argv.collection !== void 0 ? await runCollection(
933
+ deps,
934
+ session,
935
+ argv,
936
+ outDir,
937
+ spinner,
938
+ progress,
939
+ global,
940
+ runStart,
941
+ signal
942
+ ) : await runMod(deps, session, argv, outDir, spinner, progress, runStart, signal);
943
+ clearInterval(ticker);
944
+ finish(spinner, report, argv["dry-run"], Date.now() - runStart);
945
+ process.exitCode = report.failed > 0 ? 1 : 0;
946
+ } catch (e) {
947
+ clearInterval(ticker);
948
+ spinner.stop();
949
+ if (isCancel(e) || signal.aborted) {
950
+ out.warn("cancelled — partial downloads removed");
951
+ process.exitCode = 130;
952
+ } else {
953
+ out.error(e, argv.verbose);
954
+ process.exitCode = e instanceof AuthError ? 2 : 1;
955
+ }
956
+ } finally {
957
+ process.removeListener("SIGINT", onSigint);
958
+ await session.close();
959
+ }
960
+ }
961
+ };
962
+ async function runMod(deps, session, argv, outDir, spinner, progress, runStart, signal) {
963
+ const verb = argv["dry-run"] ? "Resolving" : "Downloading";
964
+ progress.start(`${verb} mod ${argv.mod}`);
965
+ spinner.text = progress.render();
966
+ const result = await downloadMod(deps, session, {
967
+ game: argv.game,
968
+ modId: argv.mod,
969
+ outDir,
970
+ dryRun: argv["dry-run"],
971
+ retryAttempts: RETRY_ATTEMPTS,
972
+ retryBaseDelayMs: RETRY_BASE_DELAY_MS,
973
+ signal,
974
+ onFileProgress: (p) => {
975
+ progress.update(p.receivedBytes, p.totalBytes);
976
+ spinner.text = composeStatus(progress, null, Date.now() - runStart);
977
+ }
978
+ });
979
+ progress.done();
980
+ return summarize([result]);
981
+ }
982
+ async function runCollection(deps, session, argv, outDir, spinner, progress, global, runStart, signal) {
983
+ spinner.text = "Resolving collection…";
984
+ return downloadCollection(deps, session, {
985
+ game: argv.game,
986
+ ref: argv.collection,
987
+ outDir,
988
+ concurrency: argv.concurrency,
989
+ dryRun: argv["dry-run"],
990
+ includeOptional: argv.optional,
991
+ retryAttempts: RETRY_ATTEMPTS,
992
+ retryBaseDelayMs: RETRY_BASE_DELAY_MS,
993
+ signal,
994
+ onResolved: (members) => {
995
+ global.setTotal(members);
996
+ global.begin();
997
+ },
998
+ onStart: (member, i, total) => {
999
+ const name = member.name ?? `mod ${member.modId}`;
1000
+ const verb = argv["dry-run"] ? "Resolving" : "Downloading";
1001
+ progress.start(`[${i}/${total}] ${verb} ${name}`);
1002
+ global.startFile(member.sizeBytes ?? 0);
1003
+ spinner.text = composeStatus(progress, global, Date.now() - runStart);
1004
+ },
1005
+ onFileProgress: (p) => {
1006
+ progress.update(p.receivedBytes, p.totalBytes);
1007
+ global.setCurrent(p.receivedBytes);
1008
+ spinner.text = composeStatus(progress, global, Date.now() - runStart);
1009
+ },
1010
+ // Failures persist a line above the spinner.
1011
+ onProgress: (r) => {
1012
+ progress.done();
1013
+ global.completeFile(r.ok);
1014
+ if (!r.ok) {
1015
+ const text = `mod ${r.modId} failed: ${r.error}`;
1016
+ spinner.stopAndPersist({ symbol: "✗", text });
1017
+ spinner.start();
1018
+ }
1019
+ }
1020
+ });
1021
+ }
1022
+ function finish(spinner, report, dryRun, elapsedMs) {
1023
+ if (dryRun) {
1024
+ spinner.stop();
1025
+ for (const r of report.results) {
1026
+ out.info(`mod ${r.modId}: ${r.files.join(", ") || "(none)"}`);
1027
+ }
1028
+ out.info(`dry run — ${report.succeeded} resolvable, ${report.failed} not`);
1029
+ return;
1030
+ }
1031
+ const took = `in ${clock(elapsedMs / 1e3)}`;
1032
+ if (report.results.length === 1 && report.results[0]?.ok) {
1033
+ const files = report.results[0].files.map((f) => basename(f));
1034
+ spinner.succeed(`Downloaded ${files.join(", ")} ${took}`);
1035
+ return;
1036
+ }
1037
+ const msg = `${report.succeeded} downloaded, ${report.failed} failed ${took}`;
1038
+ if (report.failed > 0) spinner.warn(msg);
1039
+ else spinner.succeed(msg);
1040
+ }
1041
+ class GlobalProgress {
1042
+ startedAt = Date.now();
1043
+ totalBytes = 0;
1044
+ completedBytes = 0;
1045
+ currentBytes = 0;
1046
+ /** The in-flight file's known size from the API (for skipped files). */
1047
+ currentSize = 0;
1048
+ /** Set the known total from the resolved member list. */
1049
+ setTotal(members) {
1050
+ this.totalBytes = members.reduce((sum, m) => sum + (m.sizeBytes ?? 0), 0);
1051
+ }
1052
+ /** Begin the timer once downloading actually starts. */
1053
+ begin() {
1054
+ this.startedAt = Date.now();
1055
+ }
1056
+ /** Note the file about to download and its known size. */
1057
+ startFile(sizeBytes) {
1058
+ this.currentBytes = 0;
1059
+ this.currentSize = sizeBytes;
1060
+ }
1061
+ /** Update the in-flight file's streamed byte count. */
1062
+ setCurrent(bytes) {
1063
+ this.currentBytes = bytes;
1064
+ }
1065
+ /**
1066
+ * Settle the in-flight file. On success, credit its bytes toward the total —
1067
+ * the streamed amount, or the known size when nothing streamed (a file that
1068
+ * already existed and was skipped). On failure, discard it: a file that
1069
+ * errored out must not count as downloaded.
1070
+ */
1071
+ completeFile(ok) {
1072
+ if (ok) {
1073
+ this.completedBytes += Math.max(this.currentBytes, this.currentSize);
1074
+ }
1075
+ this.currentBytes = 0;
1076
+ this.currentSize = 0;
1077
+ }
1078
+ /**
1079
+ * Render the secondary line:
1080
+ * total 18% 240 MB/1.3 GB elapsed 02:15 ETA 48:30
1081
+ * `elapsedMs` is the whole-run elapsed (so both ETAs share one clock source).
1082
+ */
1083
+ render(runElapsedMs) {
1084
+ if (this.totalBytes <= 0) return "";
1085
+ const done = this.completedBytes + this.currentBytes;
1086
+ const elapsedMs = Math.max(Date.now() - this.startedAt, 1);
1087
+ const rate = done / (elapsedMs / 1e3);
1088
+ const pct = Math.floor(done / this.totalBytes * 100);
1089
+ const cols = [
1090
+ "total",
1091
+ `${pct}%`,
1092
+ `${size(done)}/${size(this.totalBytes)}`,
1093
+ `elapsed ${clock(runElapsedMs / 1e3)}`
1094
+ ];
1095
+ if (rate > 0 && done > 0) {
1096
+ cols.push(`ETA ${clock((this.totalBytes - done) / rate)}`);
1097
+ }
1098
+ return cols.join(" ");
1099
+ }
1100
+ }
1101
+ class FileProgress {
1102
+ startedAt = 0;
1103
+ received = 0;
1104
+ total = 0;
1105
+ label = "";
1106
+ active = false;
1107
+ /** Reset for a new file. `label` is the position + name, e.g. "[3/476] SkyUI". */
1108
+ start(label) {
1109
+ this.startedAt = Date.now();
1110
+ this.received = 0;
1111
+ this.total = 0;
1112
+ this.label = label;
1113
+ this.active = true;
1114
+ }
1115
+ /** Record the latest byte counts (called from the download callback). */
1116
+ update(received, total) {
1117
+ this.received = received;
1118
+ this.total = total;
1119
+ this.active = true;
1120
+ }
1121
+ /** Render the file line against *now* so elapsed/speed/ETA advance live. */
1122
+ render() {
1123
+ if (!this.label) return "";
1124
+ if (!this.active || this.received === 0) return this.label;
1125
+ const elapsedMs = Math.max(Date.now() - this.startedAt, 1);
1126
+ const rate = this.received / (elapsedMs / 1e3);
1127
+ const cols = [this.label];
1128
+ if (this.total > 0) {
1129
+ cols.push(`${Math.floor(this.received / this.total * 100)}%`);
1130
+ cols.push(`${size(this.received)}/${size(this.total)}`);
1131
+ } else {
1132
+ cols.push(size(this.received));
1133
+ }
1134
+ cols.push(`${size(rate)}/s`);
1135
+ if (this.total > 0 && rate > 0) {
1136
+ cols.push(`ETA ${clock((this.total - this.received) / rate)}`);
1137
+ }
1138
+ return cols.join(" ");
1139
+ }
1140
+ /** Mark the file finished so the ticker stops rendering its line. */
1141
+ done() {
1142
+ this.active = false;
1143
+ }
1144
+ }
1145
+ function composeStatus(progress, global, runElapsedMs) {
1146
+ const fileLine = progress.render();
1147
+ const totalLine = global?.render(runElapsedMs) ?? "";
1148
+ return totalLine ? `${fileLine}
1149
+ ${totalLine}` : fileLine;
1150
+ }
1151
+ function size(bytes) {
1152
+ if (bytes >= 1073741824) return `${(bytes / 1073741824).toFixed(1)} GB`;
1153
+ if (bytes >= 1048576) return `${(bytes / 1048576).toFixed(1)} MB`;
1154
+ if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)} KB`;
1155
+ return `${Math.round(bytes)} B`;
1156
+ }
1157
+ function clock(seconds) {
1158
+ const s = Math.max(0, Math.floor(seconds));
1159
+ const h = Math.floor(s / 3600);
1160
+ const m = Math.floor(s % 3600 / 60);
1161
+ const sec = s % 60;
1162
+ return [h, m, sec].map((n) => String(n).padStart(2, "0")).join(":");
1163
+ }
1164
+ async function importSession(deps, params) {
1165
+ const cookies = await deps.source.read(params.domainSuffix);
1166
+ if (cookies.length === 0) {
1167
+ throw new AuthError(
1168
+ `no ${params.domainSuffix} cookies found in ${deps.source.browser} — log into Nexus there first`
1169
+ );
1170
+ }
1171
+ let username = "nexus-user";
1172
+ if (params.validate) {
1173
+ const session = await deps.browser.launch({ headful: false });
1174
+ try {
1175
+ await session.setCookies(cookies);
1176
+ if (!await session.isLoggedIn()) {
1177
+ throw new AuthError(`imported ${deps.source.browser} cookies are not logged in to Nexus`);
1178
+ }
1179
+ username = await session.resolveUsername() ?? username;
1180
+ } finally {
1181
+ await session.close();
1182
+ }
1183
+ }
1184
+ const saved = {
1185
+ username,
1186
+ cookies,
1187
+ capturedAt: (/* @__PURE__ */ new Date()).toISOString()
1188
+ };
1189
+ await deps.store.save(saved);
1190
+ return saved;
1191
+ }
1192
+ const importCommand = {
1193
+ command: "import",
1194
+ describe: "Import Nexus cookies from your existing browser session",
1195
+ builder: (y) => y.option("from", {
1196
+ type: "string",
1197
+ default: "chrome",
1198
+ describe: "Browser to import cookies from (chrome, brave, edge, opera, vivaldi, arc, firefox, safari)"
1199
+ }).option("file", {
1200
+ type: "string",
1201
+ describe: "Import from an exported cookie file (cookies.txt or JSON) instead of a browser"
1202
+ }).conflicts("file", "from").option("validate", {
1203
+ type: "boolean",
1204
+ default: true,
1205
+ describe: "Open a headless browser to confirm the cookies are logged in"
1206
+ }),
1207
+ handler: async (raw) => {
1208
+ const argv = raw;
1209
+ const { browser, store } = buildDeps();
1210
+ try {
1211
+ const source = argv.file ? fileCookieSource(argv.file) : cookieSourceFor(argv.from);
1212
+ const session = await importSession(
1213
+ { source, browser, store },
1214
+ { domainSuffix: NEXUS_COOKIE_DOMAIN, validate: argv.validate }
1215
+ );
1216
+ out.success(
1217
+ `imported ${session.cookies.length} cookie(s) from ${source.browser}` + (session.username !== "nexus-user" ? ` for ${session.username}` : "")
1218
+ );
1219
+ process.exitCode = 0;
1220
+ } catch (e) {
1221
+ out.error(e, argv.verbose);
1222
+ process.exitCode = 1;
1223
+ }
1224
+ }
1225
+ };
1226
+ const logoutCommand = {
1227
+ command: "logout",
1228
+ describe: "Clear the imported session",
1229
+ handler: async () => {
1230
+ const { store } = buildDeps();
1231
+ const existing = await store.load();
1232
+ await store.clear();
1233
+ if (existing) {
1234
+ out.success(`logged out (${existing.username})`);
1235
+ } else {
1236
+ out.info("no session to clear");
1237
+ }
1238
+ process.exitCode = 0;
1239
+ }
1240
+ };
1241
+ await yargs(hideBin(process.argv)).scriptName("nexus").usage("$0 <command> [options]").option("verbose", {
1242
+ type: "boolean",
1243
+ default: false,
1244
+ describe: "Print full stack traces on error",
1245
+ global: true
1246
+ }).command(importCommand).command(logoutCommand).command(downloadCommand).demandCommand(1, "a command is required").strict().help().parseAsync();
1247
+ //# sourceMappingURL=index.js.map