@brunosps00/dev-workflow 1.1.2 → 1.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/bin/dev-workflow.js +1 -1
- package/lib/install-deps.js +1 -1
- package/lib/setup-wsl-browser.js +10 -50
- package/package.json +1 -1
- package/scaffold/en/commands/dw-functional-doc.md +1 -1
- package/scaffold/en/commands/dw-help.md +1 -1
- package/scaffold/pt-br/commands/dw-functional-doc.md +1 -1
- package/scaffold/pt-br/commands/dw-help.md +1 -1
- package/scaffold/scripts/cdp-relay/Cargo.toml +1 -1
- package/scaffold/scripts/cdp-relay/bin/cdp-relay.exe +0 -0
- package/scaffold/scripts/cdp-relay/src/main.rs +120 -23
- package/scaffold/scripts/lib/resolve-browser.mjs +271 -42
- package/scaffold/skills/dw-testing-discipline/references/playwright-recipes.md +8 -8
package/README.md
CHANGED
|
@@ -26,7 +26,7 @@ npx @brunosps00/dev-workflow install-deps
|
|
|
26
26
|
|
|
27
27
|
## Commands
|
|
28
28
|
|
|
29
|
-
dev-workflow v1.1.
|
|
29
|
+
dev-workflow v1.1.3 ships **31 commands** organized into four tiers. Most users only invoke Tier 1 + Tier 2.
|
|
30
30
|
|
|
31
31
|
### Tier 1 — Gateway (3)
|
|
32
32
|
|
|
@@ -310,7 +310,7 @@ npx @brunosps00/dev-workflow list-installed # Show managed instal
|
|
|
310
310
|
npx @brunosps00/dev-workflow doctor # Check wrappers, agents, skills, MCPs, gates
|
|
311
311
|
npx @brunosps00/dev-workflow repair # Reconcile managed files from recorded install state
|
|
312
312
|
npx @brunosps00/dev-workflow install-deps # Install Playwright, react-doctor; check security scanners + Docker
|
|
313
|
-
npx @brunosps00/dev-workflow setup-wsl-browser # WSL NAT: install prebuilt CDP relay
|
|
313
|
+
npx @brunosps00/dev-workflow setup-wsl-browser # WSL NAT: install user-level prebuilt CDP relay (no admin)
|
|
314
314
|
npx @brunosps00/dev-workflow install-azure-skills # Opt-in: Azure skills + Microsoft Learn MCP
|
|
315
315
|
npx @brunosps00/dev-workflow install-azure-skills --products=azure-aks,azure-openai # Subset override
|
|
316
316
|
npx @brunosps00/dev-workflow install-aws-skills # Opt-in: AWS skills + AWS MCP Server (needs uv + aws cli + creds)
|
package/bin/dev-workflow.js
CHANGED
|
@@ -47,7 +47,7 @@ const HELP_TEXT = `
|
|
|
47
47
|
repair Reconcile managed files using the recorded install state
|
|
48
48
|
subtask Create, complete, consume, and list local subagent handoffs
|
|
49
49
|
install-deps Install system dependencies (Playwright browsers, MCP servers)
|
|
50
|
-
setup-wsl-browser (WSL) Install the prebuilt CDP relay
|
|
50
|
+
setup-wsl-browser (WSL) Install the user-level prebuilt CDP relay so flows can drive the real Windows browser without admin
|
|
51
51
|
install-azure-skills Opt-in: clone curated Azure skills from MicrosoftDocs/Agent-Skills
|
|
52
52
|
into .agents/skills/azure/ and register the Microsoft Learn MCP
|
|
53
53
|
server (HTTP, no-auth). Interactive category selection.
|
package/lib/install-deps.js
CHANGED
|
@@ -173,7 +173,7 @@ function printBrowserGuidance() {
|
|
|
173
173
|
console.log(' Mirrored networking detected — connects to the Windows browser on 127.0.0.1 directly.');
|
|
174
174
|
} else {
|
|
175
175
|
console.log(' NAT networking — run `npx @brunosps00/dev-workflow setup-wsl-browser` once to install the');
|
|
176
|
-
console.log(' bundled cdp-relay.exe
|
|
176
|
+
console.log(' bundled user-level cdp-relay.exe. No admin prompt or firewall rule is required. Otherwise flows');
|
|
177
177
|
console.log(' fall back to the local headless Chromium (faithful video, no Windows browser).');
|
|
178
178
|
}
|
|
179
179
|
console.log(' Details: .dw/skills/dw-testing-discipline/references/playwright-recipes.md ("Browser on WSL").');
|
package/lib/setup-wsl-browser.js
CHANGED
|
@@ -2,17 +2,13 @@ const { execSync } = require('child_process');
|
|
|
2
2
|
const fs = require('fs');
|
|
3
3
|
const path = require('path');
|
|
4
4
|
|
|
5
|
-
// Installs the prebuilt CDP relay (cdp-relay.exe)
|
|
6
|
-
// Hyper-V firewall rule so WSL (NAT networking) can drive the real Windows browser over CDP.
|
|
7
|
-
// Idempotent.
|
|
5
|
+
// Installs the prebuilt CDP relay (cdp-relay.exe) in the Windows user profile. Idempotent.
|
|
8
6
|
//
|
|
9
|
-
// Why: Chromium binds its debug port to Windows loopback only
|
|
10
|
-
//
|
|
7
|
+
// Why: Chromium binds its debug port to Windows loopback only. On WSL NAT, dev-workflow uses
|
|
8
|
+
// a reverse relay where the Windows-side .exe connects outbound to a WSL broker, so no admin
|
|
9
|
+
// prompt, firewall rule, Rust, Node, or Python runtime is required on the Windows target.
|
|
11
10
|
// The relay is a tiny prebuilt .exe — no Rust/Node/Python runtime dependency on the Windows target.
|
|
12
11
|
|
|
13
|
-
const RELAY_PORT = 39222;
|
|
14
|
-
const WSL_VM_CREATOR_ID = '{40E0AC32-46A5-438A-A0B2-2B479E8F2E90}'; // standard WSL VM creator id
|
|
15
|
-
const RULE_NAME = 'dw-cdp-relay';
|
|
16
12
|
const RELAY_EXE_SRC = path.join(__dirname, '..', 'scaffold', 'scripts', 'cdp-relay', 'bin', 'cdp-relay.exe');
|
|
17
13
|
|
|
18
14
|
function isWsl() {
|
|
@@ -50,7 +46,7 @@ function run() {
|
|
|
50
46
|
const destWin = `${localAppData}\\dev-workflow`;
|
|
51
47
|
const destWsl = execSync(`wslpath -u "${destWin}"`, { stdio: 'pipe' }).toString().trim();
|
|
52
48
|
|
|
53
|
-
//
|
|
49
|
+
// Install the prebuilt Windows relay bundled in the npm package.
|
|
54
50
|
if (!fs.existsSync(RELAY_EXE_SRC)) {
|
|
55
51
|
console.log(` \x1b[31m✗\x1b[0m Prebuilt relay not found at ${RELAY_EXE_SRC}`);
|
|
56
52
|
console.log(' The dev-workflow package is incomplete. Reinstall or update @brunosps00/dev-workflow.');
|
|
@@ -69,48 +65,12 @@ function run() {
|
|
|
69
65
|
return;
|
|
70
66
|
}
|
|
71
67
|
|
|
72
|
-
// 2) Add the Hyper-V firewall inbound rule (one-time, elevated -> UAC prompt).
|
|
73
|
-
console.log(`\n Adding Hyper-V firewall rule for TCP ${RELAY_PORT} (a UAC prompt will appear — accept it)...`);
|
|
74
|
-
const inner = [
|
|
75
|
-
`Remove-NetFirewallHyperVRule -Name '${RULE_NAME}' -ErrorAction SilentlyContinue;`,
|
|
76
|
-
`New-NetFirewallHyperVRule -Name '${RULE_NAME}' -DisplayName 'dev-workflow cdp-relay'`,
|
|
77
|
-
`-Direction Inbound -Action Allow -VMCreatorId '${WSL_VM_CREATOR_ID}' -Protocol TCP -LocalPorts ${RELAY_PORT}`,
|
|
78
|
-
].join(' ');
|
|
79
|
-
try {
|
|
80
|
-
execSync(
|
|
81
|
-
`powershell.exe -NoProfile -Command "Start-Process powershell -Verb RunAs -WindowStyle Hidden -ArgumentList '-NoProfile','-Command',\\"${inner}\\""`,
|
|
82
|
-
{ stdio: 'pipe', timeout: 60000 },
|
|
83
|
-
);
|
|
84
|
-
} catch {
|
|
85
|
-
// Start-Process returns before the elevated process finishes; verify below regardless.
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
// 3) Verify.
|
|
89
|
-
let ok = false;
|
|
90
|
-
for (let i = 0; i < 10 && !ok; i += 1) {
|
|
91
|
-
try {
|
|
92
|
-
const out = execSync(
|
|
93
|
-
`powershell.exe -NoProfile -c "Get-NetFirewallHyperVRule -Name '${RULE_NAME}' -ErrorAction SilentlyContinue | Select-Object -Expand Action"`,
|
|
94
|
-
{ stdio: 'pipe', timeout: 15000 },
|
|
95
|
-
).toString();
|
|
96
|
-
ok = /Allow/i.test(out);
|
|
97
|
-
} catch {
|
|
98
|
-
ok = false;
|
|
99
|
-
}
|
|
100
|
-
if (!ok) execSync('sleep 1');
|
|
101
|
-
}
|
|
102
|
-
|
|
103
68
|
console.log(` ${'='.repeat(40)}`);
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
} else {
|
|
109
|
-
console.log(' \x1b[33m⚠ Hyper-V firewall rule not confirmed.\x1b[0m UAC may have been declined, or this Windows lacks');
|
|
110
|
-
console.log(' Hyper-V firewall cmdlets. Flows will fall back to the local headless Chromium (faithful video, no');
|
|
111
|
-
console.log(' Windows browser). You can also enable mirrored networking instead. Re-run to retry.');
|
|
112
|
-
}
|
|
69
|
+
console.log(' \x1b[32m✓ Done.\x1b[0m The CDP relay is installed in the Windows user profile.');
|
|
70
|
+
console.log(' No admin prompt, firewall rule, or Windows Rust toolchain is required.');
|
|
71
|
+
console.log(` Set BROWSER_TEST to a Windows browser exe to use it, e.g.`);
|
|
72
|
+
console.log(' BROWSER_TEST="/mnt/c/Program Files (x86)/Microsoft/Edge/Application/msedge.exe"');
|
|
113
73
|
console.log();
|
|
114
74
|
}
|
|
115
75
|
|
|
116
|
-
module.exports = { run
|
|
76
|
+
module.exports = { run };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@brunosps00/dev-workflow",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.3",
|
|
4
4
|
"description": "AI-driven development workflow commands for any project. Scaffolds a complete PRD-to-PR pipeline with multi-platform AI assistant support.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"dev-workflow": "./bin/dev-workflow.js"
|
|
@@ -298,7 +298,7 @@ The runner drives Playwright via the Node API and records video with `page.scree
|
|
|
298
298
|
mechanism that works over CDP). It generates `scripts/<slug>.flow.mjs` — a plain ESM module exporting
|
|
299
299
|
`async function flow({ page, context, expect, baseURL, step, shot })` — not a `@playwright/test` spec,
|
|
300
300
|
so it needs no project `playwright.config.*`. The browser is chosen by `.dw/scripts/lib/resolve-browser.mjs`
|
|
301
|
-
(WSL-resilient: full headless Chromium by default; set `BROWSER_TEST` to a Windows browser exe to drive it over CDP — in NAT run `npx @brunosps00/dev-workflow setup-wsl-browser` once to install the
|
|
301
|
+
(WSL-resilient: full headless Chromium by default; set `BROWSER_TEST` to a Windows browser exe to drive it over CDP — in NAT run `npx @brunosps00/dev-workflow setup-wsl-browser` once to install the user-level reverse cdp-relay.exe, mirrored networking works directly);
|
|
302
302
|
see the "Browser on WSL" section of `dw-testing-discipline/references/playwright-recipes.md`. Chromium
|
|
303
303
|
only — `screencast`/CDP do not support Firefox/WebKit.
|
|
304
304
|
|
|
@@ -124,7 +124,7 @@ Match the keyword and suggest:
|
|
|
124
124
|
| `docker`, `compose`, `container` | `/dw-dockerize` |
|
|
125
125
|
| `new project`, `bootstrap`, `scaffold` | `/dw-new-project` |
|
|
126
126
|
| `functional doc`, `screen map`, `e2e doc` | `/dw-functional-doc` |
|
|
127
|
-
| `wsl`, `browser`, `playwright browser`, `cdp` | `npx @brunosps00/dev-workflow setup-wsl-browser` installs the
|
|
127
|
+
| `wsl`, `browser`, `playwright browser`, `cdp` | `npx @brunosps00/dev-workflow setup-wsl-browser` installs the user-level reverse relay when WSL NAT must drive the real Windows browser |
|
|
128
128
|
| `azure`, `microsoft learn`, `azure skills` | `/dw-install-azure-skills` |
|
|
129
129
|
| `aws`, `aws mcp`, `aws skills` | `/dw-install-aws-skills` |
|
|
130
130
|
| `incident`, `outage`, `postmortem`, `sev-1`, `sev-2` | (Skill `dw-incident-response` auto-invoked from `/dw-bugfix` for prod-critical) |
|
|
@@ -298,7 +298,7 @@ O runner dirige o Playwright pela API Node e grava vídeo com `page.screencast`
|
|
|
298
298
|
funciona via CDP). Ele gera `scripts/<slug>.flow.mjs` — um módulo ESM simples que exporta
|
|
299
299
|
`async function flow({ page, context, expect, baseURL, step, shot })`, não um spec `@playwright/test`,
|
|
300
300
|
então dispensa o `playwright.config.*` do projeto. O browser é escolhido por `.dw/scripts/lib/resolve-browser.mjs`
|
|
301
|
-
(resiliente no WSL: Chromium headless full por padrão; defina `BROWSER_TEST` com o caminho do exe do browser do Windows para dirigi-lo via CDP — em NAT rode `npx @brunosps00/dev-workflow setup-wsl-browser` uma vez para instalar o cdp-relay.exe
|
|
301
|
+
(resiliente no WSL: Chromium headless full por padrão; defina `BROWSER_TEST` com o caminho do exe do browser do Windows para dirigi-lo via CDP — em NAT rode `npx @brunosps00/dev-workflow setup-wsl-browser` uma vez para instalar o cdp-relay.exe reverso no contexto do usuário, em mirrored funciona direto);
|
|
302
302
|
veja a seção "Browser on WSL" de `dw-testing-discipline/references/playwright-recipes.md`. Só Chromium —
|
|
303
303
|
`screencast`/CDP não suportam Firefox/WebKit.
|
|
304
304
|
|
|
@@ -120,7 +120,7 @@ Auto-invocados por comandos primários mas disponíveis standalone.
|
|
|
120
120
|
| `docker`, `compose`, `container` | `/dw-dockerize` |
|
|
121
121
|
| `new project`, `bootstrap`, `scaffold` | `/dw-new-project` |
|
|
122
122
|
| `functional doc`, `screen map` | `/dw-functional-doc` |
|
|
123
|
-
| `wsl`, `browser`, `playwright browser`, `cdp` | `npx @brunosps00/dev-workflow setup-wsl-browser` instala o relay
|
|
123
|
+
| `wsl`, `browser`, `playwright browser`, `cdp` | `npx @brunosps00/dev-workflow setup-wsl-browser` instala o relay reverso no contexto do usuário quando WSL NAT precisa dirigir o browser real do Windows |
|
|
124
124
|
| `azure`, `microsoft learn`, `azure skills` | `/dw-install-azure-skills` |
|
|
125
125
|
| `aws`, `aws mcp`, `aws skills` | `/dw-install-aws-skills` |
|
|
126
126
|
| `incident`, `outage`, `postmortem`, `sev-1` | (Skill `dw-incident-response` auto-invocada de `/dw-bugfix`) |
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
name = "cdp-relay"
|
|
3
3
|
version = "0.1.0"
|
|
4
4
|
edition = "2021"
|
|
5
|
-
description = "Tiny TCP
|
|
5
|
+
description = "Tiny forward/reverse TCP relay so WSL can reach a Windows browser's CDP debug port in NAT mode without admin setup."
|
|
6
6
|
|
|
7
7
|
[[bin]]
|
|
8
8
|
name = "cdp-relay"
|
|
Binary file
|
|
@@ -1,15 +1,20 @@
|
|
|
1
1
|
//! cdp-relay — a tiny TCP forwarder.
|
|
2
2
|
//!
|
|
3
|
-
//!
|
|
4
|
-
//!
|
|
5
|
-
//!
|
|
6
|
-
//!
|
|
7
|
-
//!
|
|
3
|
+
//! Default mode listens on 0.0.0.0:<listen> and forwards every connection to
|
|
4
|
+
//! 127.0.0.1:<target>. Reverse mode connects outbound to a WSL broker, then forwards
|
|
5
|
+
//! each broker-approved session to 127.0.0.1:<target>. dev-workflow uses reverse mode
|
|
6
|
+
//! on WSL NAT so no Windows admin/firewall rule is required. std-only: no crates, no
|
|
7
|
+
//! runtime. dev-workflow ships a prebuilt Windows x64 .exe; this source is kept for
|
|
8
|
+
//! maintainers who need to rebuild it.
|
|
8
9
|
|
|
9
10
|
use std::env;
|
|
10
|
-
use std::io;
|
|
11
|
+
use std::io::{self, Read};
|
|
11
12
|
use std::net::{Shutdown, TcpListener, TcpStream};
|
|
12
13
|
use std::thread;
|
|
14
|
+
use std::time::Duration;
|
|
15
|
+
|
|
16
|
+
const DEFAULT_REVERSE_POOL: usize = 8;
|
|
17
|
+
const RETRY_DELAY: Duration = Duration::from_millis(200);
|
|
13
18
|
|
|
14
19
|
fn pump(mut from: TcpStream, mut to: TcpStream) {
|
|
15
20
|
let _ = io::copy(&mut from, &mut to);
|
|
@@ -17,29 +22,36 @@ fn pump(mut from: TcpStream, mut to: TcpStream) {
|
|
|
17
22
|
let _ = to.shutdown(Shutdown::Write);
|
|
18
23
|
}
|
|
19
24
|
|
|
25
|
+
fn bridge(left: TcpStream, right: TcpStream) -> io::Result<()> {
|
|
26
|
+
left.set_nodelay(true).ok();
|
|
27
|
+
right.set_nodelay(true).ok();
|
|
28
|
+
|
|
29
|
+
let left_rx = left.try_clone()?;
|
|
30
|
+
let right_rx = right.try_clone()?;
|
|
31
|
+
thread::spawn(move || pump(left, right_rx));
|
|
32
|
+
thread::spawn(move || pump(right, left_rx));
|
|
33
|
+
Ok(())
|
|
34
|
+
}
|
|
35
|
+
|
|
20
36
|
fn handle(client: TcpStream, target_port: u16) -> io::Result<()> {
|
|
21
|
-
client.set_nodelay(true).ok();
|
|
22
37
|
let upstream = TcpStream::connect(("127.0.0.1", target_port))?;
|
|
23
|
-
upstream
|
|
24
|
-
|
|
25
|
-
let client_rx = client.try_clone()?;
|
|
26
|
-
let upstream_rx = upstream.try_clone()?;
|
|
27
|
-
// client -> upstream
|
|
28
|
-
thread::spawn(move || pump(client, upstream));
|
|
29
|
-
// upstream -> client
|
|
30
|
-
thread::spawn(move || pump(upstream_rx, client_rx));
|
|
31
|
-
Ok(())
|
|
38
|
+
bridge(client, upstream)
|
|
32
39
|
}
|
|
33
40
|
|
|
34
|
-
fn
|
|
35
|
-
let
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
41
|
+
fn wait_for_go(stream: &mut TcpStream) -> io::Result<()> {
|
|
42
|
+
let mut seen = Vec::with_capacity(3);
|
|
43
|
+
let mut byte = [0u8; 1];
|
|
44
|
+
while seen.len() < 3 {
|
|
45
|
+
stream.read_exact(&mut byte)?;
|
|
46
|
+
seen.push(byte[0]);
|
|
47
|
+
if seen == b"GO\n" {
|
|
48
|
+
return Ok(());
|
|
49
|
+
}
|
|
39
50
|
}
|
|
40
|
-
|
|
41
|
-
|
|
51
|
+
Err(io::Error::new(io::ErrorKind::InvalidData, "expected GO"))
|
|
52
|
+
}
|
|
42
53
|
|
|
54
|
+
fn run_listen(listen_port: u16, target_port: u16) -> io::Result<()> {
|
|
43
55
|
let listener = TcpListener::bind(("0.0.0.0", listen_port)).expect("bind failed");
|
|
44
56
|
for stream in listener.incoming() {
|
|
45
57
|
if let Ok(client) = stream {
|
|
@@ -47,4 +59,89 @@ fn main() {
|
|
|
47
59
|
let _ = handle(client, target_port);
|
|
48
60
|
}
|
|
49
61
|
}
|
|
62
|
+
Ok(())
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
fn handle_reverse_session(mut broker: TcpStream, target_port: u16) -> io::Result<()> {
|
|
66
|
+
broker.set_nodelay(true).ok();
|
|
67
|
+
wait_for_go(&mut broker)?;
|
|
68
|
+
let upstream = TcpStream::connect(("127.0.0.1", target_port))?;
|
|
69
|
+
bridge(broker, upstream)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
fn reverse_worker(broker_host: String, broker_port: u16, target_port: u16) {
|
|
73
|
+
loop {
|
|
74
|
+
match TcpStream::connect((broker_host.as_str(), broker_port)) {
|
|
75
|
+
Ok(broker) => {
|
|
76
|
+
if handle_reverse_session(broker, target_port).is_err() {
|
|
77
|
+
thread::sleep(RETRY_DELAY);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
Err(_) => thread::sleep(RETRY_DELAY),
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
fn run_reverse(broker_host: String, broker_port: u16, target_port: u16, pool_size: usize) {
|
|
86
|
+
let pool_size = pool_size.max(1);
|
|
87
|
+
for _ in 0..pool_size {
|
|
88
|
+
let host = broker_host.clone();
|
|
89
|
+
thread::spawn(move || reverse_worker(host, broker_port, target_port));
|
|
90
|
+
}
|
|
91
|
+
loop {
|
|
92
|
+
thread::sleep(Duration::from_secs(3600));
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
fn usage() -> ! {
|
|
97
|
+
eprintln!("usage:");
|
|
98
|
+
eprintln!(" cdp-relay <listen_port> <target_port>");
|
|
99
|
+
eprintln!(" cdp-relay --reverse <broker_host> <broker_port> <target_port> [pool_size]");
|
|
100
|
+
std::process::exit(2);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
fn parse_port(value: &str, name: &str) -> u16 {
|
|
104
|
+
value.parse().unwrap_or_else(|_| {
|
|
105
|
+
eprintln!("invalid {name}");
|
|
106
|
+
std::process::exit(2);
|
|
107
|
+
})
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
fn parse_pool(value: Option<&String>) -> usize {
|
|
111
|
+
value
|
|
112
|
+
.map(|s| {
|
|
113
|
+
s.parse().unwrap_or_else(|_| {
|
|
114
|
+
eprintln!("invalid pool_size");
|
|
115
|
+
std::process::exit(2);
|
|
116
|
+
})
|
|
117
|
+
})
|
|
118
|
+
.unwrap_or(DEFAULT_REVERSE_POOL)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
fn main() {
|
|
122
|
+
let args: Vec<String> = env::args().collect();
|
|
123
|
+
if args.get(1).map(String::as_str) == Some("--reverse") {
|
|
124
|
+
if args.len() < 5 {
|
|
125
|
+
usage();
|
|
126
|
+
}
|
|
127
|
+
let broker_host = args[2].clone();
|
|
128
|
+
let broker_port = parse_port(&args[3], "broker_port");
|
|
129
|
+
let target_port = parse_port(&args[4], "target_port");
|
|
130
|
+
run_reverse(
|
|
131
|
+
broker_host,
|
|
132
|
+
broker_port,
|
|
133
|
+
target_port,
|
|
134
|
+
parse_pool(args.get(5)),
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if args.len() < 3 {
|
|
139
|
+
usage();
|
|
140
|
+
}
|
|
141
|
+
let listen_port = parse_port(&args[1], "listen_port");
|
|
142
|
+
let target_port = parse_port(&args[2], "target_port");
|
|
143
|
+
if let Err(err) = run_listen(listen_port, target_port) {
|
|
144
|
+
eprintln!("cdp-relay: {err}");
|
|
145
|
+
std::process::exit(1);
|
|
146
|
+
}
|
|
50
147
|
}
|
|
@@ -19,10 +19,9 @@
|
|
|
19
19
|
//
|
|
20
20
|
// NOTE on WSL networking: the Chromium debug port binds Windows loopback only. In mirrored
|
|
21
21
|
// networking mode WSL shares that loopback, so 127.0.0.1 connects directly. In NAT mode the
|
|
22
|
-
// helper starts the prebuilt cdp-relay.exe on Windows
|
|
23
|
-
// and connects
|
|
24
|
-
//
|
|
25
|
-
// rule). Without it, the helper falls back to the local headless Chromium.
|
|
22
|
+
// helper starts the prebuilt cdp-relay.exe on Windows in reverse mode: the Windows process
|
|
23
|
+
// connects outbound to a WSL broker, and Playwright connects to a local WSL proxy. The one-time
|
|
24
|
+
// setup only installs the relay in the Windows user profile; no admin/firewall rule is needed.
|
|
26
25
|
|
|
27
26
|
import fs from "node:fs";
|
|
28
27
|
import net from "node:net";
|
|
@@ -35,6 +34,8 @@ const BUNDLED_LAUNCH = { channel: "chromium" };
|
|
|
35
34
|
|
|
36
35
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
37
36
|
const dedupe = (items) => [...new Set(items.filter(Boolean))];
|
|
37
|
+
const REVERSE_RELAY_POOL = 8;
|
|
38
|
+
const CLIENT_WAIT_TIMEOUT_MS = 5000;
|
|
38
39
|
|
|
39
40
|
export function detectWsl() {
|
|
40
41
|
if (process.env.WSL_DISTRO_NAME) {
|
|
@@ -96,6 +97,42 @@ function getFreePort() {
|
|
|
96
97
|
});
|
|
97
98
|
}
|
|
98
99
|
|
|
100
|
+
function listen(server, port, host) {
|
|
101
|
+
return new Promise((resolve, reject) => {
|
|
102
|
+
const onError = (error) => {
|
|
103
|
+
server.off("listening", onListening);
|
|
104
|
+
reject(error);
|
|
105
|
+
};
|
|
106
|
+
const onListening = () => {
|
|
107
|
+
server.off("error", onError);
|
|
108
|
+
resolve(server.address().port);
|
|
109
|
+
};
|
|
110
|
+
server.once("error", onError);
|
|
111
|
+
server.listen(port, host, onListening);
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function removeItem(items, item) {
|
|
116
|
+
const index = items.indexOf(item);
|
|
117
|
+
if (index >= 0) items.splice(index, 1);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function destroyQuietly(item) {
|
|
121
|
+
try {
|
|
122
|
+
item.destroy();
|
|
123
|
+
} catch {
|
|
124
|
+
// ignore
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function closeServerQuietly(server) {
|
|
129
|
+
try {
|
|
130
|
+
server.close();
|
|
131
|
+
} catch {
|
|
132
|
+
// ignore
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
99
136
|
function windowsHostIp() {
|
|
100
137
|
// In WSL2 NAT mode the Windows host is the default-route gateway (the vEthernet
|
|
101
138
|
// adapter), NOT the /etc/resolv.conf nameserver (which can be a DNS-tunneling proxy
|
|
@@ -120,6 +157,27 @@ function windowsHostIp() {
|
|
|
120
157
|
return null;
|
|
121
158
|
}
|
|
122
159
|
|
|
160
|
+
function wslBrokerHosts() {
|
|
161
|
+
const hosts = ["127.0.0.1"];
|
|
162
|
+
try {
|
|
163
|
+
const out = spawnSync("ip", ["-4", "-o", "addr", "show", "dev", "eth0", "scope", "global"], { encoding: "utf8" }).stdout ?? "";
|
|
164
|
+
const match = out.match(/\binet\s+([0-9.]+)\//);
|
|
165
|
+
if (match) hosts.push(match[1]);
|
|
166
|
+
} catch {
|
|
167
|
+
// ignore
|
|
168
|
+
}
|
|
169
|
+
if (hosts.length === 1) {
|
|
170
|
+
try {
|
|
171
|
+
const out = spawnSync("hostname", ["-I"], { encoding: "utf8" }).stdout ?? "";
|
|
172
|
+
const fallback = out.split(/\s+/).find((ip) => /^[0-9.]+$/.test(ip) && !ip.startsWith("127."));
|
|
173
|
+
if (fallback) hosts.push(fallback);
|
|
174
|
+
} catch {
|
|
175
|
+
// ignore
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return dedupe(hosts);
|
|
179
|
+
}
|
|
180
|
+
|
|
123
181
|
function httpGetJson(url, timeoutMs = 1000) {
|
|
124
182
|
return new Promise((resolve) => {
|
|
125
183
|
const request = http.get(url, { timeout: timeoutMs }, (response) => {
|
|
@@ -190,21 +248,19 @@ function windowsTempDir() {
|
|
|
190
248
|
return wslPath ? { winPath, wslPath } : null;
|
|
191
249
|
}
|
|
192
250
|
|
|
193
|
-
// Fixed port the Windows-side relay listens on, so a single Hyper-V firewall rule covers it.
|
|
194
|
-
const DEFAULT_RELAY_PORT = 39222;
|
|
195
|
-
|
|
196
251
|
function relayPort(projectRoot) {
|
|
197
252
|
const fromEnv = Number(process.env.DW_RELAY_PORT);
|
|
198
|
-
if (fromEnv) return fromEnv;
|
|
253
|
+
if (Number.isInteger(fromEnv) && fromEnv > 0 && fromEnv < 65536) return fromEnv;
|
|
199
254
|
if (projectRoot) {
|
|
200
255
|
try {
|
|
201
256
|
const cfg = JSON.parse(fs.readFileSync(path.join(projectRoot, ".dw", "config.json"), "utf8"));
|
|
202
|
-
|
|
257
|
+
const fromConfig = Number(cfg?.relayPort);
|
|
258
|
+
if (Number.isInteger(fromConfig) && fromConfig > 0 && fromConfig < 65536) return fromConfig;
|
|
203
259
|
} catch {
|
|
204
260
|
// ignore
|
|
205
261
|
}
|
|
206
262
|
}
|
|
207
|
-
return
|
|
263
|
+
return 0;
|
|
208
264
|
}
|
|
209
265
|
|
|
210
266
|
// The prebuilt Windows relay (cdp-relay.exe), installed by `setup-wsl-browser`. Order:
|
|
@@ -237,13 +293,183 @@ function findRelayExe(projectRoot) {
|
|
|
237
293
|
return null;
|
|
238
294
|
}
|
|
239
295
|
|
|
296
|
+
async function startReverseRelayBroker({ relay, debugPort, projectRoot }) {
|
|
297
|
+
const idleRelays = [];
|
|
298
|
+
const pendingClients = [];
|
|
299
|
+
const relaySockets = [];
|
|
300
|
+
const localSockets = [];
|
|
301
|
+
const relayChildren = [];
|
|
302
|
+
const relayWaiters = [];
|
|
303
|
+
let closed = false;
|
|
304
|
+
|
|
305
|
+
const notifyRelayReady = () => {
|
|
306
|
+
while (idleRelays.length > 0 && relayWaiters.length > 0) {
|
|
307
|
+
const waiter = relayWaiters.shift();
|
|
308
|
+
clearTimeout(waiter.timer);
|
|
309
|
+
waiter.resolve(true);
|
|
310
|
+
}
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
const waitForRelayReady = (timeoutMs) => {
|
|
314
|
+
if (idleRelays.length > 0) return Promise.resolve(true);
|
|
315
|
+
return new Promise((resolve) => {
|
|
316
|
+
const waiter = { resolve, timer: null };
|
|
317
|
+
waiter.timer = setTimeout(() => {
|
|
318
|
+
removeItem(relayWaiters, waiter);
|
|
319
|
+
resolve(false);
|
|
320
|
+
}, timeoutMs);
|
|
321
|
+
relayWaiters.push(waiter);
|
|
322
|
+
});
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
const removePendingClient = (pending) => {
|
|
326
|
+
clearTimeout(pending.timer);
|
|
327
|
+
removeItem(pendingClients, pending);
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
const pairSockets = (client, relaySocket) => {
|
|
331
|
+
const closePair = () => {
|
|
332
|
+
destroyQuietly(client);
|
|
333
|
+
destroyQuietly(relaySocket);
|
|
334
|
+
};
|
|
335
|
+
client.on("error", closePair);
|
|
336
|
+
relaySocket.on("error", closePair);
|
|
337
|
+
client.on("close", () => destroyQuietly(relaySocket));
|
|
338
|
+
relaySocket.on("close", () => destroyQuietly(client));
|
|
339
|
+
relaySocket.write("GO\n", (error) => {
|
|
340
|
+
if (error) closePair();
|
|
341
|
+
});
|
|
342
|
+
client.pipe(relaySocket);
|
|
343
|
+
relaySocket.pipe(client);
|
|
344
|
+
};
|
|
345
|
+
|
|
346
|
+
const drainPending = () => {
|
|
347
|
+
while (pendingClients.length > 0 && idleRelays.length > 0) {
|
|
348
|
+
const pending = pendingClients.shift();
|
|
349
|
+
clearTimeout(pending.timer);
|
|
350
|
+
const relaySocket = idleRelays.shift();
|
|
351
|
+
if (pending.client.destroyed || relaySocket.destroyed) {
|
|
352
|
+
destroyQuietly(pending.client);
|
|
353
|
+
destroyQuietly(relaySocket);
|
|
354
|
+
continue;
|
|
355
|
+
}
|
|
356
|
+
pairSockets(pending.client, relaySocket);
|
|
357
|
+
}
|
|
358
|
+
};
|
|
359
|
+
|
|
360
|
+
const brokerServer = net.createServer((socket) => {
|
|
361
|
+
if (closed) {
|
|
362
|
+
socket.destroy();
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
socket.setNoDelay(true);
|
|
366
|
+
relaySockets.push(socket);
|
|
367
|
+
idleRelays.push(socket);
|
|
368
|
+
socket.on("error", () => destroyQuietly(socket));
|
|
369
|
+
socket.on("close", () => {
|
|
370
|
+
removeItem(relaySockets, socket);
|
|
371
|
+
removeItem(idleRelays, socket);
|
|
372
|
+
});
|
|
373
|
+
notifyRelayReady();
|
|
374
|
+
drainPending();
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
const localServer = net.createServer((client) => {
|
|
378
|
+
if (closed) {
|
|
379
|
+
client.destroy();
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
client.setNoDelay(true);
|
|
383
|
+
localSockets.push(client);
|
|
384
|
+
client.on("close", () => removeItem(localSockets, client));
|
|
385
|
+
client.on("error", () => destroyQuietly(client));
|
|
386
|
+
const pending = {
|
|
387
|
+
client,
|
|
388
|
+
timer: setTimeout(() => {
|
|
389
|
+
removePendingClient(pending);
|
|
390
|
+
destroyQuietly(client);
|
|
391
|
+
}, CLIENT_WAIT_TIMEOUT_MS),
|
|
392
|
+
};
|
|
393
|
+
client.on("close", () => removePendingClient(pending));
|
|
394
|
+
pendingClients.push(pending);
|
|
395
|
+
drainPending();
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
const cleanup = async () => {
|
|
399
|
+
closed = true;
|
|
400
|
+
for (const waiter of relayWaiters.splice(0)) {
|
|
401
|
+
clearTimeout(waiter.timer);
|
|
402
|
+
waiter.resolve(false);
|
|
403
|
+
}
|
|
404
|
+
for (const pending of pendingClients.splice(0)) {
|
|
405
|
+
clearTimeout(pending.timer);
|
|
406
|
+
destroyQuietly(pending.client);
|
|
407
|
+
}
|
|
408
|
+
for (const socket of [...localSockets, ...relaySockets]) destroyQuietly(socket);
|
|
409
|
+
for (const child of relayChildren) {
|
|
410
|
+
try {
|
|
411
|
+
child.kill();
|
|
412
|
+
} catch {
|
|
413
|
+
// ignore
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
closeServerQuietly(localServer);
|
|
417
|
+
closeServerQuietly(brokerServer);
|
|
418
|
+
};
|
|
419
|
+
|
|
420
|
+
let localPort;
|
|
421
|
+
let brokerPort;
|
|
422
|
+
try {
|
|
423
|
+
localPort = await listen(localServer, relayPort(projectRoot), "127.0.0.1");
|
|
424
|
+
brokerPort = await listen(brokerServer, 0, "0.0.0.0");
|
|
425
|
+
} catch (error) {
|
|
426
|
+
await cleanup();
|
|
427
|
+
throw new Error(`Could not start the WSL reverse relay broker: ${error.message}`);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
let selectedHost = null;
|
|
431
|
+
for (const brokerHost of wslBrokerHosts()) {
|
|
432
|
+
const child = spawn(
|
|
433
|
+
relay.wslPath,
|
|
434
|
+
["--reverse", brokerHost, String(brokerPort), String(debugPort), String(REVERSE_RELAY_POOL)],
|
|
435
|
+
{ detached: true, stdio: "ignore" },
|
|
436
|
+
);
|
|
437
|
+
relayChildren.push(child);
|
|
438
|
+
child.unref();
|
|
439
|
+
|
|
440
|
+
if (await waitForRelayReady(brokerHost === "127.0.0.1" ? 1800 : 3000)) {
|
|
441
|
+
selectedHost = brokerHost;
|
|
442
|
+
break;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
try {
|
|
446
|
+
child.kill();
|
|
447
|
+
} catch {
|
|
448
|
+
// ignore
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
if (!selectedHost) {
|
|
453
|
+
await cleanup();
|
|
454
|
+
throw new Error(
|
|
455
|
+
"WSL is in NAT mode, but the Windows relay could not connect back to the WSL broker. " +
|
|
456
|
+
"Run `npx @brunosps00/dev-workflow setup-wsl-browser` to reinstall the relay, or enable mirrored networking.",
|
|
457
|
+
);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
return {
|
|
461
|
+
hosts: ["127.0.0.1", "localhost"],
|
|
462
|
+
port: localPort,
|
|
463
|
+
relayHost: selectedHost,
|
|
464
|
+
cleanup,
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
|
|
240
468
|
// Launches the Windows browser in remote-debugging mode and returns a CDP descriptor.
|
|
241
469
|
// - mirrored networking: WSL shares the Windows loopback (Hyper-V LoopbackEnabled), connect 127.0.0.1.
|
|
242
|
-
// - NAT networking: the prebuilt cdp-relay.exe
|
|
243
|
-
//
|
|
244
|
-
//
|
|
245
|
-
// inbound to Block. The browser echoes the request Host into webSocketDebuggerUrl, so the ws stays
|
|
246
|
-
// reachable through the relay.
|
|
470
|
+
// - NAT networking: the prebuilt cdp-relay.exe runs on Windows in reverse mode. It connects outbound
|
|
471
|
+
// to a WSL broker, which exposes a local proxy for Playwright. The browser echoes the request Host
|
|
472
|
+
// into webSocketDebuggerUrl, so the ws stays reachable through the local proxy.
|
|
247
473
|
// --remote-allow-origins=* is required or Chromium drops the DevTools ws (anti-DNS-rebinding).
|
|
248
474
|
async function launchWindowsBrowser(exePath, { headless, projectRoot }) {
|
|
249
475
|
const mirrored = isMirroredNetworking();
|
|
@@ -282,39 +508,42 @@ async function launchWindowsBrowser(exePath, { headless, projectRoot }) {
|
|
|
282
508
|
}
|
|
283
509
|
};
|
|
284
510
|
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
511
|
+
try {
|
|
512
|
+
let hosts;
|
|
513
|
+
let port;
|
|
514
|
+
let sourceSuffix = "";
|
|
515
|
+
if (mirrored) {
|
|
516
|
+
hosts = ["127.0.0.1", "localhost"];
|
|
517
|
+
port = debugPort;
|
|
518
|
+
} else {
|
|
519
|
+
const relay = findRelayExe(projectRoot);
|
|
520
|
+
if (!relay) {
|
|
521
|
+
throw new Error(
|
|
522
|
+
"WSL is in NAT mode; reaching the Windows browser needs cdp-relay.exe. Run " +
|
|
523
|
+
"`npx @brunosps00/dev-workflow setup-wsl-browser` to install the prebuilt relay in your Windows user profile. " +
|
|
524
|
+
"Alternatively enable mirrored networking or set BROWSER_TEST to a CDP URL.",
|
|
525
|
+
);
|
|
526
|
+
}
|
|
527
|
+
const reverseRelay = await startReverseRelayBroker({ relay, debugPort, projectRoot });
|
|
528
|
+
cleanups.unshift(reverseRelay.cleanup);
|
|
529
|
+
hosts = reverseRelay.hosts;
|
|
530
|
+
port = reverseRelay.port;
|
|
531
|
+
sourceSuffix = "+reverse-relay";
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
const found = await waitForCdp(hosts, port);
|
|
535
|
+
if (!found) {
|
|
294
536
|
throw new Error(
|
|
295
|
-
|
|
296
|
-
"`npx @brunosps00/dev-workflow setup-wsl-browser`
|
|
297
|
-
"Hyper-V firewall rule). Alternatively enable mirrored networking or set BROWSER_TEST to a CDP URL.",
|
|
537
|
+
`Launched ${path.basename(exePath)} but its CDP endpoint was not reachable (${hosts.join("/")}:${port}). ` +
|
|
538
|
+
"Run `npx @brunosps00/dev-workflow setup-wsl-browser` to reinstall the user-level relay, or enable mirrored networking.",
|
|
298
539
|
);
|
|
299
540
|
}
|
|
300
|
-
const rport = relayPort(projectRoot);
|
|
301
|
-
const relayChild = spawn(relay.wslPath, [String(rport), String(debugPort)], { detached: true, stdio: "ignore" });
|
|
302
|
-
relayChild.unref();
|
|
303
|
-
cleanups.unshift(() => relayChild.kill());
|
|
304
|
-
hosts = [windowsHostIp(), "127.0.0.1"].filter(Boolean);
|
|
305
|
-
port = rport;
|
|
306
|
-
}
|
|
307
541
|
|
|
308
|
-
|
|
309
|
-
|
|
542
|
+
return { mode: "cdp", endpoint: found.base, source: `launched:${path.basename(exePath)}${sourceSuffix}`, cleanup };
|
|
543
|
+
} catch (error) {
|
|
310
544
|
await cleanup();
|
|
311
|
-
throw
|
|
312
|
-
`Launched ${path.basename(exePath)} but its CDP endpoint was not reachable (${hosts.join("/")}:${port}). ` +
|
|
313
|
-
"The Hyper-V firewall rule may be missing — run `npx @brunosps00/dev-workflow setup-wsl-browser`.",
|
|
314
|
-
);
|
|
545
|
+
throw error;
|
|
315
546
|
}
|
|
316
|
-
|
|
317
|
-
return { mode: "cdp", endpoint: found.base, source: `launched:${path.basename(exePath)}${mirrored ? "" : "+relay"}`, cleanup };
|
|
318
547
|
}
|
|
319
548
|
|
|
320
549
|
const noopCleanup = async () => {};
|
|
@@ -289,21 +289,21 @@ Resolution order: `BROWSER_TEST` env → `.dw/config.json` (`browserTest`) → d
|
|
|
289
289
|
- **`BROWSER_TEST` = Windows exe** (e.g. `/mnt/c/.../msedge.exe`): launches it with a temp profile in
|
|
290
290
|
remote-debugging mode (`--remote-allow-origins=*`) and connects over CDP. **Works in both WSL2 modes:**
|
|
291
291
|
in mirrored networking it connects to `127.0.0.1` directly; in NAT mode it starts the bundled
|
|
292
|
-
**`cdp-relay.exe`** (a tiny prebuilt Windows x64 binary, no runtime) on Windows
|
|
293
|
-
|
|
292
|
+
**`cdp-relay.exe`** (a tiny prebuilt Windows x64 binary, no runtime) on Windows in reverse mode.
|
|
293
|
+
The relay opens outbound connections to a WSL broker, which exposes a local CDP proxy. Falls back
|
|
294
|
+
to headless Chromium if the relay is absent.
|
|
294
295
|
- **`BROWSER_TEST` = channel** (`chrome` | `msedge` | `chromium`): launches that channel locally.
|
|
295
296
|
|
|
296
|
-
NAT mode needs a one-time setup
|
|
297
|
-
binds its debug port to loopback only):
|
|
297
|
+
NAT mode needs a one-time user-level setup because Chromium binds its debug port to Windows loopback only:
|
|
298
298
|
|
|
299
299
|
```bash
|
|
300
300
|
npx @brunosps00/dev-workflow setup-wsl-browser
|
|
301
301
|
```
|
|
302
302
|
|
|
303
|
-
This installs the `cdp-relay.exe` bundled with dev-workflow
|
|
304
|
-
|
|
305
|
-
driven from WSL — including `page.screencast` video — under NAT.
|
|
306
|
-
throwaway profile and are killed on exit.
|
|
303
|
+
This installs the `cdp-relay.exe` bundled with dev-workflow into `%LOCALAPPDATA%\dev-workflow`.
|
|
304
|
+
No admin prompt, firewall rule, or Rust toolchain is required on the Windows target. After that,
|
|
305
|
+
the *real* Windows browser is driven from WSL — including `page.screencast` video — under NAT.
|
|
306
|
+
The launched browser and relay use a throwaway profile and are killed on exit.
|
|
307
307
|
|
|
308
308
|
Two env vars, two jobs — keep them distinct:
|
|
309
309
|
- `BROWSER` — opens URLs for a human (used by `/dw-generate-pr`).
|