abelworkflow 0.6.0 → 0.6.2
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/lib/cli.mjs +9 -1
- package/package.json +2 -2
- package/skills/dev-browser/SKILL.md +61 -39
- package/skills/dev-browser/bun.lock +17 -0
- package/skills/dev-browser/package.json +2 -2
- package/skills/dev-browser/scripts/start.ts +279 -0
- package/skills/dev-browser/src/entrypoint.ts +157 -0
- package/skills/dev-browser/src/index.ts +4 -2
- package/skills/dev-browser/src/runtime.ts +147 -0
- package/skills/dev-browser/src/snapshot/browser-script.ts +2 -1
- package/skills/dev-browser/src/startup.test.ts +95 -0
- package/skills/dev-browser/src/startup.ts +153 -0
- package/skills/dev-browser/src/types.ts +1 -0
- package/skills/context7-auto-research/.env +0 -4
- package/skills/dev-browser/scripts/start-relay.ts +0 -32
- package/skills/dev-browser/scripts/start-server.ts +0 -117
- package/skills/dev-browser/server.sh +0 -24
- package/skills/prompt-enhancer/.env +0 -2
- package/skills/prompt-enhancer/scripts/__pycache__/_dotenv.cpython-314.pyc +0 -0
- package/skills/prompt-enhancer/scripts/__pycache__/enhance.cpython-314.pyc +0 -0
- package/skills/prompt-enhancer/scripts/__pycache__/prompt_enhancer_entry.cpython-314.pyc +0 -0
package/lib/cli.mjs
CHANGED
|
@@ -1086,9 +1086,16 @@ function commandExists(command) {
|
|
|
1086
1086
|
return result.status === 0;
|
|
1087
1087
|
}
|
|
1088
1088
|
|
|
1089
|
+
function getRunCommandSpawnOptions(platform = getPlatform()) {
|
|
1090
|
+
return {
|
|
1091
|
+
stdio: "inherit",
|
|
1092
|
+
shell: platform === "win32"
|
|
1093
|
+
};
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1089
1096
|
async function runCommand(command, args) {
|
|
1090
1097
|
await new Promise((resolvePromise, rejectPromise) => {
|
|
1091
|
-
const child = spawn(command, args,
|
|
1098
|
+
const child = spawn(command, args, getRunCommandSpawnOptions());
|
|
1092
1099
|
child.on("error", rejectPromise);
|
|
1093
1100
|
child.on("close", (code) => {
|
|
1094
1101
|
if (code === 0) {
|
|
@@ -2029,6 +2036,7 @@ async function main() {
|
|
|
2029
2036
|
|
|
2030
2037
|
export {
|
|
2031
2038
|
buildCodexConfigContent,
|
|
2039
|
+
getRunCommandSpawnOptions,
|
|
2032
2040
|
main,
|
|
2033
2041
|
mergeCodexAuthData,
|
|
2034
2042
|
mergeClaudeSettingsWithDefaults,
|
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "abelworkflow",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.2",
|
|
4
4
|
"description": "Install AbelWorkflow into ~/.agents and create Claude/Codex symlinks.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
|
-
"abelworkflow": "
|
|
7
|
+
"abelworkflow": "bin/abelworkflow.mjs"
|
|
8
8
|
},
|
|
9
9
|
"files": [
|
|
10
10
|
"bin",
|
|
@@ -15,64 +15,80 @@ Browser automation that maintains page state across script executions. Write sma
|
|
|
15
15
|
|
|
16
16
|
## Setup
|
|
17
17
|
|
|
18
|
-
Two modes available
|
|
18
|
+
Two supported startup modes are available on **Linux** and **native Windows**. Run the following commands from the installed `dev-browser` skill directory. Bash, Git Bash, and WSL are not required.
|
|
19
19
|
|
|
20
|
-
###
|
|
20
|
+
### Standalone Mode (Default)
|
|
21
21
|
|
|
22
|
-
|
|
22
|
+
Launches a new Chromium browser for fresh automation sessions.
|
|
23
23
|
|
|
24
|
-
```
|
|
25
|
-
|
|
24
|
+
```text
|
|
25
|
+
npx tsx scripts/start.ts standalone
|
|
26
26
|
```
|
|
27
27
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
Launches a new Chromium browser for fresh automation sessions.
|
|
28
|
+
Add `--headless` if needed:
|
|
31
29
|
|
|
32
|
-
```
|
|
33
|
-
|
|
34
|
-
bash "$SKILL_DIR/server.sh" &
|
|
30
|
+
```text
|
|
31
|
+
npx tsx scripts/start.ts standalone --headless
|
|
35
32
|
```
|
|
36
33
|
|
|
37
|
-
|
|
34
|
+
Wait for the stable readiness line `Ready` before running scripts.
|
|
38
35
|
|
|
39
36
|
### Extension Mode
|
|
40
37
|
|
|
41
|
-
Connects to user's existing Chrome browser. Use this when:
|
|
38
|
+
Connects to the user's existing Chrome browser. Use this when:
|
|
42
39
|
|
|
43
40
|
- The user is already logged into sites and wants you to do things behind an authed experience that isn't local dev.
|
|
44
|
-
- The user asks you to use the extension
|
|
45
|
-
|
|
46
|
-
**Important**: The core flow is still the same. You create named pages inside of their browser.
|
|
41
|
+
- The user asks you to use the extension.
|
|
47
42
|
|
|
48
|
-
|
|
43
|
+
Start the relay server with:
|
|
49
44
|
|
|
50
|
-
```
|
|
51
|
-
|
|
52
|
-
(cd "$SKILL_DIR" && npm i && npm run start-extension) &
|
|
45
|
+
```text
|
|
46
|
+
npx tsx scripts/start.ts extension
|
|
53
47
|
```
|
|
54
48
|
|
|
55
|
-
Wait for `Waiting for extension to connect
|
|
56
|
-
**Workflow:**
|
|
57
|
-
|
|
58
|
-
1. Scripts call `client.page("name")` just like the normal mode to create new pages / connect to existing ones.
|
|
59
|
-
2. Automation runs on the user's actual browser session
|
|
49
|
+
Wait for `Waiting for extension to connect...`. Once the browser extension attaches, the relay logs `Extension connected`.
|
|
60
50
|
|
|
61
51
|
If the extension hasn't connected yet, tell the user to launch and activate it. Download link: https://github.com/SawyerHood/dev-browser/releases
|
|
62
52
|
|
|
53
|
+
## Support Matrix
|
|
54
|
+
|
|
55
|
+
| Mode | Linux | Native Windows | Readiness signal | Notes |
|
|
56
|
+
|------|-------|----------------|------------------|-------|
|
|
57
|
+
| standalone mode | Supported | Supported | `Ready` | Launches a managed Chromium profile under `profiles/` |
|
|
58
|
+
| extension mode | Supported | Supported | `Waiting for extension to connect...` then `Extension connected` | Requires the external browser extension to attach |
|
|
59
|
+
|
|
60
|
+
## Verification Checklist
|
|
61
|
+
|
|
62
|
+
- Start `standalone mode` with `npx tsx scripts/start.ts standalone`
|
|
63
|
+
- Observe the readiness line `Ready`
|
|
64
|
+
- Connect with `connect()` and create a named page
|
|
65
|
+
- Start `extension mode` with `npx tsx scripts/start.ts extension`
|
|
66
|
+
- Observe `Waiting for extension to connect...`
|
|
67
|
+
- Attach the browser extension and confirm `Extension connected`
|
|
68
|
+
|
|
69
|
+
## Known Differences
|
|
70
|
+
|
|
71
|
+
- `standalone mode` launches and owns its own Chromium profile under this skill directory.
|
|
72
|
+
- `extension mode` depends on the external browser extension and the user's existing Chrome session.
|
|
73
|
+
- In `extension mode`, relay readiness means the server is waiting for the extension; it does not imply browser control is available until `Extension connected` appears.
|
|
74
|
+
- `standalone mode` is the default path for deterministic local automation; `extension mode` is for working inside an already-authenticated browser.
|
|
75
|
+
|
|
76
|
+
## Non-goal Environments
|
|
77
|
+
|
|
78
|
+
- `WSL` and `Git Bash` are not part of the supported Windows path for this skill.
|
|
79
|
+
- Native Windows support means PowerShell / Command Prompt can use the documented entrypoints directly.
|
|
80
|
+
- If a user runs inside WSL or Git Bash, treat that as a separate environment rather than the official Windows support contract.
|
|
81
|
+
|
|
63
82
|
## Writing Scripts
|
|
64
83
|
|
|
65
|
-
|
|
84
|
+
Use small TypeScript files under `tmp/` instead of shell heredocs. Run them from the same `dev-browser` skill directory so the `@/` import alias resolves correctly.
|
|
66
85
|
|
|
67
|
-
|
|
86
|
+
Example script (`tmp/example.ts`):
|
|
68
87
|
|
|
69
|
-
```
|
|
70
|
-
### SKILL_DIR ###
|
|
71
|
-
(cd "$SKILL_DIR" && npx tsx <<'EOF'
|
|
88
|
+
```typescript
|
|
72
89
|
import { connect, waitForPageLoad } from "@/client.js";
|
|
73
90
|
|
|
74
91
|
const client = await connect();
|
|
75
|
-
// Create page with custom viewport size (optional)
|
|
76
92
|
const page = await client.page("example", { viewport: { width: 1920, height: 1080 } });
|
|
77
93
|
|
|
78
94
|
await page.goto("https://example.com");
|
|
@@ -80,8 +96,12 @@ await waitForPageLoad(page);
|
|
|
80
96
|
|
|
81
97
|
console.log({ title: await page.title(), url: page.url() });
|
|
82
98
|
await client.disconnect();
|
|
83
|
-
|
|
84
|
-
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Run it with:
|
|
102
|
+
|
|
103
|
+
```text
|
|
104
|
+
npx tsx tmp/example.ts
|
|
85
105
|
```
|
|
86
106
|
|
|
87
107
|
**Write to `tmp/` files only when** the script needs reuse, is complex, or user explicitly requests it.
|
|
@@ -202,11 +222,9 @@ await element.click();
|
|
|
202
222
|
|
|
203
223
|
## Error Recovery
|
|
204
224
|
|
|
205
|
-
Page state persists after failures.
|
|
225
|
+
Page state persists after failures. To inspect the current state, save a short script such as `tmp/debug.ts` and run it from the `dev-browser` skill directory:
|
|
206
226
|
|
|
207
|
-
```
|
|
208
|
-
### SKILL_DIR ###
|
|
209
|
-
(cd "$SKILL_DIR" && npx tsx <<'EOF'
|
|
227
|
+
```typescript
|
|
210
228
|
import { connect } from "@/client.js";
|
|
211
229
|
|
|
212
230
|
const client = await connect();
|
|
@@ -220,6 +238,10 @@ console.log({
|
|
|
220
238
|
});
|
|
221
239
|
|
|
222
240
|
await client.disconnect();
|
|
223
|
-
|
|
224
|
-
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
Run it with:
|
|
244
|
+
|
|
245
|
+
```text
|
|
246
|
+
npx tsx tmp/debug.ts
|
|
225
247
|
```
|
|
@@ -5,14 +5,21 @@
|
|
|
5
5
|
"": {
|
|
6
6
|
"name": "dev-browser",
|
|
7
7
|
"dependencies": {
|
|
8
|
+
"@hono/node-server": "^1.19.7",
|
|
9
|
+
"@hono/node-ws": "^1.2.0",
|
|
8
10
|
"express": "^4.21.0",
|
|
11
|
+
"hono": "^4.11.1",
|
|
9
12
|
"playwright": "^1.49.0",
|
|
10
13
|
},
|
|
11
14
|
"devDependencies": {
|
|
12
15
|
"@types/express": "^5.0.0",
|
|
13
16
|
"tsx": "^4.21.0",
|
|
17
|
+
"typescript": "^5.0.0",
|
|
14
18
|
"vitest": "^2.1.0",
|
|
15
19
|
},
|
|
20
|
+
"optionalDependencies": {
|
|
21
|
+
"@rollup/rollup-linux-x64-gnu": "^4.0.0",
|
|
22
|
+
},
|
|
16
23
|
},
|
|
17
24
|
},
|
|
18
25
|
"packages": {
|
|
@@ -68,6 +75,10 @@
|
|
|
68
75
|
|
|
69
76
|
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.1", "", { "os": "win32", "cpu": "x64" }, "sha512-d5X6RMYv6taIymSk8JBP+nxv8DQAMY6A51GPgusqLdK9wBz5wWIXy1KjTck6HnjE9hqJzJRdk+1p/t5soSbCtw=="],
|
|
70
77
|
|
|
78
|
+
"@hono/node-server": ["@hono/node-server@1.19.14", "", { "peerDependencies": { "hono": "^4" } }, "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw=="],
|
|
79
|
+
|
|
80
|
+
"@hono/node-ws": ["@hono/node-ws@1.3.0", "", { "dependencies": { "ws": "^8.17.0" }, "peerDependencies": { "@hono/node-server": "^1.19.2", "hono": "^4.6.0" } }, "sha512-ju25YbbvLuXdqBCmLZLqnNYu1nbHIQjoyUqA8ApZOeL1k4skuiTcw5SW77/5SUYo2Xi2NVBJoVlfQurnKEp03Q=="],
|
|
81
|
+
|
|
71
82
|
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
|
|
72
83
|
|
|
73
84
|
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.53.3", "", { "os": "android", "cpu": "arm" }, "sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w=="],
|
|
@@ -234,6 +245,8 @@
|
|
|
234
245
|
|
|
235
246
|
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
|
|
236
247
|
|
|
248
|
+
"hono": ["hono@4.12.12", "", {}, "sha512-p1JfQMKaceuCbpJKAPKVqyqviZdS0eUxH9v82oWo1kb9xjQ5wA6iP3FNVAPDFlz5/p7d45lO+BpSk1tuSZMF4Q=="],
|
|
249
|
+
|
|
237
250
|
"http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="],
|
|
238
251
|
|
|
239
252
|
"iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="],
|
|
@@ -342,6 +355,8 @@
|
|
|
342
355
|
|
|
343
356
|
"type-is": ["type-is@1.6.18", "", { "dependencies": { "media-typer": "0.3.0", "mime-types": "~2.1.24" } }, "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g=="],
|
|
344
357
|
|
|
358
|
+
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
|
359
|
+
|
|
345
360
|
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
|
346
361
|
|
|
347
362
|
"unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="],
|
|
@@ -358,6 +373,8 @@
|
|
|
358
373
|
|
|
359
374
|
"why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="],
|
|
360
375
|
|
|
376
|
+
"ws": ["ws@8.20.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA=="],
|
|
377
|
+
|
|
361
378
|
"body-parser/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
|
|
362
379
|
|
|
363
380
|
"express/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
|
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
"@/*": "./src/*"
|
|
7
7
|
},
|
|
8
8
|
"scripts": {
|
|
9
|
-
"start-server": "npx tsx scripts/start
|
|
10
|
-
"start-extension": "npx tsx scripts/start
|
|
9
|
+
"start-server": "npx tsx scripts/start.ts standalone",
|
|
10
|
+
"start-extension": "npx tsx scripts/start.ts extension",
|
|
11
11
|
"dev": "npx tsx --watch src/index.ts",
|
|
12
12
|
"test": "vitest run",
|
|
13
13
|
"test:watch": "vitest"
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync } from "node:fs";
|
|
2
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
import { formatHttpUrl, parseEntrypointArgs, resolveHostForProbe } from "@/entrypoint.js";
|
|
6
|
+
import {
|
|
7
|
+
commandExists,
|
|
8
|
+
findAvailablePackageManager,
|
|
9
|
+
getMissingPackageDependencies,
|
|
10
|
+
isPlaywrightChromiumInstalled,
|
|
11
|
+
resolveSkillDirFromEntrypoint,
|
|
12
|
+
resolveRuntimePaths,
|
|
13
|
+
shouldUseShellForPackageCommands,
|
|
14
|
+
} from "@/runtime.js";
|
|
15
|
+
import {
|
|
16
|
+
ensurePlaywrightChromium,
|
|
17
|
+
preflightStandaloneStartup,
|
|
18
|
+
runEntrypoint,
|
|
19
|
+
} from "@/startup.js";
|
|
20
|
+
|
|
21
|
+
const runtimePaths = resolveRuntimePaths(resolveSkillDirFromEntrypoint(import.meta.url));
|
|
22
|
+
const useShell = shouldUseShellForPackageCommands(process.platform);
|
|
23
|
+
|
|
24
|
+
async function main() {
|
|
25
|
+
const args = parseEntrypointArgs(process.argv.slice(2));
|
|
26
|
+
await ensureSkillDependencies();
|
|
27
|
+
|
|
28
|
+
await runEntrypoint(args, {
|
|
29
|
+
runtimePaths,
|
|
30
|
+
mkdir: mkdirSync,
|
|
31
|
+
serveStandalone: async (options) => {
|
|
32
|
+
const { serve } = await import("@/index.js");
|
|
33
|
+
return serve(options);
|
|
34
|
+
},
|
|
35
|
+
serveExtension: async (options) => {
|
|
36
|
+
const { serveRelay } = await import("@/relay.js");
|
|
37
|
+
return serveRelay(options);
|
|
38
|
+
},
|
|
39
|
+
registerShutdown,
|
|
40
|
+
keepAlive: () => new Promise(() => {}),
|
|
41
|
+
log: (line) => console.log(line),
|
|
42
|
+
ensureBrowser: () =>
|
|
43
|
+
ensurePlaywrightChromium({
|
|
44
|
+
isInstalled: () =>
|
|
45
|
+
isPlaywrightChromiumInstalled({
|
|
46
|
+
platform: process.platform,
|
|
47
|
+
env: process.env,
|
|
48
|
+
exists: existsSync,
|
|
49
|
+
readDir: readdirSync,
|
|
50
|
+
}),
|
|
51
|
+
findPackageManager: () =>
|
|
52
|
+
findAvailablePackageManager((command) =>
|
|
53
|
+
commandExists(command, (candidate) =>
|
|
54
|
+
spawnSync(candidate, ["--version"], {
|
|
55
|
+
stdio: "ignore",
|
|
56
|
+
shell: useShell,
|
|
57
|
+
windowsHide: useShell,
|
|
58
|
+
})
|
|
59
|
+
)
|
|
60
|
+
),
|
|
61
|
+
runCommand,
|
|
62
|
+
log: (line) => console.log(line),
|
|
63
|
+
}),
|
|
64
|
+
preflightStandalone: () =>
|
|
65
|
+
preflightStandaloneStartup(args, {
|
|
66
|
+
checkServer: async (host, port) => {
|
|
67
|
+
try {
|
|
68
|
+
const response = await fetch(formatHttpUrl(resolveHostForProbe(host), port), {
|
|
69
|
+
signal: AbortSignal.timeout(1000),
|
|
70
|
+
});
|
|
71
|
+
if (!response.ok) {
|
|
72
|
+
return { ok: false };
|
|
73
|
+
}
|
|
74
|
+
const info = (await response.json()) as { wsEndpoint?: string };
|
|
75
|
+
return { ok: true, info };
|
|
76
|
+
} catch {
|
|
77
|
+
return { ok: false };
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
isPortInUse,
|
|
81
|
+
browserDataDir: runtimePaths.browserDataDir,
|
|
82
|
+
recoverStaleBrowser: ({ cdpPort, browserDataDir }) =>
|
|
83
|
+
recoverStaleDevBrowserChromium({
|
|
84
|
+
cdpPort,
|
|
85
|
+
browserDataDir,
|
|
86
|
+
isPortInUse,
|
|
87
|
+
log: (line) => console.log(line),
|
|
88
|
+
}),
|
|
89
|
+
log: (line) => console.log(line),
|
|
90
|
+
}),
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function ensureSkillDependencies() {
|
|
95
|
+
const packageJson = JSON.parse(readFileSync(join(runtimePaths.skillDir, "package.json"), "utf8")) as {
|
|
96
|
+
dependencies?: Record<string, string>;
|
|
97
|
+
};
|
|
98
|
+
const missingDependencies = getMissingPackageDependencies({
|
|
99
|
+
skillDir: runtimePaths.skillDir,
|
|
100
|
+
dependencies: Object.keys(packageJson.dependencies ?? {}),
|
|
101
|
+
exists: existsSync,
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
if (missingDependencies.length === 0) {
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
console.log("dev-browser dependencies not found. Installing local packages...");
|
|
109
|
+
await runCommand("npm", ["install"]);
|
|
110
|
+
console.log("dev-browser dependencies installed.");
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function runCommand(command: string, args: string[]) {
|
|
114
|
+
await new Promise<void>((resolve, reject) => {
|
|
115
|
+
const child = spawn(command, args, {
|
|
116
|
+
cwd: runtimePaths.skillDir,
|
|
117
|
+
stdio: "inherit",
|
|
118
|
+
shell: useShell,
|
|
119
|
+
windowsHide: useShell,
|
|
120
|
+
});
|
|
121
|
+
child.on("error", reject);
|
|
122
|
+
child.on("close", (code) => {
|
|
123
|
+
if (code === 0) {
|
|
124
|
+
resolve();
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
reject(new Error(`${command} ${args.join(" ")} exited with code ${code}`));
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function isPortInUse(port: number) {
|
|
133
|
+
const socket = await import("node:net");
|
|
134
|
+
return await new Promise<boolean>((resolve) => {
|
|
135
|
+
const server = socket.createServer();
|
|
136
|
+
server.once("error", () => resolve(true));
|
|
137
|
+
server.once("listening", () => {
|
|
138
|
+
server.close(() => resolve(false));
|
|
139
|
+
});
|
|
140
|
+
server.listen(port, "127.0.0.1");
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function recoverStaleDevBrowserChromium({
|
|
145
|
+
cdpPort,
|
|
146
|
+
browserDataDir,
|
|
147
|
+
isPortInUse,
|
|
148
|
+
log,
|
|
149
|
+
}: {
|
|
150
|
+
cdpPort: number;
|
|
151
|
+
browserDataDir: string;
|
|
152
|
+
isPortInUse: (port: number) => Promise<boolean>;
|
|
153
|
+
log: (line: string) => void;
|
|
154
|
+
}) {
|
|
155
|
+
const pid = findListeningPid(cdpPort);
|
|
156
|
+
if (!pid) {
|
|
157
|
+
return false;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const commandLine = readProcessCommandLine(pid);
|
|
161
|
+
if (!commandLine || !isOwnedDevBrowserProcess(commandLine, browserDataDir, cdpPort)) {
|
|
162
|
+
return false;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
log(`Cleaning up stale dev-browser Chromium on CDP port ${cdpPort} (PID: ${pid})`);
|
|
166
|
+
if (!terminateProcess(pid)) {
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const deadline = Date.now() + 3000;
|
|
171
|
+
while (Date.now() < deadline) {
|
|
172
|
+
if (!(await isPortInUse(cdpPort))) {
|
|
173
|
+
return true;
|
|
174
|
+
}
|
|
175
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return !(await isPortInUse(cdpPort));
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function findListeningPid(port: number): number | null {
|
|
182
|
+
if (process.platform === "win32") {
|
|
183
|
+
const result = runCapture("powershell.exe", [
|
|
184
|
+
"-NoProfile",
|
|
185
|
+
"-Command",
|
|
186
|
+
`Get-NetTCPConnection -LocalPort ${port} -State Listen -ErrorAction SilentlyContinue | Select-Object -ExpandProperty OwningProcess -First 1`,
|
|
187
|
+
]);
|
|
188
|
+
return parsePid(result.stdout);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const result = runCapture("lsof", ["-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-t"]);
|
|
192
|
+
return parsePid(result.stdout);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function readProcessCommandLine(pid: number): string | null {
|
|
196
|
+
if (process.platform === "win32") {
|
|
197
|
+
const result = runCapture("powershell.exe", [
|
|
198
|
+
"-NoProfile",
|
|
199
|
+
"-Command",
|
|
200
|
+
`(Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}").CommandLine`,
|
|
201
|
+
]);
|
|
202
|
+
return normalizeOutput(result.stdout);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const result = runCapture("ps", ["-p", String(pid), "-o", "args="]);
|
|
206
|
+
return normalizeOutput(result.stdout);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function isOwnedDevBrowserProcess(commandLine: string, browserDataDir: string, cdpPort: number): boolean {
|
|
210
|
+
const normalizedCommandLine = normalizePathLike(commandLine);
|
|
211
|
+
const normalizedBrowserDataDir = normalizePathLike(browserDataDir);
|
|
212
|
+
|
|
213
|
+
return (
|
|
214
|
+
normalizedCommandLine.includes(`--remote-debugging-port=${cdpPort}`) &&
|
|
215
|
+
normalizedCommandLine.includes("--user-data-dir=") &&
|
|
216
|
+
normalizedCommandLine.includes(normalizedBrowserDataDir)
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function terminateProcess(pid: number): boolean {
|
|
221
|
+
if (process.platform === "win32") {
|
|
222
|
+
return runCapture("taskkill", ["/PID", String(pid), "/T", "/F"]).status === 0;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
try {
|
|
226
|
+
process.kill(pid, "SIGKILL");
|
|
227
|
+
return true;
|
|
228
|
+
} catch (error) {
|
|
229
|
+
return error instanceof Error && "code" in error && error.code === "ESRCH";
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function runCapture(command: string, args: string[]) {
|
|
234
|
+
const result = spawnSync(command, args, {
|
|
235
|
+
cwd: runtimePaths.skillDir,
|
|
236
|
+
encoding: "utf8",
|
|
237
|
+
shell: false,
|
|
238
|
+
windowsHide: true,
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
return {
|
|
242
|
+
status: result.status,
|
|
243
|
+
stdout: typeof result.stdout === "string" ? result.stdout : "",
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function parsePid(stdout: string): number | null {
|
|
248
|
+
const value = normalizeOutput(stdout);
|
|
249
|
+
if (!value) {
|
|
250
|
+
return null;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const pid = Number.parseInt(value.split(/\s+/)[0] ?? "", 10);
|
|
254
|
+
return Number.isInteger(pid) && pid > 0 ? pid : null;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function normalizeOutput(stdout: string): string | null {
|
|
258
|
+
const value = stdout.trim();
|
|
259
|
+
return value.length > 0 ? value : null;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function normalizePathLike(value: string): string {
|
|
263
|
+
return value.replaceAll("\\", "/").toLowerCase();
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function registerShutdown(stop: () => Promise<void>) {
|
|
267
|
+
const shutdown = async () => {
|
|
268
|
+
await stop();
|
|
269
|
+
process.exit(0);
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
process.on("SIGINT", shutdown);
|
|
273
|
+
process.on("SIGTERM", shutdown);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
main().catch((error) => {
|
|
277
|
+
console.error("Failed to start dev-browser:", error);
|
|
278
|
+
process.exit(1);
|
|
279
|
+
});
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
export type EntrypointMode = "standalone" | "extension";
|
|
2
|
+
|
|
3
|
+
export interface EntrypointArgs {
|
|
4
|
+
mode: EntrypointMode;
|
|
5
|
+
host: string;
|
|
6
|
+
port: number;
|
|
7
|
+
cdpPort: number;
|
|
8
|
+
headless: boolean;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface StandaloneReadinessInfo {
|
|
12
|
+
mode: "standalone";
|
|
13
|
+
host: string;
|
|
14
|
+
port: number;
|
|
15
|
+
wsEndpoint: string;
|
|
16
|
+
tmpDir: string;
|
|
17
|
+
profileDir: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface ExtensionReadinessInfo {
|
|
21
|
+
mode: "extension";
|
|
22
|
+
host: string;
|
|
23
|
+
port: number;
|
|
24
|
+
wsEndpoint: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type ReadinessInfo = StandaloneReadinessInfo | ExtensionReadinessInfo;
|
|
28
|
+
|
|
29
|
+
export const DEFAULT_HOST = "localhost";
|
|
30
|
+
const DEFAULT_PORT = 9222;
|
|
31
|
+
const DEFAULT_CDP_PORT = 9223;
|
|
32
|
+
|
|
33
|
+
export function formatHostForUrl(host: string): string {
|
|
34
|
+
const normalizedHost =
|
|
35
|
+
host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host;
|
|
36
|
+
return normalizedHost.includes(":") ? `[${normalizedHost}]` : normalizedHost;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function formatHttpUrl(host: string, port: number): string {
|
|
40
|
+
return `http://${formatHostForUrl(host)}:${port}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function formatWsUrl(host: string, port: number, path = ""): string {
|
|
44
|
+
return `ws://${formatHostForUrl(host)}:${port}${path}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function resolveHostForProbe(host: string): string {
|
|
48
|
+
const normalizedHost =
|
|
49
|
+
host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host;
|
|
50
|
+
|
|
51
|
+
if (normalizedHost === "0.0.0.0") {
|
|
52
|
+
return "127.0.0.1";
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (normalizedHost === "::") {
|
|
56
|
+
return "::1";
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return normalizedHost;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function parseEntrypointArgs(
|
|
63
|
+
argv: string[],
|
|
64
|
+
env: Record<string, string | undefined> = process.env
|
|
65
|
+
): EntrypointArgs {
|
|
66
|
+
let index = 0;
|
|
67
|
+
let mode: EntrypointMode = "standalone";
|
|
68
|
+
|
|
69
|
+
const first = argv[0];
|
|
70
|
+
if (first && !first.startsWith("--")) {
|
|
71
|
+
if (first !== "standalone" && first !== "extension") {
|
|
72
|
+
throw new Error(`Unknown mode: ${first}`);
|
|
73
|
+
}
|
|
74
|
+
mode = first;
|
|
75
|
+
index = 1;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const args: EntrypointArgs = {
|
|
79
|
+
mode,
|
|
80
|
+
host: env.HOST?.trim() || DEFAULT_HOST,
|
|
81
|
+
port: readEnvPort(env.PORT, "PORT") ?? DEFAULT_PORT,
|
|
82
|
+
cdpPort: DEFAULT_CDP_PORT,
|
|
83
|
+
headless: env.HEADLESS?.trim().toLowerCase() === "true",
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
while (index < argv.length) {
|
|
87
|
+
const token = argv[index++];
|
|
88
|
+
switch (token) {
|
|
89
|
+
case "--headless":
|
|
90
|
+
args.headless = true;
|
|
91
|
+
break;
|
|
92
|
+
case "--host":
|
|
93
|
+
args.host = readValue(argv, index - 1);
|
|
94
|
+
index += 1;
|
|
95
|
+
break;
|
|
96
|
+
case "--port":
|
|
97
|
+
args.port = parsePort(readValue(argv, index - 1), "port");
|
|
98
|
+
index += 1;
|
|
99
|
+
break;
|
|
100
|
+
case "--cdp-port":
|
|
101
|
+
args.cdpPort = parsePort(readValue(argv, index - 1), "cdpPort");
|
|
102
|
+
index += 1;
|
|
103
|
+
break;
|
|
104
|
+
default:
|
|
105
|
+
throw new Error(`Unknown argument: ${token}`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return args;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function formatReadinessLines(info: ReadinessInfo): string[] {
|
|
113
|
+
if (info.mode === "standalone") {
|
|
114
|
+
return [
|
|
115
|
+
"Dev browser server started",
|
|
116
|
+
` HTTP: ${formatHttpUrl(info.host, info.port)}`,
|
|
117
|
+
` WebSocket: ${info.wsEndpoint}`,
|
|
118
|
+
` Tmp directory: ${info.tmpDir}`,
|
|
119
|
+
` Profile directory: ${info.profileDir}`,
|
|
120
|
+
"",
|
|
121
|
+
"Ready",
|
|
122
|
+
];
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return [
|
|
126
|
+
"CDP relay server started",
|
|
127
|
+
` HTTP: ${formatHttpUrl(info.host, info.port)}`,
|
|
128
|
+
` CDP endpoint: ${info.wsEndpoint}`,
|
|
129
|
+
` Extension endpoint: ${formatWsUrl(info.host, info.port, "/extension")}`,
|
|
130
|
+
"",
|
|
131
|
+
"Waiting for extension to connect...",
|
|
132
|
+
];
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function readValue(argv: string[], optionIndex: number): string {
|
|
136
|
+
const value = argv[optionIndex + 1];
|
|
137
|
+
if (!value || value.startsWith("--")) {
|
|
138
|
+
throw new Error(`Missing value for ${argv[optionIndex]}`);
|
|
139
|
+
}
|
|
140
|
+
return value;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function parsePort(value: string, label: string): number {
|
|
144
|
+
const port = Number.parseInt(value, 10);
|
|
145
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
146
|
+
throw new Error(`Invalid ${label}: ${value}`);
|
|
147
|
+
}
|
|
148
|
+
return port;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function readEnvPort(value: string | undefined, label: string): number | undefined {
|
|
152
|
+
const normalized = value?.trim();
|
|
153
|
+
if (!normalized) {
|
|
154
|
+
return undefined;
|
|
155
|
+
}
|
|
156
|
+
return parsePort(normalized, label);
|
|
157
|
+
}
|
|
@@ -3,6 +3,7 @@ import { chromium, type BrowserContext, type Page } from "playwright";
|
|
|
3
3
|
import { mkdirSync } from "fs";
|
|
4
4
|
import { join } from "path";
|
|
5
5
|
import type { Socket } from "net";
|
|
6
|
+
import { formatHttpUrl } from "./entrypoint";
|
|
6
7
|
import type {
|
|
7
8
|
ServeOptions,
|
|
8
9
|
GetPageRequest,
|
|
@@ -53,6 +54,7 @@ function withTimeout<T>(promise: Promise<T>, ms: number, message: string): Promi
|
|
|
53
54
|
|
|
54
55
|
export async function serve(options: ServeOptions = {}): Promise<DevBrowserServer> {
|
|
55
56
|
const port = options.port ?? 9222;
|
|
57
|
+
const host = options.host ?? "localhost";
|
|
56
58
|
const headless = options.headless ?? false;
|
|
57
59
|
const cdpPort = options.cdpPort ?? 9223;
|
|
58
60
|
const profileDir = options.profileDir;
|
|
@@ -191,8 +193,8 @@ export async function serve(options: ServeOptions = {}): Promise<DevBrowserServe
|
|
|
191
193
|
});
|
|
192
194
|
|
|
193
195
|
// Start the server
|
|
194
|
-
const server = app.listen(port, () => {
|
|
195
|
-
console.log(`HTTP API server running on
|
|
196
|
+
const server = app.listen(port, host, () => {
|
|
197
|
+
console.log(`HTTP API server running on ${formatHttpUrl(host, port)}`);
|
|
196
198
|
});
|
|
197
199
|
|
|
198
200
|
// Track active connections for clean shutdown
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { dirname, join } from "node:path";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
|
|
4
|
+
export interface CommandStatusResult {
|
|
5
|
+
status: number | null;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface PackageManager {
|
|
9
|
+
name: "bun" | "pnpm" | "npm";
|
|
10
|
+
command: string;
|
|
11
|
+
args: string[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface ChromiumInstallCheckOptions {
|
|
15
|
+
platform: NodeJS.Platform;
|
|
16
|
+
env: NodeJS.ProcessEnv;
|
|
17
|
+
exists: (path: string) => boolean;
|
|
18
|
+
readDir: (path: string) => string[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface MissingPackageDependenciesOptions {
|
|
22
|
+
skillDir: string;
|
|
23
|
+
dependencies: string[];
|
|
24
|
+
exists: (path: string) => boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function commandExists(
|
|
28
|
+
command: string,
|
|
29
|
+
runCheck: (command: string) => CommandStatusResult
|
|
30
|
+
): boolean {
|
|
31
|
+
return runCheck(command).status === 0;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function findAvailablePackageManager(
|
|
35
|
+
hasCommand: (command: string) => boolean
|
|
36
|
+
): PackageManager | null {
|
|
37
|
+
const candidates: PackageManager[] = [
|
|
38
|
+
{
|
|
39
|
+
name: "bun",
|
|
40
|
+
command: "bunx",
|
|
41
|
+
args: ["playwright", "install", "chromium"],
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
name: "pnpm",
|
|
45
|
+
command: "pnpm",
|
|
46
|
+
args: ["exec", "playwright", "install", "chromium"],
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
name: "npm",
|
|
50
|
+
command: "npx",
|
|
51
|
+
args: ["playwright", "install", "chromium"],
|
|
52
|
+
},
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
for (const candidate of candidates) {
|
|
56
|
+
const probe = candidate.name === "npm" ? "npm" : candidate.name;
|
|
57
|
+
if (hasCommand(probe)) {
|
|
58
|
+
return candidate;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function getPlaywrightInstallCommand(manager: PackageManager): string {
|
|
66
|
+
return [manager.command, ...manager.args].join(" ");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function getPlaywrightBrowserRoots({
|
|
70
|
+
platform,
|
|
71
|
+
env,
|
|
72
|
+
}: {
|
|
73
|
+
platform: NodeJS.Platform;
|
|
74
|
+
env: NodeJS.ProcessEnv;
|
|
75
|
+
}): string[] {
|
|
76
|
+
const explicitPath = env.PLAYWRIGHT_BROWSERS_PATH?.trim();
|
|
77
|
+
if (explicitPath) {
|
|
78
|
+
return [explicitPath];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (platform === "win32") {
|
|
82
|
+
const userProfile = env.USERPROFILE ?? env.HOME;
|
|
83
|
+
return userProfile ? [join(userProfile, "AppData", "Local", "ms-playwright")] : [];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const home = env.HOME ?? env.USERPROFILE;
|
|
87
|
+
return home ? [join(home, ".cache", "ms-playwright")] : [];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function isPlaywrightChromiumInstalled({
|
|
91
|
+
platform,
|
|
92
|
+
env,
|
|
93
|
+
exists,
|
|
94
|
+
readDir,
|
|
95
|
+
}: ChromiumInstallCheckOptions): boolean {
|
|
96
|
+
for (const root of getPlaywrightBrowserRoots({ platform, env })) {
|
|
97
|
+
if (!exists(root)) {
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
try {
|
|
102
|
+
const entries = readDir(root);
|
|
103
|
+
if (entries.some((entry) => entry.startsWith("chromium"))) {
|
|
104
|
+
return true;
|
|
105
|
+
}
|
|
106
|
+
} catch {
|
|
107
|
+
// Ignore unreadable directories and continue probing.
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function getMissingPackageDependencies({
|
|
115
|
+
skillDir,
|
|
116
|
+
dependencies,
|
|
117
|
+
exists,
|
|
118
|
+
}: MissingPackageDependenciesOptions): string[] {
|
|
119
|
+
return dependencies.filter(
|
|
120
|
+
(dependency) =>
|
|
121
|
+
!exists(join(skillDir, "node_modules", ...dependency.split("/"), "package.json"))
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function resolveRuntimePaths(skillDir: string) {
|
|
126
|
+
const tmpDir = join(skillDir, "tmp");
|
|
127
|
+
const profileDir = join(skillDir, "profiles");
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
skillDir,
|
|
131
|
+
tmpDir,
|
|
132
|
+
profileDir,
|
|
133
|
+
browserDataDir: join(profileDir, "browser-data"),
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function resolveImportMetaDir(moduleUrl: string): string {
|
|
138
|
+
return dirname(fileURLToPath(moduleUrl));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function resolveSkillDirFromEntrypoint(moduleUrl: string): string {
|
|
142
|
+
return dirname(resolveImportMetaDir(moduleUrl));
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function shouldUseShellForPackageCommands(platform: NodeJS.Platform): boolean {
|
|
146
|
+
return platform === "win32";
|
|
147
|
+
}
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
|
|
12
12
|
import * as fs from "fs";
|
|
13
13
|
import * as path from "path";
|
|
14
|
+
import { fileURLToPath } from "node:url";
|
|
14
15
|
|
|
15
16
|
// Cache the bundled script
|
|
16
17
|
let cachedScript: string | null = null;
|
|
@@ -26,7 +27,7 @@ export function getSnapshotScript(): string {
|
|
|
26
27
|
if (cachedScript) return cachedScript;
|
|
27
28
|
|
|
28
29
|
// Read the compiled JavaScript files
|
|
29
|
-
const snapshotDir = path.dirname(
|
|
30
|
+
const snapshotDir = path.dirname(fileURLToPath(import.meta.url));
|
|
30
31
|
|
|
31
32
|
// For now, we'll inline the functions directly
|
|
32
33
|
// In production, we could use a bundler like esbuild to create a single file
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
DEFAULT_HOST,
|
|
5
|
+
formatHttpUrl,
|
|
6
|
+
formatReadinessLines,
|
|
7
|
+
parseEntrypointArgs,
|
|
8
|
+
resolveHostForProbe,
|
|
9
|
+
} from "./entrypoint.js";
|
|
10
|
+
import { preflightStandaloneStartup } from "./startup.js";
|
|
11
|
+
|
|
12
|
+
describe("entrypoint host handling", () => {
|
|
13
|
+
it("defaults standalone host to localhost", () => {
|
|
14
|
+
const args = parseEntrypointArgs([], {});
|
|
15
|
+
|
|
16
|
+
expect(args.host).toBe(DEFAULT_HOST);
|
|
17
|
+
expect(args.host).toBe("localhost");
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("formats IPv6 hosts in readiness output", () => {
|
|
21
|
+
expect(formatHttpUrl("::1", 9222)).toBe("http://[::1]:9222");
|
|
22
|
+
|
|
23
|
+
expect(
|
|
24
|
+
formatReadinessLines({
|
|
25
|
+
mode: "standalone",
|
|
26
|
+
host: "::1",
|
|
27
|
+
port: 9222,
|
|
28
|
+
wsEndpoint: "ws://127.0.0.1:9223/devtools/browser/test",
|
|
29
|
+
tmpDir: "/tmp/dev-browser",
|
|
30
|
+
profileDir: "/tmp/dev-browser/profile",
|
|
31
|
+
})
|
|
32
|
+
).toContain(" HTTP: http://[::1]:9222");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("normalizes wildcard hosts to a reachable probe target", () => {
|
|
36
|
+
expect(resolveHostForProbe("0.0.0.0")).toBe("127.0.0.1");
|
|
37
|
+
expect(resolveHostForProbe("::")).toBe("::1");
|
|
38
|
+
expect(resolveHostForProbe("[::1]")).toBe("::1");
|
|
39
|
+
expect(resolveHostForProbe("localhost")).toBe("localhost");
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe("preflightStandaloneStartup", () => {
|
|
44
|
+
it("checks the configured host before starting", async () => {
|
|
45
|
+
const checkServer = vi.fn().mockResolvedValue({ ok: false });
|
|
46
|
+
const isPortInUse = vi.fn().mockResolvedValue(false);
|
|
47
|
+
const log = vi.fn();
|
|
48
|
+
|
|
49
|
+
await expect(
|
|
50
|
+
preflightStandaloneStartup(
|
|
51
|
+
{
|
|
52
|
+
mode: "standalone",
|
|
53
|
+
host: "::1",
|
|
54
|
+
port: 9222,
|
|
55
|
+
cdpPort: 9223,
|
|
56
|
+
headless: false,
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
checkServer,
|
|
60
|
+
isPortInUse,
|
|
61
|
+
log,
|
|
62
|
+
}
|
|
63
|
+
)
|
|
64
|
+
).resolves.toBe(true);
|
|
65
|
+
|
|
66
|
+
expect(checkServer).toHaveBeenCalledWith("::1", 9222);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("short-circuits when the configured host already has a server", async () => {
|
|
70
|
+
const checkServer = vi.fn().mockResolvedValue({ ok: true, info: { wsEndpoint: "ws://test" } });
|
|
71
|
+
const isPortInUse = vi.fn();
|
|
72
|
+
const log = vi.fn();
|
|
73
|
+
|
|
74
|
+
await expect(
|
|
75
|
+
preflightStandaloneStartup(
|
|
76
|
+
{
|
|
77
|
+
mode: "standalone",
|
|
78
|
+
host: "localhost",
|
|
79
|
+
port: 9222,
|
|
80
|
+
cdpPort: 9223,
|
|
81
|
+
headless: false,
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
checkServer,
|
|
85
|
+
isPortInUse,
|
|
86
|
+
log,
|
|
87
|
+
}
|
|
88
|
+
)
|
|
89
|
+
).resolves.toBe(false);
|
|
90
|
+
|
|
91
|
+
expect(checkServer).toHaveBeenCalledWith("localhost", 9222);
|
|
92
|
+
expect(isPortInUse).not.toHaveBeenCalled();
|
|
93
|
+
expect(log).toHaveBeenCalledWith("Server already running on port 9222");
|
|
94
|
+
});
|
|
95
|
+
});
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { formatReadinessLines, type EntrypointArgs } from "./entrypoint.js";
|
|
2
|
+
import type { PackageManager } from "./runtime.js";
|
|
3
|
+
|
|
4
|
+
export interface RuntimePaths {
|
|
5
|
+
skillDir: string;
|
|
6
|
+
tmpDir: string;
|
|
7
|
+
profileDir: string;
|
|
8
|
+
browserDataDir: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface StandaloneServer {
|
|
12
|
+
port: number;
|
|
13
|
+
wsEndpoint: string;
|
|
14
|
+
stop: () => Promise<void>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface ExtensionServer {
|
|
18
|
+
port: number;
|
|
19
|
+
wsEndpoint: string;
|
|
20
|
+
stop: () => Promise<void>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface RunEntrypointDeps {
|
|
24
|
+
runtimePaths: RuntimePaths;
|
|
25
|
+
mkdir: (path: string, options: { recursive: true }) => void;
|
|
26
|
+
serveStandalone: (options: {
|
|
27
|
+
port: number;
|
|
28
|
+
host: string;
|
|
29
|
+
headless: boolean;
|
|
30
|
+
cdpPort: number;
|
|
31
|
+
profileDir: string;
|
|
32
|
+
}) => Promise<StandaloneServer>;
|
|
33
|
+
serveExtension: (options: { port: number; host: string }) => Promise<ExtensionServer>;
|
|
34
|
+
registerShutdown: (stop: () => Promise<void>) => void;
|
|
35
|
+
keepAlive: () => Promise<void>;
|
|
36
|
+
log: (line: string) => void;
|
|
37
|
+
ensureBrowser?: () => Promise<void>;
|
|
38
|
+
preflightStandalone?: () => Promise<boolean>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface EnsurePlaywrightChromiumDeps {
|
|
42
|
+
isInstalled: () => boolean;
|
|
43
|
+
findPackageManager: () => PackageManager | null;
|
|
44
|
+
runCommand: (command: string, args: string[]) => Promise<void>;
|
|
45
|
+
log: (line: string) => void;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface PreflightStandaloneStartupDeps {
|
|
49
|
+
checkServer: (host: string, port: number) => Promise<{ ok: boolean; info?: { wsEndpoint?: string } }>;
|
|
50
|
+
isPortInUse: (port: number) => Promise<boolean>;
|
|
51
|
+
browserDataDir?: string;
|
|
52
|
+
recoverStaleBrowser?: (options: { cdpPort: number; browserDataDir: string }) => Promise<boolean>;
|
|
53
|
+
log: (line: string) => void;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function preflightStandaloneStartup(
|
|
57
|
+
args: EntrypointArgs,
|
|
58
|
+
deps: PreflightStandaloneStartupDeps
|
|
59
|
+
): Promise<boolean> {
|
|
60
|
+
const serverCheck = await deps.checkServer(args.host, args.port);
|
|
61
|
+
if (serverCheck.ok) {
|
|
62
|
+
deps.log(`Server already running on port ${args.port}`);
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (await deps.isPortInUse(args.cdpPort)) {
|
|
67
|
+
const recovered =
|
|
68
|
+
deps.browserDataDir && deps.recoverStaleBrowser
|
|
69
|
+
? await deps.recoverStaleBrowser({
|
|
70
|
+
cdpPort: args.cdpPort,
|
|
71
|
+
browserDataDir: deps.browserDataDir,
|
|
72
|
+
})
|
|
73
|
+
: false;
|
|
74
|
+
|
|
75
|
+
if (!recovered && (await deps.isPortInUse(args.cdpPort))) {
|
|
76
|
+
throw new Error(`CDP port ${args.cdpPort} is already in use by another process`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export async function ensurePlaywrightChromium(deps: EnsurePlaywrightChromiumDeps) {
|
|
84
|
+
if (deps.isInstalled()) {
|
|
85
|
+
deps.log("Playwright Chromium already installed.");
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
deps.log("Playwright Chromium not found. Installing (this may take a minute)...");
|
|
90
|
+
const manager = deps.findPackageManager();
|
|
91
|
+
if (!manager) {
|
|
92
|
+
throw new Error("No package manager found (tried bun, pnpm, npm)");
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
deps.log(`Using ${manager.name} to install Playwright...`);
|
|
96
|
+
await deps.runCommand(manager.command, manager.args);
|
|
97
|
+
deps.log("Chromium installed successfully.");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export async function runEntrypoint(args: EntrypointArgs, deps: RunEntrypointDeps) {
|
|
101
|
+
deps.mkdir(deps.runtimePaths.tmpDir, { recursive: true });
|
|
102
|
+
|
|
103
|
+
if (args.mode === "extension") {
|
|
104
|
+
const server = await deps.serveExtension({
|
|
105
|
+
port: args.port,
|
|
106
|
+
host: args.host,
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
for (const line of formatReadinessLines({
|
|
110
|
+
mode: "extension",
|
|
111
|
+
host: args.host,
|
|
112
|
+
port: args.port,
|
|
113
|
+
wsEndpoint: server.wsEndpoint,
|
|
114
|
+
})) {
|
|
115
|
+
deps.log(line);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
deps.registerShutdown(() => server.stop());
|
|
119
|
+
await deps.keepAlive();
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
deps.mkdir(deps.runtimePaths.profileDir, { recursive: true });
|
|
124
|
+
|
|
125
|
+
const shouldStart = await deps.preflightStandalone?.();
|
|
126
|
+
if (shouldStart === false) {
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
await deps.ensureBrowser?.();
|
|
131
|
+
|
|
132
|
+
const server = await deps.serveStandalone({
|
|
133
|
+
port: args.port,
|
|
134
|
+
host: args.host,
|
|
135
|
+
headless: args.headless,
|
|
136
|
+
cdpPort: args.cdpPort,
|
|
137
|
+
profileDir: deps.runtimePaths.profileDir,
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
for (const line of formatReadinessLines({
|
|
141
|
+
mode: "standalone",
|
|
142
|
+
host: args.host,
|
|
143
|
+
port: args.port,
|
|
144
|
+
wsEndpoint: server.wsEndpoint,
|
|
145
|
+
tmpDir: deps.runtimePaths.tmpDir,
|
|
146
|
+
profileDir: deps.runtimePaths.profileDir,
|
|
147
|
+
})) {
|
|
148
|
+
deps.log(line);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
deps.registerShutdown(() => server.stop());
|
|
152
|
+
await deps.keepAlive();
|
|
153
|
+
}
|
|
@@ -1,32 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Start the CDP relay server for Chrome extension mode
|
|
3
|
-
*
|
|
4
|
-
* Usage: npm run start-extension
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
import { serveRelay } from "@/relay.js";
|
|
8
|
-
|
|
9
|
-
const PORT = parseInt(process.env.PORT || "9222", 10);
|
|
10
|
-
const HOST = process.env.HOST || "127.0.0.1";
|
|
11
|
-
|
|
12
|
-
async function main() {
|
|
13
|
-
const server = await serveRelay({
|
|
14
|
-
port: PORT,
|
|
15
|
-
host: HOST,
|
|
16
|
-
});
|
|
17
|
-
|
|
18
|
-
// Handle shutdown
|
|
19
|
-
const shutdown = async () => {
|
|
20
|
-
console.log("\nShutting down relay server...");
|
|
21
|
-
await server.stop();
|
|
22
|
-
process.exit(0);
|
|
23
|
-
};
|
|
24
|
-
|
|
25
|
-
process.on("SIGINT", shutdown);
|
|
26
|
-
process.on("SIGTERM", shutdown);
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
main().catch((err) => {
|
|
30
|
-
console.error("Failed to start relay server:", err);
|
|
31
|
-
process.exit(1);
|
|
32
|
-
});
|
|
@@ -1,117 +0,0 @@
|
|
|
1
|
-
import { serve } from "@/index.js";
|
|
2
|
-
import { execSync } from "child_process";
|
|
3
|
-
import { mkdirSync, existsSync, readdirSync } from "fs";
|
|
4
|
-
import { join, dirname } from "path";
|
|
5
|
-
import { fileURLToPath } from "url";
|
|
6
|
-
|
|
7
|
-
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
8
|
-
const tmpDir = join(__dirname, "..", "tmp");
|
|
9
|
-
const profileDir = join(__dirname, "..", "profiles");
|
|
10
|
-
|
|
11
|
-
// Create tmp and profile directories if they don't exist
|
|
12
|
-
console.log("Creating tmp directory...");
|
|
13
|
-
mkdirSync(tmpDir, { recursive: true });
|
|
14
|
-
console.log("Creating profiles directory...");
|
|
15
|
-
mkdirSync(profileDir, { recursive: true });
|
|
16
|
-
|
|
17
|
-
// Install Playwright browsers if not already installed
|
|
18
|
-
console.log("Checking Playwright browser installation...");
|
|
19
|
-
|
|
20
|
-
function findPackageManager(): { name: string; command: string } | null {
|
|
21
|
-
const managers = [
|
|
22
|
-
{ name: "bun", command: "bunx playwright install chromium" },
|
|
23
|
-
{ name: "pnpm", command: "pnpm exec playwright install chromium" },
|
|
24
|
-
{ name: "npm", command: "npx playwright install chromium" },
|
|
25
|
-
];
|
|
26
|
-
|
|
27
|
-
for (const manager of managers) {
|
|
28
|
-
try {
|
|
29
|
-
execSync(`which ${manager.name}`, { stdio: "ignore" });
|
|
30
|
-
return manager;
|
|
31
|
-
} catch {
|
|
32
|
-
// Package manager not found, try next
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
return null;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
function isChromiumInstalled(): boolean {
|
|
39
|
-
const homeDir = process.env.HOME || process.env.USERPROFILE || "";
|
|
40
|
-
const playwrightCacheDir = join(homeDir, ".cache", "ms-playwright");
|
|
41
|
-
|
|
42
|
-
if (!existsSync(playwrightCacheDir)) {
|
|
43
|
-
return false;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
// Check for chromium directories (e.g., chromium-1148, chromium_headless_shell-1148)
|
|
47
|
-
try {
|
|
48
|
-
const entries = readdirSync(playwrightCacheDir);
|
|
49
|
-
return entries.some((entry) => entry.startsWith("chromium"));
|
|
50
|
-
} catch {
|
|
51
|
-
return false;
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
try {
|
|
56
|
-
if (!isChromiumInstalled()) {
|
|
57
|
-
console.log("Playwright Chromium not found. Installing (this may take a minute)...");
|
|
58
|
-
|
|
59
|
-
const pm = findPackageManager();
|
|
60
|
-
if (!pm) {
|
|
61
|
-
throw new Error("No package manager found (tried bun, pnpm, npm)");
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
console.log(`Using ${pm.name} to install Playwright...`);
|
|
65
|
-
execSync(pm.command, { stdio: "inherit" });
|
|
66
|
-
console.log("Chromium installed successfully.");
|
|
67
|
-
} else {
|
|
68
|
-
console.log("Playwright Chromium already installed.");
|
|
69
|
-
}
|
|
70
|
-
} catch (error) {
|
|
71
|
-
console.error("Failed to install Playwright browsers:", error);
|
|
72
|
-
console.log("You may need to run: npx playwright install chromium");
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
// Check if server is already running
|
|
76
|
-
console.log("Checking for existing servers...");
|
|
77
|
-
try {
|
|
78
|
-
const res = await fetch("http://localhost:9222", {
|
|
79
|
-
signal: AbortSignal.timeout(1000),
|
|
80
|
-
});
|
|
81
|
-
if (res.ok) {
|
|
82
|
-
console.log("Server already running on port 9222");
|
|
83
|
-
process.exit(0);
|
|
84
|
-
}
|
|
85
|
-
} catch {
|
|
86
|
-
// Server not running, continue to start
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
// Clean up stale CDP port if HTTP server isn't running (crash recovery)
|
|
90
|
-
// This handles the case where Node crashed but Chrome is still running on 9223
|
|
91
|
-
try {
|
|
92
|
-
const pid = execSync("lsof -ti:9223", { encoding: "utf-8" }).trim();
|
|
93
|
-
if (pid) {
|
|
94
|
-
console.log(`Cleaning up stale Chrome process on CDP port 9223 (PID: ${pid})`);
|
|
95
|
-
execSync(`kill -9 ${pid}`);
|
|
96
|
-
}
|
|
97
|
-
} catch {
|
|
98
|
-
// No process on CDP port, which is expected
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
console.log("Starting dev browser server...");
|
|
102
|
-
const headless = process.env.HEADLESS === "true";
|
|
103
|
-
const server = await serve({
|
|
104
|
-
port: 9222,
|
|
105
|
-
headless,
|
|
106
|
-
profileDir,
|
|
107
|
-
});
|
|
108
|
-
|
|
109
|
-
console.log(`Dev browser server started`);
|
|
110
|
-
console.log(` WebSocket: ${server.wsEndpoint}`);
|
|
111
|
-
console.log(` Tmp directory: ${tmpDir}`);
|
|
112
|
-
console.log(` Profile directory: ${profileDir}`);
|
|
113
|
-
console.log(`\nReady`);
|
|
114
|
-
console.log(`\nPress Ctrl+C to stop`);
|
|
115
|
-
|
|
116
|
-
// Keep the process running
|
|
117
|
-
await new Promise(() => {});
|
|
@@ -1,24 +0,0 @@
|
|
|
1
|
-
#!/bin/bash
|
|
2
|
-
|
|
3
|
-
# Get the directory where this script is located
|
|
4
|
-
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
|
|
5
|
-
|
|
6
|
-
# Change to the script directory
|
|
7
|
-
cd "$SCRIPT_DIR"
|
|
8
|
-
|
|
9
|
-
# Parse command line arguments
|
|
10
|
-
HEADLESS=false
|
|
11
|
-
while [[ "$#" -gt 0 ]]; do
|
|
12
|
-
case $1 in
|
|
13
|
-
--headless) HEADLESS=true ;;
|
|
14
|
-
*) echo "Unknown parameter: $1"; exit 1 ;;
|
|
15
|
-
esac
|
|
16
|
-
shift
|
|
17
|
-
done
|
|
18
|
-
|
|
19
|
-
echo "Installing dependencies..."
|
|
20
|
-
npm install
|
|
21
|
-
|
|
22
|
-
echo "Starting dev-browser server..."
|
|
23
|
-
export HEADLESS=$HEADLESS
|
|
24
|
-
npx tsx scripts/start-server.ts
|
|
Binary file
|
|
Binary file
|