browse 0.8.5 → 0.9.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/dist/commands/screenshot.js +82 -11
- package/dist/lib/skills/install.js +59 -8
- package/oclif.manifest.json +508 -497
- package/package.json +2 -2
- package/skills/browse/SKILL.md +3 -2
|
@@ -1,13 +1,17 @@
|
|
|
1
|
+
import { closeSync, openSync, statSync, unlinkSync } from "node:fs";
|
|
2
|
+
import { resolve } from "node:path";
|
|
1
3
|
import { Flags } from "@oclif/core";
|
|
2
4
|
import { BrowseCommand } from "../base.js";
|
|
3
5
|
import { driverCommandFlags, parseClip, runDriverCommandFromFlags, } from "../lib/driver/command-cli.js";
|
|
4
6
|
export default class Screenshot extends BrowseCommand {
|
|
5
7
|
static description = "Capture a screenshot of the active browser page.";
|
|
6
8
|
static examples = [
|
|
9
|
+
"browse screenshot",
|
|
7
10
|
"browse screenshot --path page.png",
|
|
8
|
-
"browse screenshot --full-page
|
|
11
|
+
"browse screenshot --full-page",
|
|
9
12
|
"browse screenshot --type jpeg --quality 80",
|
|
10
13
|
"browse screenshot --clip 0,0,800,600 --path clipped.png",
|
|
14
|
+
"browse screenshot --base64",
|
|
11
15
|
];
|
|
12
16
|
static flags = {
|
|
13
17
|
...driverCommandFlags,
|
|
@@ -15,6 +19,10 @@ export default class Screenshot extends BrowseCommand {
|
|
|
15
19
|
description: "Whether CSS animations run during capture.",
|
|
16
20
|
options: ["allow", "disabled"],
|
|
17
21
|
}),
|
|
22
|
+
base64: Flags.boolean({
|
|
23
|
+
description: "Print base64 to stdout instead of writing a file (legacy default).",
|
|
24
|
+
exclusive: ["path"],
|
|
25
|
+
}),
|
|
18
26
|
caret: Flags.string({
|
|
19
27
|
description: "Whether text caret is hidden during capture.",
|
|
20
28
|
options: ["hide", "initial"],
|
|
@@ -28,7 +36,7 @@ export default class Screenshot extends BrowseCommand {
|
|
|
28
36
|
}),
|
|
29
37
|
path: Flags.string({
|
|
30
38
|
char: "p",
|
|
31
|
-
description: "Write the screenshot to
|
|
39
|
+
description: "Write the screenshot to this file. Defaults to screenshot-<timestamp>.png (or .jpeg with --type jpeg) in the current directory.",
|
|
32
40
|
helpValue: "<path>",
|
|
33
41
|
}),
|
|
34
42
|
quality: Flags.integer({
|
|
@@ -42,14 +50,77 @@ export default class Screenshot extends BrowseCommand {
|
|
|
42
50
|
};
|
|
43
51
|
async run() {
|
|
44
52
|
const { flags } = await this.parse(Screenshot);
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
53
|
+
const defaultPath = getDefaultPathFromFlags(flags);
|
|
54
|
+
try {
|
|
55
|
+
await runDriverCommandFromFlags("screenshot", {
|
|
56
|
+
animations: flags.animations,
|
|
57
|
+
caret: flags.caret,
|
|
58
|
+
clip: parseClip(flags.clip),
|
|
59
|
+
fullPage: flags["full-page"],
|
|
60
|
+
path: flags.path ?? defaultPath,
|
|
61
|
+
quality: flags.quality,
|
|
62
|
+
type: flags.type,
|
|
63
|
+
}, flags);
|
|
64
|
+
}
|
|
65
|
+
catch (error) {
|
|
66
|
+
if (defaultPath)
|
|
67
|
+
removeIfEmpty(defaultPath);
|
|
68
|
+
throw error;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
// Generous upper bound on filename-collision retries; far beyond any real
|
|
73
|
+
// same-second burst, it just guarantees the loop below always terminates.
|
|
74
|
+
const MAX_RESERVE_ATTEMPTS = 1000;
|
|
75
|
+
/**
|
|
76
|
+
* Resolves the file the screenshot should be written to, or undefined when the
|
|
77
|
+
* driver should return base64 (explicit --path is handled separately; --base64
|
|
78
|
+
* opts out of a file entirely).
|
|
79
|
+
*/
|
|
80
|
+
function getDefaultPathFromFlags(flags) {
|
|
81
|
+
if (flags.path || flags.base64)
|
|
82
|
+
return undefined;
|
|
83
|
+
return reserveDefaultScreenshotPath(flags.type);
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Picks the next free screenshot-<timestamp>[-<counter>].<type> name in the
|
|
87
|
+
* current directory and reserves it. "Reserve" = create the file with an
|
|
88
|
+
* exclusive open (`wx` → O_CREAT|O_EXCL), which atomically fails with EEXIST if
|
|
89
|
+
* the name already exists, so two concurrent runs can never claim the same
|
|
90
|
+
* file. On EEXIST we advance the counter and try the next name.
|
|
91
|
+
*/
|
|
92
|
+
function reserveDefaultScreenshotPath(type) {
|
|
93
|
+
const now = new Date();
|
|
94
|
+
const pad = (value) => String(value).padStart(2, "0");
|
|
95
|
+
const stamp = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}` +
|
|
96
|
+
`-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
|
|
97
|
+
const extension = type === "jpeg" ? "jpeg" : "png";
|
|
98
|
+
for (let counter = 1; counter <= MAX_RESERVE_ATTEMPTS; counter += 1) {
|
|
99
|
+
const suffix = counter === 1 ? "" : `-${counter}`;
|
|
100
|
+
const candidate = resolve(`screenshot-${stamp}${suffix}.${extension}`);
|
|
101
|
+
try {
|
|
102
|
+
// Exclusive create reserves the name; close immediately since the driver
|
|
103
|
+
// (re)writes the file. The empty placeholder is cleaned up on failure.
|
|
104
|
+
closeSync(openSync(candidate, "wx"));
|
|
105
|
+
return candidate;
|
|
106
|
+
}
|
|
107
|
+
catch (error) {
|
|
108
|
+
if (error.code !== "EEXIST")
|
|
109
|
+
throw error;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
throw new Error(`Could not reserve a screenshot filename after ${MAX_RESERVE_ATTEMPTS} attempts; pass --path to choose one.`);
|
|
113
|
+
}
|
|
114
|
+
// Removes the reserved placeholder when the screenshot failed before the driver
|
|
115
|
+
// wrote to it. `path` is always a file we created via openSync above, so the
|
|
116
|
+
// isFile guard is just defensive against an unexpected directory/symlink.
|
|
117
|
+
function removeIfEmpty(path) {
|
|
118
|
+
try {
|
|
119
|
+
const stats = statSync(path);
|
|
120
|
+
if (stats.isFile() && stats.size === 0)
|
|
121
|
+
unlinkSync(path);
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
// Best effort: leave the placeholder behind rather than mask the error.
|
|
54
125
|
}
|
|
55
126
|
}
|
|
@@ -13,6 +13,18 @@ const domainPattern = /^[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?$/;
|
|
|
13
13
|
const taskPattern = /^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$/;
|
|
14
14
|
const maxSkillFiles = 100;
|
|
15
15
|
const maxCapturedOutputBytes = 2048;
|
|
16
|
+
const defaultInstallTimeoutMs = 180_000;
|
|
17
|
+
const defaultFetchTimeoutMs = 10_000;
|
|
18
|
+
function envTimeoutMs(name, fallback) {
|
|
19
|
+
const value = Number(process.env[name]);
|
|
20
|
+
return Number.isFinite(value) && value > 0 ? value : fallback;
|
|
21
|
+
}
|
|
22
|
+
function installTimeoutMs() {
|
|
23
|
+
return envTimeoutMs("BROWSE_SKILLS_INSTALL_TIMEOUT_MS", defaultInstallTimeoutMs);
|
|
24
|
+
}
|
|
25
|
+
function fetchTimeoutSignal() {
|
|
26
|
+
return AbortSignal.timeout(envTimeoutMs("BROWSE_SKILLS_FETCH_TIMEOUT_MS", defaultFetchTimeoutMs));
|
|
27
|
+
}
|
|
16
28
|
export function parseSkillId(rawSkillId) {
|
|
17
29
|
const parts = rawSkillId.split("/");
|
|
18
30
|
if (parts.length !== 2) {
|
|
@@ -103,10 +115,14 @@ export async function installBundledCliSkill() {
|
|
|
103
115
|
// message (including a tail of the child's output) when the child exits nonzero
|
|
104
116
|
// so the failure is recorded in telemetry instead of an opaque exit code.
|
|
105
117
|
async function runSkillsInstall(npxPath, args) {
|
|
106
|
-
const
|
|
107
|
-
|
|
118
|
+
const timeoutMs = installTimeoutMs();
|
|
119
|
+
const result = await spawnPassthrough(npxPath, args, timeoutMs);
|
|
120
|
+
if (result.exitCode === 0 && !result.timedOut) {
|
|
108
121
|
return;
|
|
109
122
|
}
|
|
123
|
+
if (result.timedOut) {
|
|
124
|
+
fail(`Skill install timed out after ${Math.round(timeoutMs / 1000)}s waiting for \`npx skills add\`. Check your network connection and rerun \`browse skills add\`.`, 1, { resultCode: "skill_install_timeout" });
|
|
125
|
+
}
|
|
110
126
|
const detail = result.output.trim();
|
|
111
127
|
const reason = detail
|
|
112
128
|
? `: ${detail}`
|
|
@@ -188,9 +204,11 @@ async function fetchSkillFilesFromApi(skillId) {
|
|
|
188
204
|
const url = skillFilesApiUrl(skillId);
|
|
189
205
|
let response;
|
|
190
206
|
try {
|
|
191
|
-
response = await fetch(url);
|
|
207
|
+
response = await fetch(url, { signal: fetchTimeoutSignal() });
|
|
192
208
|
}
|
|
193
209
|
catch {
|
|
210
|
+
// Network failures and timeouts both mean the catalog is unavailable;
|
|
211
|
+
// callers fall back (or fail cleanly) exactly as before.
|
|
194
212
|
return { status: "unavailable" };
|
|
195
213
|
}
|
|
196
214
|
if (response.status === 404) {
|
|
@@ -219,6 +237,7 @@ async function directBlobSkillExists(skillId) {
|
|
|
219
237
|
try {
|
|
220
238
|
response = await fetch(skillBlobUrl(skillId, "SKILL.md"), {
|
|
221
239
|
method: "HEAD",
|
|
240
|
+
signal: fetchTimeoutSignal(),
|
|
222
241
|
});
|
|
223
242
|
}
|
|
224
243
|
catch {
|
|
@@ -294,7 +313,7 @@ async function fetchSkillFile(url, label) {
|
|
|
294
313
|
async function fetchFromUrl(url, label) {
|
|
295
314
|
let response;
|
|
296
315
|
try {
|
|
297
|
-
response = await fetch(url);
|
|
316
|
+
response = await fetch(url, { signal: fetchTimeoutSignal() });
|
|
298
317
|
}
|
|
299
318
|
catch (error) {
|
|
300
319
|
fail(`Could not download ${label}: ${error.message}`);
|
|
@@ -347,15 +366,43 @@ async function findExecutable(command) {
|
|
|
347
366
|
}
|
|
348
367
|
return null;
|
|
349
368
|
}
|
|
369
|
+
// Quotes a single token for the command line Node hands to cmd.exe when
|
|
370
|
+
// `shell: true`. Node joins command+args UNQUOTED into `cmd.exe /d /s /c
|
|
371
|
+
// "..."`, so an unquoted `C:\Program Files\nodejs\npx.cmd` splits at the
|
|
372
|
+
// space and cmd runs `C:\Program`. Wrapping tokens that contain whitespace,
|
|
373
|
+
// quotes, or cmd metacharacters in double quotes (with embedded quotes
|
|
374
|
+
// doubled) keeps them intact; `/s` makes cmd strip only the outer quotes.
|
|
375
|
+
export function quoteForCmdShell(token) {
|
|
376
|
+
if (token === "") {
|
|
377
|
+
return '""';
|
|
378
|
+
}
|
|
379
|
+
return /[\s"^&|<>]/.test(token) ? `"${token.replaceAll('"', '""')}"` : token;
|
|
380
|
+
}
|
|
350
381
|
// Like `stdio: "inherit"` for the human watching, but the child's stdout/stderr
|
|
351
382
|
// are also buffered (tail only) so a nonzero exit can surface a real reason to
|
|
352
|
-
// telemetry instead of a bare exit code.
|
|
353
|
-
|
|
383
|
+
// telemetry instead of a bare exit code. The child is killed after `timeoutMs`
|
|
384
|
+
// (SIGTERM, then SIGKILL) so a hung `npx` cannot stall the install forever.
|
|
385
|
+
// Exported for tests.
|
|
386
|
+
export async function spawnPassthrough(command, args, timeoutMs = installTimeoutMs()) {
|
|
387
|
+
const useWindowsShell = shouldUseWindowsShell(command);
|
|
388
|
+
const spawnCommand = useWindowsShell ? quoteForCmdShell(command) : command;
|
|
389
|
+
const spawnArgs = useWindowsShell ? args.map(quoteForCmdShell) : args;
|
|
354
390
|
return await new Promise((resolvePromise) => {
|
|
355
|
-
const child = spawn(
|
|
391
|
+
const child = spawn(spawnCommand, spawnArgs, {
|
|
356
392
|
stdio: ["inherit", "pipe", "pipe"],
|
|
357
|
-
shell:
|
|
393
|
+
shell: useWindowsShell,
|
|
358
394
|
});
|
|
395
|
+
let timedOut = false;
|
|
396
|
+
let killTimer;
|
|
397
|
+
const deadline = setTimeout(() => {
|
|
398
|
+
timedOut = true;
|
|
399
|
+
child.kill("SIGTERM");
|
|
400
|
+
killTimer = setTimeout(() => child.kill("SIGKILL"), 5_000);
|
|
401
|
+
}, timeoutMs);
|
|
402
|
+
const clearTimers = () => {
|
|
403
|
+
clearTimeout(deadline);
|
|
404
|
+
clearTimeout(killTimer);
|
|
405
|
+
};
|
|
359
406
|
let captured = "";
|
|
360
407
|
const capture = (chunk) => {
|
|
361
408
|
captured += chunk.toString();
|
|
@@ -375,16 +422,20 @@ async function spawnPassthrough(command, args) {
|
|
|
375
422
|
// `skill_install_failed` by runSkillsInstall instead of escaping as an
|
|
376
423
|
// unclassified runtime error.
|
|
377
424
|
child.on("error", (error) => {
|
|
425
|
+
clearTimers();
|
|
378
426
|
const message = error instanceof Error ? error.message : String(error);
|
|
379
427
|
resolvePromise({
|
|
380
428
|
exitCode: 1,
|
|
381
429
|
output: captured ? `${captured}\n${message}` : message,
|
|
430
|
+
timedOut,
|
|
382
431
|
});
|
|
383
432
|
});
|
|
384
433
|
child.on("close", (exitCode, signal) => {
|
|
434
|
+
clearTimers();
|
|
385
435
|
resolvePromise({
|
|
386
436
|
exitCode: signal ? 1 : (exitCode ?? 0),
|
|
387
437
|
output: captured,
|
|
438
|
+
timedOut,
|
|
388
439
|
});
|
|
389
440
|
});
|
|
390
441
|
});
|