@guanzhu.me/pw-cli 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +333 -0
- package/bin/pw-cli.js +870 -0
- package/package.json +38 -0
- package/src/browser-manager.js +178 -0
- package/src/cli.js +168 -0
- package/src/executor.js +77 -0
- package/src/launch-daemon.js +65 -0
- package/src/queue.js +48 -0
- package/src/state.js +47 -0
- package/src/utils.js +55 -0
package/package.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@guanzhu.me/pw-cli",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Persistent Playwright browser CLI with headed defaults, profile support, queueing, and script execution",
|
|
5
|
+
"bin": {
|
|
6
|
+
"pw-cli": "./bin/pw-cli.js"
|
|
7
|
+
},
|
|
8
|
+
"publishConfig": {
|
|
9
|
+
"access": "public"
|
|
10
|
+
},
|
|
11
|
+
"scripts": {
|
|
12
|
+
"lint": "node scripts/check-syntax.js",
|
|
13
|
+
"test": "node --test",
|
|
14
|
+
"verify": "npm run lint && npm test"
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"bin/",
|
|
18
|
+
"src/",
|
|
19
|
+
"README.md",
|
|
20
|
+
"LICENSE"
|
|
21
|
+
],
|
|
22
|
+
"engines": {
|
|
23
|
+
"node": ">=18"
|
|
24
|
+
},
|
|
25
|
+
"peerDependencies": {
|
|
26
|
+
"playwright": ">=1.40.0",
|
|
27
|
+
"@playwright/cli": ">=0.1.0"
|
|
28
|
+
},
|
|
29
|
+
"keywords": [
|
|
30
|
+
"playwright",
|
|
31
|
+
"cli",
|
|
32
|
+
"browser",
|
|
33
|
+
"automation",
|
|
34
|
+
"chromium",
|
|
35
|
+
"testing"
|
|
36
|
+
],
|
|
37
|
+
"license": "MIT"
|
|
38
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { spawn } = require('child_process');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const os = require('os');
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
const crypto = require('crypto');
|
|
8
|
+
const { readState, writeState, clearState, getProfileDir } = require('./state');
|
|
9
|
+
const { probeCDP, findFreePort, sleep } = require('./utils');
|
|
10
|
+
|
|
11
|
+
const DAEMON_SCRIPT = path.join(__dirname, 'launch-daemon.js');
|
|
12
|
+
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
// playwright-cli session integration
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
const HOME_DIR = os.homedir();
|
|
17
|
+
const PW_CLI_DIR = path.join(HOME_DIR, '.pw-cli');
|
|
18
|
+
const WORKSPACE_HASH = crypto.createHash('sha1')
|
|
19
|
+
.update(PW_CLI_DIR)
|
|
20
|
+
.digest('hex')
|
|
21
|
+
.substring(0, 16);
|
|
22
|
+
|
|
23
|
+
function getDaemonDir() {
|
|
24
|
+
if (process.platform === 'win32') {
|
|
25
|
+
return path.join(process.env.LOCALAPPDATA || path.join(HOME_DIR, 'AppData', 'Local'), 'ms-playwright', 'daemon');
|
|
26
|
+
} else if (process.platform === 'darwin') {
|
|
27
|
+
return path.join(HOME_DIR, 'Library', 'Caches', 'ms-playwright', 'daemon');
|
|
28
|
+
}
|
|
29
|
+
return path.join(process.env.XDG_CACHE_HOME || path.join(HOME_DIR, '.cache'), 'ms-playwright', 'daemon');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function readPlaywrightCliSession(sessionName = 'default') {
|
|
33
|
+
const sessionFile = path.join(getDaemonDir(), WORKSPACE_HASH, `${sessionName}.session`);
|
|
34
|
+
try {
|
|
35
|
+
return JSON.parse(fs.readFileSync(sessionFile, 'utf8'));
|
|
36
|
+
} catch {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function getPlaywrightCliCdpPort(sessionName = 'default') {
|
|
42
|
+
const session = readPlaywrightCliSession(sessionName);
|
|
43
|
+
return session?.resolvedConfig?.browser?.launchOptions?.cdpPort || null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
// Our own CDP-based browser launcher (fallback when playwright-cli not running)
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
async function launchBrowser({ headless = false, profile = 'default', port: preferredPort = 9223 } = {}) {
|
|
50
|
+
const profileDir = getProfileDir(profile);
|
|
51
|
+
const port = await findFreePort(preferredPort);
|
|
52
|
+
|
|
53
|
+
const daemonArgs = [
|
|
54
|
+
DAEMON_SCRIPT,
|
|
55
|
+
'--profile-dir', profileDir,
|
|
56
|
+
'--port', String(port),
|
|
57
|
+
];
|
|
58
|
+
if (headless) daemonArgs.push('--headless');
|
|
59
|
+
|
|
60
|
+
return new Promise((resolve, reject) => {
|
|
61
|
+
const child = spawn(process.execPath, daemonArgs, {
|
|
62
|
+
detached: true,
|
|
63
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
let output = '';
|
|
67
|
+
const timer = setTimeout(() => {
|
|
68
|
+
child.stdout.destroy();
|
|
69
|
+
reject(new Error('Browser launch timed out (15s)'));
|
|
70
|
+
}, 15000);
|
|
71
|
+
|
|
72
|
+
child.stdout.on('data', chunk => {
|
|
73
|
+
output += chunk.toString();
|
|
74
|
+
const readyMatch = output.match(/READY:(\d+)/);
|
|
75
|
+
const errorMatch = output.match(/ERROR:(.*)/);
|
|
76
|
+
|
|
77
|
+
if (readyMatch) {
|
|
78
|
+
clearTimeout(timer);
|
|
79
|
+
const actualPort = parseInt(readyMatch[1], 10);
|
|
80
|
+
writeState({ port: actualPort, cdpUrl: `http://127.0.0.1:${actualPort}`, profile });
|
|
81
|
+
child.unref();
|
|
82
|
+
resolve(actualPort);
|
|
83
|
+
} else if (errorMatch) {
|
|
84
|
+
clearTimeout(timer);
|
|
85
|
+
reject(new Error(`Browser launch failed: ${errorMatch[1]}`));
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
child.on('error', err => {
|
|
90
|
+
clearTimeout(timer);
|
|
91
|
+
reject(err);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
child.on('exit', (code) => {
|
|
95
|
+
if (code !== null && code !== 0) {
|
|
96
|
+
clearTimeout(timer);
|
|
97
|
+
reject(new Error(`Daemon exited with code ${code}`));
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
// getConnection — tries playwright-cli browser first, then our own
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
async function getConnection({ headless = false, profile = 'default', port: preferredPort = 9223 } = {}) {
|
|
107
|
+
let playwright;
|
|
108
|
+
try {
|
|
109
|
+
playwright = require('playwright');
|
|
110
|
+
} catch {
|
|
111
|
+
throw new Error('playwright is not installed. Run: npm install -g playwright');
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// 1. Try to reuse playwright-cli's browser via its CDP port
|
|
115
|
+
const cliCdpPort = getPlaywrightCliCdpPort();
|
|
116
|
+
if (cliCdpPort) {
|
|
117
|
+
const alive = await probeCDP(cliCdpPort, 2000);
|
|
118
|
+
if (alive) {
|
|
119
|
+
try {
|
|
120
|
+
const browser = await playwright.chromium.connectOverCDP(`http://127.0.0.1:${cliCdpPort}`);
|
|
121
|
+
const contexts = browser.contexts();
|
|
122
|
+
const context = contexts.length > 0 ? contexts[0] : await browser.newContext();
|
|
123
|
+
const pages = context.pages();
|
|
124
|
+
const page = pages.length > 0 ? pages[0] : await context.newPage();
|
|
125
|
+
return { browser, context, page, playwright };
|
|
126
|
+
} catch {
|
|
127
|
+
// fall through to own browser
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// 2. Try our own CDP browser (state file)
|
|
133
|
+
let state = readState();
|
|
134
|
+
let cdpUrl;
|
|
135
|
+
|
|
136
|
+
if (state) {
|
|
137
|
+
const alive = await probeCDP(state.port, 2000);
|
|
138
|
+
if (alive) {
|
|
139
|
+
cdpUrl = state.cdpUrl;
|
|
140
|
+
} else {
|
|
141
|
+
clearState();
|
|
142
|
+
state = null;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (!state) {
|
|
147
|
+
const port = await launchBrowser({ headless, profile, port: preferredPort });
|
|
148
|
+
cdpUrl = `http://127.0.0.1:${port}`;
|
|
149
|
+
await sleep(200);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const browser = await playwright.chromium.connectOverCDP(cdpUrl);
|
|
153
|
+
const contexts = browser.contexts();
|
|
154
|
+
const context = contexts.length > 0 ? contexts[0] : await browser.newContext();
|
|
155
|
+
const pages = context.pages();
|
|
156
|
+
const page = pages.length > 0 ? pages[0] : await context.newPage();
|
|
157
|
+
|
|
158
|
+
return { browser, context, page, playwright };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async function killBrowser() {
|
|
162
|
+
const state = readState();
|
|
163
|
+
if (!state) return false;
|
|
164
|
+
|
|
165
|
+
const alive = await probeCDP(state.port, 1000);
|
|
166
|
+
if (alive) {
|
|
167
|
+
try {
|
|
168
|
+
const playwright = require('playwright');
|
|
169
|
+
const browser = await playwright.chromium.connectOverCDP(state.cdpUrl);
|
|
170
|
+
await browser.close();
|
|
171
|
+
} catch { /* ignore */ }
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
clearState();
|
|
175
|
+
return true;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
module.exports = { getConnection, killBrowser };
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { getConnection, killBrowser } = require('./browser-manager');
|
|
4
|
+
const { execCode, execScript } = require('./executor');
|
|
5
|
+
const { readState } = require('./state');
|
|
6
|
+
const { readStdin, die, probeCDP } = require('./utils');
|
|
7
|
+
|
|
8
|
+
function parseArgs(argv) {
|
|
9
|
+
const global = { headless: false, profile: 'default', port: 9222 };
|
|
10
|
+
const rest = [];
|
|
11
|
+
let i = 0;
|
|
12
|
+
|
|
13
|
+
while (i < argv.length) {
|
|
14
|
+
const arg = argv[i];
|
|
15
|
+
if (arg === '--headless') {
|
|
16
|
+
global.headless = true;
|
|
17
|
+
} else if (arg === '--profile' && argv[i + 1]) {
|
|
18
|
+
global.profile = argv[++i];
|
|
19
|
+
} else if (arg === '--port' && argv[i + 1]) {
|
|
20
|
+
global.port = parseInt(argv[++i], 10);
|
|
21
|
+
} else {
|
|
22
|
+
rest.push(arg);
|
|
23
|
+
}
|
|
24
|
+
i++;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return { global, rest };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function cmdRunCode(rest, opts) {
|
|
31
|
+
let code;
|
|
32
|
+
|
|
33
|
+
if (rest.length > 0) {
|
|
34
|
+
code = rest.join(' ');
|
|
35
|
+
} else if (!process.stdin.isTTY) {
|
|
36
|
+
code = await readStdin();
|
|
37
|
+
} else {
|
|
38
|
+
die('No code provided. Pass inline or pipe via stdin.\n\nExamples:\n pw-cli run-code "await page.goto(\'https://example.com\')"\n cat script.js | pw-cli run-code');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (!code) die('Empty code provided.');
|
|
42
|
+
|
|
43
|
+
const conn = await getConnection(opts);
|
|
44
|
+
try {
|
|
45
|
+
const result = await execCode(code, conn);
|
|
46
|
+
if (result !== undefined) {
|
|
47
|
+
console.log(result);
|
|
48
|
+
}
|
|
49
|
+
} finally {
|
|
50
|
+
await conn.browser.close(); // disconnect only, browser keeps running
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function cmdRunScript(rest, opts) {
|
|
55
|
+
const [scriptPath, ...scriptArgs] = rest;
|
|
56
|
+
if (!scriptPath) {
|
|
57
|
+
die('No script path provided.\n\nUsage: pw-cli run-script <file.js> [args...]');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const conn = await getConnection(opts);
|
|
61
|
+
try {
|
|
62
|
+
const result = await execScript(scriptPath, scriptArgs, conn);
|
|
63
|
+
if (result !== undefined) {
|
|
64
|
+
console.log(result);
|
|
65
|
+
}
|
|
66
|
+
} finally {
|
|
67
|
+
await conn.browser.close();
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function cmdKill() {
|
|
72
|
+
const killed = await killBrowser();
|
|
73
|
+
console.log(killed ? 'Browser stopped.' : 'No browser running.');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function cmdStatus() {
|
|
77
|
+
const state = readState();
|
|
78
|
+
if (!state) {
|
|
79
|
+
console.log('Status: stopped');
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
const alive = await probeCDP(state.port, 2000);
|
|
83
|
+
if (alive) {
|
|
84
|
+
console.log(`Status: running`);
|
|
85
|
+
console.log(` CDP: ${state.cdpUrl}`);
|
|
86
|
+
console.log(` Profile: ${state.profile || 'default'}`);
|
|
87
|
+
} else {
|
|
88
|
+
console.log('Status: stopped (stale state file cleared)');
|
|
89
|
+
const { clearState } = require('./state');
|
|
90
|
+
clearState();
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function printHelp() {
|
|
95
|
+
console.log(`
|
|
96
|
+
pw-cli — Persistent Playwright browser CLI
|
|
97
|
+
|
|
98
|
+
USAGE
|
|
99
|
+
pw-cli [--headless] [--profile <name>] [--port <number>] <command> [...]
|
|
100
|
+
|
|
101
|
+
GLOBAL OPTIONS
|
|
102
|
+
--headless Run browser headlessly (default: headed)
|
|
103
|
+
--profile <name> Named profile to use (default: "default")
|
|
104
|
+
--port <number> CDP port (default: 9222)
|
|
105
|
+
|
|
106
|
+
COMMANDS
|
|
107
|
+
run-code [code] Execute inline JS (reads stdin if omitted)
|
|
108
|
+
run-script <file> [...] Execute a .js script with optional args
|
|
109
|
+
kill Stop the running browser
|
|
110
|
+
status Show browser status
|
|
111
|
+
|
|
112
|
+
SCRIPT GLOBALS
|
|
113
|
+
page, context, browser, playwright, args, require, __filename, __dirname
|
|
114
|
+
|
|
115
|
+
EXAMPLES
|
|
116
|
+
pw-cli run-code "await page.goto('https://example.com'); console.log(await page.title())"
|
|
117
|
+
echo "await page.screenshot({ path: 'out.png' })" | pw-cli run-code
|
|
118
|
+
pw-cli run-script ./scrape.js --url https://example.com
|
|
119
|
+
pw-cli --headless run-code "await page.goto('https://example.com')"
|
|
120
|
+
pw-cli --profile work status
|
|
121
|
+
pw-cli kill
|
|
122
|
+
`.trim());
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function run(argv) {
|
|
126
|
+
const { global: opts, rest } = parseArgs(argv);
|
|
127
|
+
const [command, ...cmdArgs] = rest;
|
|
128
|
+
|
|
129
|
+
if (!command || command === '--help' || command === '-h' || command === 'help') {
|
|
130
|
+
printHelp();
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
try {
|
|
135
|
+
switch (command) {
|
|
136
|
+
case 'run-code':
|
|
137
|
+
await cmdRunCode(cmdArgs, opts);
|
|
138
|
+
break;
|
|
139
|
+
case 'run-script':
|
|
140
|
+
await cmdRunScript(cmdArgs, opts);
|
|
141
|
+
break;
|
|
142
|
+
case 'kill':
|
|
143
|
+
await cmdKill();
|
|
144
|
+
break;
|
|
145
|
+
case 'status':
|
|
146
|
+
await cmdStatus();
|
|
147
|
+
break;
|
|
148
|
+
default:
|
|
149
|
+
die(`Unknown command: ${command}\nRun "pw-cli help" for usage.`);
|
|
150
|
+
}
|
|
151
|
+
} catch (err) {
|
|
152
|
+
if (err.code === 'ENOENT' && err.message.includes('Script not found')) {
|
|
153
|
+
process.exit(3);
|
|
154
|
+
}
|
|
155
|
+
if (err.message && (err.message.includes('Target closed') || err.message.includes('Connection closed') || err.message.includes('Protocol error'))) {
|
|
156
|
+
const { clearState } = require('./state');
|
|
157
|
+
clearState();
|
|
158
|
+
process.stderr.write(`pw-cli error: Browser disconnected unexpectedly. Run the command again to relaunch.\n\nDetails: ${err.message}\n`);
|
|
159
|
+
process.exit(2);
|
|
160
|
+
}
|
|
161
|
+
process.stderr.write(`pw-cli error: ${err.message || err}\n`);
|
|
162
|
+
process.exit(1);
|
|
163
|
+
}
|
|
164
|
+
// Force exit: playwright CDP connections keep the event loop alive
|
|
165
|
+
process.exit(0);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
module.exports = { run };
|
package/src/executor.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
|
|
6
|
+
function isFunctionExpression(code) {
|
|
7
|
+
const text = code.trim();
|
|
8
|
+
return /^(async\s+function\b|function\b|async\s*(\([^)]*\)|[A-Za-z_$][\w$]*)\s*=>|\([^)]*\)\s*=>|[A-Za-z_$][\w$]*\s*=>)/.test(text);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
async function runFunctionExpression(code, globals) {
|
|
12
|
+
const factory = new Function(...Object.keys(globals), `return (${code});`);
|
|
13
|
+
const maybeFn = factory(...Object.values(globals));
|
|
14
|
+
|
|
15
|
+
if (typeof maybeFn === 'function') {
|
|
16
|
+
return maybeFn(
|
|
17
|
+
globals.page,
|
|
18
|
+
globals.context,
|
|
19
|
+
globals.browser,
|
|
20
|
+
globals.playwright,
|
|
21
|
+
globals.args
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return maybeFn;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Use AsyncFunction constructor to execute in the same V8 context as playwright objects.
|
|
29
|
+
// This avoids vm.runInNewContext's cross-context prototype issues.
|
|
30
|
+
async function runCode(code, globals) {
|
|
31
|
+
if (isFunctionExpression(code)) {
|
|
32
|
+
return runFunctionExpression(code, globals);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor;
|
|
36
|
+
const fn = new AsyncFunction(...Object.keys(globals), code);
|
|
37
|
+
return fn.call(globals, ...Object.values(globals));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function execCode(code, { browser, context, page, playwright }) {
|
|
41
|
+
const globals = {
|
|
42
|
+
browser,
|
|
43
|
+
context,
|
|
44
|
+
page,
|
|
45
|
+
playwright,
|
|
46
|
+
require,
|
|
47
|
+
console,
|
|
48
|
+
process,
|
|
49
|
+
};
|
|
50
|
+
return runCode(code, globals);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function execScript(scriptPath, scriptArgs, { browser, context, page, playwright }) {
|
|
54
|
+
const absPath = path.resolve(scriptPath);
|
|
55
|
+
if (!fs.existsSync(absPath)) {
|
|
56
|
+
const err = new Error(`Script not found: ${absPath}`);
|
|
57
|
+
err.code = 'ENOENT';
|
|
58
|
+
throw err;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const code = fs.readFileSync(absPath, 'utf8');
|
|
62
|
+
const globals = {
|
|
63
|
+
browser,
|
|
64
|
+
context,
|
|
65
|
+
page,
|
|
66
|
+
playwright,
|
|
67
|
+
args: scriptArgs,
|
|
68
|
+
require,
|
|
69
|
+
console,
|
|
70
|
+
process,
|
|
71
|
+
__filename: absPath,
|
|
72
|
+
__dirname: path.dirname(absPath),
|
|
73
|
+
};
|
|
74
|
+
return runCode(code, globals);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
module.exports = { execCode, execScript };
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// This script is spawned as a detached child process.
|
|
4
|
+
// It launches a persistent Chromium browser and holds it open.
|
|
5
|
+
// It signals readiness by writing "READY:<port>\n" to stdout.
|
|
6
|
+
// Args: --profile-dir <dir> --port <port> [--headless]
|
|
7
|
+
|
|
8
|
+
const args = process.argv.slice(2);
|
|
9
|
+
|
|
10
|
+
function getArg(name) {
|
|
11
|
+
const i = args.indexOf(name);
|
|
12
|
+
return i !== -1 ? args[i + 1] : undefined;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const profileDir = getArg('--profile-dir');
|
|
16
|
+
const port = parseInt(getArg('--port') || '9222', 10);
|
|
17
|
+
const headless = args.includes('--headless');
|
|
18
|
+
|
|
19
|
+
if (!profileDir) {
|
|
20
|
+
process.stderr.write('ERROR:missing --profile-dir\n');
|
|
21
|
+
process.exit(1);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
(async () => {
|
|
25
|
+
let playwright;
|
|
26
|
+
try {
|
|
27
|
+
playwright = require('playwright');
|
|
28
|
+
} catch (e) {
|
|
29
|
+
process.stdout.write(`ERROR:playwright not found - run: npm install -g playwright\n`);
|
|
30
|
+
process.exit(1);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
try {
|
|
34
|
+
const context = await playwright.chromium.launchPersistentContext(profileDir, {
|
|
35
|
+
headless,
|
|
36
|
+
args: [`--remote-debugging-port=${port}`],
|
|
37
|
+
ignoreDefaultArgs: ['--enable-automation'],
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
// Wait briefly for CDP to be ready, then signal
|
|
41
|
+
const { probeCDP, sleep } = require('./utils');
|
|
42
|
+
let ready = false;
|
|
43
|
+
for (let i = 0; i < 20; i++) {
|
|
44
|
+
if (await probeCDP(port, 1000)) { ready = true; break; }
|
|
45
|
+
await sleep(300);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (!ready) {
|
|
49
|
+
process.stdout.write(`ERROR:CDP not available on port ${port} after launch\n`);
|
|
50
|
+
await context.close();
|
|
51
|
+
process.exit(1);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
process.stdout.write(`READY:${port}\n`);
|
|
55
|
+
|
|
56
|
+
// Keep process alive until browser closes
|
|
57
|
+
context.on('close', () => process.exit(0));
|
|
58
|
+
|
|
59
|
+
// Prevent premature exit
|
|
60
|
+
process.stdin.resume();
|
|
61
|
+
} catch (e) {
|
|
62
|
+
process.stdout.write(`ERROR:${e.message}\n`);
|
|
63
|
+
process.exit(1);
|
|
64
|
+
}
|
|
65
|
+
})();
|
package/src/queue.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const os = require('os');
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
|
|
7
|
+
const HOME_DIR = os.homedir();
|
|
8
|
+
const PW_CLI_DIR = path.join(HOME_DIR, '.pw-cli');
|
|
9
|
+
const QUEUE_FILE = path.join(PW_CLI_DIR, 'queue.json');
|
|
10
|
+
|
|
11
|
+
function readQueue() {
|
|
12
|
+
try {
|
|
13
|
+
return JSON.parse(fs.readFileSync(QUEUE_FILE, 'utf8'));
|
|
14
|
+
} catch {
|
|
15
|
+
return [];
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function saveQueue(items) {
|
|
20
|
+
fs.mkdirSync(PW_CLI_DIR, { recursive: true });
|
|
21
|
+
const tmp = QUEUE_FILE + '.tmp';
|
|
22
|
+
fs.writeFileSync(tmp, JSON.stringify(items, null, 2), 'utf8');
|
|
23
|
+
fs.renameSync(tmp, QUEUE_FILE);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function addItem(command, args) {
|
|
27
|
+
const queue = readQueue();
|
|
28
|
+
const id = Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
|
|
29
|
+
const item = { id, command, args, addedAt: new Date().toISOString() };
|
|
30
|
+
queue.push(item);
|
|
31
|
+
saveQueue(queue);
|
|
32
|
+
return item;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function removeItem(idPrefix) {
|
|
36
|
+
const queue = readQueue();
|
|
37
|
+
const idx = queue.findIndex(i => i.id === idPrefix || i.id.startsWith(idPrefix));
|
|
38
|
+
if (idx === -1) return null;
|
|
39
|
+
const [removed] = queue.splice(idx, 1);
|
|
40
|
+
saveQueue(queue);
|
|
41
|
+
return removed;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function clearQueue() {
|
|
45
|
+
saveQueue([]);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
module.exports = { readQueue, saveQueue, addItem, removeItem, clearQueue, QUEUE_FILE };
|
package/src/state.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const os = require('os');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
|
|
7
|
+
function getPwCliDir() {
|
|
8
|
+
return path.join(os.homedir(), '.pw-cli');
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function getStateFile() {
|
|
12
|
+
return path.join(getPwCliDir(), 'browser.json');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function getProfileDir(profile = 'default') {
|
|
16
|
+
const dir = path.join(getPwCliDir(), 'profiles', profile);
|
|
17
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
18
|
+
return dir;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function readState() {
|
|
22
|
+
try {
|
|
23
|
+
const raw = fs.readFileSync(getStateFile(), 'utf8');
|
|
24
|
+
return JSON.parse(raw);
|
|
25
|
+
} catch {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function writeState(state) {
|
|
31
|
+
const dir = getPwCliDir();
|
|
32
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
33
|
+
const file = getStateFile();
|
|
34
|
+
const tmp = file + '.tmp';
|
|
35
|
+
fs.writeFileSync(tmp, JSON.stringify(state, null, 2));
|
|
36
|
+
fs.renameSync(tmp, file);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function clearState() {
|
|
40
|
+
try {
|
|
41
|
+
fs.unlinkSync(getStateFile());
|
|
42
|
+
} catch {
|
|
43
|
+
// already gone
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
module.exports = { getPwCliDir, getProfileDir, readState, writeState, clearState };
|
package/src/utils.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const http = require('http');
|
|
4
|
+
const net = require('net');
|
|
5
|
+
|
|
6
|
+
function readStdin() {
|
|
7
|
+
return new Promise((resolve, reject) => {
|
|
8
|
+
let data = '';
|
|
9
|
+
process.stdin.setEncoding('utf8');
|
|
10
|
+
process.stdin.on('data', chunk => { data += chunk; });
|
|
11
|
+
process.stdin.on('end', () => resolve(data.trim()));
|
|
12
|
+
process.stdin.on('error', reject);
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function die(msg, code = 1) {
|
|
17
|
+
process.stderr.write(`pw-cli error: ${msg}\n`);
|
|
18
|
+
process.exit(code);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function probeCDP(port, timeout = 3000) {
|
|
22
|
+
return new Promise(resolve => {
|
|
23
|
+
const req = http.get(`http://127.0.0.1:${port}/json/version`, { timeout }, res => {
|
|
24
|
+
res.resume();
|
|
25
|
+
resolve(res.statusCode === 200);
|
|
26
|
+
});
|
|
27
|
+
req.on('error', () => resolve(false));
|
|
28
|
+
req.on('timeout', () => { req.destroy(); resolve(false); });
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function findFreePort(preferred = 9222) {
|
|
33
|
+
return new Promise((resolve, reject) => {
|
|
34
|
+
const server = net.createServer();
|
|
35
|
+
server.listen(preferred, '127.0.0.1', () => {
|
|
36
|
+
const { port } = server.address();
|
|
37
|
+
server.close(() => resolve(port));
|
|
38
|
+
});
|
|
39
|
+
server.on('error', () => {
|
|
40
|
+
// preferred port busy, get a random free one
|
|
41
|
+
const s2 = net.createServer();
|
|
42
|
+
s2.listen(0, '127.0.0.1', () => {
|
|
43
|
+
const { port } = s2.address();
|
|
44
|
+
s2.close(() => resolve(port));
|
|
45
|
+
});
|
|
46
|
+
s2.on('error', reject);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function sleep(ms) {
|
|
52
|
+
return new Promise(r => setTimeout(r, ms));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
module.exports = { readStdin, die, probeCDP, findFreePort, sleep };
|