botprint 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/PRIVACY.md +94 -0
- package/README.md +221 -0
- package/docs/agent-usage.md +109 -0
- package/package.json +56 -0
- package/src/cdp.ts +267 -0
- package/src/cli.ts +758 -0
- package/src/coerce.ts +19 -0
- package/src/compare.ts +363 -0
- package/src/errors.ts +15 -0
- package/src/exit-codes.ts +10 -0
- package/src/findings.ts +342 -0
- package/src/guide.ts +152 -0
- package/src/probe.ts +700 -0
- package/src/profile.ts +188 -0
- package/src/render.ts +268 -0
- package/src/run.ts +432 -0
- package/src/user-agent.ts +50 -0
- package/src/version.ts +1 -0
package/src/findings.ts
ADDED
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
import type { BotprintProfile } from "./profile";
|
|
2
|
+
import { parseUserAgent } from "./user-agent";
|
|
3
|
+
|
|
4
|
+
export type FindingSeverity =
|
|
5
|
+
| "normal"
|
|
6
|
+
| "notable"
|
|
7
|
+
| "warning"
|
|
8
|
+
| "compatibility_risk"
|
|
9
|
+
| "high_confidence_automation_signal";
|
|
10
|
+
|
|
11
|
+
export type FindingCategory = "automation" | "identity" | "environment" | "rendering" | "storage" | "permissions" | "webrtc" | "http";
|
|
12
|
+
|
|
13
|
+
export type Finding = {
|
|
14
|
+
id: string;
|
|
15
|
+
severity: FindingSeverity;
|
|
16
|
+
category: FindingCategory;
|
|
17
|
+
title: string;
|
|
18
|
+
evidence: string[];
|
|
19
|
+
explanation: string;
|
|
20
|
+
likely_implications: string;
|
|
21
|
+
suggested_investigation: string;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
type FindingRule = {
|
|
25
|
+
id: string;
|
|
26
|
+
evaluate(profile: BotprintProfile): Omit<Finding, "id"> | undefined;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const RULES: FindingRule[] = [
|
|
30
|
+
{
|
|
31
|
+
id: "automation.webdriver_exposed",
|
|
32
|
+
evaluate(profile) {
|
|
33
|
+
if (profile.signals.automation.webdriver !== true) return undefined;
|
|
34
|
+
return {
|
|
35
|
+
severity: "high_confidence_automation_signal",
|
|
36
|
+
category: "automation",
|
|
37
|
+
title: "navigator.webdriver is exposed",
|
|
38
|
+
evidence: ["navigator.webdriver is true."],
|
|
39
|
+
explanation: "The browser is explicitly reporting WebDriver automation through a standard browser API.",
|
|
40
|
+
likely_implications: "Sites that treat WebDriver exposure as automation evidence may route this session differently from a normal browser.",
|
|
41
|
+
suggested_investigation:
|
|
42
|
+
"Chromium sets navigator.webdriver when launched with --enable-automation; Puppeteer and Playwright pass it by default, and Selenium always does. Confirm which launcher produced this browser and whether the baseline was launched the same way.",
|
|
43
|
+
};
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
id: "automation.common_globals_present",
|
|
48
|
+
evaluate(profile) {
|
|
49
|
+
const present = Object.entries(profile.signals.automation.globals)
|
|
50
|
+
.filter(([, value]) => value)
|
|
51
|
+
.map(([name]) => name)
|
|
52
|
+
.sort();
|
|
53
|
+
if (present.length === 0) return undefined;
|
|
54
|
+
return {
|
|
55
|
+
severity: "high_confidence_automation_signal",
|
|
56
|
+
category: "automation",
|
|
57
|
+
title: "Known automation globals are present",
|
|
58
|
+
evidence: [`Present globals: ${present.join(", ")}.`],
|
|
59
|
+
explanation: "One or more fixed automation marker globals were visible on the page window.",
|
|
60
|
+
likely_implications: "This is strong evidence that the browser context was created or modified by an automation stack.",
|
|
61
|
+
suggested_investigation: "cdc_* globals come from ChromeDriver; _phantom and __nightmare from legacy headless stacks. Check the automation framework, browser extensions, and launch wrapper used for this run.",
|
|
62
|
+
};
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
id: "identity.headless_user_agent",
|
|
67
|
+
evaluate(profile) {
|
|
68
|
+
const ua = parseUserAgent(profile.signals.identity.user_agent);
|
|
69
|
+
const brands = profile.signals.identity.user_agent_data?.brands.map((entry) => entry.brand) ?? [];
|
|
70
|
+
const headlessBrand = brands.find((brand) => /headless/i.test(brand));
|
|
71
|
+
if (!ua.headless && headlessBrand === undefined) return undefined;
|
|
72
|
+
const evidence: string[] = [];
|
|
73
|
+
if (ua.headless) evidence.push(`User agent reports ${ua.family}/${ua.major ?? "?"}.`);
|
|
74
|
+
if (headlessBrand !== undefined) evidence.push(`Client hint brands include ${headlessBrand}.`);
|
|
75
|
+
return {
|
|
76
|
+
severity: "warning",
|
|
77
|
+
category: "identity",
|
|
78
|
+
title: "Browser identifies itself as headless",
|
|
79
|
+
evidence,
|
|
80
|
+
explanation: "The user agent string and/or the Sec-CH-UA client hint brands advertise a headless browser build.",
|
|
81
|
+
likely_implications: "Sites can distinguish this browser from a normal desktop browser from the first request, before any script runs.",
|
|
82
|
+
suggested_investigation:
|
|
83
|
+
"Both Chromium headless modes report HeadlessChrome in the user agent: the headless shell (Playwright's default chromium_headless_shell) and new headless mode (`--headless=new`, Playwright's `channel: 'chromium'`). Only a headed browser reports Chrome. Client hint brands name HeadlessChrome only for the headless shell. Confirm whether this environment is meant to run headless and whether the baseline does.",
|
|
84
|
+
};
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
id: "automation.chrome_object_missing",
|
|
89
|
+
evaluate(profile) {
|
|
90
|
+
if (profile.signals.automation.chrome_object) return undefined;
|
|
91
|
+
if (!/chrom/i.test(profile.signals.cdp.product)) return undefined;
|
|
92
|
+
return {
|
|
93
|
+
severity: "notable",
|
|
94
|
+
category: "automation",
|
|
95
|
+
title: "window.chrome is missing in a Chromium browser",
|
|
96
|
+
evidence: ["window.chrome is undefined while the CDP product reports a Chromium browser."],
|
|
97
|
+
explanation: "Headed Chrome and new headless mode define window.chrome; the headless shell binary does not.",
|
|
98
|
+
likely_implications: "Scripts that feature-detect Chrome through window.chrome will treat this browser as something else.",
|
|
99
|
+
suggested_investigation: "Check whether the environment runs the headless shell binary rather than the full Chrome/Chromium binary in new headless mode.",
|
|
100
|
+
};
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
id: "identity.empty_plugins",
|
|
105
|
+
evaluate(profile) {
|
|
106
|
+
if (profile.signals.identity.plugins_count !== 0) return undefined;
|
|
107
|
+
return {
|
|
108
|
+
severity: "notable",
|
|
109
|
+
category: "identity",
|
|
110
|
+
title: "Plugin list is empty",
|
|
111
|
+
evidence: ["navigator.plugins.length is 0."],
|
|
112
|
+
explanation: "Headed Chrome and new headless mode expose a fixed set of PDF viewer plugins; the headless shell exposes none.",
|
|
113
|
+
likely_implications: "This may help explain differences between local, Docker, CI, and remote-browser profiles.",
|
|
114
|
+
suggested_investigation: "Compare the same signal across the baseline browser and the candidate browser before treating it as material.",
|
|
115
|
+
};
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
id: "environment.default_viewport",
|
|
120
|
+
evaluate(profile) {
|
|
121
|
+
const viewport = profile.signals.environment.viewport;
|
|
122
|
+
if (viewport.width !== 800 || viewport.height !== 600 || viewport.device_scale_factor !== 1) return undefined;
|
|
123
|
+
return {
|
|
124
|
+
severity: "notable",
|
|
125
|
+
category: "environment",
|
|
126
|
+
title: "Viewport matches a common automation default",
|
|
127
|
+
evidence: ["Viewport is 800x600 with device scale factor 1."],
|
|
128
|
+
explanation: "800x600 is Chromium's default window size for a context with no viewport configured.",
|
|
129
|
+
likely_implications: "Layout, responsive behavior, and environment comparisons may differ from the user's normal browser.",
|
|
130
|
+
suggested_investigation: "Check whether the run configures a viewport (Playwright `viewport`, Puppeteer `defaultViewport`, or `--window-size`) and whether the baseline uses the same value.",
|
|
131
|
+
};
|
|
132
|
+
},
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
id: "environment.zero_outer_window",
|
|
136
|
+
evaluate(profile) {
|
|
137
|
+
const outer = profile.signals.environment.outer_window;
|
|
138
|
+
if (outer.width !== 0 && outer.height !== 0) return undefined;
|
|
139
|
+
return {
|
|
140
|
+
severity: "notable",
|
|
141
|
+
category: "environment",
|
|
142
|
+
title: "Outer window size is zero",
|
|
143
|
+
evidence: [`window.outerWidth x outerHeight is ${outer.width}x${outer.height}.`],
|
|
144
|
+
explanation: "A headed browser window always has a non-zero outer size. Zero outer dimensions indicate a headless or windowless context.",
|
|
145
|
+
likely_implications: "Scripts that compare inner and outer window sizes will classify this browser as headless.",
|
|
146
|
+
suggested_investigation: "Confirm the headless mode in use. New headless mode reports a window size; the headless shell does not.",
|
|
147
|
+
};
|
|
148
|
+
},
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
id: "rendering.swiftshader_webgl",
|
|
152
|
+
evaluate(profile) {
|
|
153
|
+
const webgl = profile.signals.rendering.webgl;
|
|
154
|
+
const renderer = [webgl.renderer, webgl.unmasked_renderer].filter(Boolean).join(" ");
|
|
155
|
+
if (!/swiftshader|llvmpipe/i.test(renderer)) return undefined;
|
|
156
|
+
return {
|
|
157
|
+
severity: "warning",
|
|
158
|
+
category: "rendering",
|
|
159
|
+
title: "Software WebGL renderer detected",
|
|
160
|
+
evidence: [`Renderer evidence: ${renderer}.`],
|
|
161
|
+
explanation: "The WebGL renderer appears to be software-backed rather than normal device GPU rendering.",
|
|
162
|
+
likely_implications: "Rendering hashes and graphics capability may differ materially from a desktop browser baseline.",
|
|
163
|
+
suggested_investigation: "Software rendering is normal in headless, Docker, and CI without a GPU, and is forced by --disable-gpu. Check the launch flags and whether a GPU is available in the candidate environment.",
|
|
164
|
+
};
|
|
165
|
+
},
|
|
166
|
+
},
|
|
167
|
+
{
|
|
168
|
+
id: "rendering.webgl_unavailable",
|
|
169
|
+
evaluate(profile) {
|
|
170
|
+
const status = profile.signals.rendering.webgl.status;
|
|
171
|
+
if (status === "available") return undefined;
|
|
172
|
+
return {
|
|
173
|
+
severity: "compatibility_risk",
|
|
174
|
+
category: "rendering",
|
|
175
|
+
title: "WebGL is not available",
|
|
176
|
+
evidence: [`WebGL status is ${status}.`],
|
|
177
|
+
explanation: "The browser could not provide a usable WebGL context during the probe.",
|
|
178
|
+
likely_implications: "Pages that depend on graphics APIs may behave differently, and rendering comparison signals will be limited.",
|
|
179
|
+
suggested_investigation: "Check browser launch flags, headless mode, GPU availability, and the runtime environment.",
|
|
180
|
+
};
|
|
181
|
+
},
|
|
182
|
+
},
|
|
183
|
+
{
|
|
184
|
+
id: "rendering.unmasked_webgl_unavailable",
|
|
185
|
+
evaluate(profile) {
|
|
186
|
+
const webgl = profile.signals.rendering.webgl;
|
|
187
|
+
if (webgl.status !== "available" || webgl.unmasked_renderer !== undefined || webgl.unmasked_vendor !== undefined) return undefined;
|
|
188
|
+
return {
|
|
189
|
+
severity: "notable",
|
|
190
|
+
category: "rendering",
|
|
191
|
+
title: "Unmasked WebGL details are unavailable",
|
|
192
|
+
evidence: ["WEBGL_debug_renderer_info did not expose unmasked vendor or renderer details."],
|
|
193
|
+
explanation: "Some browser configurations withhold unmasked WebGL details. This is not proof of automation by itself.",
|
|
194
|
+
likely_implications: "Renderer comparisons may have less detail than expected for this profile.",
|
|
195
|
+
suggested_investigation: "Compare masked and unmasked WebGL availability across the baseline and candidate profiles.",
|
|
196
|
+
};
|
|
197
|
+
},
|
|
198
|
+
},
|
|
199
|
+
{
|
|
200
|
+
id: "rendering.canvas_unstable",
|
|
201
|
+
evaluate(profile) {
|
|
202
|
+
const status = profile.signals.rendering.canvas.status;
|
|
203
|
+
if (status === "stable") return undefined;
|
|
204
|
+
return {
|
|
205
|
+
severity: status === "unstable" ? "notable" : "compatibility_risk",
|
|
206
|
+
category: "rendering",
|
|
207
|
+
title: "Canvas sample was not stable",
|
|
208
|
+
evidence: [`Canvas status is ${status}.`],
|
|
209
|
+
explanation: "Botprint only emits a canvas hash when the same sample is stable twice in one probe run.",
|
|
210
|
+
likely_implications: "Canvas cannot be used as a reliable comparison signal for this profile.",
|
|
211
|
+
suggested_investigation: "Re-run the profile and compare browser version, GPU, and runtime environment if instability persists.",
|
|
212
|
+
};
|
|
213
|
+
},
|
|
214
|
+
},
|
|
215
|
+
{
|
|
216
|
+
id: "rendering.no_common_fonts",
|
|
217
|
+
evaluate(profile) {
|
|
218
|
+
const fonts = profile.signals.rendering.fonts;
|
|
219
|
+
if (fonts.status !== "available" || fonts.checked === 0 || fonts.detected.length > 0) return undefined;
|
|
220
|
+
return {
|
|
221
|
+
severity: "warning",
|
|
222
|
+
category: "rendering",
|
|
223
|
+
title: "No common fonts detected",
|
|
224
|
+
evidence: [`None of ${fonts.checked} common fonts rendered differently from the generic fallback families.`],
|
|
225
|
+
explanation: "The environment appears to have no commonly installed fonts. Text falls back to whatever the system provides, which is often a single generic face or missing glyphs.",
|
|
226
|
+
likely_implications: "Screenshots, layout metrics, and text-dependent rendering will differ from a desktop baseline, and some sites treat an empty font set as a non-standard environment.",
|
|
227
|
+
suggested_investigation: "Check the container or CI image for installed fonts (for example `fc-list`). Minimal Docker images frequently ship none.",
|
|
228
|
+
};
|
|
229
|
+
},
|
|
230
|
+
},
|
|
231
|
+
{
|
|
232
|
+
id: "storage.cookies_disabled",
|
|
233
|
+
evaluate(profile) {
|
|
234
|
+
if (profile.signals.storage.cookies_enabled) return undefined;
|
|
235
|
+
return {
|
|
236
|
+
severity: "compatibility_risk",
|
|
237
|
+
category: "storage",
|
|
238
|
+
title: "Cookies are disabled",
|
|
239
|
+
evidence: ["navigator.cookieEnabled is false."],
|
|
240
|
+
explanation: "The profile reports that browser cookies are disabled.",
|
|
241
|
+
likely_implications: "Authentication, personalization, and many application flows may diverge from the baseline.",
|
|
242
|
+
suggested_investigation: "Confirm whether cookies were intentionally disabled for this browser context.",
|
|
243
|
+
};
|
|
244
|
+
},
|
|
245
|
+
},
|
|
246
|
+
{
|
|
247
|
+
id: "storage.indexeddb_unavailable",
|
|
248
|
+
evaluate(profile) {
|
|
249
|
+
const status = profile.signals.storage.indexed_db;
|
|
250
|
+
if (status === "available") return undefined;
|
|
251
|
+
return {
|
|
252
|
+
severity: status === "timeout" ? "notable" : "compatibility_risk",
|
|
253
|
+
category: "storage",
|
|
254
|
+
title: status === "timeout" ? "IndexedDB probe timed out" : "IndexedDB is unavailable",
|
|
255
|
+
evidence: [`IndexedDB status is ${status}.`],
|
|
256
|
+
explanation:
|
|
257
|
+
status === "timeout"
|
|
258
|
+
? "The IndexedDB open request did not settle within the probe timeout. This is a measurement limit, not proof that IndexedDB is unavailable."
|
|
259
|
+
: "The profile could not open a basic IndexedDB database on the probed page.",
|
|
260
|
+
likely_implications: "Applications that rely on browser storage may fail or behave differently in this environment.",
|
|
261
|
+
suggested_investigation:
|
|
262
|
+
status === "timeout"
|
|
263
|
+
? "Re-run with a larger --probe-timeout. If it persists, check for a slow or blocked storage backend in the candidate environment."
|
|
264
|
+
: "Check browser policy, incognito-like modes, and origin/storage restrictions in the candidate environment.",
|
|
265
|
+
};
|
|
266
|
+
},
|
|
267
|
+
},
|
|
268
|
+
{
|
|
269
|
+
id: "permissions.unexpected_permissions_behavior",
|
|
270
|
+
evaluate(profile) {
|
|
271
|
+
const unusual = Object.entries(profile.signals.permissions)
|
|
272
|
+
.filter(([, value]) => value === "error" || value === "unknown")
|
|
273
|
+
.map(([name, value]) => `${name}=${value}`)
|
|
274
|
+
.sort();
|
|
275
|
+
if (unusual.length === 0) return undefined;
|
|
276
|
+
return {
|
|
277
|
+
severity: "notable",
|
|
278
|
+
category: "permissions",
|
|
279
|
+
title: "Permission queries returned unusual states",
|
|
280
|
+
evidence: [`Permission states: ${unusual.join(", ")}.`],
|
|
281
|
+
explanation: "At least one Permissions API query returned an error or an unrecognized state.",
|
|
282
|
+
likely_implications: "Permission-dependent application flows may not match the baseline browser.",
|
|
283
|
+
suggested_investigation: "Compare the same permission queries across Chrome versions and browser launch modes.",
|
|
284
|
+
};
|
|
285
|
+
},
|
|
286
|
+
},
|
|
287
|
+
{
|
|
288
|
+
id: "permissions.notifications_denied",
|
|
289
|
+
evaluate(profile) {
|
|
290
|
+
if (profile.signals.permissions.notification_api !== "denied") return undefined;
|
|
291
|
+
return {
|
|
292
|
+
severity: "notable",
|
|
293
|
+
category: "permissions",
|
|
294
|
+
title: "Notification permission is denied by default",
|
|
295
|
+
evidence: ["Notification.permission is denied on a fresh page."],
|
|
296
|
+
explanation: "Headed Chrome and new headless mode report Notification.permission as default (prompt). The headless shell reports denied without any user interaction.",
|
|
297
|
+
likely_implications: "This distinguishes the headless shell from a headed browser, and notification-dependent features will not prompt.",
|
|
298
|
+
suggested_investigation: "Confirm the headless mode in use, or whether a policy or context option denies notifications in the candidate environment.",
|
|
299
|
+
};
|
|
300
|
+
},
|
|
301
|
+
},
|
|
302
|
+
{
|
|
303
|
+
id: "webrtc.host_candidate_error",
|
|
304
|
+
evaluate(profile) {
|
|
305
|
+
const webrtc = profile.signals.webrtc;
|
|
306
|
+
if (webrtc.host_candidate_gathering !== "error" && webrtc.host_candidate_gathering !== "timeout") return undefined;
|
|
307
|
+
return {
|
|
308
|
+
severity: "notable",
|
|
309
|
+
category: "webrtc",
|
|
310
|
+
title: `WebRTC host-candidate check ${webrtc.host_candidate_gathering === "timeout" ? "timed out" : "errored"}`,
|
|
311
|
+
evidence: [`WebRTC host-candidate gathering status is ${webrtc.host_candidate_gathering}.`],
|
|
312
|
+
explanation: "The browser exposed WebRTC APIs, but the local no-STUN candidate probe did not complete.",
|
|
313
|
+
likely_implications: "WebRTC-dependent behavior may differ from the baseline.",
|
|
314
|
+
suggested_investigation: "Check browser policy, network sandboxing, and runtime permissions for the candidate environment. For a timeout, re-run with a larger --probe-timeout.",
|
|
315
|
+
};
|
|
316
|
+
},
|
|
317
|
+
},
|
|
318
|
+
{
|
|
319
|
+
id: "http.accept_language_missing",
|
|
320
|
+
evaluate(profile) {
|
|
321
|
+
const http = profile.signals.http;
|
|
322
|
+
if (http === undefined || http.status !== "captured") return undefined;
|
|
323
|
+
if (http.request_headers["accept-language"] !== undefined) return undefined;
|
|
324
|
+
return {
|
|
325
|
+
severity: "warning",
|
|
326
|
+
category: "http",
|
|
327
|
+
title: "No Accept-Language header is sent",
|
|
328
|
+
evidence: ["The browser's request for the diagnostic page carried no Accept-Language header."],
|
|
329
|
+
explanation: "Every headed browser sends Accept-Language. A missing header usually means the browser was launched with no locale configured, which happens with the headless shell and some minimal containers.",
|
|
330
|
+
likely_implications: "Sites that negotiate language, currency, or region from Accept-Language will serve different content than the baseline, and some treat the missing header as a non-browser client.",
|
|
331
|
+
suggested_investigation: "Set a locale on the context (Playwright `locale`, Puppeteer `--lang`/`--accept-lang`) or in the container environment, matching the baseline.",
|
|
332
|
+
};
|
|
333
|
+
},
|
|
334
|
+
},
|
|
335
|
+
];
|
|
336
|
+
|
|
337
|
+
export function analyzeProfile(profile: BotprintProfile): Finding[] {
|
|
338
|
+
return RULES.flatMap((rule) => {
|
|
339
|
+
const result = rule.evaluate(profile);
|
|
340
|
+
return result === undefined ? [] : [{ id: rule.id, ...result }];
|
|
341
|
+
});
|
|
342
|
+
}
|
package/src/guide.ts
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { createInterface, type Interface } from "node:readline/promises";
|
|
2
|
+
import { stdin as input, stdout as output } from "node:process";
|
|
3
|
+
import { EXIT_CODES, type ExitCode } from "./exit-codes";
|
|
4
|
+
import { runCli } from "./cli";
|
|
5
|
+
|
|
6
|
+
type Choice = {
|
|
7
|
+
key: string;
|
|
8
|
+
label: string;
|
|
9
|
+
run: (rl: Interface) => Promise<ExitCode>;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const CHOICES: Choice[] = [
|
|
13
|
+
{
|
|
14
|
+
key: "1",
|
|
15
|
+
label: "Collect a fresh diagnostic-page profile",
|
|
16
|
+
run: collectFreshProfile,
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
key: "2",
|
|
20
|
+
label: "List existing page targets",
|
|
21
|
+
run: listExistingTargets,
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
key: "3",
|
|
25
|
+
label: "Collect an existing-target profile",
|
|
26
|
+
run: collectExistingTargetProfile,
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
key: "4",
|
|
30
|
+
label: "Compare two profiles",
|
|
31
|
+
run: compareProfilesFlow,
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
key: "5",
|
|
35
|
+
label: "Generate a Markdown report",
|
|
36
|
+
run: generateReport,
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
key: "6",
|
|
40
|
+
label: "Generate an agent explanation",
|
|
41
|
+
run: explainForAgent,
|
|
42
|
+
},
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
export async function runGuide(): Promise<ExitCode> {
|
|
46
|
+
const rl = createInterface({ input, output });
|
|
47
|
+
try {
|
|
48
|
+
output.write("Botprint guide\n\n");
|
|
49
|
+
for (const choice of CHOICES) {
|
|
50
|
+
output.write(`${choice.key}. ${choice.label}\n`);
|
|
51
|
+
}
|
|
52
|
+
output.write("\n");
|
|
53
|
+
|
|
54
|
+
const selected = await askRequired(rl, "Choose an action");
|
|
55
|
+
const choice = CHOICES.find((candidate) => candidate.key === selected);
|
|
56
|
+
if (choice === undefined) {
|
|
57
|
+
output.write(`Unknown action: ${selected}\n`);
|
|
58
|
+
return EXIT_CODES.usage;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
output.write(`\n${choice.label}\n`);
|
|
62
|
+
return await choice.run(rl);
|
|
63
|
+
} finally {
|
|
64
|
+
rl.close();
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function collectFreshProfile(rl: Interface): Promise<ExitCode> {
|
|
69
|
+
const cdp = await askDefault(rl, "CDP endpoint", "http://127.0.0.1:9222");
|
|
70
|
+
const out = await askDefault(rl, "Output profile path", "profiles/local.profile.json");
|
|
71
|
+
const label = await askOptional(rl, "Label");
|
|
72
|
+
const args = ["run", "--cdp", cdp, "--out", out];
|
|
73
|
+
appendOptionalFlag(args, "--label", label);
|
|
74
|
+
return runGuideCommand(args);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function listExistingTargets(rl: Interface): Promise<ExitCode> {
|
|
78
|
+
const cdp = await askDefault(rl, "CDP endpoint", "http://127.0.0.1:9222");
|
|
79
|
+
return runGuideCommand(["targets", "--cdp", cdp]);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function collectExistingTargetProfile(rl: Interface): Promise<ExitCode> {
|
|
83
|
+
const cdp = await askDefault(rl, "CDP endpoint", "http://127.0.0.1:9222");
|
|
84
|
+
const selectorKind = await askDefault(rl, "Select by target id or url prefix", "id");
|
|
85
|
+
const out = await askDefault(rl, "Output profile path", "profiles/site.profile.json");
|
|
86
|
+
const label = await askOptional(rl, "Label");
|
|
87
|
+
const args = ["run", "--cdp", cdp, "--out", out];
|
|
88
|
+
|
|
89
|
+
if (selectorKind.toLowerCase().startsWith("url")) {
|
|
90
|
+
args.push("--target-url", await askRequired(rl, "Target URL prefix"));
|
|
91
|
+
} else {
|
|
92
|
+
args.push("--target", await askRequired(rl, "Target ID"));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
appendOptionalFlag(args, "--label", label);
|
|
96
|
+
return runGuideCommand(args);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function compareProfilesFlow(rl: Interface): Promise<ExitCode> {
|
|
100
|
+
const baseline = await askRequired(rl, "Baseline profile path");
|
|
101
|
+
const candidate = await askRequired(rl, "Candidate profile path");
|
|
102
|
+
const markdown = await askOptional(rl, "Markdown output path");
|
|
103
|
+
const args = ["compare", baseline, candidate];
|
|
104
|
+
appendOptionalFlag(args, "--markdown", markdown);
|
|
105
|
+
return runGuideCommand(args);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function generateReport(rl: Interface): Promise<ExitCode> {
|
|
109
|
+
const profile = await askRequired(rl, "Profile path");
|
|
110
|
+
const markdown = await askDefault(rl, "Markdown output path", profile.replace(/\.json$/u, ".report.md"));
|
|
111
|
+
return runGuideCommand(["report", profile, "--markdown", markdown, "--quiet"]);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function explainForAgent(rl: Interface): Promise<ExitCode> {
|
|
115
|
+
const inputPath = await askRequired(rl, "Profile or compare JSON path");
|
|
116
|
+
return runGuideCommand(["explain", inputPath, "--for-agent"]);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function askRequired(rl: Interface, label: string): Promise<string> {
|
|
120
|
+
while (true) {
|
|
121
|
+
const value = (await rl.question(`${label}: `)).trim();
|
|
122
|
+
if (value.length > 0) return value;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function askOptional(rl: Interface, label: string): Promise<string | undefined> {
|
|
127
|
+
const value = (await rl.question(`${label} (optional): `)).trim();
|
|
128
|
+
return value.length > 0 ? value : undefined;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function askDefault(rl: Interface, label: string, defaultValue: string): Promise<string> {
|
|
132
|
+
const value = (await rl.question(`${label} [${defaultValue}]: `)).trim();
|
|
133
|
+
return value.length > 0 ? value : defaultValue;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function appendOptionalFlag(args: string[], flag: string, value: string | undefined): void {
|
|
137
|
+
if (value !== undefined) {
|
|
138
|
+
args.push(flag, value);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function runGuideCommand(args: string[]): Promise<ExitCode> {
|
|
143
|
+
output.write(`\nRunning: botprint ${args.map(shellQuote).join(" ")}\n\n`);
|
|
144
|
+
return runCli(args);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function shellQuote(value: string): string {
|
|
148
|
+
if (/^[A-Za-z0-9_./:=@+-]+$/u.test(value)) {
|
|
149
|
+
return value;
|
|
150
|
+
}
|
|
151
|
+
return `'${value.replaceAll("'", "'\\''")}'`;
|
|
152
|
+
}
|