cloak22 2.2.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.
package/src/main.ts ADDED
@@ -0,0 +1,1085 @@
1
+ #!/usr/bin/env node
2
+ import { chromium as patchrightChromium } from "patchright"
3
+ import fs from "node:fs"
4
+ import os from "node:os"
5
+ import path from "node:path"
6
+ import readline from "node:readline/promises"
7
+ import { resolveAppPaths, type AppPaths } from "./app-paths.js"
8
+ import { buildRunArguments, isProcessRunning, spawnDaemonProcess, stopProcess } from "./daemon.js"
9
+ import { prepareRequiredExtension } from "./extension.js"
10
+ import { readCookiesFromFile, type Cookie } from "./cookies.js"
11
+ import { parseCli, type RunModeConfig } from "./cli.js"
12
+ import {
13
+ CHROME_COOKIE_LIMITATION_WARNING,
14
+ readChromeCookies,
15
+ } from "./chrome-cookies.js"
16
+ import {
17
+ listChromeProfileCookieUrls,
18
+ } from "./chrome-profile-sites.js"
19
+ import {
20
+ hasChromeUserDataDir,
21
+ listChromeProfiles,
22
+ type ChromeProfile,
23
+ } from "./chrome-profiles.js"
24
+ import {
25
+ CloakStateDb,
26
+ type DaemonState,
27
+ type StoredRunCommand,
28
+ } from "./state-db.js"
29
+ import { formatError, formatInfo, formatSuccess, formatWarning } from "./output.js"
30
+
31
+ type ResolveStartupCookiesDependencies = {
32
+ readChromeCookies?: typeof readChromeCookies;
33
+ readCookiesFromFile?: typeof readCookiesFromFile;
34
+ warn?: (message: string) => void;
35
+ };
36
+
37
+ type StartupContext = {
38
+ addCookies(cookies: Cookie[]): Promise<void>;
39
+ browser(): { on(event: "disconnected", listener: () => void): void } | null;
40
+ on(event: "close", listener: () => void): void;
41
+ close(): Promise<void>;
42
+ };
43
+
44
+ type LaunchOptions = {
45
+ headless: boolean;
46
+ args: string[];
47
+ executablePath?: string;
48
+ };
49
+
50
+ type SelectCookieUrlsOptions = {
51
+ profile: string;
52
+ urls: string[];
53
+ };
54
+
55
+ type MainDependencies = ResolveStartupCookiesDependencies & {
56
+ appPaths?: AppPaths;
57
+ createStateDb?: (dbPath: string) => CloakStateDb;
58
+ confirmCreateConfigDir?: (configDir: string) => Promise<boolean>;
59
+ confirmDestroyState?: (configDir: string) => Promise<boolean>;
60
+ selectCookieUrls?: (
61
+ options: SelectCookieUrlsOptions
62
+ ) => Promise<string[] | undefined>;
63
+ prepareRequiredExtension?: typeof prepareRequiredExtension;
64
+ listChromeProfiles?: typeof listChromeProfiles;
65
+ hasChromeUserDataDir?: typeof hasChromeUserDataDir;
66
+ listChromeProfileCookieUrls?: typeof listChromeProfileCookieUrls;
67
+ launchPersistentContext?: (
68
+ userDataDir: string,
69
+ options: LaunchOptions
70
+ ) => Promise<StartupContext>;
71
+ patchrightLaunchPersistentContext?: (
72
+ userDataDir: string,
73
+ options: LaunchOptions
74
+ ) => Promise<StartupContext>;
75
+ patchrightExecutablePath?: () => string;
76
+ makeTempDir?: (prefix: string) => string;
77
+ makeDir?: (path: string, options: { recursive: true }) => void;
78
+ removeDir?: (path: string, options: { recursive: true; force: true }) => void;
79
+ readFile?: (path: string, encoding: "utf8") => string;
80
+ writeFile?: (path: string, data: string) => void;
81
+ pathExists?: (path: string) => boolean;
82
+ writeStdout?: (message: string) => void;
83
+ log?: (message: string) => void;
84
+ stdinIsTTY?: boolean;
85
+ stdoutIsTTY?: boolean;
86
+ processExecPath?: string;
87
+ processExecArgv?: string[];
88
+ scriptPath?: string;
89
+ spawnDaemonProcess?: typeof spawnDaemonProcess;
90
+ isProcessRunning?: typeof isProcessRunning;
91
+ stopProcess?: typeof stopProcess;
92
+ now?: () => Date;
93
+ };
94
+
95
+ type ResolvedRunConfig = {
96
+ headless: boolean;
97
+ daemon: boolean;
98
+ profile?: string;
99
+ cookieUrls: string[];
100
+ cookieFile?: string;
101
+ explicitCookieUrls: string[];
102
+ persistCookies: boolean;
103
+ };
104
+
105
+ export function dedupeCookies(cookies: Cookie[]): Cookie[] {
106
+ const seen = new Map<string, Cookie>()
107
+
108
+ for (const cookie of cookies) {
109
+ const key = [cookie.name, cookie.domain, cookie.path].join("\u0000")
110
+ seen.set(key, cookie)
111
+ }
112
+
113
+ return [...seen.values()]
114
+ }
115
+
116
+ export function parseSelectionInput(
117
+ input: string,
118
+ total: number
119
+ ): number[] | undefined {
120
+ const normalized = input.trim().toLowerCase()
121
+
122
+ if (normalized.length === 0) {
123
+ return undefined
124
+ }
125
+
126
+ if (normalized === "all" || normalized === "a" || normalized === "*") {
127
+ return Array.from({ length: total }, (_, index) => index)
128
+ }
129
+
130
+ if (normalized === "none" || normalized === "n" || normalized === "clear") {
131
+ return []
132
+ }
133
+
134
+ const indexes = new Set<number>()
135
+
136
+ for (const part of normalized.split(",")) {
137
+ const token = part.trim()
138
+
139
+ if (!token) {
140
+ continue
141
+ }
142
+
143
+ const rangeMatch = token.match(/^(\d+)\s*-\s*(\d+)$/)
144
+
145
+ if (rangeMatch) {
146
+ const start = Number(rangeMatch[1])
147
+ const end = Number(rangeMatch[2])
148
+
149
+ if (start < 1 || end < 1 || start > total || end > total) {
150
+ throw new Error("selection is out of range")
151
+ }
152
+
153
+ const lower = Math.min(start, end)
154
+ const upper = Math.max(start, end)
155
+
156
+ for (let value = lower; value <= upper; value += 1) {
157
+ indexes.add(value - 1)
158
+ }
159
+
160
+ continue
161
+ }
162
+
163
+ if (!/^\d+$/.test(token)) {
164
+ throw new Error("selection must use indexes, ranges, all, or none")
165
+ }
166
+
167
+ const value = Number(token)
168
+
169
+ if (value < 1 || value > total) {
170
+ throw new Error("selection is out of range")
171
+ }
172
+
173
+ indexes.add(value - 1)
174
+ }
175
+
176
+ return [...indexes].sort((left, right) => left - right)
177
+ }
178
+
179
+ export async function resolveStartupCookies(
180
+ options: {
181
+ cookieUrls: string[];
182
+ cookieFile?: string;
183
+ profile?: string;
184
+ },
185
+ dependencies: ResolveStartupCookiesDependencies = {}
186
+ ): Promise<Cookie[]> {
187
+ if (options.cookieUrls.length === 0 && !options.cookieFile) {
188
+ return []
189
+ }
190
+
191
+ const readChromeCookiesFn =
192
+ dependencies.readChromeCookies ?? readChromeCookies
193
+ const readCookiesFromFileFn =
194
+ dependencies.readCookiesFromFile ?? readCookiesFromFile
195
+ const warn =
196
+ dependencies.warn ??
197
+ ((message: string) => console.warn(formatWarning(message)))
198
+ const importedCookies: Cookie[] = []
199
+ let usedChromeProfileImport = false
200
+
201
+ for (const url of options.cookieUrls) {
202
+ const cookies = await readChromeCookiesFn({
203
+ url,
204
+ profile: options.profile,
205
+ })
206
+
207
+ if (cookies.length === 0) {
208
+ throw new Error(`No cookies found for ${url}`)
209
+ }
210
+
211
+ importedCookies.push(...cookies)
212
+ usedChromeProfileImport = true
213
+ }
214
+
215
+ if (options.cookieFile) {
216
+ importedCookies.push(...readCookiesFromFileFn(options.cookieFile))
217
+ }
218
+
219
+ if (usedChromeProfileImport) {
220
+ warn(CHROME_COOKIE_LIMITATION_WARNING)
221
+ }
222
+
223
+ return dedupeCookies(importedCookies)
224
+ }
225
+
226
+ function readPackageVersion(): string {
227
+ const packageJsonPath = path.resolve(__dirname, "..", "package.json")
228
+ const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")) as {
229
+ version?: string
230
+ }
231
+
232
+ if (!packageJson.version) {
233
+ throw new Error("Unable to determine cloak version from package.json")
234
+ }
235
+
236
+ return packageJson.version
237
+ }
238
+
239
+ export async function main(
240
+ argv: string[] = process.argv,
241
+ dependencies: MainDependencies = {}
242
+ ) {
243
+ const cli = parseCli(argv)
244
+ const appPaths = dependencies.appPaths ?? resolveAppPaths()
245
+ const writeStdout =
246
+ dependencies.writeStdout ??
247
+ ((message: string) => process.stdout.write(message))
248
+ const log = dependencies.log ?? ((message: string) => console.log(message))
249
+
250
+ if (cli.mode === "help") {
251
+ writeStdout(cli.text)
252
+ return
253
+ }
254
+
255
+ if (cli.mode === "version") {
256
+ log(readPackageVersion())
257
+ return
258
+ }
259
+
260
+ if (cli.mode === "profile-list") {
261
+ await handleListProfiles(log, dependencies)
262
+ return
263
+ }
264
+
265
+ if (cli.mode === "profile-show") {
266
+ const stateDb = loadExistingStateDb(appPaths, dependencies)
267
+ const profile = stateDb?.getDefaultProfile()
268
+
269
+ if (!profile) {
270
+ log("No default profile selected. Use `cloak profile use <profile>`.")
271
+ return
272
+ }
273
+
274
+ log(`Current active profile: ${profile}`)
275
+ return
276
+ }
277
+
278
+ if (cli.mode === "profile-use") {
279
+ const stateDb = await ensureStateDb(appPaths, cli.consent, dependencies)
280
+
281
+ if (!stateDb) {
282
+ log(formatInfo("Aborted."))
283
+ return
284
+ }
285
+
286
+ const profile = await resolveChromeProfile(cli.profile, dependencies)
287
+ stateDb.setDefaultProfile(profile.directory)
288
+ log(formatSuccess(`Saved default profile: ${profile.directory}`))
289
+ return
290
+ }
291
+
292
+ if (cli.mode === "sites-list") {
293
+ await handleCookiesList(cli, appPaths, log, dependencies)
294
+ return
295
+ }
296
+
297
+ if (cli.mode === "daemon-status") {
298
+ handleInspect(appPaths, log, dependencies)
299
+ return
300
+ }
301
+
302
+ if (cli.mode === "daemon-logs") {
303
+ handleDaemonLogs(appPaths, log, dependencies)
304
+ return
305
+ }
306
+
307
+ if (cli.mode === "storage-show") {
308
+ handleStateDisplay(appPaths, log, dependencies)
309
+ return
310
+ }
311
+
312
+ if (cli.mode === "storage-destroy") {
313
+ await handleStateDestroy(appPaths, log, dependencies)
314
+ return
315
+ }
316
+
317
+ if (cli.mode === "daemon-stop") {
318
+ await handleStop(appPaths, log, dependencies)
319
+ return
320
+ }
321
+
322
+ if (cli.mode === "daemon-restart") {
323
+ await handleRestart(appPaths, log, dependencies)
324
+ return
325
+ }
326
+
327
+ const resolvedCli = await resolveRunConfig(cli, appPaths, dependencies)
328
+ const runSummary = formatRunSettings(resolvedCli)
329
+ log(runSummary)
330
+
331
+ if (resolvedCli.daemon) {
332
+ await rememberCookieUrls(resolvedCli, appPaths, log, dependencies)
333
+ await handleDaemonRun(resolvedCli, appPaths, log, dependencies)
334
+ return
335
+ }
336
+
337
+ const cookies = await resolveStartupCookies(resolvedCli, dependencies)
338
+ await rememberCookieUrls(resolvedCli, appPaths, log, dependencies)
339
+ await launchBrowser(resolvedCli, cookies, appPaths, dependencies)
340
+ }
341
+
342
+ async function handleListProfiles(
343
+ log: (message: string) => void,
344
+ dependencies: Pick<MainDependencies, "hasChromeUserDataDir" | "listChromeProfiles">
345
+ ) {
346
+ const hasChrome =
347
+ dependencies.hasChromeUserDataDir ?? hasChromeUserDataDir
348
+
349
+ if (!hasChrome()) {
350
+ log("No Chrome data directory detected on this machine.")
351
+ return
352
+ }
353
+
354
+ const listProfiles = dependencies.listChromeProfiles ?? listChromeProfiles
355
+ const profiles = listProfiles()
356
+
357
+ if (profiles.length === 0) {
358
+ log("Listing profiles for Chrome\n(no profiles found)")
359
+ return
360
+ }
361
+
362
+ const lines = ["Listing profiles for Chrome"]
363
+
364
+ for (const profile of profiles) {
365
+ const label = profile.accountName ?? profile.name
366
+
367
+ if (!label || label === profile.directory) {
368
+ lines.push(`- ${profile.directory}`)
369
+ continue
370
+ }
371
+
372
+ lines.push(`- ${profile.directory} <${label}>`)
373
+ }
374
+
375
+ log(lines.join("\n"))
376
+ }
377
+
378
+ async function handleCookiesList(
379
+ cli: { limit: number; noPager: boolean; consent: boolean },
380
+ appPaths: AppPaths,
381
+ log: (message: string) => void,
382
+ dependencies: MainDependencies
383
+ ) {
384
+ const stateDb = loadExistingStateDb(appPaths, dependencies)
385
+ const profile = stateDb?.getDefaultProfile()
386
+
387
+ if (!profile) {
388
+ throw new Error("No default profile selected. Use `cloak profile use <profile>`.")
389
+ }
390
+
391
+ await resolveChromeProfile(profile, dependencies)
392
+ const listCookieUrls =
393
+ dependencies.listChromeProfileCookieUrls ?? listChromeProfileCookieUrls
394
+ const urls = await listCookieUrls({
395
+ profileDirectory: profile,
396
+ })
397
+ const limitedUrls = urls.slice(0, cli.limit)
398
+
399
+ if (limitedUrls.length === 0) {
400
+ log(`No cookie URLs found for ${profile}.`)
401
+ return
402
+ }
403
+
404
+ const lines = [`Cookie URLs for ${profile}`]
405
+
406
+ limitedUrls.forEach((url, index) => {
407
+ lines.push(`${index + 1}. ${url}`)
408
+ })
409
+
410
+ log(lines.join("\n"))
411
+
412
+ if (cli.noPager || !isInteractiveTerminal(dependencies)) {
413
+ return
414
+ }
415
+
416
+ const remembered = await selectCookieUrls(
417
+ {
418
+ profile,
419
+ urls: limitedUrls,
420
+ },
421
+ dependencies
422
+ )
423
+
424
+ if (remembered === undefined) {
425
+ return
426
+ }
427
+
428
+ const stateDbForWrite = await ensureStateDb(appPaths, cli.consent, dependencies)
429
+
430
+ if (!stateDbForWrite) {
431
+ log(formatInfo("Aborted."))
432
+ return
433
+ }
434
+
435
+ stateDbForWrite.replaceRememberedCookieUrls(profile, remembered)
436
+
437
+ if (remembered.length === 0) {
438
+ log(formatSuccess(`Cleared remembered cookie URLs for ${profile}`))
439
+ return
440
+ }
441
+
442
+ log(formatSuccess(`Saved ${remembered.length} cookie URL(s) for ${profile}`))
443
+ }
444
+
445
+ function handleInspect(
446
+ appPaths: AppPaths,
447
+ log: (message: string) => void,
448
+ dependencies: Pick<MainDependencies, "createStateDb" | "pathExists" | "isProcessRunning">
449
+ ) {
450
+ const stateDb = loadExistingStateDb(appPaths, dependencies)
451
+ const daemon = stateDb?.getDaemonState()
452
+
453
+ if (!daemon) {
454
+ log("No cloak daemon running.")
455
+ return
456
+ }
457
+
458
+ const processIsRunning =
459
+ dependencies.isProcessRunning ?? isProcessRunning
460
+
461
+ if (!processIsRunning(daemon.pid)) {
462
+ stateDb?.clearDaemonState()
463
+ log(`Recorded daemon pid ${daemon.pid} is not running. Cleared stale state.`)
464
+ return
465
+ }
466
+
467
+ log(formatDaemonState(daemon))
468
+ }
469
+
470
+ function handleDaemonLogs(
471
+ appPaths: AppPaths,
472
+ log: (message: string) => void,
473
+ dependencies: Pick<MainDependencies, "pathExists" | "readFile">
474
+ ) {
475
+ const pathExists = dependencies.pathExists ?? fs.existsSync
476
+
477
+ if (!pathExists(appPaths.daemonLogPath)) {
478
+ log(`No daemon log found at ${appPaths.daemonLogPath}.`)
479
+ return
480
+ }
481
+
482
+ const readFile =
483
+ dependencies.readFile ??
484
+ ((targetPath: string, encoding: "utf8") => fs.readFileSync(targetPath, encoding))
485
+ const contents = readFile(appPaths.daemonLogPath, "utf8").replace(/\n$/, "")
486
+
487
+ if (contents.length === 0) {
488
+ log(`Daemon log is empty: ${appPaths.daemonLogPath}`)
489
+ return
490
+ }
491
+
492
+ log(contents)
493
+ }
494
+
495
+ function handleStateDisplay(
496
+ appPaths: AppPaths,
497
+ log: (message: string) => void,
498
+ dependencies: Pick<
499
+ MainDependencies,
500
+ "createStateDb" | "pathExists" | "isProcessRunning"
501
+ >
502
+ ) {
503
+ const pathExists = dependencies.pathExists ?? fs.existsSync
504
+ const stateDbExists = pathExists(appPaths.stateDbPath)
505
+ const daemonLogExists = pathExists(appPaths.daemonLogPath)
506
+ const stateDb = stateDbExists ? loadExistingStateDb(appPaths, dependencies) : undefined
507
+ const defaultProfile = stateDb?.getDefaultProfile()
508
+ const daemon = stateDb?.getDaemonState()
509
+ const processIsRunning = dependencies.isProcessRunning ?? isProcessRunning
510
+ const daemonStatus = !daemon
511
+ ? "(none)"
512
+ : processIsRunning(daemon.pid)
513
+ ? `running (${daemon.pid})`
514
+ : `stale (${daemon.pid})`
515
+ const lines = [
516
+ "cloak storage",
517
+ `config dir: ${appPaths.configDir}`,
518
+ `sqlite: ${appPaths.stateDbPath}`,
519
+ `sqlite present: ${stateDbExists ? "yes" : "no"}`,
520
+ `daemon log: ${appPaths.daemonLogPath}`,
521
+ `daemon log present: ${daemonLogExists ? "yes" : "no"}`,
522
+ `default profile: ${defaultProfile ?? "(none)"}`,
523
+ `daemon: ${daemonStatus}`,
524
+ ]
525
+
526
+ log(lines.join("\n"))
527
+ }
528
+
529
+ async function handleStop(
530
+ appPaths: AppPaths,
531
+ log: (message: string) => void,
532
+ dependencies: Pick<
533
+ MainDependencies,
534
+ "createStateDb" | "pathExists" | "isProcessRunning" | "stopProcess"
535
+ >
536
+ ) {
537
+ const stateDb = loadExistingStateDb(appPaths, dependencies)
538
+ const daemon = stateDb?.getDaemonState()
539
+
540
+ if (!daemon) {
541
+ log("No cloak daemon running.")
542
+ return
543
+ }
544
+
545
+ const processIsRunning =
546
+ dependencies.isProcessRunning ?? isProcessRunning
547
+
548
+ if (!processIsRunning(daemon.pid)) {
549
+ stateDb?.clearDaemonState()
550
+ log(`Recorded daemon pid ${daemon.pid} is not running. Cleared stale state.`)
551
+ return
552
+ }
553
+
554
+ const stopProcessFn = dependencies.stopProcess ?? stopProcess
555
+ await stopProcessFn(daemon.pid)
556
+ stateDb?.clearDaemonState()
557
+ log(formatSuccess(`Stopped cloak daemon (${daemon.pid})`))
558
+ }
559
+
560
+ async function handleStateDestroy(
561
+ appPaths: AppPaths,
562
+ log: (message: string) => void,
563
+ dependencies: Pick<
564
+ MainDependencies,
565
+ | "confirmDestroyState"
566
+ | "createStateDb"
567
+ | "isProcessRunning"
568
+ | "pathExists"
569
+ | "removeDir"
570
+ | "stdinIsTTY"
571
+ | "stdoutIsTTY"
572
+ | "stopProcess"
573
+ >
574
+ ) {
575
+ const pathExists = dependencies.pathExists ?? fs.existsSync
576
+
577
+ if (!pathExists(appPaths.configDir)) {
578
+ log("No cloak storage to destroy.")
579
+ return
580
+ }
581
+
582
+ if (!isInteractiveTerminal(dependencies)) {
583
+ throw new Error("`cloak storage destroy` requires an interactive terminal.")
584
+ }
585
+
586
+ const confirmDestroyState =
587
+ dependencies.confirmDestroyState ?? defaultConfirmDestroyState
588
+
589
+ if (!(await confirmDestroyState(appPaths.configDir))) {
590
+ log(formatInfo("Aborted."))
591
+ return
592
+ }
593
+
594
+ const stateDb = loadExistingStateDb(appPaths, dependencies)
595
+ const daemon = stateDb?.getDaemonState()
596
+ const processIsRunning = dependencies.isProcessRunning ?? isProcessRunning
597
+
598
+ if (daemon && processIsRunning(daemon.pid)) {
599
+ const stopProcessFn = dependencies.stopProcess ?? stopProcess
600
+ await stopProcessFn(daemon.pid)
601
+ }
602
+
603
+ const removeDir = dependencies.removeDir ?? fs.rmSync
604
+ removeDir(appPaths.configDir, { recursive: true, force: true })
605
+ log(formatSuccess(`Destroyed cloak storage under ${appPaths.configDir}`))
606
+ }
607
+
608
+ async function handleRestart(
609
+ appPaths: AppPaths,
610
+ log: (message: string) => void,
611
+ dependencies: MainDependencies
612
+ ) {
613
+ const stateDb = loadExistingStateDb(appPaths, dependencies)
614
+
615
+ if (!stateDb) {
616
+ throw new Error("No saved daemon command found. Run `cloak daemon start` first.")
617
+ }
618
+
619
+ const currentDaemon = stateDb.getDaemonState()
620
+ const processIsRunning =
621
+ dependencies.isProcessRunning ?? isProcessRunning
622
+ let command = stateDb.getLastDaemonCommand()
623
+
624
+ if (currentDaemon) {
625
+ command = {
626
+ headless: currentDaemon.headless,
627
+ profile: currentDaemon.profile,
628
+ cookieFile: currentDaemon.cookieFile,
629
+ cookieUrls: currentDaemon.cookieUrls,
630
+ }
631
+
632
+ if (processIsRunning(currentDaemon.pid)) {
633
+ const stopProcessFn = dependencies.stopProcess ?? stopProcess
634
+ await stopProcessFn(currentDaemon.pid)
635
+ }
636
+
637
+ stateDb.clearDaemonState()
638
+ }
639
+
640
+ if (!command) {
641
+ throw new Error("No saved daemon command found. Run `cloak daemon start` first.")
642
+ }
643
+
644
+ await startDaemon(command, appPaths, log, dependencies)
645
+ }
646
+
647
+ async function resolveRunConfig(
648
+ cli: RunModeConfig,
649
+ appPaths: AppPaths,
650
+ dependencies: MainDependencies
651
+ ): Promise<ResolvedRunConfig> {
652
+ const stateDb =
653
+ cli.daemon || cli.persistCookies
654
+ ? await ensureStateDb(appPaths, cli.consent, dependencies)
655
+ : loadExistingStateDb(appPaths, dependencies)
656
+
657
+ if ((cli.daemon || cli.persistCookies) && !stateDb) {
658
+ throw new Error("Config directory creation was declined.")
659
+ }
660
+
661
+ const defaultProfile = cli.profile ?? stateDb?.getDefaultProfile()
662
+ const profile = defaultProfile
663
+ ? (await resolveChromeProfile(defaultProfile, dependencies)).directory
664
+ : undefined
665
+ const cookieFile = cli.cookieFile
666
+ ? path.resolve(cli.cookieFile)
667
+ : undefined
668
+ const explicitCookieUrls = cli.cookieUrls
669
+ const rememberedCookieUrls =
670
+ explicitCookieUrls.length === 0 && profile && stateDb
671
+ ? stateDb.getRememberedCookieUrls(profile)
672
+ : []
673
+ const cookieUrls =
674
+ explicitCookieUrls.length > 0 ? explicitCookieUrls : rememberedCookieUrls
675
+
676
+ if ((cookieUrls.length > 0 || cli.persistCookies) && !profile) {
677
+ throw new Error(
678
+ "A Chrome profile is required. Use `cloak profile use <profile>` or pass --profile."
679
+ )
680
+ }
681
+
682
+ return {
683
+ headless: cli.headless,
684
+ daemon: cli.daemon,
685
+ profile,
686
+ cookieUrls,
687
+ cookieFile,
688
+ explicitCookieUrls,
689
+ persistCookies: cli.persistCookies,
690
+ }
691
+ }
692
+
693
+ async function handleDaemonRun(
694
+ config: ResolvedRunConfig,
695
+ appPaths: AppPaths,
696
+ log: (message: string) => void,
697
+ dependencies: MainDependencies
698
+ ) {
699
+ const stateDb = await ensureStateDb(appPaths, true, dependencies)
700
+
701
+ if (!stateDb) {
702
+ throw new Error("Unable to create cloak storage.")
703
+ }
704
+
705
+ const existing = stateDb.getDaemonState()
706
+ const processIsRunning =
707
+ dependencies.isProcessRunning ?? isProcessRunning
708
+
709
+ if (existing) {
710
+ if (processIsRunning(existing.pid)) {
711
+ throw new Error("A cloak daemon is already running. Use `cloak daemon restart` or `cloak daemon stop`.")
712
+ }
713
+
714
+ stateDb.clearDaemonState()
715
+ }
716
+
717
+ await startDaemon(
718
+ {
719
+ headless: config.headless,
720
+ profile: config.profile,
721
+ cookieFile: config.cookieFile,
722
+ cookieUrls: config.cookieUrls,
723
+ },
724
+ appPaths,
725
+ log,
726
+ dependencies
727
+ )
728
+ }
729
+
730
+ async function startDaemon(
731
+ command: StoredRunCommand,
732
+ appPaths: AppPaths,
733
+ log: (message: string) => void,
734
+ dependencies: MainDependencies
735
+ ) {
736
+ const stateDb = await ensureStateDb(appPaths, true, dependencies)
737
+
738
+ if (!stateDb) {
739
+ throw new Error("Unable to create cloak storage.")
740
+ }
741
+
742
+ const spawnDaemonProcessFn =
743
+ dependencies.spawnDaemonProcess ?? spawnDaemonProcess
744
+ const makeDir = dependencies.makeDir ?? fs.mkdirSync
745
+ const now = dependencies.now ?? (() => new Date())
746
+ makeDir(appPaths.configDir, { recursive: true })
747
+ makeDir(path.dirname(appPaths.daemonLogPath), { recursive: true })
748
+ const pid = spawnDaemonProcessFn({
749
+ execPath: dependencies.processExecPath ?? process.execPath,
750
+ execArgv: dependencies.processExecArgv ?? process.execArgv,
751
+ scriptPath: dependencies.scriptPath ?? process.argv[1],
752
+ command,
753
+ logPath: appPaths.daemonLogPath,
754
+ })
755
+
756
+ stateDb.setLastDaemonCommand(command)
757
+ stateDb.setDaemonState({
758
+ ...command,
759
+ pid,
760
+ startedAt: now().toISOString(),
761
+ logPath: appPaths.daemonLogPath,
762
+ })
763
+ log(formatSuccess(`Started cloak daemon (${pid})`))
764
+ }
765
+
766
+ async function rememberCookieUrls(
767
+ config: ResolvedRunConfig,
768
+ appPaths: AppPaths,
769
+ log: (message: string) => void,
770
+ dependencies: MainDependencies
771
+ ) {
772
+ if (!config.persistCookies || !config.profile || config.explicitCookieUrls.length === 0) {
773
+ return
774
+ }
775
+
776
+ const stateDb = await ensureStateDb(appPaths, true, dependencies)
777
+
778
+ if (!stateDb) {
779
+ throw new Error("Unable to create cloak storage.")
780
+ }
781
+
782
+ const remembered = stateDb.rememberCookieUrls(
783
+ config.profile,
784
+ config.explicitCookieUrls
785
+ )
786
+ log(formatSuccess(`Remembered ${remembered.length} cookie URL(s) for ${config.profile}`))
787
+ }
788
+
789
+ async function launchBrowser(
790
+ config: ResolvedRunConfig,
791
+ cookies: Cookie[],
792
+ appPaths: AppPaths,
793
+ dependencies: MainDependencies
794
+ ) {
795
+ const prepareExtension =
796
+ dependencies.prepareRequiredExtension ?? prepareRequiredExtension
797
+ const launchPersistentContext =
798
+ dependencies.patchrightLaunchPersistentContext ??
799
+ dependencies.launchPersistentContext ??
800
+ patchrightChromium.launchPersistentContext.bind(patchrightChromium)
801
+ const makeTempDir = dependencies.makeTempDir ?? fs.mkdtempSync
802
+ const makeDir = dependencies.makeDir ?? fs.mkdirSync
803
+ const writeFile = dependencies.writeFile ?? fs.writeFileSync
804
+ const extensionPath = await prepareExtension(appPaths.extensionsDir)
805
+ const args = [
806
+ `--disable-extensions-except=${extensionPath}`,
807
+ `--load-extension=${extensionPath}`,
808
+ ]
809
+
810
+ const userDataDir = makeTempDir(path.join(os.tmpdir(), "cloak-profile-"))
811
+ const defaultDir = path.join(userDataDir, "Default")
812
+ makeDir(defaultDir, { recursive: true })
813
+ writeFile(
814
+ path.join(defaultDir, "Preferences"),
815
+ JSON.stringify({
816
+ extensions: { ui: { developer_mode: true } },
817
+ })
818
+ )
819
+
820
+ const executablePath =
821
+ dependencies.patchrightExecutablePath ??
822
+ patchrightChromium.executablePath.bind(patchrightChromium)
823
+ const context = await launchPersistentContext(userDataDir, {
824
+ headless: config.headless,
825
+ executablePath: executablePath(),
826
+ args,
827
+ })
828
+
829
+ if (cookies.length > 0) {
830
+ await context.addCookies(cookies)
831
+ console.log(formatSuccess(`Injected ${cookies.length} cookies`))
832
+ }
833
+
834
+ console.log(formatInfo("Browser running. Ctrl+C to exit."))
835
+
836
+ const browser = context.browser()
837
+ let onSigint: (() => void) | undefined
838
+ let onSigterm: (() => void) | undefined
839
+ const handleSignal = () => {
840
+ console.log(formatInfo("\nShutting down..."))
841
+ }
842
+
843
+ try {
844
+ await new Promise<void>((resolve) => {
845
+ let settled = false
846
+ const finish = () => {
847
+ if (settled) {
848
+ return
849
+ }
850
+
851
+ settled = true
852
+ resolve()
853
+ }
854
+
855
+ onSigint = () => {
856
+ handleSignal()
857
+ finish()
858
+ }
859
+ onSigterm = () => {
860
+ handleSignal()
861
+ finish()
862
+ }
863
+
864
+ context.on("close", finish)
865
+ browser?.on("disconnected", finish)
866
+ process.on("SIGINT", onSigint)
867
+ process.on("SIGTERM", onSigterm)
868
+ })
869
+ } finally {
870
+ if (onSigint) {
871
+ process.removeListener("SIGINT", onSigint)
872
+ }
873
+
874
+ if (onSigterm) {
875
+ process.removeListener("SIGTERM", onSigterm)
876
+ }
877
+
878
+ loadExistingStateDb(appPaths, dependencies)?.clearDaemonState(process.pid)
879
+ }
880
+
881
+ await context.close().catch(() => {})
882
+ }
883
+
884
+ async function resolveChromeProfile(
885
+ profileName: string,
886
+ dependencies: Pick<MainDependencies, "listChromeProfiles">
887
+ ): Promise<ChromeProfile> {
888
+ const listProfiles = dependencies.listChromeProfiles ?? listChromeProfiles
889
+ const profiles = listProfiles()
890
+ const profile = profiles.find((candidate) => candidate.directory === profileName)
891
+
892
+ if (!profile) {
893
+ throw new Error(`Chrome profile not found: ${profileName}`)
894
+ }
895
+
896
+ return profile
897
+ }
898
+
899
+ function formatRunSettings(config: ResolvedRunConfig): string {
900
+ const lines = [
901
+ "Running with settings",
902
+ `profile: ${config.profile ?? "(none)"}`,
903
+ `cookie file: ${config.cookieFile ?? "(none)"}`,
904
+ "cookie urls:",
905
+ ]
906
+
907
+ if (config.cookieUrls.length === 0) {
908
+ lines.push(" (none)")
909
+ } else {
910
+ for (const url of config.cookieUrls) {
911
+ lines.push(` - ${url}`)
912
+ }
913
+ }
914
+
915
+ return lines.join("\n")
916
+ }
917
+
918
+ function formatDaemonState(state: DaemonState): string {
919
+ const lines = [
920
+ "cloak daemon",
921
+ `pid: ${state.pid}`,
922
+ "status: running",
923
+ `profile: ${state.profile ?? "(none)"}`,
924
+ `cookie file: ${state.cookieFile ?? "(none)"}`,
925
+ `headless: ${state.headless ? "yes" : "no"}`,
926
+ `started at: ${state.startedAt}`,
927
+ `log: ${state.logPath}`,
928
+ "cookie urls:",
929
+ ]
930
+
931
+ if (state.cookieUrls.length === 0) {
932
+ lines.push(" (none)")
933
+ } else {
934
+ for (const url of state.cookieUrls) {
935
+ lines.push(` - ${url}`)
936
+ }
937
+ }
938
+
939
+ return lines.join("\n")
940
+ }
941
+
942
+ function loadExistingStateDb(
943
+ appPaths: AppPaths,
944
+ dependencies: Pick<MainDependencies, "createStateDb" | "pathExists">
945
+ ): CloakStateDb | undefined {
946
+ const pathExists = dependencies.pathExists ?? fs.existsSync
947
+
948
+ if (!pathExists(appPaths.stateDbPath)) {
949
+ return undefined
950
+ }
951
+
952
+ const createStateDb = dependencies.createStateDb ?? ((dbPath: string) => new CloakStateDb(dbPath))
953
+ return createStateDb(appPaths.stateDbPath)
954
+ }
955
+
956
+ async function ensureStateDb(
957
+ appPaths: AppPaths,
958
+ consent: boolean,
959
+ dependencies: Pick<
960
+ MainDependencies,
961
+ | "createStateDb"
962
+ | "confirmCreateConfigDir"
963
+ | "makeDir"
964
+ | "pathExists"
965
+ | "stdinIsTTY"
966
+ | "stdoutIsTTY"
967
+ >
968
+ ): Promise<CloakStateDb | undefined> {
969
+ const pathExists = dependencies.pathExists ?? fs.existsSync
970
+ const existing = loadExistingStateDb(appPaths, dependencies)
971
+
972
+ if (existing) {
973
+ return existing
974
+ }
975
+
976
+ const makeDir = dependencies.makeDir ?? fs.mkdirSync
977
+ const configDirExists = pathExists(appPaths.configDir)
978
+
979
+ if (!configDirExists && !consent) {
980
+ if (!isInteractiveTerminal(dependencies)) {
981
+ throw new Error(
982
+ `Config directory ${appPaths.configDir} does not exist. Re-run with --consent to create it.`
983
+ )
984
+ }
985
+
986
+ const confirmCreateConfigDir =
987
+ dependencies.confirmCreateConfigDir ?? defaultConfirmCreateConfigDir
988
+
989
+ if (!(await confirmCreateConfigDir(appPaths.configDir))) {
990
+ return undefined
991
+ }
992
+ }
993
+
994
+ makeDir(appPaths.configDir, { recursive: true })
995
+ const createStateDb = dependencies.createStateDb ?? ((dbPath: string) => new CloakStateDb(dbPath))
996
+ return createStateDb(appPaths.stateDbPath)
997
+ }
998
+
999
+ function isInteractiveTerminal(
1000
+ dependencies: Pick<MainDependencies, "stdinIsTTY" | "stdoutIsTTY">
1001
+ ): boolean {
1002
+ const stdinIsTTY = dependencies.stdinIsTTY ?? process.stdin.isTTY ?? false
1003
+ const stdoutIsTTY = dependencies.stdoutIsTTY ?? process.stdout.isTTY ?? false
1004
+ return stdinIsTTY && stdoutIsTTY
1005
+ }
1006
+
1007
+ async function defaultConfirmCreateConfigDir(configDir: string): Promise<boolean> {
1008
+ const rl = readline.createInterface({
1009
+ input: process.stdin,
1010
+ output: process.stdout,
1011
+ })
1012
+
1013
+ try {
1014
+ const answer = await rl.question(`Create ${configDir}? [y/N] `)
1015
+ return /^(y|yes)$/i.test(answer.trim())
1016
+ } finally {
1017
+ rl.close()
1018
+ }
1019
+ }
1020
+
1021
+ async function defaultConfirmDestroyState(configDir: string): Promise<boolean> {
1022
+ const rl = readline.createInterface({
1023
+ input: process.stdin,
1024
+ output: process.stdout,
1025
+ })
1026
+
1027
+ try {
1028
+ const answer = await rl.question(
1029
+ `Destroy cloak storage under ${configDir}? This cannot be undone. [y/N] `
1030
+ )
1031
+ return /^(y|yes)$/i.test(answer.trim())
1032
+ } finally {
1033
+ rl.close()
1034
+ }
1035
+ }
1036
+
1037
+ async function selectCookieUrls(
1038
+ options: SelectCookieUrlsOptions,
1039
+ dependencies: Pick<MainDependencies, "selectCookieUrls">
1040
+ ): Promise<string[] | undefined> {
1041
+ const promptFn = dependencies.selectCookieUrls ?? defaultSelectCookieUrls
1042
+ return promptFn(options)
1043
+ }
1044
+
1045
+ async function defaultSelectCookieUrls(
1046
+ options: SelectCookieUrlsOptions
1047
+ ): Promise<string[] | undefined> {
1048
+ const rl = readline.createInterface({
1049
+ input: process.stdin,
1050
+ output: process.stdout,
1051
+ })
1052
+
1053
+ try {
1054
+ while (true) {
1055
+ const answer = await rl.question(
1056
+ `Remember URLs for ${options.profile}? [all, none, 1,3-5, Enter to skip] `
1057
+ )
1058
+
1059
+ try {
1060
+ const indexes = parseSelectionInput(answer, options.urls.length)
1061
+
1062
+ if (indexes === undefined) {
1063
+ return undefined
1064
+ }
1065
+
1066
+ return indexes.map((index) => options.urls[index])
1067
+ } catch (error) {
1068
+ console.log(formatWarning(String(error)))
1069
+ }
1070
+ }
1071
+ } finally {
1072
+ rl.close()
1073
+ }
1074
+ }
1075
+
1076
+ if (require.main === module) {
1077
+ main().catch((error) => {
1078
+ console.error(formatError(String(error)))
1079
+ process.exit(1)
1080
+ })
1081
+ }
1082
+
1083
+ export function daemonCommandToArgs(command: StoredRunCommand): string[] {
1084
+ return buildRunArguments(command)
1085
+ }