@steipete/oracle 1.0.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/LICENSE +21 -0
- package/README.md +85 -0
- package/dist/bin/oracle-cli.js +458 -0
- package/dist/bin/oracle.js +683 -0
- package/dist/scripts/browser-tools.js +536 -0
- package/dist/scripts/check.js +21 -0
- package/dist/scripts/chrome/browser-tools.js +295 -0
- package/dist/scripts/run-cli.js +14 -0
- package/dist/src/browser/actions/assistantResponse.js +471 -0
- package/dist/src/browser/actions/attachments.js +82 -0
- package/dist/src/browser/actions/modelSelection.js +190 -0
- package/dist/src/browser/actions/navigation.js +75 -0
- package/dist/src/browser/actions/promptComposer.js +167 -0
- package/dist/src/browser/chromeLifecycle.js +104 -0
- package/dist/src/browser/config.js +33 -0
- package/dist/src/browser/constants.js +40 -0
- package/dist/src/browser/cookies.js +210 -0
- package/dist/src/browser/domDebug.js +36 -0
- package/dist/src/browser/index.js +319 -0
- package/dist/src/browser/pageActions.js +5 -0
- package/dist/src/browser/prompt.js +56 -0
- package/dist/src/browser/promptSummary.js +20 -0
- package/dist/src/browser/sessionRunner.js +77 -0
- package/dist/src/browser/types.js +1 -0
- package/dist/src/browser/utils.js +62 -0
- package/dist/src/browserMode.js +1 -0
- package/dist/src/cli/browserConfig.js +44 -0
- package/dist/src/cli/dryRun.js +59 -0
- package/dist/src/cli/engine.js +17 -0
- package/dist/src/cli/errorUtils.js +9 -0
- package/dist/src/cli/help.js +68 -0
- package/dist/src/cli/options.js +103 -0
- package/dist/src/cli/promptRequirement.js +14 -0
- package/dist/src/cli/rootAlias.js +16 -0
- package/dist/src/cli/sessionCommand.js +48 -0
- package/dist/src/cli/sessionDisplay.js +222 -0
- package/dist/src/cli/sessionRunner.js +94 -0
- package/dist/src/heartbeat.js +43 -0
- package/dist/src/oracle/client.js +48 -0
- package/dist/src/oracle/config.js +29 -0
- package/dist/src/oracle/errors.js +101 -0
- package/dist/src/oracle/files.js +220 -0
- package/dist/src/oracle/format.js +33 -0
- package/dist/src/oracle/fsAdapter.js +7 -0
- package/dist/src/oracle/request.js +48 -0
- package/dist/src/oracle/run.js +411 -0
- package/dist/src/oracle/tokenStats.js +39 -0
- package/dist/src/oracle/types.js +1 -0
- package/dist/src/oracle.js +9 -0
- package/dist/src/sessionManager.js +205 -0
- package/dist/src/version.js +39 -0
- package/package.json +69 -0
|
@@ -0,0 +1,536 @@
|
|
|
1
|
+
#!/usr/bin/env ts-node
|
|
2
|
+
/**
|
|
3
|
+
* Minimal Chrome DevTools helpers inspired by Mario Zechner's
|
|
4
|
+
* "What if you don't need MCP?" article.
|
|
5
|
+
*
|
|
6
|
+
* Keeps everything in one TypeScript CLI so agents (or humans) can drive Chrome
|
|
7
|
+
* directly via the DevTools protocol without pulling in a large MCP server.
|
|
8
|
+
*/
|
|
9
|
+
import { Command } from 'commander';
|
|
10
|
+
import { execSync, spawn } from 'node:child_process';
|
|
11
|
+
import http from 'node:http';
|
|
12
|
+
import os from 'node:os';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
import readline from 'node:readline/promises';
|
|
15
|
+
import { stdin as input, stdout as output } from 'node:process';
|
|
16
|
+
import puppeteer from 'puppeteer-core';
|
|
17
|
+
const DEFAULT_PORT = 9222;
|
|
18
|
+
const DEFAULT_PROFILE_DIR = path.join(os.homedir(), '.cache', 'scraping');
|
|
19
|
+
const DEFAULT_CHROME_BIN = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
|
|
20
|
+
function browserURL(port) {
|
|
21
|
+
return `http://localhost:${port}`;
|
|
22
|
+
}
|
|
23
|
+
async function connectBrowser(port) {
|
|
24
|
+
return puppeteer.connect({ browserURL: browserURL(port), defaultViewport: null });
|
|
25
|
+
}
|
|
26
|
+
async function getActivePage(port) {
|
|
27
|
+
const browser = await connectBrowser(port);
|
|
28
|
+
const pages = await browser.pages();
|
|
29
|
+
const page = pages.at(-1);
|
|
30
|
+
if (!page) {
|
|
31
|
+
await browser.disconnect();
|
|
32
|
+
throw new Error('No active tab found');
|
|
33
|
+
}
|
|
34
|
+
return { browser, page };
|
|
35
|
+
}
|
|
36
|
+
const program = new Command();
|
|
37
|
+
program
|
|
38
|
+
.name('browser-tools')
|
|
39
|
+
.description('Lightweight Chrome DevTools helpers (no MCP required).')
|
|
40
|
+
.configureHelp({ sortSubcommands: true })
|
|
41
|
+
.showSuggestionAfterError();
|
|
42
|
+
program
|
|
43
|
+
.command('start')
|
|
44
|
+
.description('Launch Chrome with remote debugging enabled.')
|
|
45
|
+
.option('-p, --port <number>', 'Remote debugging port (default: 9222)', (value) => Number.parseInt(value, 10), DEFAULT_PORT)
|
|
46
|
+
.option('--profile', 'Copy your default Chrome profile before launch.', false)
|
|
47
|
+
.option('--profile-dir <path>', 'Directory for the temporary Chrome profile.', DEFAULT_PROFILE_DIR)
|
|
48
|
+
.option('--chrome-path <path>', 'Path to the Chrome binary.', DEFAULT_CHROME_BIN)
|
|
49
|
+
.action(async (options) => {
|
|
50
|
+
const { port, profile, profileDir, chromePath } = options;
|
|
51
|
+
try {
|
|
52
|
+
execSync("killall 'Google Chrome'", { stdio: 'ignore' });
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
// ignore missing processes
|
|
56
|
+
}
|
|
57
|
+
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
58
|
+
execSync(`mkdir -p "${profileDir}"`);
|
|
59
|
+
if (profile) {
|
|
60
|
+
const source = `${path.join(os.homedir(), 'Library', 'Application Support', 'Google', 'Chrome')}/`;
|
|
61
|
+
execSync(`rsync -a --delete "${source}" "${profileDir}/"`, { stdio: 'ignore' });
|
|
62
|
+
}
|
|
63
|
+
spawn(chromePath, [`--remote-debugging-port=${port}`, `--user-data-dir=${profileDir}`, '--no-first-run', '--disable-popup-blocking'], {
|
|
64
|
+
detached: true,
|
|
65
|
+
stdio: 'ignore',
|
|
66
|
+
}).unref();
|
|
67
|
+
let connected = false;
|
|
68
|
+
for (let attempt = 0; attempt < 30; attempt++) {
|
|
69
|
+
try {
|
|
70
|
+
const browser = await connectBrowser(port);
|
|
71
|
+
await browser.disconnect();
|
|
72
|
+
connected = true;
|
|
73
|
+
break;
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
if (!connected) {
|
|
80
|
+
console.error(`✗ Failed to start Chrome on port ${port}`);
|
|
81
|
+
process.exit(1);
|
|
82
|
+
}
|
|
83
|
+
console.log(`✓ Chrome listening on http://localhost:${port}${profile ? ' (profile copied)' : ''}`);
|
|
84
|
+
});
|
|
85
|
+
program
|
|
86
|
+
.command('nav <url>')
|
|
87
|
+
.description('Navigate the current tab or open a new tab.')
|
|
88
|
+
.option('--port <number>', 'Debugger port (default: 9222)', (value) => Number.parseInt(value, 10), DEFAULT_PORT)
|
|
89
|
+
.option('--new', 'Open in a new tab.', false)
|
|
90
|
+
.action(async (url, options) => {
|
|
91
|
+
const port = options.port;
|
|
92
|
+
const browser = await connectBrowser(port);
|
|
93
|
+
try {
|
|
94
|
+
if (options.new) {
|
|
95
|
+
const page = await browser.newPage();
|
|
96
|
+
await page.goto(url, { waitUntil: 'domcontentloaded' });
|
|
97
|
+
console.log('✓ Opened in new tab:', url);
|
|
98
|
+
}
|
|
99
|
+
else {
|
|
100
|
+
const pages = await browser.pages();
|
|
101
|
+
const page = pages.at(-1);
|
|
102
|
+
if (!page) {
|
|
103
|
+
throw new Error('No active tab found');
|
|
104
|
+
}
|
|
105
|
+
await page.goto(url, { waitUntil: 'domcontentloaded' });
|
|
106
|
+
console.log('✓ Navigated current tab to:', url);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
finally {
|
|
110
|
+
await browser.disconnect();
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
program
|
|
114
|
+
.command('eval <code...>')
|
|
115
|
+
.description('Evaluate JavaScript in the active page context.')
|
|
116
|
+
.option('--port <number>', 'Debugger port (default: 9222)', (value) => Number.parseInt(value, 10), DEFAULT_PORT)
|
|
117
|
+
.action(async (code, options) => {
|
|
118
|
+
const snippet = code.join(' ');
|
|
119
|
+
const port = options.port;
|
|
120
|
+
const { browser, page } = await getActivePage(port);
|
|
121
|
+
try {
|
|
122
|
+
const result = await page.evaluate((body) => {
|
|
123
|
+
const ASYNC_FN = Object.getPrototypeOf(async () => { }).constructor;
|
|
124
|
+
return new ASYNC_FN(`return (${body})`)();
|
|
125
|
+
}, snippet);
|
|
126
|
+
if (Array.isArray(result)) {
|
|
127
|
+
result.forEach((entry, index) => {
|
|
128
|
+
if (index > 0) {
|
|
129
|
+
console.log('');
|
|
130
|
+
}
|
|
131
|
+
Object.entries(entry).forEach(([key, value]) => {
|
|
132
|
+
console.log(`${key}: ${value}`);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
else if (typeof result === 'object' && result !== null) {
|
|
137
|
+
Object.entries(result).forEach(([key, value]) => {
|
|
138
|
+
console.log(`${key}: ${value}`);
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
else {
|
|
142
|
+
console.log(result);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
finally {
|
|
146
|
+
await browser.disconnect();
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
program
|
|
150
|
+
.command('screenshot')
|
|
151
|
+
.description('Capture the current viewport and print the temp PNG path.')
|
|
152
|
+
.option('--port <number>', 'Debugger port (default: 9222)', (value) => Number.parseInt(value, 10), DEFAULT_PORT)
|
|
153
|
+
.action(async (options) => {
|
|
154
|
+
const port = options.port;
|
|
155
|
+
const { browser, page } = await getActivePage(port);
|
|
156
|
+
try {
|
|
157
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
158
|
+
const filePath = path.join(os.tmpdir(), `screenshot-${timestamp}.png`);
|
|
159
|
+
await page.screenshot({ path: filePath });
|
|
160
|
+
console.log(filePath);
|
|
161
|
+
}
|
|
162
|
+
finally {
|
|
163
|
+
await browser.disconnect();
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
program
|
|
167
|
+
.command('pick <message...>')
|
|
168
|
+
.description('Interactive DOM picker that prints metadata for clicked elements.')
|
|
169
|
+
.option('--port <number>', 'Debugger port (default: 9222)', (value) => Number.parseInt(value, 10), DEFAULT_PORT)
|
|
170
|
+
.action(async (messageParts, options) => {
|
|
171
|
+
const message = messageParts.join(' ');
|
|
172
|
+
const port = options.port;
|
|
173
|
+
const { browser, page } = await getActivePage(port);
|
|
174
|
+
try {
|
|
175
|
+
await page.evaluate(() => {
|
|
176
|
+
const scope = globalThis;
|
|
177
|
+
if (scope.pickOverlayInjected) {
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
scope.pickOverlayInjected = true;
|
|
181
|
+
scope.pick = async (prompt) => new Promise((resolve) => {
|
|
182
|
+
const selections = [];
|
|
183
|
+
const selectedElements = new Set();
|
|
184
|
+
const overlay = document.createElement('div');
|
|
185
|
+
overlay.style.cssText =
|
|
186
|
+
'position:fixed;top:0;left:0;width:100%;height:100%;z-index:2147483647;pointer-events:none';
|
|
187
|
+
const highlight = document.createElement('div');
|
|
188
|
+
highlight.style.cssText =
|
|
189
|
+
'position:absolute;border:2px solid #3b82f6;background:rgba(59,130,246,0.1);transition:all 0.05s ease';
|
|
190
|
+
overlay.appendChild(highlight);
|
|
191
|
+
const banner = document.createElement('div');
|
|
192
|
+
banner.style.cssText =
|
|
193
|
+
'position:fixed;bottom:20px;left:50%;transform:translateX(-50%);background:#1f2937;color:#fff;padding:12px 24px;border-radius:8px;font:14px system-ui;box-shadow:0 4px 12px rgba(0,0,0,0.3);pointer-events:auto;z-index:2147483647';
|
|
194
|
+
const updateBanner = () => {
|
|
195
|
+
banner.textContent = `${prompt} (${selections.length} selected, Cmd/Ctrl+click to add, Enter to finish, ESC to cancel)`;
|
|
196
|
+
};
|
|
197
|
+
const cleanup = () => {
|
|
198
|
+
document.removeEventListener('mousemove', onMove, true);
|
|
199
|
+
document.removeEventListener('click', onClick, true);
|
|
200
|
+
document.removeEventListener('keydown', onKey, true);
|
|
201
|
+
overlay.remove();
|
|
202
|
+
banner.remove();
|
|
203
|
+
selectedElements.forEach((el) => {
|
|
204
|
+
el.style.outline = '';
|
|
205
|
+
});
|
|
206
|
+
};
|
|
207
|
+
const serialize = (el) => {
|
|
208
|
+
const parents = [];
|
|
209
|
+
let current = el.parentElement;
|
|
210
|
+
while (current && current !== document.body) {
|
|
211
|
+
const id = current.id ? `#${current.id}` : '';
|
|
212
|
+
const cls = current.className ? `.${current.className.trim().split(/\s+/).join('.')}` : '';
|
|
213
|
+
parents.push(`${current.tagName.toLowerCase()}${id}${cls}`);
|
|
214
|
+
current = current.parentElement;
|
|
215
|
+
}
|
|
216
|
+
return {
|
|
217
|
+
tag: el.tagName.toLowerCase(),
|
|
218
|
+
id: el.id || null,
|
|
219
|
+
class: el.className || null,
|
|
220
|
+
text: el.textContent?.trim()?.slice(0, 200) || null,
|
|
221
|
+
html: el.outerHTML.slice(0, 500),
|
|
222
|
+
parents: parents.join(' > '),
|
|
223
|
+
};
|
|
224
|
+
};
|
|
225
|
+
const onMove = (event) => {
|
|
226
|
+
const node = document.elementFromPoint(event.clientX, event.clientY);
|
|
227
|
+
if (!node || overlay.contains(node) || banner.contains(node))
|
|
228
|
+
return;
|
|
229
|
+
const rect = node.getBoundingClientRect();
|
|
230
|
+
highlight.style.cssText = `position:absolute;border:2px solid #3b82f6;background:rgba(59,130,246,0.1);top:${rect.top}px;left:${rect.left}px;width:${rect.width}px;height:${rect.height}px`;
|
|
231
|
+
};
|
|
232
|
+
const onClick = (event) => {
|
|
233
|
+
if (banner.contains(event.target))
|
|
234
|
+
return;
|
|
235
|
+
event.preventDefault();
|
|
236
|
+
event.stopPropagation();
|
|
237
|
+
const node = document.elementFromPoint(event.clientX, event.clientY);
|
|
238
|
+
if (!node || overlay.contains(node) || banner.contains(node))
|
|
239
|
+
return;
|
|
240
|
+
if (event.metaKey || event.ctrlKey) {
|
|
241
|
+
if (!selectedElements.has(node)) {
|
|
242
|
+
selectedElements.add(node);
|
|
243
|
+
node.style.outline = '3px solid #10b981';
|
|
244
|
+
selections.push(serialize(node));
|
|
245
|
+
updateBanner();
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
else {
|
|
249
|
+
cleanup();
|
|
250
|
+
const info = serialize(node);
|
|
251
|
+
resolve(selections.length > 0 ? selections : info);
|
|
252
|
+
}
|
|
253
|
+
};
|
|
254
|
+
const onKey = (event) => {
|
|
255
|
+
if (event.key === 'Escape') {
|
|
256
|
+
cleanup();
|
|
257
|
+
resolve(null);
|
|
258
|
+
}
|
|
259
|
+
else if (event.key === 'Enter' && selections.length > 0) {
|
|
260
|
+
cleanup();
|
|
261
|
+
resolve(selections);
|
|
262
|
+
}
|
|
263
|
+
};
|
|
264
|
+
document.addEventListener('mousemove', onMove, true);
|
|
265
|
+
document.addEventListener('click', onClick, true);
|
|
266
|
+
document.addEventListener('keydown', onKey, true);
|
|
267
|
+
document.body.append(overlay, banner);
|
|
268
|
+
updateBanner();
|
|
269
|
+
});
|
|
270
|
+
});
|
|
271
|
+
const result = await page.evaluate((msg) => {
|
|
272
|
+
const pickFn = window.pick;
|
|
273
|
+
if (!pickFn) {
|
|
274
|
+
return null;
|
|
275
|
+
}
|
|
276
|
+
return pickFn(msg);
|
|
277
|
+
}, message);
|
|
278
|
+
if (Array.isArray(result)) {
|
|
279
|
+
result.forEach((entry, index) => {
|
|
280
|
+
if (index > 0) {
|
|
281
|
+
console.log('');
|
|
282
|
+
}
|
|
283
|
+
Object.entries(entry).forEach(([key, value]) => {
|
|
284
|
+
console.log(`${key}: ${value}`);
|
|
285
|
+
});
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
else if (result && typeof result === 'object') {
|
|
289
|
+
Object.entries(result).forEach(([key, value]) => {
|
|
290
|
+
console.log(`${key}: ${value}`);
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
else {
|
|
294
|
+
console.log(result);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
finally {
|
|
298
|
+
await browser.disconnect();
|
|
299
|
+
}
|
|
300
|
+
});
|
|
301
|
+
program
|
|
302
|
+
.command('cookies')
|
|
303
|
+
.description('Dump cookies from the active tab as JSON.')
|
|
304
|
+
.option('--port <number>', 'Debugger port (default: 9222)', (value) => Number.parseInt(value, 10), DEFAULT_PORT)
|
|
305
|
+
.action(async (options) => {
|
|
306
|
+
const port = options.port;
|
|
307
|
+
const { browser, page } = await getActivePage(port);
|
|
308
|
+
try {
|
|
309
|
+
const cookies = await page.cookies();
|
|
310
|
+
console.log(JSON.stringify(cookies, null, 2));
|
|
311
|
+
}
|
|
312
|
+
finally {
|
|
313
|
+
await browser.disconnect();
|
|
314
|
+
}
|
|
315
|
+
});
|
|
316
|
+
program
|
|
317
|
+
.command('inspect')
|
|
318
|
+
.description('List Chrome processes launched with --remote-debugging-port and show their open tabs.')
|
|
319
|
+
.option('--ports <list>', 'Comma-separated list of ports to include.', parseNumberListArg)
|
|
320
|
+
.option('--pids <list>', 'Comma-separated list of PIDs to include.', parseNumberListArg)
|
|
321
|
+
.option('--json', 'Emit machine-readable JSON output.', false)
|
|
322
|
+
.action(async (options) => {
|
|
323
|
+
const ports = options.ports?.filter((entry) => Number.isFinite(entry) && entry > 0);
|
|
324
|
+
const pids = options.pids?.filter((entry) => Number.isFinite(entry) && entry > 0);
|
|
325
|
+
const sessions = await describeChromeSessions({
|
|
326
|
+
ports,
|
|
327
|
+
pids,
|
|
328
|
+
includeAll: !ports?.length && !pids?.length,
|
|
329
|
+
});
|
|
330
|
+
if (options.json) {
|
|
331
|
+
console.log(JSON.stringify(sessions, null, 2));
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
if (sessions.length === 0) {
|
|
335
|
+
console.log('No Chrome instances with DevTools ports found.');
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
sessions.forEach((session, index) => {
|
|
339
|
+
if (index > 0) {
|
|
340
|
+
console.log('');
|
|
341
|
+
}
|
|
342
|
+
const header = [`Chrome PID ${session.pid}`, `(port ${session.port})`];
|
|
343
|
+
if (session.version?.Browser) {
|
|
344
|
+
header.push(`- ${session.version.Browser}`);
|
|
345
|
+
}
|
|
346
|
+
console.log(header.join(' '));
|
|
347
|
+
if (session.tabs.length === 0) {
|
|
348
|
+
console.log(' (no tabs reported)');
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
session.tabs.forEach((tab, idx) => {
|
|
352
|
+
const title = tab.title || '(untitled)';
|
|
353
|
+
const url = tab.url || '(no url)';
|
|
354
|
+
console.log(` Tab ${idx + 1}: ${title}`);
|
|
355
|
+
console.log(` ${url}`);
|
|
356
|
+
});
|
|
357
|
+
});
|
|
358
|
+
});
|
|
359
|
+
program
|
|
360
|
+
.command('kill')
|
|
361
|
+
.description('Terminate Chrome instances that have DevTools ports open.')
|
|
362
|
+
.option('--ports <list>', 'Comma-separated list of ports to target.', parseNumberListArg)
|
|
363
|
+
.option('--pids <list>', 'Comma-separated list of PIDs to target.', parseNumberListArg)
|
|
364
|
+
.option('--all', 'Kill every matching Chrome instance.', false)
|
|
365
|
+
.option('--force', 'Skip the confirmation prompt.', false)
|
|
366
|
+
.action(async (options) => {
|
|
367
|
+
const ports = options.ports?.filter((entry) => Number.isFinite(entry) && entry > 0);
|
|
368
|
+
const pids = options.pids?.filter((entry) => Number.isFinite(entry) && entry > 0);
|
|
369
|
+
const killAll = Boolean(options.all);
|
|
370
|
+
if (!killAll && (!ports?.length && !pids?.length)) {
|
|
371
|
+
console.error('Specify --all, --ports <list>, or --pids <list> to select targets.');
|
|
372
|
+
process.exit(1);
|
|
373
|
+
}
|
|
374
|
+
const sessions = await describeChromeSessions({ ports, pids, includeAll: killAll });
|
|
375
|
+
if (sessions.length === 0) {
|
|
376
|
+
console.log('No matching Chrome instances found.');
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
if (!options.force) {
|
|
380
|
+
console.log('About to terminate the following Chrome sessions:');
|
|
381
|
+
sessions.forEach((session) => {
|
|
382
|
+
console.log(` PID ${session.pid} (port ${session.port})`);
|
|
383
|
+
});
|
|
384
|
+
const rl = readline.createInterface({ input, output });
|
|
385
|
+
const answer = (await rl.question('Proceed? [y/N] ')).trim().toLowerCase();
|
|
386
|
+
rl.close();
|
|
387
|
+
if (answer !== 'y' && answer !== 'yes') {
|
|
388
|
+
console.log('Aborted.');
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
const failures = [];
|
|
393
|
+
sessions.forEach((session) => {
|
|
394
|
+
try {
|
|
395
|
+
process.kill(session.pid);
|
|
396
|
+
console.log(`✓ Killed Chrome PID ${session.pid} (port ${session.port})`);
|
|
397
|
+
}
|
|
398
|
+
catch (error) {
|
|
399
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
400
|
+
console.error(`✗ Failed to kill PID ${session.pid}: ${message}`);
|
|
401
|
+
failures.push({ pid: session.pid, error: message });
|
|
402
|
+
}
|
|
403
|
+
});
|
|
404
|
+
if (failures.length > 0) {
|
|
405
|
+
process.exitCode = 1;
|
|
406
|
+
}
|
|
407
|
+
});
|
|
408
|
+
function parseNumberListArg(value) {
|
|
409
|
+
return parseNumberList(value) ?? [];
|
|
410
|
+
}
|
|
411
|
+
function parseNumberList(inputValue) {
|
|
412
|
+
if (!inputValue) {
|
|
413
|
+
return undefined;
|
|
414
|
+
}
|
|
415
|
+
const parsed = inputValue
|
|
416
|
+
.split(',')
|
|
417
|
+
.map((entry) => Number.parseInt(entry.trim(), 10))
|
|
418
|
+
.filter((value) => Number.isFinite(value));
|
|
419
|
+
return parsed.length > 0 ? parsed : undefined;
|
|
420
|
+
}
|
|
421
|
+
async function describeChromeSessions(options) {
|
|
422
|
+
const { ports, pids, includeAll } = options;
|
|
423
|
+
const processes = await listDevtoolsChromes();
|
|
424
|
+
const portSet = new Set(ports ?? []);
|
|
425
|
+
const pidSet = new Set(pids ?? []);
|
|
426
|
+
const candidates = processes.filter((proc) => {
|
|
427
|
+
if (includeAll) {
|
|
428
|
+
return true;
|
|
429
|
+
}
|
|
430
|
+
if (portSet.size > 0 && portSet.has(proc.port)) {
|
|
431
|
+
return true;
|
|
432
|
+
}
|
|
433
|
+
if (pidSet.size > 0 && pidSet.has(proc.pid)) {
|
|
434
|
+
return true;
|
|
435
|
+
}
|
|
436
|
+
return false;
|
|
437
|
+
});
|
|
438
|
+
const results = [];
|
|
439
|
+
for (const proc of candidates) {
|
|
440
|
+
const [version, tabs] = await Promise.all([
|
|
441
|
+
fetchJson(`http://localhost:${proc.port}/json/version`).catch(() => undefined),
|
|
442
|
+
fetchJson(`http://localhost:${proc.port}/json/list`).catch(() => []),
|
|
443
|
+
]);
|
|
444
|
+
const filteredTabs = Array.isArray(tabs)
|
|
445
|
+
? tabs.filter((tab) => {
|
|
446
|
+
const type = tab.type?.toLowerCase() ?? '';
|
|
447
|
+
if (type && type !== 'page' && type !== 'app') {
|
|
448
|
+
if (!tab.url || tab.url.startsWith('devtools://') || tab.url.startsWith('chrome-extension://')) {
|
|
449
|
+
return false;
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
if (!tab.url || tab.url.trim().length === 0) {
|
|
453
|
+
return false;
|
|
454
|
+
}
|
|
455
|
+
return true;
|
|
456
|
+
})
|
|
457
|
+
: [];
|
|
458
|
+
results.push({
|
|
459
|
+
...proc,
|
|
460
|
+
version: version ?? undefined,
|
|
461
|
+
tabs: filteredTabs,
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
return results;
|
|
465
|
+
}
|
|
466
|
+
async function listDevtoolsChromes() {
|
|
467
|
+
if (process.platform !== 'darwin' && process.platform !== 'linux') {
|
|
468
|
+
console.warn('Chrome inspection is only supported on macOS and Linux for now.');
|
|
469
|
+
return [];
|
|
470
|
+
}
|
|
471
|
+
let output = '';
|
|
472
|
+
try {
|
|
473
|
+
output = execSync('ps -ax -o pid=,command=', { encoding: 'utf8' });
|
|
474
|
+
}
|
|
475
|
+
catch (error) {
|
|
476
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
477
|
+
throw new Error(`Failed to enumerate processes: ${message}`);
|
|
478
|
+
}
|
|
479
|
+
const processes = [];
|
|
480
|
+
output
|
|
481
|
+
.split('\n')
|
|
482
|
+
.map((line) => line.trim())
|
|
483
|
+
.filter(Boolean)
|
|
484
|
+
.forEach((line) => {
|
|
485
|
+
const match = line.match(/^(\d+)\s+(.+)$/);
|
|
486
|
+
if (!match) {
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
const pid = Number.parseInt(match[1], 10);
|
|
490
|
+
const command = match[2];
|
|
491
|
+
if (!Number.isFinite(pid) || pid <= 0) {
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
if (!/chrome/i.test(command) || !/--remote-debugging-port/.test(command)) {
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
const portMatch = command.match(/--remote-debugging-port(?:=|\s+)(\d+)/);
|
|
498
|
+
if (!portMatch) {
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
const port = Number.parseInt(portMatch[1], 10);
|
|
502
|
+
if (!Number.isFinite(port)) {
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
processes.push({ pid, port, command });
|
|
506
|
+
});
|
|
507
|
+
return processes;
|
|
508
|
+
}
|
|
509
|
+
function fetchJson(url, timeoutMs = 2000) {
|
|
510
|
+
return new Promise((resolve, reject) => {
|
|
511
|
+
const request = http.get(url, { timeout: timeoutMs }, (response) => {
|
|
512
|
+
const chunks = [];
|
|
513
|
+
response.on('data', (chunk) => chunks.push(chunk));
|
|
514
|
+
response.on('end', () => {
|
|
515
|
+
const body = Buffer.concat(chunks).toString('utf8');
|
|
516
|
+
if ((response.statusCode ?? 500) >= 400) {
|
|
517
|
+
reject(new Error(`HTTP ${response.statusCode} for ${url}`));
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
try {
|
|
521
|
+
resolve(JSON.parse(body));
|
|
522
|
+
}
|
|
523
|
+
catch {
|
|
524
|
+
resolve(undefined);
|
|
525
|
+
}
|
|
526
|
+
});
|
|
527
|
+
});
|
|
528
|
+
request.on('timeout', () => {
|
|
529
|
+
request.destroy(new Error(`Request to ${url} timed out`));
|
|
530
|
+
});
|
|
531
|
+
request.on('error', (error) => {
|
|
532
|
+
reject(error);
|
|
533
|
+
});
|
|
534
|
+
});
|
|
535
|
+
}
|
|
536
|
+
program.parseAsync(process.argv);
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import process from 'node:process';
|
|
3
|
+
const buildConfig = {
|
|
4
|
+
entrypoints: ['./bin/oracle-cli.js'],
|
|
5
|
+
outdir: './.bun-check',
|
|
6
|
+
target: 'bun',
|
|
7
|
+
minify: false,
|
|
8
|
+
write: false,
|
|
9
|
+
};
|
|
10
|
+
const result = await Bun.build(buildConfig);
|
|
11
|
+
if (!result.success) {
|
|
12
|
+
console.error('Build failed while checking syntax:');
|
|
13
|
+
for (const log of result.logs) {
|
|
14
|
+
console.error(log.message);
|
|
15
|
+
if (log.position) {
|
|
16
|
+
console.error(`\tat ${log.position.file}:${log.position.line}:${log.position.column}`);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
process.exit(1);
|
|
20
|
+
}
|
|
21
|
+
console.log('Syntax OK');
|