chrome-cli-bridge 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,152 @@
1
+ #!/usr/bin/env node
2
+ import { createInterface } from 'node:readline'
3
+ import { getClient } from '../src/client.js'
4
+ import { outLine } from '../src/output.js'
5
+
6
+ // ── Pipe mode ───────────────────────────────────────────────────────────────
7
+ // Activated when stdin is not a TTY or --pipe flag is present
8
+
9
+ const forcePipe = process.argv.includes('--pipe')
10
+ const isPipeMode = forcePipe || !process.stdin.isTTY
11
+
12
+ if (isPipeMode) {
13
+ // Remove --pipe flag from args so yargs doesn't complain
14
+ const idx = process.argv.indexOf('--pipe')
15
+ if (idx !== -1) process.argv.splice(idx, 1)
16
+
17
+ runPipeMode()
18
+ } else {
19
+ runCLI()
20
+ }
21
+
22
+ async function runPipeMode() {
23
+ const portArg = process.argv.indexOf('--port')
24
+ const port = portArg !== -1 ? parseInt(process.argv[portArg + 1], 10) : 9876
25
+
26
+ let client
27
+ try {
28
+ client = await getClient(port)
29
+ } catch (e) {
30
+ process.stderr.write(JSON.stringify({ error: 'connection_failed', message: e.message }) + '\n')
31
+ process.exit(1)
32
+ }
33
+
34
+ const rl = createInterface({ input: process.stdin, crlfDelay: Infinity })
35
+
36
+ rl.on('line', async (line) => {
37
+ const trimmed = line.trim()
38
+ if (!trimmed) return
39
+
40
+ let msg
41
+ try {
42
+ msg = JSON.parse(trimmed)
43
+ } catch {
44
+ outLine({ error: 'invalid_input', message: `Invalid JSON: ${trimmed.slice(0, 80)}` })
45
+ return
46
+ }
47
+
48
+ const { command, ...params } = msg
49
+ if (!command) {
50
+ outLine({ error: 'invalid_input', message: 'Missing "command" field' })
51
+ return
52
+ }
53
+
54
+ const methodMap = {
55
+ tabs: 'tabs.list',
56
+ select: 'tabs.select',
57
+ query: 'page.query',
58
+ exec: 'page.exec',
59
+ logs: 'page.logs',
60
+ network: 'page.network',
61
+ trigger: 'page.trigger',
62
+ screenshot: 'page.screenshot',
63
+ navigate: 'page.navigate',
64
+ storage: 'page.storage',
65
+ wait: 'page.wait',
66
+ inject: 'page.inject',
67
+ snapshot: 'page.snapshot',
68
+ type: 'page.type',
69
+ click: 'page.click',
70
+ hover: 'page.hover',
71
+ }
72
+
73
+ const method = methodMap[command]
74
+ if (!method) {
75
+ outLine({ error: 'unknown_command', message: `Unknown command: ${command}` })
76
+ return
77
+ }
78
+
79
+ try {
80
+ const result = await client.call(method, params)
81
+ outLine({ result })
82
+ } catch (e) {
83
+ outLine({ error: e.message, code: e.code, ...(e.data && { data: e.data }) })
84
+ }
85
+ })
86
+
87
+ rl.on('close', () => {
88
+ client.close()
89
+ process.exit(0)
90
+ })
91
+ }
92
+
93
+ async function runCLI() {
94
+ const yargs = (await import('yargs')).default
95
+ const { hideBin } = await import('yargs/helpers')
96
+
97
+ // commandDir() is not supported in ESM — import commands explicitly
98
+ const [start, stop, status, tabs, query, exec, logs, trigger,
99
+ screenshot, navigate, storage, wait, inject, snapshot,
100
+ type, click, hover, repl, devtools] = await Promise.all([
101
+ import('../src/commands/start.js'),
102
+ import('../src/commands/stop.js'),
103
+ import('../src/commands/status.js'),
104
+ import('../src/commands/tabs.js'),
105
+ import('../src/commands/query.js'),
106
+ import('../src/commands/exec.js'),
107
+ import('../src/commands/logs.js'),
108
+ import('../src/commands/trigger.js'),
109
+ import('../src/commands/screenshot.js'),
110
+ import('../src/commands/navigate.js'),
111
+ import('../src/commands/storage.js'),
112
+ import('../src/commands/wait.js'),
113
+ import('../src/commands/inject.js'),
114
+ import('../src/commands/snapshot.js'),
115
+ import('../src/commands/type.js'),
116
+ import('../src/commands/click.js'),
117
+ import('../src/commands/hover.js'),
118
+ import('../src/commands/repl.js'),
119
+ import('../src/commands/devtools.js'),
120
+ ])
121
+
122
+ yargs(hideBin(process.argv))
123
+ .scriptName('chrome-bridge')
124
+ .usage('$0 <command> [options]')
125
+ .command(start)
126
+ .command(stop)
127
+ .command(status)
128
+ .command(tabs)
129
+ .command(query)
130
+ .command(exec)
131
+ .command(logs)
132
+ .command(trigger)
133
+ .command(screenshot)
134
+ .command(navigate)
135
+ .command(storage)
136
+ .command(wait)
137
+ .command(inject)
138
+ .command(snapshot)
139
+ .command(type)
140
+ .command(click)
141
+ .command(hover)
142
+ .command(repl)
143
+ .command(devtools)
144
+ .option('port', { type: 'number', default: 9876, global: true, describe: 'Relay port' })
145
+ .option('pipe', { type: 'boolean', describe: 'Force stdin pipe mode (NDJSON in, NDJSON out)' })
146
+ .demandCommand(1, 'Specify a command. Run --help for available commands.')
147
+ .strict()
148
+ .help()
149
+ .alias('h', 'help')
150
+ .wrap(null)
151
+ .parse()
152
+ }
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "chrome-cli-bridge",
3
+ "version": "0.1.0",
4
+ "description": "Connect CLI tools and AI agents to a live Chrome tab via WebSocket bridge",
5
+ "type": "module",
6
+ "main": "src/index.js",
7
+ "bin": {
8
+ "chrome-bridge": "./bin/chrome-bridge.js"
9
+ },
10
+ "exports": {
11
+ ".": "./src/index.js"
12
+ },
13
+ "engines": {
14
+ "node": ">=18.0.0"
15
+ },
16
+ "scripts": {
17
+ "start": "node bin/chrome-bridge.js start",
18
+ "bridge": "node bin/chrome-bridge.js",
19
+ "test": "node --test tests/unit/**/*.test.js",
20
+ "test:integration": "node --test tests/integration/**/*.test.js"
21
+ },
22
+ "keywords": [
23
+ "chrome",
24
+ "browser",
25
+ "automation",
26
+ "cli",
27
+ "ai",
28
+ "websocket",
29
+ "devtools",
30
+ "scraping",
31
+ "bridge",
32
+ "agent"
33
+ ],
34
+ "author": "Nestor Mata <nestor.mata@gmail.com>",
35
+ "license": "MIT",
36
+ "repository": {
37
+ "type": "git",
38
+ "url": "https://github.com/nestormata/chrome-bridge.git"
39
+ },
40
+ "homepage": "https://github.com/nestormata/chrome-bridge#readme",
41
+ "bugs": {
42
+ "url": "https://github.com/nestormata/chrome-bridge/issues"
43
+ },
44
+ "dependencies": {
45
+ "ws": "^8.17.0",
46
+ "yargs": "^17.7.2"
47
+ },
48
+ "devDependencies": {
49
+ "puppeteer": "^22.0.0"
50
+ }
51
+ }
package/src/client.js ADDED
@@ -0,0 +1,96 @@
1
+ import { readToken } from './token.js'
2
+ import { Relay } from './relay.js'
3
+
4
+ let _client = null
5
+
6
+ export async function getClient(port = 9876) {
7
+ if (_client) return _client
8
+ const token = await readToken()
9
+ if (!token) throw new Error('No session token found. Run: chrome-bridge start')
10
+
11
+ _client = new RelayClient(port, token)
12
+ await _client.connect()
13
+ return _client
14
+ }
15
+
16
+ export class RelayClient {
17
+ #ws = null
18
+ #token
19
+ #port
20
+ #pendingCalls = new Map()
21
+ #eventHandlers = new Map()
22
+ #nextId = (() => { let i = 0; return () => ++i })()
23
+
24
+ constructor(port, token) {
25
+ this.#port = port
26
+ this.#token = token
27
+ }
28
+
29
+ async connect() {
30
+ const { WebSocket } = await import('ws')
31
+ await new Promise((resolve, reject) => {
32
+ this.#ws = new WebSocket(`ws://127.0.0.1:${this.#port}`)
33
+ this.#ws.once('error', reject)
34
+ this.#ws.once('open', () => {
35
+ this.#ws.off('error', reject)
36
+ this.#ws.on('error', () => {})
37
+ this.#ws.on('message', (data) => this.#onMessage(data))
38
+ // send handshake with cli role
39
+ this.#ws.send(JSON.stringify({
40
+ jsonrpc: '2.0', method: 'handshake', params: { token: this.#token, role: 'cli' }, id: 0,
41
+ }))
42
+ resolve()
43
+ })
44
+ })
45
+ }
46
+
47
+ close() {
48
+ this.#ws?.terminate()
49
+ this.#ws = null
50
+ }
51
+
52
+ async call(method, params = {}, timeoutMs = 15000) {
53
+ return new Promise((resolve, reject) => {
54
+ const id = this.#nextId()
55
+ const timer = setTimeout(() => {
56
+ this.#pendingCalls.delete(id)
57
+ reject(new Error(`Timeout waiting for ${method}`))
58
+ }, timeoutMs)
59
+ this.#pendingCalls.set(id, { resolve, reject, timer })
60
+ this.#ws.send(JSON.stringify({ jsonrpc: '2.0', method, params, id }))
61
+ })
62
+ }
63
+
64
+ on(event, handler) {
65
+ if (!this.#eventHandlers.has(event)) this.#eventHandlers.set(event, new Set())
66
+ this.#eventHandlers.get(event).add(handler)
67
+ return () => this.#eventHandlers.get(event)?.delete(handler)
68
+ }
69
+
70
+ #onMessage(data) {
71
+ let msg
72
+ try { msg = JSON.parse(data.toString()) } catch { return }
73
+
74
+ if (msg.id !== undefined && (msg.result !== undefined || msg.error)) {
75
+ const pending = this.#pendingCalls.get(msg.id)
76
+ if (pending) {
77
+ clearTimeout(pending.timer)
78
+ this.#pendingCalls.delete(msg.id)
79
+ if (msg.error) {
80
+ const e = new Error(msg.error.message)
81
+ e.code = msg.error.code
82
+ e.data = msg.error.data
83
+ pending.reject(e)
84
+ } else {
85
+ pending.resolve(msg.result)
86
+ }
87
+ }
88
+ return
89
+ }
90
+
91
+ if (msg.method && msg.id === undefined) {
92
+ const handlers = this.#eventHandlers.get(msg.method)
93
+ if (handlers) handlers.forEach((fn) => fn(msg.params))
94
+ }
95
+ }
96
+ }
@@ -0,0 +1,22 @@
1
+ import { getClient } from '../client.js'
2
+ import { out, err } from '../output.js'
3
+
4
+ export const command = 'click'
5
+ export const describe = 'Simulate a mouse click on a DOM element'
6
+ export const builder = {
7
+ selector: { type: 'string', demandOption: true, describe: 'CSS selector for target element' },
8
+ port: { type: 'number', default: 9876, describe: 'Relay port' },
9
+ }
10
+
11
+ export async function handler(argv) {
12
+ try {
13
+ const client = await getClient(argv.port)
14
+ const result = await client.call('page.click', { selector: argv.selector })
15
+ out(result)
16
+ client.close()
17
+ if (result?.error) process.exit(1)
18
+ } catch (e) {
19
+ err(e.message)
20
+ process.exit(1)
21
+ }
22
+ }
@@ -0,0 +1,57 @@
1
+ import { getClient } from '../client.js'
2
+ import { out, err } from '../output.js'
3
+
4
+ export const command = 'devtools <subcommand>'
5
+ export const describe = 'Access Chrome DevTools data (performance, memory, coverage)'
6
+ export const builder = (yargs) => {
7
+ yargs
8
+ .command({
9
+ command: 'performance',
10
+ describe: 'Collect runtime performance metrics',
11
+ builder: { port: { type: 'number', default: 9876 } },
12
+ async handler(argv) {
13
+ try {
14
+ const client = await getClient(argv.port)
15
+ const result = await client.call('devtools.performance', {})
16
+ out(result)
17
+ client.close()
18
+ } catch (e) { err(e.message); process.exit(1) }
19
+ },
20
+ })
21
+ .command({
22
+ command: 'memory',
23
+ describe: 'Take a heap memory snapshot',
24
+ builder: {
25
+ output: { type: 'string', alias: 'o', describe: 'Output file path (default: ./heap-<ts>.heapsnapshot)' },
26
+ port: { type: 'number', default: 9876 },
27
+ },
28
+ async handler(argv) {
29
+ try {
30
+ const client = await getClient(argv.port)
31
+ const output = argv.output || `./heap-${Date.now()}.heapsnapshot`
32
+ const result = await client.call('devtools.memory', { output })
33
+ out(result)
34
+ client.close()
35
+ } catch (e) { err(e.message); process.exit(1) }
36
+ },
37
+ })
38
+ .command({
39
+ command: 'coverage',
40
+ describe: 'Capture JavaScript code coverage',
41
+ builder: {
42
+ duration: { type: 'number', default: 5000, describe: 'Recording duration in ms' },
43
+ port: { type: 'number', default: 9876 },
44
+ },
45
+ async handler(argv) {
46
+ try {
47
+ const client = await getClient(argv.port)
48
+ const result = await client.call('devtools.coverage', { duration: argv.duration })
49
+ out(result)
50
+ client.close()
51
+ } catch (e) { err(e.message); process.exit(1) }
52
+ },
53
+ })
54
+ .demandCommand(1, 'Specify a devtools subcommand: performance | memory | coverage')
55
+ }
56
+
57
+ export function handler() {}
@@ -0,0 +1,22 @@
1
+ import { getClient } from '../client.js'
2
+ import { out, err } from '../output.js'
3
+
4
+ export const command = 'exec'
5
+ export const describe = 'Execute JavaScript in the selected tab'
6
+ export const builder = {
7
+ code: { type: 'string', demandOption: true, describe: 'JavaScript expression or statement' },
8
+ port: { type: 'number', default: 9876, describe: 'Relay port' },
9
+ }
10
+
11
+ export async function handler(argv) {
12
+ try {
13
+ const client = await getClient(argv.port)
14
+ const result = await client.call('page.exec', { code: argv.code })
15
+ out(result)
16
+ client.close()
17
+ if (result?.error) process.exit(1)
18
+ } catch (e) {
19
+ err(e.message, e.data)
20
+ process.exit(1)
21
+ }
22
+ }
@@ -0,0 +1,22 @@
1
+ import { getClient } from '../client.js'
2
+ import { out, err } from '../output.js'
3
+
4
+ export const command = 'hover'
5
+ export const describe = 'Simulate a mouse hover over a DOM element'
6
+ export const builder = {
7
+ selector: { type: 'string', demandOption: true, describe: 'CSS selector for target element' },
8
+ port: { type: 'number', default: 9876, describe: 'Relay port' },
9
+ }
10
+
11
+ export async function handler(argv) {
12
+ try {
13
+ const client = await getClient(argv.port)
14
+ const result = await client.call('page.hover', { selector: argv.selector })
15
+ out(result)
16
+ client.close()
17
+ if (result?.error) process.exit(1)
18
+ } catch (e) {
19
+ err(e.message)
20
+ process.exit(1)
21
+ }
22
+ }
@@ -0,0 +1,31 @@
1
+ import { getClient } from '../client.js'
2
+ import { out, err } from '../output.js'
3
+ import { readFile } from 'node:fs/promises'
4
+
5
+ export const command = 'inject'
6
+ export const describe = 'Inject a local JavaScript file into the selected tab'
7
+ export const builder = {
8
+ file: { type: 'string', demandOption: true, describe: 'Path to JS file to inject' },
9
+ port: { type: 'number', default: 9876, describe: 'Relay port' },
10
+ }
11
+
12
+ export async function handler(argv) {
13
+ let code
14
+ try {
15
+ code = await readFile(argv.file, 'utf8')
16
+ } catch {
17
+ err(`file_not_found: ${argv.file}`)
18
+ process.exit(1)
19
+ }
20
+
21
+ try {
22
+ const client = await getClient(argv.port)
23
+ const result = await client.call('page.inject', { code })
24
+ out(result)
25
+ client.close()
26
+ if (result?.error) process.exit(1)
27
+ } catch (e) {
28
+ err(e.message)
29
+ process.exit(1)
30
+ }
31
+ }
@@ -0,0 +1,47 @@
1
+ import { getClient } from '../client.js'
2
+ import { out, outLine, err } from '../output.js'
3
+
4
+ export const command = 'logs'
5
+ export const describe = 'Read console or network logs from the selected tab'
6
+ export const builder = {
7
+ follow: { type: 'boolean', alias: 'f', describe: 'Stream logs in real time (legacy alias for --watch)' },
8
+ watch: { type: 'boolean', alias: 'w', describe: 'Stream logs in real time via push events' },
9
+ level: { type: 'string', describe: 'Filter by level: log|warn|error|info|debug' },
10
+ network: { type: 'boolean', describe: 'Show network requests instead of console logs' },
11
+ port: { type: 'number', default: 9876, describe: 'Relay port' },
12
+ }
13
+
14
+ export async function handler(argv) {
15
+ try {
16
+ const client = await getClient(argv.port)
17
+
18
+ if (argv.network) {
19
+ const result = await client.call('page.network', {})
20
+ out(result)
21
+ client.close()
22
+ return
23
+ }
24
+
25
+ if (argv.watch || argv.follow) {
26
+ // Print buffered logs first, then stream push events
27
+ const buffered = await client.call('page.logs', { level: argv.level })
28
+ buffered.forEach((entry) => outLine(entry))
29
+
30
+ // Subscribe to push events (stream.log is canonical; page:log is legacy)
31
+ client.on('stream.log', (entry) => {
32
+ if (!argv.level || entry.level === argv.level) outLine(entry)
33
+ })
34
+ client.on('page:log', () => {}) // drain legacy events silently
35
+
36
+ process.on('SIGINT', () => { client.close(); process.exit(0) })
37
+ return
38
+ }
39
+
40
+ const result = await client.call('page.logs', { level: argv.level })
41
+ out(result)
42
+ client.close()
43
+ } catch (e) {
44
+ err(e.message)
45
+ process.exit(1)
46
+ }
47
+ }
@@ -0,0 +1,21 @@
1
+ import { getClient } from '../client.js'
2
+ import { out, err } from '../output.js'
3
+
4
+ export const command = 'navigate'
5
+ export const describe = 'Navigate the selected tab to a URL'
6
+ export const builder = {
7
+ url: { type: 'string', demandOption: true, describe: 'URL to navigate to' },
8
+ port: { type: 'number', default: 9876, describe: 'Relay port' },
9
+ }
10
+
11
+ export async function handler(argv) {
12
+ try {
13
+ const client = await getClient(argv.port)
14
+ const result = await client.call('page.navigate', { url: argv.url })
15
+ out(result)
16
+ client.close()
17
+ } catch (e) {
18
+ err(e.message)
19
+ process.exit(1)
20
+ }
21
+ }
@@ -0,0 +1,29 @@
1
+ import { getClient } from '../client.js'
2
+ import { out, err } from '../output.js'
3
+
4
+ export const command = 'query'
5
+ export const describe = 'Query the DOM of the selected tab'
6
+ export const builder = {
7
+ selector: { type: 'string', describe: 'CSS selector to query' },
8
+ html: { type: 'boolean', describe: 'Return full page HTML' },
9
+ port: { type: 'number', default: 9876, describe: 'Relay port' },
10
+ }
11
+
12
+ export async function handler(argv) {
13
+ if (!argv.selector && !argv.html) {
14
+ err('Provide --selector <css> or --html')
15
+ process.exit(1)
16
+ }
17
+ try {
18
+ const client = await getClient(argv.port)
19
+ const result = await client.call('page.query', {
20
+ selector: argv.selector,
21
+ full: argv.html ?? false,
22
+ })
23
+ out(result)
24
+ client.close()
25
+ } catch (e) {
26
+ err(e.message)
27
+ process.exit(1)
28
+ }
29
+ }
@@ -0,0 +1,63 @@
1
+ import { createInterface } from 'node:readline'
2
+ import { getClient } from '../client.js'
3
+ import { err } from '../output.js'
4
+
5
+ export const command = 'repl'
6
+ export const describe = 'Start an interactive JavaScript REPL in the selected tab'
7
+ export const builder = {
8
+ port: { type: 'number', default: 9876, describe: 'Relay port' },
9
+ }
10
+
11
+ export async function handler(argv) {
12
+ let client
13
+ try {
14
+ client = await getClient(argv.port)
15
+ } catch (e) {
16
+ err(e.message)
17
+ process.exit(1)
18
+ }
19
+ await runRepl(process.stdin, process.stdout, client)
20
+ client.close()
21
+ process.exit(0)
22
+ }
23
+
24
+ /**
25
+ * Run the REPL loop.
26
+ * Accepts injectable input/output streams and a client for testability.
27
+ * @param {import('node:stream').Readable} input
28
+ * @param {import('node:stream').Writable} output
29
+ * @param {{ call: (method: string, params: object) => Promise<any> }} client
30
+ * @returns {Promise<void>} resolves when the REPL exits
31
+ */
32
+ export async function runRepl(input, output, client) {
33
+ const isTTY = output.isTTY ?? false
34
+
35
+ if (isTTY) {
36
+ output.write('chrome-bridge REPL — JavaScript in the selected tab\n')
37
+ output.write('Type .exit or press Ctrl+C/Ctrl+D to quit.\n')
38
+ }
39
+
40
+ const rl = createInterface({ input, output: isTTY ? output : undefined, terminal: false, crlfDelay: Infinity })
41
+
42
+ if (isTTY) rl.setPrompt('> ')
43
+
44
+ return new Promise((resolve) => {
45
+ rl.on('line', async (line) => {
46
+ const code = line.trim()
47
+ if (!code || code === '.exit') { rl.close(); return }
48
+ rl.pause()
49
+ try {
50
+ const res = await client.call('page.exec', { code })
51
+ output.write(JSON.stringify(res.result) + '\n')
52
+ } catch (e) {
53
+ output.write(`Error: ${e.message}\n`)
54
+ }
55
+ rl.resume()
56
+ if (isTTY) rl.prompt()
57
+ })
58
+
59
+ rl.on('close', resolve)
60
+
61
+ if (isTTY) rl.prompt()
62
+ })
63
+ }
@@ -0,0 +1,29 @@
1
+ import { getClient } from '../client.js'
2
+ import { out, err } from '../output.js'
3
+ import { writeFile } from 'node:fs/promises'
4
+
5
+ export const command = 'screenshot'
6
+ export const describe = 'Capture a screenshot of the selected tab'
7
+ export const builder = {
8
+ output: { type: 'string', alias: 'o', describe: 'Save PNG to file path instead of printing base64' },
9
+ port: { type: 'number', default: 9876, describe: 'Relay port' },
10
+ }
11
+
12
+ export async function handler(argv) {
13
+ try {
14
+ const client = await getClient(argv.port)
15
+ const result = await client.call('page.screenshot', {})
16
+ client.close()
17
+
18
+ if (argv.output) {
19
+ const base64 = result.dataUrl.replace(/^data:image\/png;base64,/, '')
20
+ await writeFile(argv.output, Buffer.from(base64, 'base64'))
21
+ out({ saved: argv.output })
22
+ } else {
23
+ out(result)
24
+ }
25
+ } catch (e) {
26
+ err(e.message)
27
+ process.exit(1)
28
+ }
29
+ }
@@ -0,0 +1,21 @@
1
+ import { getClient } from '../client.js'
2
+ import { out, err } from '../output.js'
3
+
4
+ export const command = 'snapshot'
5
+ export const describe = 'Capture the full HTML of the current page'
6
+ export const builder = {
7
+ styles: { type: 'boolean', default: false, describe: 'Include computed styles inline' },
8
+ port: { type: 'number', default: 9876, describe: 'Relay port' },
9
+ }
10
+
11
+ export async function handler(argv) {
12
+ try {
13
+ const client = await getClient(argv.port)
14
+ const result = await client.call('page.snapshot', { styles: argv.styles })
15
+ out(result)
16
+ client.close()
17
+ } catch (e) {
18
+ err(e.message)
19
+ process.exit(1)
20
+ }
21
+ }
@@ -0,0 +1,42 @@
1
+ import { Relay } from '../relay.js'
2
+ import { readToken, TOKEN_PATH } from '../token.js'
3
+ import { writeFile, readFile, unlink } from 'node:fs/promises'
4
+ import { join } from 'node:path'
5
+ import { homedir } from 'node:os'
6
+
7
+ const PID_FILE = join(homedir(), '.chrome-cli-bridge.pid')
8
+
9
+ export const command = 'start'
10
+ export const describe = 'Start the relay server'
11
+ export const builder = {
12
+ port: { type: 'number', default: 9876, describe: 'Port to listen on' },
13
+ detach: { type: 'boolean', default: false, describe: 'Run in background (daemon mode)' },
14
+ }
15
+
16
+ export async function handler(argv) {
17
+ const relay = new Relay(argv.port)
18
+ try {
19
+ const token = await relay.start()
20
+ await writeFile(PID_FILE, String(process.pid), 'utf8')
21
+ console.log(`Relay started on ws://127.0.0.1:${argv.port}`)
22
+ console.log(`Session token: ${token}`)
23
+ console.log(`Token saved to: ${TOKEN_PATH}`)
24
+ console.log('')
25
+ console.log('Open the chrome-cli-bridge extension popup in Chrome, paste the token, and click Connect.')
26
+ console.log('Press Ctrl+C to stop.')
27
+
28
+ process.on('SIGINT', async () => {
29
+ await relay.stop()
30
+ await unlink(PID_FILE).catch(() => {})
31
+ process.exit(0)
32
+ })
33
+ process.on('SIGTERM', async () => {
34
+ await relay.stop()
35
+ await unlink(PID_FILE).catch(() => {})
36
+ process.exit(0)
37
+ })
38
+ } catch (e) {
39
+ console.error(e.message)
40
+ process.exit(1)
41
+ }
42
+ }
@@ -0,0 +1,43 @@
1
+ import { readFile } from 'node:fs/promises'
2
+ import { join } from 'node:path'
3
+ import { homedir } from 'node:os'
4
+ import { out } from '../output.js'
5
+ import { readToken } from '../token.js'
6
+ import { RelayClient } from '../client.js'
7
+
8
+ const PID_FILE = join(homedir(), '.chrome-cli-bridge.pid')
9
+
10
+ export const command = 'status'
11
+ export const describe = 'Check relay and extension connection status'
12
+ export const builder = {
13
+ port: { type: 'number', default: 9876, describe: 'Relay port' },
14
+ }
15
+
16
+ export async function handler(argv) {
17
+ const result = { relay: 'stopped', extension: 'disconnected', pid: null, token: null }
18
+
19
+ try {
20
+ const pid = parseInt(await readFile(PID_FILE, 'utf8'), 10)
21
+ process.kill(pid, 0) // check if alive
22
+ result.relay = 'running'
23
+ result.pid = pid
24
+ } catch { /* not running */ }
25
+
26
+ result.token = (await readToken()) ? '(set)' : '(not set)'
27
+
28
+ if (result.relay === 'running') {
29
+ try {
30
+ const token = await readToken()
31
+ const client = new RelayClient(argv.port, token)
32
+ await client.connect()
33
+ // If connect succeeded, relay responded; check if extension is attached
34
+ const tabs = await client.call('tabs.list', {}).catch(() => null)
35
+ result.extension = tabs !== null ? 'connected' : 'not connected'
36
+ client.close()
37
+ } catch {
38
+ result.extension = 'not connected'
39
+ }
40
+ }
41
+
42
+ out(result)
43
+ }
@@ -0,0 +1,28 @@
1
+ import { readFile, unlink } from 'node:fs/promises'
2
+ import { join } from 'node:path'
3
+ import { homedir } from 'node:os'
4
+
5
+ const PID_FILE = join(homedir(), '.chrome-cli-bridge.pid')
6
+
7
+ export const command = 'stop'
8
+ export const describe = 'Stop the relay server'
9
+ export const builder = {}
10
+
11
+ export async function handler() {
12
+ try {
13
+ const pid = parseInt(await readFile(PID_FILE, 'utf8'), 10)
14
+ process.kill(pid, 'SIGTERM')
15
+ await unlink(PID_FILE).catch(() => {})
16
+ console.log(`Stopped relay (pid ${pid})`)
17
+ } catch (e) {
18
+ if (e.code === 'ENOENT') {
19
+ console.error('No relay is running (no PID file found)')
20
+ } else if (e.code === 'ESRCH') {
21
+ console.error('Relay process not found — cleaning up stale PID file')
22
+ await unlink(PID_FILE).catch(() => {})
23
+ } else {
24
+ console.error(e.message)
25
+ }
26
+ process.exit(1)
27
+ }
28
+ }
@@ -0,0 +1,26 @@
1
+ import { getClient } from '../client.js'
2
+ import { out, err } from '../output.js'
3
+
4
+ export const command = 'storage'
5
+ export const describe = 'Read or write cookies, localStorage, or sessionStorage'
6
+ export const builder = {
7
+ type: { type: 'string', choices: ['local', 'session', 'cookies'], demandOption: true, describe: 'Storage type' },
8
+ key: { type: 'string', describe: 'Key to read or write' },
9
+ set: { type: 'string', describe: 'Value to write (requires --key)' },
10
+ port: { type: 'number', default: 9876, describe: 'Relay port' },
11
+ }
12
+
13
+ export async function handler(argv) {
14
+ try {
15
+ const client = await getClient(argv.port)
16
+ const params = { type: argv.type }
17
+ if (argv.key !== undefined) params.key = argv.key
18
+ if (argv.set !== undefined) params.set = argv.set
19
+ const result = await client.call('page.storage', params)
20
+ out(result)
21
+ client.close()
22
+ } catch (e) {
23
+ err(e.message)
24
+ process.exit(1)
25
+ }
26
+ }
@@ -0,0 +1,29 @@
1
+ import { getClient } from '../client.js'
2
+ import { out, err } from '../output.js'
3
+
4
+ export const command = 'tabs'
5
+ export const describe = 'List open tabs or select a target tab'
6
+ export const builder = {
7
+ select: { type: 'string', describe: 'Select a tab by ID or "active"' },
8
+ port: { type: 'number', default: 9876, describe: 'Relay port' },
9
+ }
10
+
11
+ export async function handler(argv) {
12
+ try {
13
+ const client = await getClient(argv.port)
14
+
15
+ if (argv.select !== undefined) {
16
+ const tabId = argv.select === 'active' ? 'active' : parseInt(argv.select, 10)
17
+ const result = await client.call('tabs.select', { tabId })
18
+ out(result)
19
+ } else {
20
+ const result = await client.call('tabs.list', {})
21
+ out(result)
22
+ }
23
+
24
+ client.close()
25
+ } catch (e) {
26
+ err(e.message)
27
+ process.exit(1)
28
+ }
29
+ }
@@ -0,0 +1,22 @@
1
+ import { getClient } from '../client.js'
2
+ import { out, err } from '../output.js'
3
+
4
+ export const command = 'trigger'
5
+ export const describe = 'Dispatch a DOM event on an element in the selected tab'
6
+ export const builder = {
7
+ selector: { type: 'string', demandOption: true, describe: 'CSS selector for the target element' },
8
+ event: { type: 'string', demandOption: true, describe: 'Event type: click|input|change|submit|keydown|keyup' },
9
+ port: { type: 'number', default: 9876, describe: 'Relay port' },
10
+ }
11
+
12
+ export async function handler(argv) {
13
+ try {
14
+ const client = await getClient(argv.port)
15
+ const result = await client.call('page.trigger', { selector: argv.selector, event: argv.event })
16
+ out(result)
17
+ client.close()
18
+ } catch (e) {
19
+ err(e.message)
20
+ process.exit(e.code === -32001 ? 1 : 1)
21
+ }
22
+ }
@@ -0,0 +1,23 @@
1
+ import { getClient } from '../client.js'
2
+ import { out, err } from '../output.js'
3
+
4
+ export const command = 'type'
5
+ export const describe = 'Simulate keyboard typing into a DOM element'
6
+ export const builder = {
7
+ selector: { type: 'string', demandOption: true, describe: 'CSS selector for target element' },
8
+ text: { type: 'string', demandOption: true, describe: 'Text to type' },
9
+ port: { type: 'number', default: 9876, describe: 'Relay port' },
10
+ }
11
+
12
+ export async function handler(argv) {
13
+ try {
14
+ const client = await getClient(argv.port)
15
+ const result = await client.call('page.type', { selector: argv.selector, text: argv.text })
16
+ out(result)
17
+ client.close()
18
+ if (result?.error) process.exit(1)
19
+ } catch (e) {
20
+ err(e.message)
21
+ process.exit(1)
22
+ }
23
+ }
@@ -0,0 +1,23 @@
1
+ import { getClient } from '../client.js'
2
+ import { out, err } from '../output.js'
3
+
4
+ export const command = 'wait'
5
+ export const describe = 'Wait until a CSS selector appears in the DOM'
6
+ export const builder = {
7
+ selector: { type: 'string', demandOption: true, describe: 'CSS selector to wait for' },
8
+ timeout: { type: 'number', default: 5000, describe: 'Timeout in ms (default 5000)' },
9
+ port: { type: 'number', default: 9876, describe: 'Relay port' },
10
+ }
11
+
12
+ export async function handler(argv) {
13
+ try {
14
+ const client = await getClient(argv.port)
15
+ const result = await client.call('page.wait', { selector: argv.selector, timeout: argv.timeout })
16
+ out(result)
17
+ client.close()
18
+ if (!result.found) process.exit(1)
19
+ } catch (e) {
20
+ err(e.message)
21
+ process.exit(1)
22
+ }
23
+ }
package/src/index.js ADDED
@@ -0,0 +1,83 @@
1
+ import { Relay } from './relay.js'
2
+ import { RelayClient } from './client.js'
3
+ import { readToken } from './token.js'
4
+
5
+ /**
6
+ * ChromeBridge SDK — programmatic interface for Node.js consumers.
7
+ *
8
+ * Usage:
9
+ * const bridge = new ChromeBridge()
10
+ * await bridge.connect()
11
+ * const tabs = await bridge.tabs()
12
+ * await bridge.selectTab(tabs[0].id)
13
+ * const { result } = await bridge.exec({ code: 'document.title' })
14
+ * await bridge.disconnect()
15
+ */
16
+ export class ChromeBridge {
17
+ #client = null
18
+ #port
19
+
20
+ constructor({ port = 9876 } = {}) {
21
+ this.#port = port
22
+ }
23
+
24
+ async connect() {
25
+ const token = await readToken()
26
+ if (!token) throw new Error('No session token found. Run: chrome-bridge start')
27
+ this.#client = new RelayClient(this.#port, token)
28
+ await this.#client.connect()
29
+ }
30
+
31
+ async disconnect() {
32
+ this.#client?.close()
33
+ this.#client = null
34
+ }
35
+
36
+ #call(method, params) {
37
+ if (!this.#client) throw new Error('Not connected. Call bridge.connect() first.')
38
+ return this.#client.call(method, params)
39
+ }
40
+
41
+ // ── Existing commands ────────────────────────────────────────────────────
42
+ tabs() { return this.#call('tabs.list', {}) }
43
+ selectTab(tabId) { return this.#call('tabs.select', { tabId }) }
44
+ query(params) { return this.#call('page.query', params) }
45
+ exec(params) { return this.#call('page.exec', params) }
46
+ logs(params = {}) { return this.#call('page.logs', params) }
47
+ network() { return this.#call('page.network', {}) }
48
+ trigger(params) { return this.#call('page.trigger', params) }
49
+
50
+ // ── New commands ─────────────────────────────────────────────────────────
51
+ screenshot(params = {}) { return this.#call('page.screenshot', params) }
52
+ navigate(params) { return this.#call('page.navigate', params) }
53
+ storage(params) { return this.#call('page.storage', params) }
54
+ wait(params) { return this.#call('page.wait', params) }
55
+ inject(params) { return this.#call('page.inject', params) }
56
+ snapshot(params = {}) { return this.#call('page.snapshot', params) }
57
+ type(params) { return this.#call('page.type', params) }
58
+ click(params) { return this.#call('page.click', params) }
59
+ hover(params) { return this.#call('page.hover', params) }
60
+
61
+ /** Subscribe to real-time console log push events.
62
+ * @returns {() => void} unsubscribe function */
63
+ streamLogs(handler) {
64
+ if (!this.#client) throw new Error('Not connected.')
65
+ return this.#client.on('stream.log', handler)
66
+ }
67
+
68
+ /** DevTools namespace */
69
+ get devtools() {
70
+ return {
71
+ performance: () => this.#call('devtools.performance', {}),
72
+ memory: (params = {}) => this.#call('devtools.memory', params),
73
+ coverage: (params = {}) => this.#call('devtools.coverage', params),
74
+ }
75
+ }
76
+
77
+ on(event, handler) {
78
+ if (!this.#client) throw new Error('Not connected.')
79
+ return this.#client.on(event, handler)
80
+ }
81
+ }
82
+
83
+ export { Relay, RelayClient, readToken }
package/src/output.js ADDED
@@ -0,0 +1,56 @@
1
+ const isTTY = process.stdout.isTTY
2
+
3
+ export function out(data) {
4
+ if (isTTY) {
5
+ prettyPrint(data)
6
+ } else {
7
+ process.stdout.write(JSON.stringify(data) + '\n')
8
+ }
9
+ }
10
+
11
+ export function outLine(data) {
12
+ process.stdout.write(JSON.stringify(data) + '\n')
13
+ }
14
+
15
+ export function err(msg, data) {
16
+ const obj = { error: msg, ...(data && { data }) }
17
+ if (isTTY) {
18
+ process.stderr.write(`\x1b[31mError:\x1b[0m ${msg}${data ? '\n' + JSON.stringify(data, null, 2) : ''}\n`)
19
+ } else {
20
+ process.stderr.write(JSON.stringify(obj) + '\n')
21
+ }
22
+ }
23
+
24
+ function prettyPrint(data) {
25
+ if (Array.isArray(data)) {
26
+ if (data.length === 0) { console.log('(empty)'); return }
27
+ // Detect tab list shape
28
+ if (data[0]?.id !== undefined && data[0]?.url !== undefined) {
29
+ const rows = data.map((t) =>
30
+ ` [${t.id}] ${t.active ? '●' : '○'} ${t.title?.slice(0, 50).padEnd(50)} ${t.url}`)
31
+ console.log(rows.join('\n'))
32
+ return
33
+ }
34
+ // Detect element list shape
35
+ if (data[0]?.tag !== undefined) {
36
+ data.forEach((el, i) => {
37
+ console.log(` [${i}] <${el.tag}${el.id ? '#' + el.id : ''}> ${el.textContent?.slice(0, 80)}`)
38
+ })
39
+ return
40
+ }
41
+ console.log(JSON.stringify(data, null, 2))
42
+ return
43
+ }
44
+ if (typeof data === 'object' && data !== null) {
45
+ // Log entry shape
46
+ if (data.level && data.message && data.timestamp) {
47
+ const d = new Date(data.timestamp).toISOString()
48
+ const lvl = data.level.toUpperCase().padEnd(5)
49
+ console.log(` ${d} [${lvl}] ${data.message}`)
50
+ return
51
+ }
52
+ console.log(JSON.stringify(data, null, 2))
53
+ return
54
+ }
55
+ console.log(data)
56
+ }
package/src/relay.js ADDED
@@ -0,0 +1,200 @@
1
+ import { WebSocketServer } from 'ws'
2
+ import { generateToken, readToken } from './token.js'
3
+ import { isRequest, isResponse, isEvent, error, ERR } from './rpc.js'
4
+
5
+ const HANDSHAKE_TIMEOUT_MS = 5000
6
+ const KEEPALIVE_INTERVAL_MS = 20000
7
+
8
+ export class Relay {
9
+ #port
10
+ #wss = null
11
+ #extension = null // the authenticated extension WS
12
+ #cliClients = new Set() // all authenticated CLI client sockets
13
+ #token = null
14
+ #pendingCalls = new Map() // id → { resolve, reject, timer }
15
+ #eventHandlers = new Map() // method → Set<fn>
16
+ #keepaliveTimer = null
17
+
18
+ constructor(port = 9876) {
19
+ this.#port = port
20
+ }
21
+
22
+ get port() { return this.#port }
23
+ get token() { return this.#token }
24
+ get connected() { return this.#extension !== null }
25
+
26
+ async start() {
27
+ this.#token = await generateToken()
28
+
29
+ await new Promise((resolve, reject) => {
30
+ this.#wss = new WebSocketServer({ host: '127.0.0.1', port: this.#port })
31
+
32
+ this.#wss.once('error', (err) => {
33
+ if (err.code === 'EADDRINUSE') {
34
+ reject(new Error(`Port ${this.#port} is already in use. Is chrome-bridge already running?\nRun: chrome-bridge stop`))
35
+ } else {
36
+ reject(err)
37
+ }
38
+ })
39
+
40
+ this.#wss.once('listening', resolve)
41
+ this.#wss.on('connection', (ws) => this.#onConnection(ws))
42
+ })
43
+
44
+ return this.#token
45
+ }
46
+
47
+ async stop() {
48
+ clearInterval(this.#keepaliveTimer)
49
+ // Terminate ALL connected sockets (extension + any CLI clients) so wss.close()
50
+ // completes immediately instead of waiting for them to close on their own.
51
+ if (this.#wss) {
52
+ for (const ws of this.#wss.clients) ws.terminate()
53
+ }
54
+ this.#extension = null
55
+ await new Promise((resolve) => this.#wss ? this.#wss.close(resolve) : resolve())
56
+ this.#wss = null
57
+ }
58
+
59
+ // Send a JSON-RPC request to the extension and await the response
60
+ async call(method, params = {}, timeoutMs = 10000) {
61
+ if (!this.#extension) throw new Error('Extension not connected')
62
+
63
+ return new Promise((resolve, reject) => {
64
+ const msg = { jsonrpc: '2.0', method, params, id: this.#nextId() }
65
+ const timer = setTimeout(() => {
66
+ this.#pendingCalls.delete(msg.id)
67
+ reject(new Error(`Timeout waiting for response to ${method}`))
68
+ }, timeoutMs)
69
+
70
+ this.#pendingCalls.set(msg.id, { resolve, reject, timer })
71
+ this.#extension.send(JSON.stringify(msg))
72
+ })
73
+ }
74
+
75
+ on(event, handler) {
76
+ if (!this.#eventHandlers.has(event)) this.#eventHandlers.set(event, new Set())
77
+ this.#eventHandlers.get(event).add(handler)
78
+ return () => this.#eventHandlers.get(event)?.delete(handler)
79
+ }
80
+
81
+ #nextId = (() => { let i = 0; return () => ++i })()
82
+
83
+ #onConnection(ws) {
84
+ let role = null // 'extension' | 'cli'
85
+
86
+ const handshakeTimer = setTimeout(() => {
87
+ if (!role) ws.close(4001, 'Unauthorized: handshake timeout')
88
+ }, HANDSHAKE_TIMEOUT_MS)
89
+
90
+ ws.on('message', (data) => {
91
+ let msg
92
+ try { msg = JSON.parse(data.toString()) } catch {
93
+ ws.send(JSON.stringify(error(null, ERR.PARSE_ERROR, 'Parse error')))
94
+ return
95
+ }
96
+
97
+ if (!role) {
98
+ if (msg.method === 'handshake' && msg.params?.token === this.#token) {
99
+ clearTimeout(handshakeTimer)
100
+ role = msg.params?.role === 'cli' ? 'cli' : 'extension'
101
+
102
+ if (role === 'extension') {
103
+ this.#extension = ws
104
+ this.#startKeepalive()
105
+ } else if (role === 'cli') {
106
+ this.#cliClients.add(ws)
107
+ }
108
+
109
+ ws.send(JSON.stringify({ jsonrpc: '2.0', result: { status: 'ok', role }, id: msg.id }))
110
+ } else {
111
+ ws.close(4001, 'Unauthorized')
112
+ }
113
+ return
114
+ }
115
+
116
+ if (role === 'cli') {
117
+ // Forward request to extension, send response back to this CLI client
118
+ if (!this.#extension) {
119
+ ws.send(JSON.stringify(error(msg.id, -32000, 'Extension not connected')))
120
+ return
121
+ }
122
+ const id = this.#nextId()
123
+ const forwarded = { ...msg, id }
124
+ const timer = setTimeout(() => {
125
+ this.#pendingCalls.delete(id)
126
+ ws.send(JSON.stringify(error(msg.id, -32000, 'Timeout')))
127
+ }, 10000)
128
+ this.#pendingCalls.set(id, {
129
+ resolve: (result) => ws.send(JSON.stringify({ jsonrpc: '2.0', result, id: msg.id })),
130
+ reject: (err) => ws.send(JSON.stringify(error(msg.id, err.code ?? -32000, err.message))),
131
+ timer,
132
+ })
133
+ this.#extension.send(JSON.stringify(forwarded))
134
+ return
135
+ }
136
+
137
+ // role === 'extension': handle responses to our outgoing calls
138
+ if (isResponse(msg)) {
139
+ const pending = this.#pendingCalls.get(msg.id)
140
+ if (pending) {
141
+ clearTimeout(pending.timer)
142
+ this.#pendingCalls.delete(msg.id)
143
+ if (msg.error) {
144
+ const err = new Error(msg.error.message)
145
+ err.code = msg.error.code
146
+ err.data = msg.error.data
147
+ pending.reject(err)
148
+ } else {
149
+ pending.resolve(msg.result)
150
+ }
151
+ }
152
+ return
153
+ }
154
+
155
+ // Handle push events from the extension — forward to local handlers AND all CLI clients
156
+ if (isEvent(msg)) {
157
+ const handlers = this.#eventHandlers.get(msg.method)
158
+ if (handlers) handlers.forEach((fn) => fn(msg.params))
159
+ // Forward the raw event to all connected CLI WebSocket clients
160
+ const raw = data.toString()
161
+ for (const cliWs of this.#cliClients) {
162
+ if (cliWs.readyState === 1 /* OPEN */) cliWs.send(raw)
163
+ }
164
+ return
165
+ }
166
+ })
167
+
168
+ ws.on('close', () => {
169
+ this.#cliClients.delete(ws)
170
+ if (this.#extension === ws) {
171
+ this.#extension = null
172
+ clearInterval(this.#keepaliveTimer)
173
+ this.#keepaliveTimer = null
174
+ // reject all pending calls
175
+ for (const [, pending] of this.#pendingCalls) {
176
+ clearTimeout(pending.timer)
177
+ pending.reject(new Error('Extension disconnected'))
178
+ }
179
+ this.#pendingCalls.clear()
180
+ }
181
+ })
182
+ }
183
+
184
+ #startKeepalive() {
185
+ clearInterval(this.#keepaliveTimer)
186
+ this.#keepaliveTimer = setInterval(() => {
187
+ if (this.#extension?.readyState === 1 /* OPEN */) {
188
+ this.#extension.ping()
189
+ }
190
+ }, KEEPALIVE_INTERVAL_MS)
191
+ }
192
+ }
193
+
194
+ // Singleton for CLI commands
195
+ let _relay = null
196
+
197
+ export function getRelay(port) {
198
+ if (!_relay) _relay = new Relay(port)
199
+ return _relay
200
+ }
package/src/rpc.js ADDED
@@ -0,0 +1,36 @@
1
+ let _id = 0
2
+
3
+ export function request(method, params = {}) {
4
+ return { jsonrpc: '2.0', method, params, id: ++_id }
5
+ }
6
+
7
+ export function response(id, result) {
8
+ return { jsonrpc: '2.0', result, id }
9
+ }
10
+
11
+ export function error(id, code, message, data) {
12
+ return { jsonrpc: '2.0', error: { code, message, ...(data && { data }) }, id }
13
+ }
14
+
15
+ export function isRequest(msg) {
16
+ return msg.jsonrpc === '2.0' && typeof msg.method === 'string' && msg.id !== undefined
17
+ }
18
+
19
+ export function isResponse(msg) {
20
+ return msg.jsonrpc === '2.0' && (msg.result !== undefined || msg.error !== undefined) && msg.id !== undefined
21
+ }
22
+
23
+ export function isEvent(msg) {
24
+ return msg.jsonrpc === '2.0' && typeof msg.method === 'string' && msg.id === undefined
25
+ }
26
+
27
+ export const ERR = {
28
+ PARSE_ERROR: -32700,
29
+ INVALID_REQUEST: -32600,
30
+ METHOD_NOT_FOUND: -32601,
31
+ INVALID_PARAMS: -32602,
32
+ TAB_NOT_FOUND: -32000,
33
+ ELEMENT_NOT_FOUND: -32001,
34
+ EXEC_ERROR: -32002,
35
+ DEBUGGER_ATTACH_FAILED: -32003,
36
+ }
package/src/token.js ADDED
@@ -0,0 +1,24 @@
1
+ import { randomUUID } from 'node:crypto'
2
+ import { writeFile, readFile } from 'node:fs/promises'
3
+ import { homedir } from 'node:os'
4
+ import { join } from 'node:path'
5
+ import { chmod } from 'node:fs/promises'
6
+
7
+ const TOKEN_PATH = join(homedir(), '.chrome-cli-bridge.token')
8
+
9
+ export async function generateToken() {
10
+ const token = randomUUID()
11
+ await writeFile(TOKEN_PATH, token, { encoding: 'utf8', mode: 0o600 })
12
+ await chmod(TOKEN_PATH, 0o600)
13
+ return token
14
+ }
15
+
16
+ export async function readToken() {
17
+ try {
18
+ return (await readFile(TOKEN_PATH, 'utf8')).trim()
19
+ } catch {
20
+ return null
21
+ }
22
+ }
23
+
24
+ export { TOKEN_PATH }