huntr-cli 1.0.9
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/.env.example +7 -0
- package/.github/ISSUE_TEMPLATE/bug_report.md +43 -0
- package/.github/ISSUE_TEMPLATE/config.yml +8 -0
- package/.github/ISSUE_TEMPLATE/feature_request.md +29 -0
- package/.github/labels.json +92 -0
- package/.github/pull_request_template.md +64 -0
- package/.github/workflows/ci.yml +87 -0
- package/.github/workflows/labels.yml +27 -0
- package/.github/workflows/manual-publish.yml +105 -0
- package/.github/workflows/publish.yml +57 -0
- package/.github/workflows/release.yml +124 -0
- package/.github/workflows/security-audit.yml +44 -0
- package/.husky/pre-commit +12 -0
- package/.husky/pre-push +27 -0
- package/.lintstagedrc.json +3 -0
- package/AGENTS.md +449 -0
- package/CHANGELOG.md +38 -0
- package/CHANGES.md +259 -0
- package/LICENSE +15 -0
- package/PUBLISHING.md +191 -0
- package/README.md +385 -0
- package/ROADMAP.md +158 -0
- package/SETUP-COMPLETE.md +446 -0
- package/WORKFLOW-SUMMARY.md +368 -0
- package/completions/_huntr +168 -0
- package/completions/huntr.1 +266 -0
- package/completions/huntr.bash +91 -0
- package/dist/api/client.d.ts +14 -0
- package/dist/api/client.d.ts.map +1 -0
- package/dist/api/client.js +74 -0
- package/dist/api/client.js.map +1 -0
- package/dist/api/personal/activities.d.ts +20 -0
- package/dist/api/personal/activities.d.ts.map +1 -0
- package/dist/api/personal/activities.js +50 -0
- package/dist/api/personal/activities.js.map +1 -0
- package/dist/api/personal/boards.d.ts +9 -0
- package/dist/api/personal/boards.d.ts.map +1 -0
- package/dist/api/personal/boards.js +16 -0
- package/dist/api/personal/boards.js.map +1 -0
- package/dist/api/personal/index.d.ts +17 -0
- package/dist/api/personal/index.d.ts.map +1 -0
- package/dist/api/personal/index.js +37 -0
- package/dist/api/personal/index.js.map +1 -0
- package/dist/api/personal/jobs.d.ts +13 -0
- package/dist/api/personal/jobs.d.ts.map +1 -0
- package/dist/api/personal/jobs.js +31 -0
- package/dist/api/personal/jobs.js.map +1 -0
- package/dist/api/personal/user.d.ts +8 -0
- package/dist/api/personal/user.d.ts.map +1 -0
- package/dist/api/personal/user.js +13 -0
- package/dist/api/personal/user.js.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +501 -0
- package/dist/cli.js.map +1 -0
- package/dist/commands/capture-session.d.ts +10 -0
- package/dist/commands/capture-session.d.ts.map +1 -0
- package/dist/commands/capture-session.js +478 -0
- package/dist/commands/capture-session.js.map +1 -0
- package/dist/config/clerk-session-manager.d.ts +44 -0
- package/dist/config/clerk-session-manager.d.ts.map +1 -0
- package/dist/config/clerk-session-manager.js +232 -0
- package/dist/config/clerk-session-manager.js.map +1 -0
- package/dist/config/config-manager.d.ts +15 -0
- package/dist/config/config-manager.d.ts.map +1 -0
- package/dist/config/config-manager.js +51 -0
- package/dist/config/config-manager.js.map +1 -0
- package/dist/config/keychain-manager.d.ts +6 -0
- package/dist/config/keychain-manager.d.ts.map +1 -0
- package/dist/config/keychain-manager.js +37 -0
- package/dist/config/keychain-manager.js.map +1 -0
- package/dist/config/token-capture.d.ts +11 -0
- package/dist/config/token-capture.d.ts.map +1 -0
- package/dist/config/token-capture.js +252 -0
- package/dist/config/token-capture.js.map +1 -0
- package/dist/config/token-manager.d.ts +38 -0
- package/dist/config/token-manager.d.ts.map +1 -0
- package/dist/config/token-manager.js +153 -0
- package/dist/config/token-manager.js.map +1 -0
- package/dist/lib/list-options.d.ts +69 -0
- package/dist/lib/list-options.d.ts.map +1 -0
- package/dist/lib/list-options.js +299 -0
- package/dist/lib/list-options.js.map +1 -0
- package/dist/types/personal.d.ts +113 -0
- package/dist/types/personal.d.ts.map +1 -0
- package/dist/types/personal.js +4 -0
- package/dist/types/personal.js.map +1 -0
- package/docs/AUTOMATIC-PUBLISHING.md +520 -0
- package/docs/CHANGELOG-AUTOMATION.md +418 -0
- package/docs/CI-CD-SETUP.md +582 -0
- package/docs/DEV-SETUP.md +512 -0
- package/docs/ENHANCEMENT-PLAN.md +204 -0
- package/docs/ENTITY-TYPES.md +462 -0
- package/docs/GITHUB-ACTIONS-GUIDE.md +367 -0
- package/docs/NPM-PUBLISHING.md +324 -0
- package/docs/OUTPUT-EXAMPLES.md +414 -0
- package/docs/OUTPUT-FORMATS.md +299 -0
- package/docs/TESTING.md +216 -0
- package/eslint.config.js +68 -0
- package/package.json +64 -0
- package/src/api/client.ts +88 -0
- package/src/api/personal/activities.ts +66 -0
- package/src/api/personal/boards.ts +14 -0
- package/src/api/personal/index.ts +25 -0
- package/src/api/personal/jobs.ts +33 -0
- package/src/api/personal/user.ts +10 -0
- package/src/cli.ts +487 -0
- package/src/commands/capture-session.ts +582 -0
- package/src/config/clerk-session-manager.ts +263 -0
- package/src/config/config-manager.ts +56 -0
- package/src/config/keychain-manager.ts +30 -0
- package/src/config/token-capture.ts +233 -0
- package/src/config/token-manager.ts +139 -0
- package/src/lib/list-options.ts +370 -0
- package/src/types/personal.ts +114 -0
- package/tests/example.test.ts +130 -0
- package/tsconfig.json +19 -0
|
@@ -0,0 +1,582 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Session capture via Chrome DevTools Protocol (CDP).
|
|
4
|
+
* - Connects to a Chrome instance started with --remote-debugging-port.
|
|
5
|
+
* - Reads required Huntr/Clerk cookies from the active huntr.co tab.
|
|
6
|
+
* - Verifies refresh with Clerk and stores session data in macOS Keychain.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { mkdirSync } from 'fs';
|
|
10
|
+
import { tmpdir } from 'os';
|
|
11
|
+
import { join } from 'path';
|
|
12
|
+
import { spawn } from 'child_process';
|
|
13
|
+
import net from 'net';
|
|
14
|
+
import { ClerkSessionManager } from '../config/clerk-session-manager';
|
|
15
|
+
|
|
16
|
+
const CDP_PORT = 9222;
|
|
17
|
+
const CDP_USER_DATA_DIR = join(tmpdir(), 'huntr-cdp-profile');
|
|
18
|
+
const HUNTR_APP_URL = 'https://huntr.co/home';
|
|
19
|
+
const HUNTR_COOKIE_URLS = ['https://huntr.co/', HUNTR_APP_URL, 'https://clerk.huntr.co/'];
|
|
20
|
+
const CAPTURED_COOKIE_NAMES = new Set(['__session', '__client_uat', '__client', '__cf_bm', '_cfuvid']);
|
|
21
|
+
const TAB_WAIT_TIMEOUT_MS = 45_000;
|
|
22
|
+
const TOKEN_WAIT_TIMEOUT_MS = 120_000;
|
|
23
|
+
const POLL_INTERVAL_MS = 1_500;
|
|
24
|
+
const CLERK_SESSION_ID_EVAL_EXPRESSION = `(() => {
|
|
25
|
+
const sid = window.Clerk?.session?.id;
|
|
26
|
+
return typeof sid === 'string' ? sid : '';
|
|
27
|
+
})()`;
|
|
28
|
+
|
|
29
|
+
type ChromeTab = {
|
|
30
|
+
url?: string;
|
|
31
|
+
type?: string;
|
|
32
|
+
title?: string;
|
|
33
|
+
webSocketDebuggerUrl?: string;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
type SessionCookieWaitResult = {
|
|
37
|
+
sessionCookie: string | null;
|
|
38
|
+
sessionId?: string;
|
|
39
|
+
clientUat?: string | null;
|
|
40
|
+
extraCookies?: Record<string, string>;
|
|
41
|
+
freshToken?: string;
|
|
42
|
+
tab?: ChromeTab;
|
|
43
|
+
lastValueDescription?: string;
|
|
44
|
+
lastError?: string;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
type SessionSnapshot = {
|
|
48
|
+
sessionCookie: string;
|
|
49
|
+
clientUat: string | null;
|
|
50
|
+
extraCookies: Record<string, string>;
|
|
51
|
+
clerkSessionId: string | null;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export async function captureSession(): Promise<void> {
|
|
55
|
+
console.log('\nConnecting to Chrome via DevTools Protocol…');
|
|
56
|
+
|
|
57
|
+
// Step 1: Check if Chrome is already running with remote debugging
|
|
58
|
+
let tabs = await getChromeTabs().catch(() => null);
|
|
59
|
+
|
|
60
|
+
if (!tabs) {
|
|
61
|
+
console.log(' Chrome not running with --remote-debugging-port. Launching…');
|
|
62
|
+
await launchChromeWithDebugging();
|
|
63
|
+
// Give it a moment to start
|
|
64
|
+
await new Promise(r => setTimeout(r, 2000));
|
|
65
|
+
tabs = await getChromeTabs().catch(() => null);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (!tabs) {
|
|
69
|
+
throw new Error(
|
|
70
|
+
'Could not connect to Chrome DevTools Protocol.\n' +
|
|
71
|
+
'Please quit Chrome and re-run this command, or start Chrome manually with:\n' +
|
|
72
|
+
` /Applications/Google\\ Chrome.app/Contents/MacOS/Google\\ Chrome --remote-debugging-port=${CDP_PORT} --user-data-dir=${CDP_USER_DATA_DIR} ${HUNTR_APP_URL}\n` +
|
|
73
|
+
' (If this is your first run, sign in to huntr.co in that profile once.)\n' +
|
|
74
|
+
'Then run: huntr config capture-session',
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Step 2: Find the huntr.co tab
|
|
79
|
+
let huntrTab = findBestHuntrTab(tabs);
|
|
80
|
+
if (!huntrTab) {
|
|
81
|
+
console.log(` Opening ${HUNTR_APP_URL} in debug profile…`);
|
|
82
|
+
await openHuntrAppInDebugProfile();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
console.log(' Waiting for huntr.co tab to become available…');
|
|
86
|
+
huntrTab = await waitForHuntrTab(TAB_WAIT_TIMEOUT_MS);
|
|
87
|
+
if (!huntrTab || !huntrTab.webSocketDebuggerUrl) {
|
|
88
|
+
throw new Error(
|
|
89
|
+
'No huntr.co tab found in Chrome DevTools.\n' +
|
|
90
|
+
`Please open ${HUNTR_APP_URL} in Chrome (in the debug profile) and re-run.`,
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
console.log(` Found huntr.co tab: ${huntrTab.title}`);
|
|
95
|
+
const mgr = new ClerkSessionManager();
|
|
96
|
+
console.log(' Waiting for login and extracting Clerk session cookie…');
|
|
97
|
+
|
|
98
|
+
// Step 3: Poll until we have a cookie pair that actually refreshes via Clerk
|
|
99
|
+
const cookieWait = await waitForValidHuntrSessionCookie(mgr, TOKEN_WAIT_TIMEOUT_MS);
|
|
100
|
+
if (!cookieWait.sessionCookie) {
|
|
101
|
+
const details = [
|
|
102
|
+
`Last tab URL: ${cookieWait.tab?.url ?? '(none)'}`,
|
|
103
|
+
cookieWait.lastValueDescription ? `Last cookie value: ${cookieWait.lastValueDescription}` : '',
|
|
104
|
+
cookieWait.lastError ? `Last eval error: ${cookieWait.lastError}` : '',
|
|
105
|
+
].filter(Boolean).join('\n');
|
|
106
|
+
throw new Error(
|
|
107
|
+
'Timed out waiting for authenticated huntr session.\n' +
|
|
108
|
+
'Please finish signing in on the opened Chrome window, then re-run.\n' +
|
|
109
|
+
details,
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
const sessionCookie = cookieWait.sessionCookie;
|
|
113
|
+
const sessionId = cookieWait.sessionId;
|
|
114
|
+
const clientUat = cookieWait.clientUat;
|
|
115
|
+
const extraCookies = cookieWait.extraCookies ?? {};
|
|
116
|
+
|
|
117
|
+
if (!sessionId) {
|
|
118
|
+
throw new Error('Could not extract session ID from session cookie.');
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
console.log(` Session ID: ${sessionId}`);
|
|
122
|
+
|
|
123
|
+
await mgr.saveSession(sessionCookie, sessionId, clientUat ?? undefined, extraCookies);
|
|
124
|
+
console.log(' Saved to macOS Keychain.');
|
|
125
|
+
|
|
126
|
+
// Step 5: Immediately test the refresh endpoint
|
|
127
|
+
process.stdout.write(' Testing auto-refresh… ');
|
|
128
|
+
await mgr.getFreshToken();
|
|
129
|
+
console.log('✓');
|
|
130
|
+
console.log('\n✓ Session captured and verified!');
|
|
131
|
+
console.log(' Tokens will auto-refresh before every command.');
|
|
132
|
+
console.log(' Run: node dist/cli.js activities week-csv 68bf9e33f871e5004a5eb58e');
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export async function checkCdpSession(): Promise<void> {
|
|
136
|
+
console.log('\nChecking Chrome DevTools session visibility…');
|
|
137
|
+
const tabs = await getChromeTabs().catch(() => null);
|
|
138
|
+
|
|
139
|
+
if (!tabs) {
|
|
140
|
+
throw new Error(
|
|
141
|
+
'Could not connect to Chrome DevTools Protocol.\n' +
|
|
142
|
+
'Start Chrome with:\n' +
|
|
143
|
+
` /Applications/Google\\ Chrome.app/Contents/MacOS/Google\\ Chrome --remote-debugging-port=${CDP_PORT} --user-data-dir=${CDP_USER_DATA_DIR} ${HUNTR_APP_URL}`,
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const huntrTab = findBestHuntrTab(tabs);
|
|
148
|
+
if (!huntrTab?.webSocketDebuggerUrl) {
|
|
149
|
+
throw new Error('No huntr.co page tab found. Open huntr.co in the debug-profile Chrome and retry.');
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
console.log(` Using tab: ${huntrTab.title ?? '(untitled)'}`);
|
|
153
|
+
console.log(` URL: ${huntrTab.url ?? '(unknown)'}`);
|
|
154
|
+
|
|
155
|
+
const snapshot = await getSessionSnapshotInTab(huntrTab.webSocketDebuggerUrl, huntrTab.url);
|
|
156
|
+
if (!snapshot?.sessionCookie) {
|
|
157
|
+
throw new Error(
|
|
158
|
+
'CDP connected, but __session cookie is not visible yet.\n' +
|
|
159
|
+
'Finish login in that tab, wait for the app page to load, then retry.',
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const sessionId =
|
|
164
|
+
ClerkSessionManager.extractSessionId(snapshot.sessionCookie) ??
|
|
165
|
+
snapshot.clerkSessionId ??
|
|
166
|
+
undefined;
|
|
167
|
+
if (!sessionId || !sessionId.startsWith('sess_')) {
|
|
168
|
+
throw new Error('Found __session cookie, but could not derive a valid Clerk session ID.');
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const visibleCookies = [
|
|
172
|
+
'__session',
|
|
173
|
+
snapshot.clientUat ? '__client_uat' : null,
|
|
174
|
+
...Object.keys(snapshot.extraCookies),
|
|
175
|
+
].filter(Boolean) as string[];
|
|
176
|
+
console.log(` Visible cookies: ${visibleCookies.sort().join(', ')}`);
|
|
177
|
+
|
|
178
|
+
const mgr = new ClerkSessionManager();
|
|
179
|
+
process.stdout.write(' Testing Clerk refresh with visible cookies… ');
|
|
180
|
+
const fresh = await mgr.refreshFromProvidedSession(
|
|
181
|
+
snapshot.sessionCookie,
|
|
182
|
+
sessionId,
|
|
183
|
+
snapshot.clientUat,
|
|
184
|
+
snapshot.extraCookies,
|
|
185
|
+
);
|
|
186
|
+
console.log('✓');
|
|
187
|
+
console.log(` Session ID: ${sessionId}`);
|
|
188
|
+
console.log(` Refresh token preview: ${fresh.substring(0, 20)}…`);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async function getChromeTabs(): Promise<ChromeTab[]> {
|
|
192
|
+
const response = await fetch(`http://127.0.0.1:${CDP_PORT}/json`);
|
|
193
|
+
if (!response.ok) throw new Error(`CDP returned ${response.status}`);
|
|
194
|
+
const json: unknown = await response.json();
|
|
195
|
+
if (!Array.isArray(json)) throw new Error('Unexpected CDP /json response');
|
|
196
|
+
return json as ChromeTab[];
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async function launchChromeWithDebugging(): Promise<void> {
|
|
200
|
+
// Chrome requires an explicit user-data-dir when remote debugging is enabled.
|
|
201
|
+
mkdirSync(CDP_USER_DATA_DIR, { recursive: true });
|
|
202
|
+
const chromePath = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
|
|
203
|
+
spawn(chromePath, [
|
|
204
|
+
`--remote-debugging-port=${CDP_PORT}`,
|
|
205
|
+
`--user-data-dir=${CDP_USER_DATA_DIR}`,
|
|
206
|
+
'--no-first-run',
|
|
207
|
+
'--no-default-browser-check',
|
|
208
|
+
HUNTR_APP_URL,
|
|
209
|
+
], { detached: true, stdio: 'ignore' }).unref();
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async function openHuntrAppInDebugProfile(): Promise<void> {
|
|
213
|
+
// Launching again with the same user-data-dir opens a tab in that profile.
|
|
214
|
+
await launchChromeWithDebugging();
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async function waitForHuntrTab(timeoutMs: number): Promise<ChromeTab | undefined> {
|
|
218
|
+
const deadline = Date.now() + timeoutMs;
|
|
219
|
+
while (Date.now() < deadline) {
|
|
220
|
+
const tabs = await getChromeTabs().catch(() => null);
|
|
221
|
+
const tab = tabs ? findBestHuntrTab(tabs) : undefined;
|
|
222
|
+
if (tab?.webSocketDebuggerUrl) return tab;
|
|
223
|
+
await sleep(POLL_INTERVAL_MS);
|
|
224
|
+
}
|
|
225
|
+
return undefined;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async function waitForValidHuntrSessionCookie(
|
|
229
|
+
mgr: ClerkSessionManager,
|
|
230
|
+
timeoutMs: number,
|
|
231
|
+
): Promise<SessionCookieWaitResult> {
|
|
232
|
+
const deadline = Date.now() + timeoutMs;
|
|
233
|
+
let lastTab: ChromeTab | undefined;
|
|
234
|
+
let lastValueDescription: string | undefined;
|
|
235
|
+
let lastError: string | undefined;
|
|
236
|
+
let printedHint = false;
|
|
237
|
+
|
|
238
|
+
while (Date.now() < deadline) {
|
|
239
|
+
const tabs = await getChromeTabs().catch(() => null);
|
|
240
|
+
const huntrTabs = tabs ? findHuntrTabs(tabs) : [];
|
|
241
|
+
|
|
242
|
+
for (const tab of huntrTabs) {
|
|
243
|
+
if (!tab.webSocketDebuggerUrl) continue;
|
|
244
|
+
lastTab = tab;
|
|
245
|
+
|
|
246
|
+
try {
|
|
247
|
+
const snapshot = await getSessionSnapshotInTab(tab.webSocketDebuggerUrl, tab.url);
|
|
248
|
+
lastValueDescription = describeValue(snapshot);
|
|
249
|
+
|
|
250
|
+
if (!snapshot?.sessionCookie || snapshot.sessionCookie.split('.').length !== 3) {
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const sessionId =
|
|
255
|
+
ClerkSessionManager.extractSessionId(snapshot.sessionCookie) ??
|
|
256
|
+
snapshot.clerkSessionId ??
|
|
257
|
+
undefined;
|
|
258
|
+
|
|
259
|
+
if (!sessionId || !sessionId.startsWith('sess_')) {
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const freshToken = await mgr.refreshFromProvidedSession(
|
|
264
|
+
snapshot.sessionCookie,
|
|
265
|
+
sessionId,
|
|
266
|
+
snapshot.clientUat,
|
|
267
|
+
snapshot.extraCookies,
|
|
268
|
+
);
|
|
269
|
+
if (freshToken && freshToken.split('.').length === 3) {
|
|
270
|
+
return {
|
|
271
|
+
sessionCookie: snapshot.sessionCookie,
|
|
272
|
+
sessionId,
|
|
273
|
+
clientUat: snapshot.clientUat,
|
|
274
|
+
extraCookies: snapshot.extraCookies,
|
|
275
|
+
freshToken,
|
|
276
|
+
tab,
|
|
277
|
+
lastValueDescription,
|
|
278
|
+
lastError,
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
} catch (e) {
|
|
282
|
+
lastError = e instanceof Error ? e.message : String(e);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if (!printedHint && huntrTabs.length > 0) {
|
|
287
|
+
console.log(' Waiting for you to finish signing into huntr.co in that Chrome window…');
|
|
288
|
+
printedHint = true;
|
|
289
|
+
}
|
|
290
|
+
await sleep(POLL_INTERVAL_MS);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
return { sessionCookie: null, tab: lastTab, lastValueDescription, lastError };
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function isHuntrAppTab(tab: ChromeTab): boolean {
|
|
297
|
+
if (!tab.url || tab.type !== 'page') return false;
|
|
298
|
+
try {
|
|
299
|
+
const parsed = new URL(tab.url);
|
|
300
|
+
if (!parsed.hostname.endsWith('huntr.co')) return false;
|
|
301
|
+
return parsed.pathname !== '/' && parsed.pathname.length > 1;
|
|
302
|
+
} catch {
|
|
303
|
+
return false;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function isHuntrTab(tab: ChromeTab): boolean {
|
|
308
|
+
if (!tab.url || tab.type !== 'page') return false;
|
|
309
|
+
try {
|
|
310
|
+
const parsed = new URL(tab.url);
|
|
311
|
+
return parsed.hostname.endsWith('huntr.co');
|
|
312
|
+
} catch {
|
|
313
|
+
return false;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function findHuntrTabs(tabs: ChromeTab[]): ChromeTab[] {
|
|
318
|
+
const huntrTabs = tabs.filter(isHuntrTab);
|
|
319
|
+
const appTabs = huntrTabs.filter(isHuntrAppTab);
|
|
320
|
+
return appTabs.length > 0 ? appTabs : huntrTabs;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function findBestHuntrTab(tabs: ChromeTab[]): ChromeTab | undefined {
|
|
324
|
+
return findHuntrTabs(tabs)[0];
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
async function sleep(ms: number): Promise<void> {
|
|
328
|
+
await new Promise(resolve => setTimeout(resolve, ms));
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
async function getSessionSnapshotInTab(wsUrl: string, pageUrl?: string): Promise<SessionSnapshot | null> {
|
|
332
|
+
const cookies = await getCookiesInTab(wsUrl, pageUrl);
|
|
333
|
+
const sessionCookie = cookies.__session ?? null;
|
|
334
|
+
if (!sessionCookie) return null;
|
|
335
|
+
|
|
336
|
+
const clerkSessionIdRaw = await evaluateInTab(wsUrl, CLERK_SESSION_ID_EVAL_EXPRESSION).catch(() => '');
|
|
337
|
+
const clerkSessionId =
|
|
338
|
+
typeof clerkSessionIdRaw === 'string' && clerkSessionIdRaw.startsWith('sess_')
|
|
339
|
+
? clerkSessionIdRaw
|
|
340
|
+
: null;
|
|
341
|
+
|
|
342
|
+
return {
|
|
343
|
+
sessionCookie,
|
|
344
|
+
clientUat: cookies.__client_uat ?? null,
|
|
345
|
+
extraCookies: Object.fromEntries(
|
|
346
|
+
Object.entries(cookies).filter(([name]) => name !== '__session' && name !== '__client_uat'),
|
|
347
|
+
),
|
|
348
|
+
clerkSessionId,
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
async function getCookiesInTab(wsUrl: string, pageUrl?: string): Promise<Record<string, string>> {
|
|
353
|
+
try {
|
|
354
|
+
await cdpRequestInTab(wsUrl, 'Network.enable');
|
|
355
|
+
} catch {
|
|
356
|
+
// Not all targets require/allow Network.enable first.
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const urls = pageUrl ? Array.from(new Set([pageUrl, ...HUNTR_COOKIE_URLS])) : HUNTR_COOKIE_URLS;
|
|
360
|
+
const result = await cdpRequestInTab(wsUrl, 'Network.getCookies', { urls });
|
|
361
|
+
const cookies = (result as { cookies?: unknown[] } | null)?.cookies;
|
|
362
|
+
const out: Record<string, string> = {};
|
|
363
|
+
|
|
364
|
+
if (!Array.isArray(cookies)) return out;
|
|
365
|
+
|
|
366
|
+
for (const cookie of cookies) {
|
|
367
|
+
if (!cookie || typeof cookie !== 'object') continue;
|
|
368
|
+
const c = cookie as Record<string, unknown>;
|
|
369
|
+
const name = c.name;
|
|
370
|
+
const value = c.value;
|
|
371
|
+
const domain = c.domain;
|
|
372
|
+
|
|
373
|
+
if (typeof name !== 'string' || typeof value !== 'string') continue;
|
|
374
|
+
if (typeof domain === 'string' && !domain.includes('huntr.co')) continue;
|
|
375
|
+
if (CAPTURED_COOKIE_NAMES.has(name)) {
|
|
376
|
+
out[name] = value;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
return out;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
async function evaluateInTab(wsUrl: string, expression: string): Promise<unknown> {
|
|
384
|
+
const result = await cdpRequestInTab(wsUrl, 'Runtime.evaluate', {
|
|
385
|
+
expression,
|
|
386
|
+
awaitPromise: true,
|
|
387
|
+
returnByValue: true,
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
const evalResult = (result ?? {}) as Record<string, unknown>;
|
|
391
|
+
const exceptionDetails = evalResult.exceptionDetails as Record<string, unknown> | undefined;
|
|
392
|
+
if (exceptionDetails) {
|
|
393
|
+
const text = typeof exceptionDetails.text === 'string' ? exceptionDetails.text : 'Runtime.evaluate failed';
|
|
394
|
+
throw new Error(text);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
const runtimeResult = evalResult.result as Record<string, unknown> | undefined;
|
|
398
|
+
if (!runtimeResult || !Object.prototype.hasOwnProperty.call(runtimeResult, 'value')) return undefined;
|
|
399
|
+
return runtimeResult.value;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function cdpRequestInTab(
|
|
403
|
+
wsUrl: string,
|
|
404
|
+
method: string,
|
|
405
|
+
params: Record<string, unknown> = {},
|
|
406
|
+
): Promise<unknown> {
|
|
407
|
+
return new Promise((resolve, reject) => {
|
|
408
|
+
// Use Node's built-in WebSocket (Node 22+) or fall back to a raw WS handshake
|
|
409
|
+
const WebSocketImpl = (globalThis as any).WebSocket;
|
|
410
|
+
|
|
411
|
+
if (WebSocketImpl) {
|
|
412
|
+
connectWithWebSocket(wsUrl, WebSocketImpl, method, params, resolve, reject);
|
|
413
|
+
} else {
|
|
414
|
+
// Node < 22: implement minimal WS client over net/tls
|
|
415
|
+
connectWithRawWS(wsUrl, method, params, resolve, reject);
|
|
416
|
+
}
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function connectWithWebSocket(
|
|
421
|
+
wsUrl: string,
|
|
422
|
+
WS: typeof WebSocket,
|
|
423
|
+
method: string,
|
|
424
|
+
params: Record<string, unknown>,
|
|
425
|
+
resolve: (v: unknown) => void,
|
|
426
|
+
reject: (e: Error) => void,
|
|
427
|
+
): void {
|
|
428
|
+
const ws = new WS(wsUrl);
|
|
429
|
+
const id = 1;
|
|
430
|
+
|
|
431
|
+
ws.onopen = () => {
|
|
432
|
+
ws.send(JSON.stringify({
|
|
433
|
+
id,
|
|
434
|
+
method,
|
|
435
|
+
params,
|
|
436
|
+
}));
|
|
437
|
+
};
|
|
438
|
+
|
|
439
|
+
ws.onmessage = async (evt: MessageEvent) => {
|
|
440
|
+
try {
|
|
441
|
+
const raw = await wsDataToText(evt.data);
|
|
442
|
+
const msg = JSON.parse(raw);
|
|
443
|
+
if (msg.id !== id) return;
|
|
444
|
+
if (msg.error) {
|
|
445
|
+
ws.close();
|
|
446
|
+
reject(new Error(msg.error?.message ?? `CDP ${method} failed`));
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
ws.close();
|
|
451
|
+
resolve(msg.result as unknown);
|
|
452
|
+
} catch (e) {
|
|
453
|
+
ws.close();
|
|
454
|
+
reject(e instanceof Error ? e : new Error(String(e)));
|
|
455
|
+
}
|
|
456
|
+
};
|
|
457
|
+
|
|
458
|
+
ws.onerror = () => reject(new Error('WebSocket connection to Chrome DevTools failed'));
|
|
459
|
+
|
|
460
|
+
setTimeout(() => { ws.close(); reject(new Error('Timeout waiting for Chrome response')); }, 10_000);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
async function wsDataToText(data: unknown): Promise<string> {
|
|
464
|
+
if (typeof data === 'string') return data;
|
|
465
|
+
if (typeof Buffer !== 'undefined' && Buffer.isBuffer(data)) return data.toString('utf8');
|
|
466
|
+
if (data instanceof ArrayBuffer) return Buffer.from(data).toString('utf8');
|
|
467
|
+
if (typeof Blob !== 'undefined' && data instanceof Blob) return await data.text();
|
|
468
|
+
return String(data);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function connectWithRawWS(
|
|
472
|
+
wsUrl: string,
|
|
473
|
+
method: string,
|
|
474
|
+
params: Record<string, unknown>,
|
|
475
|
+
resolve: (v: unknown) => void,
|
|
476
|
+
reject: (e: Error) => void,
|
|
477
|
+
): void {
|
|
478
|
+
// Parse ws://host:port/path
|
|
479
|
+
const match = wsUrl.match(/^ws:\/\/([^/:]+):(\d+)(\/.*)?$/);
|
|
480
|
+
if (!match) { reject(new Error(`Cannot parse WS URL: ${wsUrl}`)); return; }
|
|
481
|
+
|
|
482
|
+
const host = match[1];
|
|
483
|
+
const port = parseInt(match[2], 10);
|
|
484
|
+
const path = match[3] ?? '/';
|
|
485
|
+
|
|
486
|
+
const socket = net.createConnection(port, host);
|
|
487
|
+
|
|
488
|
+
const key = Buffer.from(Math.random().toString(36)).toString('base64');
|
|
489
|
+
let buffer = '';
|
|
490
|
+
let handshakeDone = false;
|
|
491
|
+
const msgId = 1;
|
|
492
|
+
|
|
493
|
+
socket.on('connect', () => {
|
|
494
|
+
socket.write(
|
|
495
|
+
`GET ${path} HTTP/1.1\r\n` +
|
|
496
|
+
`Host: ${host}:${port}\r\n` +
|
|
497
|
+
'Upgrade: websocket\r\n' +
|
|
498
|
+
'Connection: Upgrade\r\n' +
|
|
499
|
+
`Sec-WebSocket-Key: ${key}\r\n` +
|
|
500
|
+
'Sec-WebSocket-Version: 13\r\n\r\n',
|
|
501
|
+
);
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
socket.on('data', (data: Buffer) => {
|
|
505
|
+
if (!handshakeDone) {
|
|
506
|
+
buffer += data.toString();
|
|
507
|
+
if (buffer.includes('\r\n\r\n')) {
|
|
508
|
+
handshakeDone = true;
|
|
509
|
+
// Send the evaluate command
|
|
510
|
+
const payload = JSON.stringify({
|
|
511
|
+
id: msgId,
|
|
512
|
+
method,
|
|
513
|
+
params,
|
|
514
|
+
});
|
|
515
|
+
socket.write(encodeWsFrame(payload));
|
|
516
|
+
}
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// Parse WebSocket frame
|
|
521
|
+
try {
|
|
522
|
+
const msg = decodeWsFrame(data);
|
|
523
|
+
if (msg) {
|
|
524
|
+
const parsed = JSON.parse(msg);
|
|
525
|
+
if (parsed.id !== msgId) return;
|
|
526
|
+
if (parsed.error) {
|
|
527
|
+
socket.destroy();
|
|
528
|
+
reject(new Error(parsed.error?.message ?? `CDP ${method} failed`));
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
socket.destroy();
|
|
533
|
+
resolve(parsed.result as unknown);
|
|
534
|
+
}
|
|
535
|
+
} catch {
|
|
536
|
+
// partial frame, wait for more data
|
|
537
|
+
}
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
socket.on('error', (e: Error) => reject(e));
|
|
541
|
+
setTimeout(() => { socket.destroy(); reject(new Error('Timeout')); }, 10_000);
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
function describeValue(value: unknown): string {
|
|
545
|
+
if (value === null) return 'null';
|
|
546
|
+
if (value === undefined) return 'undefined';
|
|
547
|
+
if (typeof value === 'string') return `string, ${value.length} chars`;
|
|
548
|
+
if (Array.isArray(value)) return `array, ${value.length} items`;
|
|
549
|
+
if (typeof value === 'object') {
|
|
550
|
+
const keys = Object.keys(value as Record<string, unknown>);
|
|
551
|
+
return `object, keys: ${keys.slice(0, 6).join(', ') || '(none)'}`;
|
|
552
|
+
}
|
|
553
|
+
return typeof value;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
function encodeWsFrame(payload: string): Buffer {
|
|
557
|
+
const data = Buffer.from(payload, 'utf8');
|
|
558
|
+
const len = data.length;
|
|
559
|
+
const mask = Buffer.from([
|
|
560
|
+
Math.random() * 256, Math.random() * 256,
|
|
561
|
+
Math.random() * 256, Math.random() * 256,
|
|
562
|
+
].map(Math.floor));
|
|
563
|
+
|
|
564
|
+
const header = len < 126
|
|
565
|
+
? Buffer.from([0x81, 0x80 | len])
|
|
566
|
+
: len < 65536
|
|
567
|
+
? Buffer.from([0x81, 0xfe, len >> 8, len & 0xff])
|
|
568
|
+
: Buffer.from([0x81, 0xff, 0, 0, 0, 0, (len >> 24) & 0xff, (len >> 16) & 0xff, (len >> 8) & 0xff, len & 0xff]);
|
|
569
|
+
|
|
570
|
+
const masked = Buffer.alloc(len);
|
|
571
|
+
for (let i = 0; i < len; i++) masked[i] = data[i] ^ mask[i % 4];
|
|
572
|
+
return Buffer.concat([header, mask, masked]);
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
function decodeWsFrame(buf: Buffer): string | null {
|
|
576
|
+
if (buf.length < 2) return null;
|
|
577
|
+
const len = buf[1] & 0x7f;
|
|
578
|
+
let offset = 2;
|
|
579
|
+
const payloadLen = len < 126 ? len : len === 126 ? (buf.readUInt16BE(2), offset += 2, buf.readUInt16BE(2)) : null;
|
|
580
|
+
if (payloadLen === null) return null;
|
|
581
|
+
return buf.slice(offset, offset + (payloadLen as number)).toString('utf8');
|
|
582
|
+
}
|