glidercli 0.3.8 → 0.3.10
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/README.md +8 -4
- package/bin/glider.js +267 -28
- package/docs/setup.json +2 -0
- package/lib/bexplore.js +174 -88
- package/lib/bsnapshot.js +69 -0
- package/lib/domain-resolve.js +72 -0
- package/lib/glider-daemon.sh +48 -2
- package/lib/guard.js +73 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -58,6 +58,7 @@
|
|
|
58
58
|
| | Browser | Config |
|
|
59
59
|
|:---:|--------|--------|
|
|
60
60
|
| <img src="https://raw.githubusercontent.com/vdutts7/squircle/main/webp/chrome.webp" width="16" alt=""> | Google Chrome | Default for `glider connect`|
|
|
61
|
+
| <img src="https://raw.githubusercontent.com/vdutts7/squircle/main/webp/chrome-canary.webp" width="16" alt=""> | Chrome Canary | n/a |
|
|
61
62
|
| <img src="https://raw.githubusercontent.com/vdutts7/squircle/main/webp/arc.webp" width="16" alt=""> | Arc | [browser.json](config/browser.json.example) (`{ "use": "arc" }`) |
|
|
62
63
|
| <img src="https://raw.githubusercontent.com/vdutts7/squircle/main/webp/microsoft/microsoft-edge.webp" width="16" alt=""> | Microsoft Edge | n/a |
|
|
63
64
|
| <img src="https://raw.githubusercontent.com/vdutts7/squircle/main/webp/brave.webp" width="16" alt=""> | Brave | n/a |
|
|
@@ -85,9 +86,13 @@
|
|
|
85
86
|
| <img src="https://raw.githubusercontent.com/vdutts7/squircle/main/webp/mullvad-browser.webp" width="16" alt=""> | Mullvad Browser | Gecko- same constraints as Firefox |
|
|
86
87
|
| <img src="https://raw.githubusercontent.com/vdutts7/squircle/main/webp/icecat.webp" width="16" alt=""> | IceCat | Gecko- same constraints as Firefox |
|
|
87
88
|
| <img src="https://raw.githubusercontent.com/vdutts7/squircle/main/webp/safari.webp" width="16" alt=""> | Safari | WebKit (Apple desktop). Not Chromium |
|
|
89
|
+
| <img src="https://raw.githubusercontent.com/vdutts7/squircle/main/webp/safari-technology-preview.webp" width="16" alt=""> | Safari Technology Preview | WebKit preview channel (Apple desktop). Not Chromium |
|
|
88
90
|
| <img src="https://raw.githubusercontent.com/vdutts7/squircle/main/webp/orion.webp" width="16" alt=""> | Orion | WebKit-based desktop browser. Not Chromium |
|
|
91
|
+
| <img src="https://raw.githubusercontent.com/vdutts7/squircle/main/webp/dia-browser.webp" width="16" alt=""> | Dia | AI-first browser, not in Glider’s supported Chromium + CWS model today |
|
|
89
92
|
| <img src="https://raw.githubusercontent.com/vdutts7/squircle/main/webp/chatgpt-atlas.webp" width="16" alt=""> | ChatGPT Atlas | AI-first browser, not in Glider’s supported Chromium + CWS model today |
|
|
90
93
|
| <img src="https://raw.githubusercontent.com/vdutts7/squircle/main/webp/perplexity-comet.webp" width="16" alt=""> | Perplexity Comet | AI-first browser, not in Glider’s supported Chromium + CWS model today |
|
|
94
|
+
| <img src="https://raw.githubusercontent.com/vdutts7/squircle/main/webp/whale-browser.webp" width="16" alt=""> | Whale Browser | Chromium-based, support not implemented in Glider today |
|
|
95
|
+
| <img src="https://raw.githubusercontent.com/vdutts7/squircle/main/webp/yandex-browser.webp" width="16" alt=""> | Yandex Browser | Chromium-based, support not implemented in Glider today |
|
|
91
96
|
| <img src="https://raw.githubusercontent.com/vdutts7/squircle/main/webp/bromite.webp" width="16" alt=""> | Bromite | Chromium-derived, no practical Chrome Web Store path for Glider |
|
|
92
97
|
| <img src="https://raw.githubusercontent.com/vdutts7/squircle/main/webp/chromium.webp" width="16" alt=""> | Chromite | Chromium-derived, no practical Chrome Web Store path for Glider |
|
|
93
98
|
| <img src="https://raw.githubusercontent.com/vdutts7/squircle/main/webp/grapheneos.webp" width="16" alt=""> | Vanadium | Chromium-derived (GrapheneOS), no practical Chrome Web Store path for Glider |
|
|
@@ -111,7 +116,7 @@ Set browser by key from the browsers registry. Run `glider use <key>` to write t
|
|
|
111
116
|
}
|
|
112
117
|
```
|
|
113
118
|
|
|
114
|
-
Registry is loaded from
|
|
119
|
+
Registry is loaded from: `$HOME/.glider/config/browsers-registry.json`. Keys are predefined (e.g. `arc`, `brave`, `chrome`, `edge`, `opera`, `vivaldi`, `chromium`). Edit the registry to add or change paths
|
|
115
120
|
|
|
116
121
|
**Option B- Explicit name/path:**
|
|
117
122
|
|
|
@@ -147,9 +152,8 @@ Check app name in Finder/Spotlight, process name: `ps aux | grep -i <name>`
|
|
|
147
152
|
|
|
148
153
|
A single registry file can define all Chromium browsers, Glider picks one by **key**
|
|
149
154
|
|
|
150
|
-
**Registry location
|
|
155
|
+
**Registry location:**
|
|
151
156
|
|
|
152
|
-
- `$HOME/.cursor/registry/browsers.json`
|
|
153
157
|
- `$HOME/.glider/config/browsers-registry.json`
|
|
154
158
|
|
|
155
159
|
**Registry format:**
|
|
@@ -190,7 +194,7 @@ Add or edit entries in the registry to match your machine (e.g. custom install p
|
|
|
190
194
|
|-------|--------|
|
|
191
195
|
| Supported | Chromium-based + Chrome Web Store extension (see table above). |
|
|
192
196
|
| Not supported | See **Future** in this README. |
|
|
193
|
-
| Configure | `$HOME/.glider/config/browser.json` (use key or name/path). Registry: `$HOME/.
|
|
197
|
+
| Configure | `$HOME/.glider/config/browser.json` (use key or name/path). Registry: `$HOME/.glider/config/browsers-registry.json` |
|
|
194
198
|
| Switch | `glider use <key>` (e.g. `glider use arc`, `glider use brave`). |
|
|
195
199
|
| Path | Optional, use when app is not in default location |
|
|
196
200
|
|
package/bin/glider.js
CHANGED
|
@@ -39,6 +39,14 @@ const STATE_FILE = '/tmp/glider-state.json';
|
|
|
39
39
|
const LOG_FILE = '/tmp/glider.log';
|
|
40
40
|
const REGISTRY_FILE = path.join(LIB_DIR, 'registry.json');
|
|
41
41
|
|
|
42
|
+
// Active CDP session (multi-tab). Env or --session / --session-id on CLI.
|
|
43
|
+
let activeSessionId = process.env.GLIDER_SESSION_ID || null;
|
|
44
|
+
let jsonOutput = false;
|
|
45
|
+
let allowedDomainList = null;
|
|
46
|
+
const SESSION_STORE = path.join(os.homedir(), '.glider', 'config', 'active-session.json');
|
|
47
|
+
const { resolveAllowedDomains, assertUrlAllowed, urlAllowed } = require(path.join(LIB_DIR, 'guard.js'));
|
|
48
|
+
const { buildSnapshotExpression, formatSnapshotText } = require(path.join(LIB_DIR, 'bsnapshot.js'));
|
|
49
|
+
|
|
42
50
|
// Load pattern registry
|
|
43
51
|
let REGISTRY = {};
|
|
44
52
|
if (fs.existsSync(REGISTRY_FILE)) {
|
|
@@ -49,6 +57,7 @@ if (fs.existsSync(REGISTRY_FILE)) {
|
|
|
49
57
|
|
|
50
58
|
// Direct CDP module
|
|
51
59
|
const { DirectCDP, checkChrome } = require(path.join(LIB_DIR, 'cdp-direct.js'));
|
|
60
|
+
const { resolveDomain } = require(path.join(LIB_DIR, 'domain-resolve.js'));
|
|
52
61
|
|
|
53
62
|
// Domain extensions - load from ~/.glider/config/domains.json
|
|
54
63
|
const DOMAIN_CONFIG_PATHS = [
|
|
@@ -197,10 +206,63 @@ function httpGet(urlPath) {
|
|
|
197
206
|
});
|
|
198
207
|
}
|
|
199
208
|
|
|
209
|
+
function loadPersistedSession() {
|
|
210
|
+
if (activeSessionId) return;
|
|
211
|
+
if (!fs.existsSync(SESSION_STORE)) return;
|
|
212
|
+
try {
|
|
213
|
+
const data = JSON.parse(fs.readFileSync(SESSION_STORE, 'utf8'));
|
|
214
|
+
if (data.sessionId) activeSessionId = data.sessionId;
|
|
215
|
+
} catch { /* ignore */ }
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function persistSession(sessionId) {
|
|
219
|
+
activeSessionId = sessionId;
|
|
220
|
+
fs.mkdirSync(path.dirname(SESSION_STORE), { recursive: true });
|
|
221
|
+
fs.writeFileSync(SESSION_STORE, JSON.stringify({ sessionId, updated: new Date().toISOString() }, null, 2));
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function emitJson(ok, observation, error = null, warnings = []) {
|
|
225
|
+
console.log(JSON.stringify({ ok, observation, error, warnings }, null, 2));
|
|
226
|
+
if (!ok) process.exit(1);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async function assertCurrentUrlAllowed(action) {
|
|
230
|
+
if (!allowedDomainList) return;
|
|
231
|
+
const result = await httpPost('/cdp', {
|
|
232
|
+
method: 'Runtime.evaluate',
|
|
233
|
+
params: { expression: 'location.href', returnByValue: true },
|
|
234
|
+
});
|
|
235
|
+
const url = result?.result?.value;
|
|
236
|
+
if (url) assertUrlAllowed(url, allowedDomainList, action);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function parseGlobalFlags(argv) {
|
|
240
|
+
const rest = [];
|
|
241
|
+
const cliDomains = [];
|
|
242
|
+
for (let i = 0; i < argv.length; i++) {
|
|
243
|
+
const a = argv[i];
|
|
244
|
+
if ((a === '--session' || a === '--session-id') && argv[i + 1]) {
|
|
245
|
+
activeSessionId = argv[++i];
|
|
246
|
+
} else if (a === '--json') {
|
|
247
|
+
jsonOutput = true;
|
|
248
|
+
} else if (a === '--allowed-domains' && argv[i + 1]) {
|
|
249
|
+
cliDomains.push(...String(argv[++i]).split(',').map((s) => s.trim()).filter(Boolean));
|
|
250
|
+
} else {
|
|
251
|
+
rest.push(a);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
allowedDomainList = resolveAllowedDomains(cliDomains);
|
|
255
|
+
return rest;
|
|
256
|
+
}
|
|
257
|
+
|
|
200
258
|
function httpPost(urlPath, body) {
|
|
259
|
+
const payload = { ...body };
|
|
260
|
+
if (urlPath === '/cdp' && activeSessionId && payload.sessionId == null) {
|
|
261
|
+
payload.sessionId = activeSessionId;
|
|
262
|
+
}
|
|
201
263
|
return new Promise((resolve, reject) => {
|
|
202
264
|
const url = new URL(urlPath, SERVER_URL);
|
|
203
|
-
const data = JSON.stringify(
|
|
265
|
+
const data = JSON.stringify(payload);
|
|
204
266
|
const req = http.request(url, {
|
|
205
267
|
method: 'POST',
|
|
206
268
|
headers: { 'Content-Type': 'application/json' },
|
|
@@ -421,21 +483,33 @@ async function cmdGoto(url) {
|
|
|
421
483
|
process.exit(1);
|
|
422
484
|
}
|
|
423
485
|
|
|
424
|
-
// Auto-connect if not connected
|
|
425
486
|
if (!await ensureConnected()) {
|
|
426
487
|
process.exit(1);
|
|
427
488
|
}
|
|
489
|
+
|
|
490
|
+
try {
|
|
491
|
+
assertUrlAllowed(url, allowedDomainList, 'goto');
|
|
492
|
+
} catch (e) {
|
|
493
|
+
if (jsonOutput) emitJson(false, null, e.message);
|
|
494
|
+
log.fail(e.message);
|
|
495
|
+
process.exit(1);
|
|
496
|
+
}
|
|
428
497
|
|
|
429
|
-
log.info(`Navigating to: ${url}`);
|
|
498
|
+
if (!jsonOutput) log.info(`Navigating to: ${url}`);
|
|
430
499
|
|
|
431
500
|
try {
|
|
432
501
|
const result = await httpPost('/cdp', {
|
|
433
502
|
method: 'Page.navigate',
|
|
434
503
|
params: { url }
|
|
435
504
|
});
|
|
436
|
-
|
|
437
|
-
|
|
505
|
+
if (jsonOutput) {
|
|
506
|
+
emitJson(true, { url, navigate: result });
|
|
507
|
+
} else {
|
|
508
|
+
console.log(JSON.stringify(result));
|
|
509
|
+
log.ok('Navigated');
|
|
510
|
+
}
|
|
438
511
|
} catch (e) {
|
|
512
|
+
if (jsonOutput) emitJson(false, null, e.message);
|
|
439
513
|
log.fail(`Navigation failed: ${e.message}`);
|
|
440
514
|
process.exit(1);
|
|
441
515
|
}
|
|
@@ -462,14 +536,18 @@ async function cmdEval(js) {
|
|
|
462
536
|
}
|
|
463
537
|
});
|
|
464
538
|
|
|
465
|
-
|
|
466
|
-
|
|
539
|
+
const value = result.result?.value;
|
|
540
|
+
if (jsonOutput) {
|
|
541
|
+
emitJson(true, value !== undefined ? value : result.result);
|
|
542
|
+
} else if (value !== undefined) {
|
|
543
|
+
console.log(JSON.stringify(value));
|
|
467
544
|
} else if (result.result?.description) {
|
|
468
545
|
console.log(result.result.description);
|
|
469
546
|
} else {
|
|
470
547
|
console.log(JSON.stringify(result));
|
|
471
548
|
}
|
|
472
549
|
} catch (e) {
|
|
550
|
+
if (jsonOutput) emitJson(false, null, e.message);
|
|
473
551
|
log.fail(`Eval failed: ${e.message}`);
|
|
474
552
|
process.exit(1);
|
|
475
553
|
}
|
|
@@ -481,10 +559,17 @@ async function cmdClick(selector) {
|
|
|
481
559
|
process.exit(1);
|
|
482
560
|
}
|
|
483
561
|
|
|
484
|
-
// Auto-connect if not connected
|
|
485
562
|
if (!await ensureConnected()) {
|
|
486
563
|
process.exit(1);
|
|
487
564
|
}
|
|
565
|
+
|
|
566
|
+
try {
|
|
567
|
+
await assertCurrentUrlAllowed('click');
|
|
568
|
+
} catch (e) {
|
|
569
|
+
if (jsonOutput) emitJson(false, null, e.message);
|
|
570
|
+
log.fail(e.message);
|
|
571
|
+
process.exit(1);
|
|
572
|
+
}
|
|
488
573
|
|
|
489
574
|
const js = `
|
|
490
575
|
(() => {
|
|
@@ -502,11 +587,17 @@ async function cmdClick(selector) {
|
|
|
502
587
|
});
|
|
503
588
|
|
|
504
589
|
if (result.result?.value?.error) {
|
|
590
|
+
if (jsonOutput) emitJson(false, null, result.result.value.error);
|
|
505
591
|
log.fail(result.result.value.error);
|
|
506
592
|
process.exit(1);
|
|
507
593
|
}
|
|
508
|
-
|
|
594
|
+
if (jsonOutput) {
|
|
595
|
+
emitJson(true, { selector, clicked: true });
|
|
596
|
+
} else {
|
|
597
|
+
log.ok(`Clicked: ${selector}`);
|
|
598
|
+
}
|
|
509
599
|
} catch (e) {
|
|
600
|
+
if (jsonOutput) emitJson(false, null, e.message);
|
|
510
601
|
log.fail(`Click failed: ${e.message}`);
|
|
511
602
|
process.exit(1);
|
|
512
603
|
}
|
|
@@ -979,7 +1070,7 @@ async function cmdWindow(args) {
|
|
|
979
1070
|
}
|
|
980
1071
|
|
|
981
1072
|
async function cmdDomains() {
|
|
982
|
-
const domainKeys = Object.keys(DOMAINS);
|
|
1073
|
+
const domainKeys = Object.keys(DOMAINS).filter((k) => k !== 'meta');
|
|
983
1074
|
if (domainKeys.length === 0) {
|
|
984
1075
|
log.warn('No domains configured');
|
|
985
1076
|
log.info('Add domains to ~/.glider/config/domains.json');
|
|
@@ -988,14 +1079,30 @@ async function cmdDomains() {
|
|
|
988
1079
|
console.log(`${GREEN}${domainKeys.length}${NC} domain(s) configured:\n`);
|
|
989
1080
|
for (const key of domainKeys) {
|
|
990
1081
|
const d = DOMAINS[key];
|
|
991
|
-
const
|
|
992
|
-
const
|
|
1082
|
+
const shortcut = d.shortcut || {};
|
|
1083
|
+
const type = shortcut.type || (d.script ? 'script' : d.url ? 'url' : 'none');
|
|
1084
|
+
const target = shortcut.target || d.script || d.url || '';
|
|
993
1085
|
console.log(` ${CYAN}${key}${NC} ${DIM}(${type})${NC}`);
|
|
1086
|
+
if (d.host) console.log(` ${DIM}host: ${d.host}${NC}`);
|
|
1087
|
+
if (d.warch) console.log(` ${DIM}warch: ${d.warch}${NC}`);
|
|
994
1088
|
if (d.description) console.log(` ${d.description}`);
|
|
995
|
-
console.log(` ${DIM}${target}${NC}`);
|
|
1089
|
+
if (target) console.log(` ${DIM}${target}${NC}`);
|
|
996
1090
|
}
|
|
997
1091
|
}
|
|
998
1092
|
|
|
1093
|
+
async function cmdResolve(input, opts = []) {
|
|
1094
|
+
if (!input) {
|
|
1095
|
+
log.fail('Usage: glider resolve <url|host> [--json]');
|
|
1096
|
+
process.exit(1);
|
|
1097
|
+
}
|
|
1098
|
+
const result = resolveDomain(input);
|
|
1099
|
+
if (opts.includes('--json') || jsonOutput) {
|
|
1100
|
+
console.log(JSON.stringify({ ok: true, ...result }));
|
|
1101
|
+
return;
|
|
1102
|
+
}
|
|
1103
|
+
console.log(JSON.stringify(result, null, 2));
|
|
1104
|
+
}
|
|
1105
|
+
|
|
999
1106
|
async function cmdOpen(url) {
|
|
1000
1107
|
if (!url) {
|
|
1001
1108
|
log.fail('Usage: glider open <url>');
|
|
@@ -1055,7 +1162,6 @@ async function cmdTitle() {
|
|
|
1055
1162
|
}
|
|
1056
1163
|
|
|
1057
1164
|
async function cmdUrl() {
|
|
1058
|
-
// Auto-connect if not connected
|
|
1059
1165
|
if (!await ensureConnected()) {
|
|
1060
1166
|
process.exit(1);
|
|
1061
1167
|
}
|
|
@@ -1065,13 +1171,106 @@ async function cmdUrl() {
|
|
|
1065
1171
|
method: 'Runtime.evaluate',
|
|
1066
1172
|
params: { expression: 'window.location.href', returnByValue: true }
|
|
1067
1173
|
});
|
|
1068
|
-
|
|
1174
|
+
const url = result.result?.value || '';
|
|
1175
|
+
if (jsonOutput) emitJson(true, { url });
|
|
1176
|
+
else console.log(url);
|
|
1069
1177
|
} catch (e) {
|
|
1178
|
+
if (jsonOutput) emitJson(false, null, e.message);
|
|
1070
1179
|
log.fail(`URL extraction failed: ${e.message}`);
|
|
1071
1180
|
process.exit(1);
|
|
1072
1181
|
}
|
|
1073
1182
|
}
|
|
1074
1183
|
|
|
1184
|
+
async function cmdSnapshot(opts = []) {
|
|
1185
|
+
let interactiveOnly = false;
|
|
1186
|
+
for (const o of opts) {
|
|
1187
|
+
if (o === '--interactive-only' || o === '-i') interactiveOnly = true;
|
|
1188
|
+
}
|
|
1189
|
+
if (!await ensureConnected()) process.exit(1);
|
|
1190
|
+
try {
|
|
1191
|
+
const result = await httpPost('/cdp', {
|
|
1192
|
+
method: 'Runtime.evaluate',
|
|
1193
|
+
params: {
|
|
1194
|
+
expression: buildSnapshotExpression(interactiveOnly),
|
|
1195
|
+
returnByValue: true,
|
|
1196
|
+
awaitPromise: true,
|
|
1197
|
+
},
|
|
1198
|
+
});
|
|
1199
|
+
const data = result?.result?.value;
|
|
1200
|
+
if (!data || data.error) {
|
|
1201
|
+
const err = data?.error || 'snapshot failed';
|
|
1202
|
+
if (jsonOutput) emitJson(false, null, err);
|
|
1203
|
+
log.fail(err);
|
|
1204
|
+
process.exit(1);
|
|
1205
|
+
}
|
|
1206
|
+
if (jsonOutput) {
|
|
1207
|
+
emitJson(true, data);
|
|
1208
|
+
} else {
|
|
1209
|
+
console.log(formatSnapshotText(data));
|
|
1210
|
+
}
|
|
1211
|
+
} catch (e) {
|
|
1212
|
+
if (jsonOutput) emitJson(false, null, e.message);
|
|
1213
|
+
log.fail(`Snapshot failed: ${e.message}`);
|
|
1214
|
+
process.exit(1);
|
|
1215
|
+
}
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
async function cmdTargets() {
|
|
1219
|
+
const raw = await httpGet('/targets');
|
|
1220
|
+
const targets = (Array.isArray(raw) ? raw : []).map((t) => ({
|
|
1221
|
+
sessionId: t.sessionId,
|
|
1222
|
+
targetId: t.targetId,
|
|
1223
|
+
title: t.title || t.targetInfo?.title || '',
|
|
1224
|
+
url: t.url || t.targetInfo?.url || '',
|
|
1225
|
+
}));
|
|
1226
|
+
if (jsonOutput) {
|
|
1227
|
+
emitJson(true, { targets, activeSessionId });
|
|
1228
|
+
} else {
|
|
1229
|
+
if (!targets.length) {
|
|
1230
|
+
log.warn('No targets');
|
|
1231
|
+
return;
|
|
1232
|
+
}
|
|
1233
|
+
console.log(`${GREEN}${targets.length}${NC} target(s):\n`);
|
|
1234
|
+
for (const t of targets) {
|
|
1235
|
+
const mark = t.sessionId === activeSessionId ? `${GREEN}*${NC} ` : ' ';
|
|
1236
|
+
console.log(`${mark}${CYAN}${t.sessionId}${NC} ${t.title}`);
|
|
1237
|
+
console.log(` ${DIM}${t.url}${NC}`);
|
|
1238
|
+
}
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
async function cmdUseSession(arg, opts = []) {
|
|
1243
|
+
let sessionId = arg;
|
|
1244
|
+
const urlIdx = opts.indexOf('--url');
|
|
1245
|
+
if (urlIdx >= 0 && opts[urlIdx + 1]) {
|
|
1246
|
+
const needle = opts[urlIdx + 1];
|
|
1247
|
+
const raw = await httpGet('/targets');
|
|
1248
|
+
const targets = Array.isArray(raw) ? raw : [];
|
|
1249
|
+
const hit = targets.find((t) => {
|
|
1250
|
+
const u = t.url || t.targetInfo?.url || '';
|
|
1251
|
+
return u.includes(needle);
|
|
1252
|
+
});
|
|
1253
|
+
if (!hit) {
|
|
1254
|
+
const msg = `no target matching --url ${needle}`;
|
|
1255
|
+
if (jsonOutput) emitJson(false, null, msg);
|
|
1256
|
+
log.fail(msg);
|
|
1257
|
+
process.exit(1);
|
|
1258
|
+
}
|
|
1259
|
+
sessionId = hit.sessionId;
|
|
1260
|
+
}
|
|
1261
|
+
if (!sessionId) {
|
|
1262
|
+
log.fail('Usage: glider use-session <sessionId> | glider use-session --url <host-fragment>');
|
|
1263
|
+
process.exit(1);
|
|
1264
|
+
}
|
|
1265
|
+
persistSession(sessionId);
|
|
1266
|
+
if (jsonOutput) {
|
|
1267
|
+
emitJson(true, { sessionId, persisted: SESSION_STORE });
|
|
1268
|
+
} else {
|
|
1269
|
+
log.ok(`Active session: ${sessionId}`);
|
|
1270
|
+
console.log(SESSION_STORE);
|
|
1271
|
+
}
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1075
1274
|
// Fetch URL using browser session (authenticated)
|
|
1076
1275
|
async function cmdFetch(url, opts = []) {
|
|
1077
1276
|
if (!url) {
|
|
@@ -1079,7 +1278,15 @@ async function cmdFetch(url, opts = []) {
|
|
|
1079
1278
|
process.exit(1);
|
|
1080
1279
|
}
|
|
1081
1280
|
|
|
1082
|
-
|
|
1281
|
+
try {
|
|
1282
|
+
assertUrlAllowed(url, allowedDomainList, 'fetch');
|
|
1283
|
+
} catch (e) {
|
|
1284
|
+
if (jsonOutput) emitJson(false, null, e.message);
|
|
1285
|
+
log.fail(e.message);
|
|
1286
|
+
process.exit(1);
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
if (!jsonOutput) log.info(`Fetching: ${url}`);
|
|
1083
1290
|
|
|
1084
1291
|
let outputFile = null;
|
|
1085
1292
|
for (let i = 0; i < opts.length; i++) {
|
|
@@ -1107,13 +1314,16 @@ async function cmdFetch(url, opts = []) {
|
|
|
1107
1314
|
const data = result?.result?.value;
|
|
1108
1315
|
const output = typeof data === 'object' ? JSON.stringify(data, null, 2) : data;
|
|
1109
1316
|
|
|
1110
|
-
if (
|
|
1317
|
+
if (jsonOutput) {
|
|
1318
|
+
emitJson(true, { url, data });
|
|
1319
|
+
} else if (outputFile) {
|
|
1111
1320
|
fs.writeFileSync(outputFile, output);
|
|
1112
1321
|
log.ok(`Saved to ${outputFile}`);
|
|
1113
1322
|
} else {
|
|
1114
1323
|
console.log(output);
|
|
1115
1324
|
}
|
|
1116
1325
|
} catch (e) {
|
|
1326
|
+
if (jsonOutput) emitJson(false, null, e.message);
|
|
1117
1327
|
log.fail(`Fetch failed: ${e.message}`);
|
|
1118
1328
|
process.exit(1);
|
|
1119
1329
|
}
|
|
@@ -1422,7 +1632,7 @@ async function cmdRegistry(patternName, opts = []) {
|
|
|
1422
1632
|
// Explore site (clicks around, captures network)
|
|
1423
1633
|
async function cmdExplore(url, opts = []) {
|
|
1424
1634
|
if (!url) {
|
|
1425
|
-
log.fail('Usage: glider explore <url> [--depth N] [--output dir] [--har file]');
|
|
1635
|
+
log.fail('Usage: glider explore <url> [--depth N] [--output dir] [--har file] [--session-id id]');
|
|
1426
1636
|
process.exit(1);
|
|
1427
1637
|
}
|
|
1428
1638
|
|
|
@@ -1435,7 +1645,16 @@ async function cmdExplore(url, opts = []) {
|
|
|
1435
1645
|
if (opts[i] === '--depth' || opts[i] === '-d') depth = parseInt(opts[++i], 10);
|
|
1436
1646
|
else if (opts[i] === '--output' || opts[i] === '-o') outputDir = opts[++i];
|
|
1437
1647
|
else if (opts[i] === '--har') harFile = opts[++i];
|
|
1438
|
-
else if (opts[i] === '--session-id') sessionId = opts[++i];
|
|
1648
|
+
else if (opts[i] === '--session-id' || opts[i] === '--session') sessionId = opts[++i];
|
|
1649
|
+
}
|
|
1650
|
+
if (!sessionId && activeSessionId) sessionId = activeSessionId;
|
|
1651
|
+
|
|
1652
|
+
try {
|
|
1653
|
+
assertUrlAllowed(url, allowedDomainList, 'explore');
|
|
1654
|
+
} catch (e) {
|
|
1655
|
+
if (jsonOutput) emitJson(false, null, e.message);
|
|
1656
|
+
log.fail(e.message);
|
|
1657
|
+
process.exit(1);
|
|
1439
1658
|
}
|
|
1440
1659
|
|
|
1441
1660
|
log.info(`Exploring: ${url} (depth: ${depth})`);
|
|
@@ -1739,7 +1958,8 @@ function showHelp() {
|
|
|
1739
1958
|
showBanner();
|
|
1740
1959
|
console.log(`
|
|
1741
1960
|
${B5}USAGE${NC}
|
|
1742
|
-
glider <command> [args]
|
|
1961
|
+
glider [--session <id>] [--json] [--allowed-domains 'host,*'] <command> [args]
|
|
1962
|
+
${DIM}GLIDER_SESSION_ID=session-N${NC} ${DIM}pin tab for all /cdp commands${NC}
|
|
1743
1963
|
|
|
1744
1964
|
${B5}SETUP${NC}
|
|
1745
1965
|
${BW}install${NC} Install daemon ${DIM}(runs at login, auto-restarts)${NC}
|
|
@@ -1752,6 +1972,8 @@ ${B5}STATUS${NC}
|
|
|
1752
1972
|
${BW}browser${NC} Show browser config ${DIM}(name, path, processName, or use key)${NC}
|
|
1753
1973
|
${BW}use${NC} <key> Set browser by registry key ${DIM}(e.g. arc, brave, chrome)${NC}
|
|
1754
1974
|
${BW}test${NC} Run diagnostics
|
|
1975
|
+
${BW}domains${NC} List ~/.glider domain shortcuts + warch paths
|
|
1976
|
+
${BW}resolve${NC} <url> Resolve host to local warch intel ${DIM}(--json)${NC}
|
|
1755
1977
|
|
|
1756
1978
|
${B5}NAVIGATION${NC}
|
|
1757
1979
|
${BW}goto${NC} <url> Navigate to URL
|
|
@@ -1761,6 +1983,7 @@ ${B5}NAVIGATION${NC}
|
|
|
1761
1983
|
${BW}screenshot${NC} [path] Take screenshot
|
|
1762
1984
|
|
|
1763
1985
|
${B5}PAGE INFO${NC}
|
|
1986
|
+
${BW}snapshot${NC} [opts] Page index for agents ${DIM}(--json, --interactive-only)${NC}
|
|
1764
1987
|
${BW}text${NC} Get page text
|
|
1765
1988
|
${BW}html${NC} [selector] Get HTML
|
|
1766
1989
|
${BW}title${NC} Get page title
|
|
@@ -1776,6 +1999,8 @@ ${B5}MULTI-WINDOW${NC}
|
|
|
1776
1999
|
${BW}window list${NC} List all windows/tabs
|
|
1777
2000
|
|
|
1778
2001
|
${B5}MULTI-TAB${NC}
|
|
2002
|
+
${BW}targets${NC} List targets ${DIM}(sessionId, title, url)${NC}
|
|
2003
|
+
${BW}use-session${NC} <id> Pin session ${DIM}(or --url host-fragment)${NC}
|
|
1779
2004
|
${BW}fetch${NC} <url> Fetch URL with browser session ${DIM}(auth)${NC}
|
|
1780
2005
|
${BW}spawn${NC} <urls...> Open multiple tabs
|
|
1781
2006
|
${BW}extract${NC} [opts] Extract from all tabs
|
|
@@ -1923,7 +2148,8 @@ async function cmdUpdate() {
|
|
|
1923
2148
|
|
|
1924
2149
|
// Main
|
|
1925
2150
|
async function main() {
|
|
1926
|
-
const args = process.argv.slice(2);
|
|
2151
|
+
const args = parseGlobalFlags(process.argv.slice(2));
|
|
2152
|
+
loadPersistedSession();
|
|
1927
2153
|
const cmd = args[0];
|
|
1928
2154
|
|
|
1929
2155
|
if (!cmd || cmd === '--help' || cmd === '-h') {
|
|
@@ -1937,7 +2163,7 @@ async function main() {
|
|
|
1937
2163
|
}
|
|
1938
2164
|
|
|
1939
2165
|
// Ensure server is running for most commands
|
|
1940
|
-
if (!['start', 'stop', 'help', '--help', '-h', 'update', 'version', '-v', '--version'].includes(cmd)) {
|
|
2166
|
+
if (!['start', 'stop', 'help', '--help', '-h', 'update', 'version', '-v', '--version', 'domains', 'resolve'].includes(cmd)) {
|
|
1941
2167
|
if (!await checkServer()) {
|
|
1942
2168
|
log.info('Server not running, starting...');
|
|
1943
2169
|
await cmdStart();
|
|
@@ -1991,6 +2217,15 @@ async function main() {
|
|
|
1991
2217
|
case 'tabs':
|
|
1992
2218
|
await cmdTabs();
|
|
1993
2219
|
break;
|
|
2220
|
+
case 'targets':
|
|
2221
|
+
await cmdTargets();
|
|
2222
|
+
break;
|
|
2223
|
+
case 'use-session':
|
|
2224
|
+
await cmdUseSession(args[1], args.slice(2));
|
|
2225
|
+
break;
|
|
2226
|
+
case 'snapshot':
|
|
2227
|
+
await cmdSnapshot(args.slice(1));
|
|
2228
|
+
break;
|
|
1994
2229
|
case 'window':
|
|
1995
2230
|
case 'win':
|
|
1996
2231
|
await cmdWindow(args.slice(1));
|
|
@@ -1998,6 +2233,9 @@ async function main() {
|
|
|
1998
2233
|
case 'domains':
|
|
1999
2234
|
await cmdDomains();
|
|
2000
2235
|
break;
|
|
2236
|
+
case 'resolve':
|
|
2237
|
+
await cmdResolve(args[1], args.slice(2));
|
|
2238
|
+
break;
|
|
2001
2239
|
case 'goto':
|
|
2002
2240
|
case 'navigate':
|
|
2003
2241
|
await cmdGoto(args[1]);
|
|
@@ -2081,9 +2319,11 @@ async function main() {
|
|
|
2081
2319
|
// Check if it's a domain command from config
|
|
2082
2320
|
if (DOMAINS[cmd]) {
|
|
2083
2321
|
const domain = DOMAINS[cmd];
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
2322
|
+
const shortcut = domain.shortcut || {};
|
|
2323
|
+
const scriptPathRaw = shortcut.type === 'script' ? shortcut.target : domain.script;
|
|
2324
|
+
const urlRaw = shortcut.type === 'url' ? shortcut.target : domain.url;
|
|
2325
|
+
if (scriptPathRaw) {
|
|
2326
|
+
const scriptPath = scriptPathRaw.replace(/^~/, os.homedir()).replace(/\$HOME/g, os.homedir());
|
|
2087
2327
|
if (fs.existsSync(scriptPath)) {
|
|
2088
2328
|
const { execSync } = require('child_process');
|
|
2089
2329
|
try {
|
|
@@ -2095,9 +2335,8 @@ async function main() {
|
|
|
2095
2335
|
log.fail(`Domain script not found: ${scriptPath}`);
|
|
2096
2336
|
process.exit(1);
|
|
2097
2337
|
}
|
|
2098
|
-
} else if (
|
|
2099
|
-
|
|
2100
|
-
await cmdGoto(domain.url);
|
|
2338
|
+
} else if (urlRaw) {
|
|
2339
|
+
await cmdGoto(urlRaw);
|
|
2101
2340
|
}
|
|
2102
2341
|
break;
|
|
2103
2342
|
}
|
package/docs/setup.json
CHANGED
|
@@ -33,6 +33,8 @@
|
|
|
33
33
|
},
|
|
34
34
|
"config_paths": {
|
|
35
35
|
"domains": "~/.glider/config/domains.json",
|
|
36
|
+
"domains_template": "$CURREGISTRY/glider/domains.template.json",
|
|
37
|
+
"resolve": "glider resolve <url> --json",
|
|
36
38
|
"browser": "~/.glider/config/browser.json",
|
|
37
39
|
"daemon_log": "~/.glider/daemon.log"
|
|
38
40
|
},
|
package/lib/bexplore.js
CHANGED
|
@@ -10,8 +10,10 @@
|
|
|
10
10
|
const WebSocket = require('ws');
|
|
11
11
|
const fs = require('fs');
|
|
12
12
|
const path = require('path');
|
|
13
|
+
const http = require('http');
|
|
13
14
|
|
|
14
15
|
const RELAY_URL = process.env.RELAY_URL || 'ws://127.0.0.1:19988/cdp';
|
|
16
|
+
const RELAY_HTTP = process.env.RELAY_HTTP || 'http://127.0.0.1:19988';
|
|
15
17
|
|
|
16
18
|
class SiteExplorer {
|
|
17
19
|
constructor(options = {}) {
|
|
@@ -24,7 +26,7 @@ class SiteExplorer {
|
|
|
24
26
|
// Exploration state
|
|
25
27
|
this.visited = new Set();
|
|
26
28
|
this.toVisit = [];
|
|
27
|
-
this.depth = options.depth
|
|
29
|
+
this.depth = Number.isFinite(options.depth) ? options.depth : 3;
|
|
28
30
|
this.outputDir = options.outputDir || '/tmp/explore';
|
|
29
31
|
this.harFile = options.harFile;
|
|
30
32
|
this.wantedSessionId = options.sessionId || null;
|
|
@@ -168,17 +170,18 @@ class SiteExplorer {
|
|
|
168
170
|
this.eventHandlers.get(event).add(handler);
|
|
169
171
|
}
|
|
170
172
|
|
|
171
|
-
async send(method, params = {}) {
|
|
173
|
+
async send(method, params = {}, options = {}) {
|
|
172
174
|
const id = ++this.messageId;
|
|
173
175
|
const msg = { id, method, params };
|
|
174
176
|
if (this.sessionId) msg.sessionId = this.sessionId;
|
|
175
177
|
this.ws.send(JSON.stringify(msg));
|
|
176
|
-
|
|
178
|
+
const timeoutMs = options.timeoutMs ?? 30000;
|
|
179
|
+
|
|
177
180
|
return new Promise((resolve, reject) => {
|
|
178
181
|
const timer = setTimeout(() => {
|
|
179
182
|
this.pending.delete(id);
|
|
180
183
|
reject(new Error(`Timeout: ${method}`));
|
|
181
|
-
},
|
|
184
|
+
}, timeoutMs);
|
|
182
185
|
this.pending.set(id, {
|
|
183
186
|
resolve: (r) => { clearTimeout(timer); resolve(r); },
|
|
184
187
|
reject: (e) => { clearTimeout(timer); reject(e); }
|
|
@@ -186,29 +189,92 @@ class SiteExplorer {
|
|
|
186
189
|
});
|
|
187
190
|
}
|
|
188
191
|
|
|
192
|
+
async sendWithRetry(method, params = {}, options = {}) {
|
|
193
|
+
const retries = options.retries ?? 3;
|
|
194
|
+
const delayMs = options.delayMs ?? 400;
|
|
195
|
+
let lastErr;
|
|
196
|
+
for (let attempt = 1; attempt <= retries; attempt++) {
|
|
197
|
+
try {
|
|
198
|
+
return await this.send(method, params, options);
|
|
199
|
+
} catch (err) {
|
|
200
|
+
lastErr = err;
|
|
201
|
+
if (attempt < retries) {
|
|
202
|
+
console.error(`[explore] ${method} attempt ${attempt}/${retries} failed: ${err.message}`);
|
|
203
|
+
await new Promise((r) => setTimeout(r, delayMs * attempt));
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
throw lastErr;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async fetchTargets() {
|
|
211
|
+
return new Promise((resolve, reject) => {
|
|
212
|
+
const url = new URL('/targets', RELAY_HTTP);
|
|
213
|
+
http.get(url, (res) => {
|
|
214
|
+
let body = '';
|
|
215
|
+
res.on('data', (chunk) => { body += chunk; });
|
|
216
|
+
res.on('end', () => {
|
|
217
|
+
try {
|
|
218
|
+
resolve(JSON.parse(body));
|
|
219
|
+
} catch (e) {
|
|
220
|
+
reject(new Error(`Invalid /targets JSON: ${e.message}`));
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
}).on('error', reject);
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
async prefetchSessionFromTargets() {
|
|
228
|
+
if (!this.wantedSessionId) return false;
|
|
229
|
+
const targets = await this.fetchTargets();
|
|
230
|
+
if (!Array.isArray(targets)) {
|
|
231
|
+
throw new Error('/targets did not return an array');
|
|
232
|
+
}
|
|
233
|
+
const hit = targets.find((t) => t.sessionId === this.wantedSessionId);
|
|
234
|
+
if (!hit) {
|
|
235
|
+
const available = targets.map((t) => t.sessionId).filter(Boolean).join(', ') || 'none';
|
|
236
|
+
throw new Error(`Session ${this.wantedSessionId} not in /targets (available: ${available})`);
|
|
237
|
+
}
|
|
238
|
+
this.sessionId = this.wantedSessionId;
|
|
239
|
+
console.error(`[explore] Session pinned from /targets: ${this.sessionId} (${hit.url || hit.title || 'tab'})`);
|
|
240
|
+
return true;
|
|
241
|
+
}
|
|
242
|
+
|
|
189
243
|
async init() {
|
|
244
|
+
if (this.wantedSessionId) {
|
|
245
|
+
await this.prefetchSessionFromTargets();
|
|
246
|
+
}
|
|
247
|
+
|
|
190
248
|
await this.send('Target.setAutoAttach', { autoAttach: true, waitForDebuggerOnStart: false, flatten: true });
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
249
|
+
|
|
250
|
+
if (!this.sessionId) {
|
|
251
|
+
await new Promise((resolve, reject) => {
|
|
252
|
+
this.sessionResolved = resolve;
|
|
253
|
+
setTimeout(() => {
|
|
254
|
+
if (!this.sessionId) reject(new Error('Timeout waiting for tab attachment'));
|
|
255
|
+
else resolve();
|
|
256
|
+
}, 10000);
|
|
257
|
+
});
|
|
258
|
+
} else if (this.sessionResolved) {
|
|
259
|
+
this.sessionResolved();
|
|
260
|
+
}
|
|
261
|
+
|
|
198
262
|
if (!this.sessionId) throw new Error('No browser tab connected');
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
])
|
|
210
|
-
|
|
211
|
-
|
|
263
|
+
|
|
264
|
+
const domainEnables = [
|
|
265
|
+
['Runtime.enable', {}],
|
|
266
|
+
['Page.enable', {}],
|
|
267
|
+
['DOM.enable', {}],
|
|
268
|
+
['CSS.enable', {}],
|
|
269
|
+
['Network.enable', {}],
|
|
270
|
+
['Debugger.enable', {}],
|
|
271
|
+
['Log.enable', {}],
|
|
272
|
+
];
|
|
273
|
+
for (const [method, params] of domainEnables) {
|
|
274
|
+
const retries = method === 'CSS.enable' ? 4 : 2;
|
|
275
|
+
await this.sendWithRetry(method, params, { retries, delayMs: 500 });
|
|
276
|
+
}
|
|
277
|
+
|
|
212
278
|
await this.send('Network.setCacheDisabled', { cacheDisabled: false });
|
|
213
279
|
|
|
214
280
|
console.error('[explore] All CDP domains enabled');
|
|
@@ -492,7 +558,9 @@ class SiteExplorer {
|
|
|
492
558
|
}
|
|
493
559
|
|
|
494
560
|
async explore(startUrl, options = {}) {
|
|
495
|
-
|
|
561
|
+
const depth = this.depth;
|
|
562
|
+
this.clicksPerformed = 0;
|
|
563
|
+
console.error(`[explore] Starting exploration of ${startUrl} (depth=${depth})`);
|
|
496
564
|
fs.mkdirSync(this.outputDir, { recursive: true });
|
|
497
565
|
|
|
498
566
|
// If we want fresh network capture, reload the page
|
|
@@ -512,63 +580,70 @@ class SiteExplorer {
|
|
|
512
580
|
const elements = await this.discoverElements();
|
|
513
581
|
console.error(`[explore] Found: ${elements.tabs.length} tabs, ${elements.buttons.length} buttons, ${elements.links.length} links`);
|
|
514
582
|
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
583
|
+
if (depth === 0) {
|
|
584
|
+
console.error('[explore] depth=0 passive — no clicks, scroll, or toggles');
|
|
585
|
+
} else {
|
|
586
|
+
// Click through tabs
|
|
587
|
+
for (const tab of elements.tabs.slice(0, depth)) {
|
|
588
|
+
if (tab.text) {
|
|
589
|
+
console.error(`[explore] Clicking tab: ${tab.text}`);
|
|
590
|
+
try {
|
|
591
|
+
await this.clickByText(tab.text);
|
|
592
|
+
this.clicksPerformed++;
|
|
593
|
+
await new Promise(r => setTimeout(r, 1000));
|
|
594
|
+
await this.screenshot(`tab-${tab.text.replace(/[^a-z0-9]/gi, '-').slice(0, 20)}`);
|
|
595
|
+
await this.discoverElements(); // Re-discover after tab change
|
|
596
|
+
} catch (e) {
|
|
597
|
+
console.error(`[explore] Tab click failed: ${e.message}`);
|
|
598
|
+
}
|
|
526
599
|
}
|
|
527
600
|
}
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
601
|
+
|
|
602
|
+
// Click through buttons (non-destructive ones)
|
|
603
|
+
const safeButtons = elements.buttons.filter(b => {
|
|
604
|
+
const text = (b.text || '').toLowerCase();
|
|
605
|
+
return !text.includes('delete') && !text.includes('remove') && !text.includes('submit') && !text.includes('save') && !text.includes('menu');
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
for (const btn of safeButtons.slice(0, depth)) {
|
|
609
|
+
if (btn.text) {
|
|
610
|
+
console.error(`[explore] Clicking button: ${btn.text}`);
|
|
611
|
+
try {
|
|
612
|
+
await this.clickByText(btn.text);
|
|
613
|
+
this.clicksPerformed++;
|
|
614
|
+
await new Promise(r => setTimeout(r, 500));
|
|
615
|
+
} catch (e) {
|
|
616
|
+
console.error(`[explore] Button click failed: ${e.message}`);
|
|
617
|
+
}
|
|
544
618
|
}
|
|
545
619
|
}
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
await this.evaluate(`
|
|
620
|
+
|
|
621
|
+
// Expand dropdowns
|
|
622
|
+
for (const dropdown of elements.dropdowns.slice(0, Math.min(depth, 5))) {
|
|
623
|
+
console.error(`[explore] Opening dropdown`);
|
|
624
|
+
// Click to open, then click away
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
// Scroll to trigger lazy loading
|
|
628
|
+
console.error('[explore] Scrolling to trigger lazy loading...');
|
|
629
|
+
await this.scrollToBottom();
|
|
630
|
+
await this.screenshot('scrolled');
|
|
631
|
+
|
|
632
|
+
// Check all checkboxes (to see what filters do)
|
|
633
|
+
for (const cb of elements.checkboxes.slice(0, Math.min(depth, 5))) {
|
|
634
|
+
if (cb.label) {
|
|
635
|
+
console.error(`[explore] Toggling checkbox: ${cb.label}`);
|
|
636
|
+
try {
|
|
637
|
+
await this.evaluate(`
|
|
565
638
|
const cb = Array.from(document.querySelectorAll('input[type="checkbox"]'))
|
|
566
639
|
.find(e => e.labels?.[0]?.textContent?.includes(${JSON.stringify(cb.label)}));
|
|
567
640
|
if (cb) cb.click();
|
|
568
641
|
`);
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
642
|
+
this.clicksPerformed++;
|
|
643
|
+
await new Promise(r => setTimeout(r, 500));
|
|
644
|
+
} catch (e) {
|
|
645
|
+
console.error(`[explore] Checkbox toggle failed: ${e.message}`);
|
|
646
|
+
}
|
|
572
647
|
}
|
|
573
648
|
}
|
|
574
649
|
}
|
|
@@ -584,6 +659,10 @@ class SiteExplorer {
|
|
|
584
659
|
url: this.siteMap.url,
|
|
585
660
|
title: this.siteMap.title,
|
|
586
661
|
timestamp: new Date().toISOString(),
|
|
662
|
+
depth: this.depth,
|
|
663
|
+
clicksPerformed: this.clicksPerformed ?? 0,
|
|
664
|
+
passive: this.depth === 0,
|
|
665
|
+
sessionId: this.sessionId,
|
|
587
666
|
|
|
588
667
|
// Site structure
|
|
589
668
|
structure: {
|
|
@@ -673,14 +752,16 @@ class SiteExplorer {
|
|
|
673
752
|
steps.push({ wait: 2 });
|
|
674
753
|
|
|
675
754
|
// Add clicks for key buttons (with selectors)
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
755
|
+
if (this.depth > 0) {
|
|
756
|
+
const actionButtons = (report.raw.buttons || [])
|
|
757
|
+
.filter(b => b.selector && b.qa && !b.qa.includes('history') && !b.qa.includes('search'))
|
|
758
|
+
.slice(0, this.depth);
|
|
759
|
+
|
|
760
|
+
for (const btn of actionButtons) {
|
|
761
|
+
steps.push({ log: `Click: ${btn.text || btn.label}` });
|
|
762
|
+
steps.push({ click: btn.selector });
|
|
763
|
+
steps.push({ wait: 0.5 });
|
|
764
|
+
}
|
|
684
765
|
}
|
|
685
766
|
|
|
686
767
|
// Add screenshot at end
|
|
@@ -746,22 +827,27 @@ Usage:
|
|
|
746
827
|
node bexplore.js [options]
|
|
747
828
|
|
|
748
829
|
Options:
|
|
749
|
-
--depth N Exploration depth (default: 3)
|
|
830
|
+
--depth N Exploration depth (default: 3; 0 = passive, no clicks)
|
|
750
831
|
--output DIR Output directory (default: /tmp/explore)
|
|
751
832
|
--har FILE Save HAR file
|
|
833
|
+
--session-id ID CDP session (e.g. session-6 from GET /targets)
|
|
752
834
|
--help Show this help
|
|
753
835
|
|
|
836
|
+
Depth 0 (passive): reload, discover DOM, screenshots, HAR — zero clicks.
|
|
837
|
+
Depth N>0: up to N tab/button/checkbox interactions per category.
|
|
838
|
+
|
|
754
839
|
The tool will:
|
|
755
840
|
1. Discover all tabs, buttons, links, forms, tables
|
|
756
|
-
2. Click through tabs to reveal content
|
|
757
|
-
3. Click safe buttons to trigger XHR
|
|
758
|
-
4. Scroll to trigger lazy loading
|
|
759
|
-
5. Toggle checkboxes/filters
|
|
841
|
+
2. Click through tabs to reveal content (depth > 0 only)
|
|
842
|
+
3. Click safe buttons to trigger XHR (depth > 0 only)
|
|
843
|
+
4. Scroll to trigger lazy loading (depth > 0 only)
|
|
844
|
+
5. Toggle checkboxes/filters (depth > 0 only)
|
|
760
845
|
6. Capture all network requests
|
|
761
846
|
7. Save screenshots at each step
|
|
762
847
|
8. Generate a comprehensive report
|
|
763
848
|
|
|
764
849
|
Examples:
|
|
850
|
+
node bexplore.js --depth 0 --output /tmp/passive --har /tmp/passive.har --session-id session-3
|
|
765
851
|
node bexplore.js --output /tmp/sage-explore --har /tmp/sage.har
|
|
766
852
|
node bexplore.js --depth 5 --output ~/explore-results
|
|
767
853
|
`);
|
|
@@ -774,7 +860,7 @@ Examples:
|
|
|
774
860
|
let sessionId = null;
|
|
775
861
|
|
|
776
862
|
for (let i = 0; i < args.length; i++) {
|
|
777
|
-
if (args[i] === '--depth') depth = parseInt(args[++i]);
|
|
863
|
+
if (args[i] === '--depth') depth = parseInt(args[++i], 10);
|
|
778
864
|
else if (args[i] === '--output') outputDir = args[++i];
|
|
779
865
|
else if (args[i] === '--har') harFile = args[++i];
|
|
780
866
|
else if (args[i] === '--session-id') sessionId = args[++i];
|
package/lib/bsnapshot.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
function buildSnapshotExpression(interactiveOnly) {
|
|
4
|
+
return `(() => {
|
|
5
|
+
const interactiveOnly = ${interactiveOnly ? 'true' : 'false'};
|
|
6
|
+
const vis = (el) => {
|
|
7
|
+
if (!el) return false;
|
|
8
|
+
const s = getComputedStyle(el);
|
|
9
|
+
if (s.visibility === 'hidden' || s.display === 'none') return false;
|
|
10
|
+
const r = el.getBoundingClientRect();
|
|
11
|
+
return r.width > 0 && r.height > 0;
|
|
12
|
+
};
|
|
13
|
+
const cssPath = (el) => {
|
|
14
|
+
if (el.id) return '#' + CSS.escape(el.id);
|
|
15
|
+
const tid = el.getAttribute('data-testid');
|
|
16
|
+
if (tid) return '[data-testid="' + tid.replace(/"/g, '\\\\"') + '"]';
|
|
17
|
+
const parts = [];
|
|
18
|
+
let cur = el;
|
|
19
|
+
while (cur && cur.nodeType === 1 && cur !== document.body) {
|
|
20
|
+
let part = cur.tagName.toLowerCase();
|
|
21
|
+
const parent = cur.parentElement;
|
|
22
|
+
if (parent) {
|
|
23
|
+
const sibs = Array.from(parent.children).filter((c) => c.tagName === cur.tagName);
|
|
24
|
+
if (sibs.length > 1) part += ':nth-of-type(' + (sibs.indexOf(cur) + 1) + ')';
|
|
25
|
+
}
|
|
26
|
+
parts.unshift(part);
|
|
27
|
+
cur = parent;
|
|
28
|
+
if (parts.length >= 4) break;
|
|
29
|
+
}
|
|
30
|
+
return parts.join(' > ');
|
|
31
|
+
};
|
|
32
|
+
const roleOf = (el) => el.getAttribute('role') || ({ A: 'link', BUTTON: 'button', INPUT: 'textbox', SELECT: 'combobox', TEXTAREA: 'textbox' }[el.tagName] || el.tagName.toLowerCase());
|
|
33
|
+
const textOf = (el) => (el.getAttribute('aria-label') || el.textContent || el.value || el.getAttribute('placeholder') || '').trim().replace(/\\s+/g, ' ').slice(0, 120);
|
|
34
|
+
const sel = interactiveOnly
|
|
35
|
+
? 'a[href],button,input,select,textarea,[role="button"],[role="link"],[role="tab"],[role="menuitem"],[contenteditable="true"]'
|
|
36
|
+
: 'a[href],button,input,select,textarea,[role],[onclick],h1,h2,h3,label,summary';
|
|
37
|
+
const seen = new Set();
|
|
38
|
+
const elements = [];
|
|
39
|
+
let n = 0;
|
|
40
|
+
for (const el of document.querySelectorAll(sel)) {
|
|
41
|
+
if (!vis(el)) continue;
|
|
42
|
+
const selector = cssPath(el);
|
|
43
|
+
if (seen.has(selector)) continue;
|
|
44
|
+
seen.add(selector);
|
|
45
|
+
n += 1;
|
|
46
|
+
elements.push({
|
|
47
|
+
id: n,
|
|
48
|
+
role: roleOf(el),
|
|
49
|
+
text: textOf(el),
|
|
50
|
+
selector,
|
|
51
|
+
aria: el.getAttribute('aria-label') || null,
|
|
52
|
+
tag: el.tagName.toLowerCase(),
|
|
53
|
+
href: el.href || null,
|
|
54
|
+
});
|
|
55
|
+
if (n >= 200) break;
|
|
56
|
+
}
|
|
57
|
+
return { url: location.href, title: document.title, elements };
|
|
58
|
+
})()`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function formatSnapshotText(data) {
|
|
62
|
+
const lines = [`URL: ${data.url}`, `Title: ${data.title}`, '', 'Elements:'];
|
|
63
|
+
for (const el of data.elements || []) {
|
|
64
|
+
lines.push(`[${el.id}]<${el.role}> ${el.text || '(empty)'} ${el.selector}`);
|
|
65
|
+
}
|
|
66
|
+
return lines.join('\n');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
module.exports = { buildSnapshotExpression, formatSnapshotText };
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const os = require('os');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const { URL } = require('url');
|
|
7
|
+
|
|
8
|
+
const DOMAIN_CONFIG_PATHS = [
|
|
9
|
+
path.join(os.homedir(), '.glider', 'config', 'domains.json'),
|
|
10
|
+
path.join(os.homedir(), '.glider', 'domains.json'),
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
function warchRoot() {
|
|
14
|
+
const base = process.env.CURREGISTRY || path.join(os.homedir(), '.cursor', 'registry');
|
|
15
|
+
return path.join(base, 'warch');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function loadDomainsIndex() {
|
|
19
|
+
for (const cfgPath of DOMAIN_CONFIG_PATHS) {
|
|
20
|
+
if (!fs.existsSync(cfgPath)) continue;
|
|
21
|
+
try {
|
|
22
|
+
return JSON.parse(fs.readFileSync(cfgPath, 'utf8'));
|
|
23
|
+
} catch (_) { /* ignore */ }
|
|
24
|
+
}
|
|
25
|
+
return {};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function hostFromUrl(input) {
|
|
29
|
+
if (!input) return null;
|
|
30
|
+
try {
|
|
31
|
+
const u = input.includes('://') ? input : `https://${input}`;
|
|
32
|
+
return new URL(u).hostname || null;
|
|
33
|
+
} catch (_) {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function resolveDomain(input) {
|
|
39
|
+
const host = hostFromUrl(input);
|
|
40
|
+
const root = warchRoot();
|
|
41
|
+
const warchPath = host ? path.join(root, host) : null;
|
|
42
|
+
const domains = loadDomainsIndex();
|
|
43
|
+
let indexHit = null;
|
|
44
|
+
for (const [key, val] of Object.entries(domains)) {
|
|
45
|
+
if (key === 'meta' || !val || typeof val !== 'object') continue;
|
|
46
|
+
if (val.host === host || key === host) {
|
|
47
|
+
indexHit = key;
|
|
48
|
+
break;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
const gliderJsonPath = warchPath && path.join(warchPath, 'glider.json');
|
|
52
|
+
const gotchasPath = warchPath && path.join(warchPath, 'gotchas.json');
|
|
53
|
+
const gliderJsonExists = !!(gliderJsonPath && fs.existsSync(gliderJsonPath));
|
|
54
|
+
const gotchasExists = !!(gotchasPath && fs.existsSync(gotchasPath));
|
|
55
|
+
let gliderJson = null;
|
|
56
|
+
if (gliderJsonExists) {
|
|
57
|
+
try {
|
|
58
|
+
gliderJson = JSON.parse(fs.readFileSync(gliderJsonPath, 'utf8'));
|
|
59
|
+
} catch (_) { /* ignore */ }
|
|
60
|
+
}
|
|
61
|
+
return {
|
|
62
|
+
host,
|
|
63
|
+
warch_path: warchPath,
|
|
64
|
+
glider_json_exists: gliderJsonExists,
|
|
65
|
+
gotchas_exists: gotchasExists,
|
|
66
|
+
domains_index_hit: indexHit,
|
|
67
|
+
capture_mode: gliderJson?.capture_mode ?? null,
|
|
68
|
+
wait_ms: gliderJson?.wait_ms ?? null,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
module.exports = { resolveDomain, hostFromUrl, warchRoot, loadDomainsIndex };
|
package/lib/glider-daemon.sh
CHANGED
|
@@ -1,45 +1,91 @@
|
|
|
1
1
|
#!/bin/bash
|
|
2
|
+
|
|
2
3
|
# Glider daemon - respawns relay forever with log rotation
|
|
3
4
|
|
|
5
|
+
|
|
6
|
+
|
|
4
7
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
8
|
+
|
|
5
9
|
BSERVE="$SCRIPT_DIR/bserve.js"
|
|
10
|
+
|
|
6
11
|
LOG_DIR="$HOME/.glider"
|
|
12
|
+
|
|
7
13
|
LOG_FILE="$LOG_DIR/daemon.log"
|
|
14
|
+
|
|
8
15
|
PID_FILE="$LOG_DIR/daemon.pid"
|
|
16
|
+
|
|
9
17
|
MAX_LOG_SIZE=10485760 # 10MB
|
|
10
18
|
|
|
19
|
+
|
|
20
|
+
|
|
11
21
|
mkdir -p "$LOG_DIR"
|
|
12
22
|
|
|
23
|
+
|
|
24
|
+
|
|
13
25
|
# Rotate log if > 10MB
|
|
26
|
+
|
|
14
27
|
rotate_log() {
|
|
28
|
+
|
|
15
29
|
if [ -f "$LOG_FILE" ] && [ $(stat -f%z "$LOG_FILE" 2>/dev/null || echo 0) -gt $MAX_LOG_SIZE ]; then
|
|
16
|
-
|
|
30
|
+
|
|
31
|
+
/bin/unlink "$LOG_FILE.3"
|
|
32
|
+
|
|
17
33
|
[ -f "$LOG_FILE.2" ] && mv "$LOG_FILE.2" "$LOG_FILE.3"
|
|
34
|
+
|
|
18
35
|
[ -f "$LOG_FILE.1" ] && mv "$LOG_FILE.1" "$LOG_FILE.2"
|
|
36
|
+
|
|
19
37
|
mv "$LOG_FILE" "$LOG_FILE.1"
|
|
38
|
+
|
|
20
39
|
echo "[$(date)] Log rotated" > "$LOG_FILE"
|
|
40
|
+
|
|
21
41
|
fi
|
|
42
|
+
|
|
22
43
|
}
|
|
23
44
|
|
|
45
|
+
|
|
46
|
+
|
|
24
47
|
# Kill any existing
|
|
48
|
+
|
|
25
49
|
if [ -f "$PID_FILE" ]; then
|
|
50
|
+
|
|
26
51
|
kill $(cat "$PID_FILE") 2>/dev/null
|
|
52
|
+
|
|
27
53
|
rm "$PID_FILE"
|
|
54
|
+
|
|
28
55
|
fi
|
|
29
56
|
|
|
57
|
+
|
|
58
|
+
|
|
30
59
|
echo $$ > "$PID_FILE"
|
|
31
60
|
|
|
61
|
+
|
|
62
|
+
|
|
32
63
|
cleanup() {
|
|
33
|
-
|
|
64
|
+
|
|
65
|
+
/bin/unlink "$PID_FILE"
|
|
66
|
+
|
|
34
67
|
exit 0
|
|
68
|
+
|
|
35
69
|
}
|
|
70
|
+
|
|
36
71
|
trap cleanup SIGTERM SIGINT
|
|
37
72
|
|
|
73
|
+
|
|
74
|
+
|
|
38
75
|
while true; do
|
|
76
|
+
|
|
39
77
|
rotate_log
|
|
78
|
+
|
|
40
79
|
echo "[$(date)] Starting relay..." >> "$LOG_FILE"
|
|
80
|
+
|
|
41
81
|
node "$BSERVE" >> "$LOG_FILE" 2>&1
|
|
82
|
+
|
|
42
83
|
EXIT_CODE=$?
|
|
84
|
+
|
|
43
85
|
echo "[$(date)] Relay exited with code $EXIT_CODE, restarting in 2s..." >> "$LOG_FILE"
|
|
86
|
+
|
|
44
87
|
sleep 2
|
|
88
|
+
|
|
45
89
|
done
|
|
90
|
+
|
|
91
|
+
|
package/lib/guard.js
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const os = require('os');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
|
|
7
|
+
const DOMAIN_CONFIG = path.join(os.homedir(), '.glider', 'config', 'allowed-domains.json');
|
|
8
|
+
|
|
9
|
+
function parseDomainList(raw) {
|
|
10
|
+
if (!raw) return [];
|
|
11
|
+
return String(raw)
|
|
12
|
+
.split(',')
|
|
13
|
+
.map((s) => s.trim())
|
|
14
|
+
.filter(Boolean);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function loadConfigDomains() {
|
|
18
|
+
if (!fs.existsSync(DOMAIN_CONFIG)) return [];
|
|
19
|
+
try {
|
|
20
|
+
const data = JSON.parse(fs.readFileSync(DOMAIN_CONFIG, 'utf8'));
|
|
21
|
+
return Array.isArray(data) ? data : data.allowed || data.domains || [];
|
|
22
|
+
} catch {
|
|
23
|
+
return [];
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function resolveAllowedDomains(cliList) {
|
|
28
|
+
const fromEnv = parseDomainList(process.env.GLIDER_ALLOWED_DOMAINS);
|
|
29
|
+
const fromCli = cliList && cliList.length ? cliList : [];
|
|
30
|
+
const fromFile = loadConfigDomains();
|
|
31
|
+
if (fromCli.length) return fromCli;
|
|
32
|
+
if (fromEnv.length) return fromEnv;
|
|
33
|
+
if (fromFile.length) return fromFile;
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function hostMatchesPattern(host, pattern) {
|
|
38
|
+
const h = (host || '').toLowerCase();
|
|
39
|
+
const p = (pattern || '').toLowerCase();
|
|
40
|
+
if (!h || !p) return false;
|
|
41
|
+
if (p.includes('*')) {
|
|
42
|
+
const re = new RegExp(`^${p.replace(/\./g, '\\.').replace(/\*/g, '.*')}$`);
|
|
43
|
+
return re.test(h);
|
|
44
|
+
}
|
|
45
|
+
return h === p || h.endsWith(`.${p}`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function urlAllowed(url, allowed) {
|
|
49
|
+
if (!allowed || !allowed.length) return true;
|
|
50
|
+
let host;
|
|
51
|
+
try {
|
|
52
|
+
host = new URL(url).hostname;
|
|
53
|
+
} catch {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
return allowed.some((p) => hostMatchesPattern(host, p));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function assertUrlAllowed(url, allowed, action) {
|
|
60
|
+
if (urlAllowed(url, allowed)) return;
|
|
61
|
+
const host = (() => {
|
|
62
|
+
try { return new URL(url).hostname; } catch { return url; }
|
|
63
|
+
})();
|
|
64
|
+
throw new Error(`blocked by allowed_domains: ${action} on ${host}`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
module.exports = {
|
|
68
|
+
parseDomainList,
|
|
69
|
+
resolveAllowedDomains,
|
|
70
|
+
hostMatchesPattern,
|
|
71
|
+
urlAllowed,
|
|
72
|
+
assertUrlAllowed,
|
|
73
|
+
};
|
package/package.json
CHANGED