playwriter 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bippy.js +5 -5
- package/dist/browser-config.d.ts.map +1 -1
- package/dist/browser-config.js +8 -2
- package/dist/browser-config.js.map +1 -1
- package/dist/browser-install.d.ts +16 -0
- package/dist/browser-install.d.ts.map +1 -0
- package/dist/browser-install.js +237 -0
- package/dist/browser-install.js.map +1 -0
- package/dist/cdp-relay.d.ts.map +1 -1
- package/dist/cdp-relay.js +261 -29
- package/dist/cdp-relay.js.map +1 -1
- package/dist/chrome-discovery.d.ts.map +1 -1
- package/dist/chrome-discovery.js +8 -0
- package/dist/chrome-discovery.js.map +1 -1
- package/dist/cli.js +578 -17
- package/dist/cli.js.map +1 -1
- package/dist/cloud-client.d.ts +56 -0
- package/dist/cloud-client.d.ts.map +1 -0
- package/dist/cloud-client.js +120 -0
- package/dist/cloud-client.js.map +1 -0
- package/dist/executor.d.ts +46 -3
- package/dist/executor.d.ts.map +1 -1
- package/dist/executor.js +249 -26
- package/dist/executor.js.map +1 -1
- package/dist/extension/background.js +106 -23
- package/dist/extension/manifest.json +1 -1
- package/dist/playwright-import.d.ts +19 -0
- package/dist/playwright-import.d.ts.map +1 -0
- package/dist/playwright-import.js +39 -0
- package/dist/playwright-import.js.map +1 -0
- package/dist/prompt.md +32 -0
- package/dist/readability.js +1 -1
- package/dist/relay-session.test.js +1 -1
- package/dist/relay-session.test.js.map +1 -1
- package/dist/relay-state.d.ts +1 -0
- package/dist/relay-state.d.ts.map +1 -1
- package/dist/relay-state.js +18 -0
- package/dist/relay-state.js.map +1 -1
- package/dist/relay-state.test.js +22 -0
- package/dist/relay-state.test.js.map +1 -1
- package/dist/selector-generator.js +1 -1
- package/dist/utils.d.ts +2 -2
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +4 -4
- package/dist/utils.js.map +1 -1
- package/package.json +3 -1
- package/src/browser-config.ts +11 -2
- package/src/browser-install.ts +283 -0
- package/src/cdp-relay.ts +306 -32
- package/src/chrome-discovery.ts +9 -0
- package/src/cli.ts +645 -19
- package/src/cloud-client.ts +172 -0
- package/src/executor.ts +295 -28
- package/src/playwright-import.ts +58 -0
- package/src/relay-session.test.ts +1 -1
- package/src/relay-state.test.ts +32 -0
- package/src/relay-state.ts +19 -1
- package/src/skill.md +154 -14
- package/src/utils.ts +4 -5
package/dist/cli.js
CHANGED
|
@@ -3,7 +3,7 @@ import fs from 'node:fs';
|
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import util from 'node:util';
|
|
5
5
|
import { fileURLToPath } from 'node:url';
|
|
6
|
-
import { goke } from 'goke';
|
|
6
|
+
import { goke, openInBrowser } from 'goke';
|
|
7
7
|
import { z } from 'zod';
|
|
8
8
|
import pc from 'picocolors';
|
|
9
9
|
// Prevent Buffers from dumping hex bytes in util.inspect output.
|
|
@@ -15,8 +15,8 @@ import { canEmitKittyGraphics, emitKittyImage } from './kitty-graphics.js';
|
|
|
15
15
|
import { VERSION, LOG_FILE_PATH, LOG_CDP_FILE_PATH, parseRelayHost } from './utils.js';
|
|
16
16
|
import { ensureRelayServer, RELAY_PORT, waitForConnectedExtensions, getExtensionOutdatedWarning, getExtensionStatus, } from './relay-client.js';
|
|
17
17
|
import { discoverChromeInstances, resolveDirectInput } from './chrome-discovery.js';
|
|
18
|
+
import { getCloudClient, loadCloudAuth, saveCloudAuth, buildLiveUrl } from './cloud-client.js';
|
|
18
19
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
19
|
-
const cliRelayEnv = { PLAYWRITER_AUTO_ENABLE: '1' };
|
|
20
20
|
const cli = goke('playwriter');
|
|
21
21
|
cli
|
|
22
22
|
.command('browser start [binaryPath]', 'Start Chromium or Chrome for Testing with the bundled Playwriter extension')
|
|
@@ -38,7 +38,7 @@ cli
|
|
|
38
38
|
import('./browser-config.js'),
|
|
39
39
|
import('./package-paths.js'),
|
|
40
40
|
]);
|
|
41
|
-
await ensureRelayServer({ logger: console
|
|
41
|
+
await ensureRelayServer({ logger: console });
|
|
42
42
|
const browserPath = resolveBrowserExecutablePath({ browserPath: binaryPath });
|
|
43
43
|
const extensionPath = getBundledExtensionPath();
|
|
44
44
|
const userDataDir = path.resolve(options.userDataDir || getDefaultBrowserUserDataDir());
|
|
@@ -77,6 +77,18 @@ cli
|
|
|
77
77
|
process.exit(1);
|
|
78
78
|
}
|
|
79
79
|
});
|
|
80
|
+
cli
|
|
81
|
+
.command('browser install', 'Download Chrome for Testing for headless browser automation')
|
|
82
|
+
.action(async () => {
|
|
83
|
+
try {
|
|
84
|
+
const { installChrome } = await import('./browser-install.js');
|
|
85
|
+
await installChrome();
|
|
86
|
+
}
|
|
87
|
+
catch (error) {
|
|
88
|
+
console.error(`Error: ${error.message}`);
|
|
89
|
+
process.exit(1);
|
|
90
|
+
}
|
|
91
|
+
});
|
|
80
92
|
cli
|
|
81
93
|
.command('', 'Start the MCP server or controls the browser with -e')
|
|
82
94
|
.option('--host <host>', 'Remote relay server host to connect to (or use PLAYWRITER_HOST env var)')
|
|
@@ -84,8 +96,12 @@ cli
|
|
|
84
96
|
.option('-s, --session <name>', 'Session ID (required for -e, get one with `playwriter session new`)')
|
|
85
97
|
.option('-e, --eval <code>', 'Execute JavaScript code and exit, read https://playwriter.dev/SKILL.md for usage')
|
|
86
98
|
.option('-f, --file <path>', 'Execute JavaScript from a file and exit')
|
|
99
|
+
.option('--patchright', 'Use @playwriter/patchright-core for stealth mode (bypasses bot detection)')
|
|
87
100
|
.option('--timeout [ms]', z.number().default(10000).describe('Execution timeout in milliseconds'))
|
|
88
101
|
.action(async (options) => {
|
|
102
|
+
if (options.patchright) {
|
|
103
|
+
process.env.PLAYWRITER_PATCHRIGHT = '1';
|
|
104
|
+
}
|
|
89
105
|
if (options.eval && options.file) {
|
|
90
106
|
console.error('Error: -e and -f cannot be used together.');
|
|
91
107
|
process.exit(1);
|
|
@@ -192,7 +208,7 @@ async function executeCode(options) {
|
|
|
192
208
|
const serverUrl = await getServerUrl(host);
|
|
193
209
|
// Ensure relay server is running (only for local)
|
|
194
210
|
if (!host && !process.env.PLAYWRITER_HOST) {
|
|
195
|
-
const restarted = await ensureRelayServer({ logger: console
|
|
211
|
+
const restarted = await ensureRelayServer({ logger: console });
|
|
196
212
|
if (restarted) {
|
|
197
213
|
const connectedExtensions = await waitForConnectedExtensions({
|
|
198
214
|
logger: console,
|
|
@@ -261,6 +277,9 @@ async function executeCode(options) {
|
|
|
261
277
|
}
|
|
262
278
|
}
|
|
263
279
|
}
|
|
280
|
+
if (result.isCloud) {
|
|
281
|
+
console.error(pc.dim(`\nCloud session. Run \`playwriter session delete ${sessionId}\` when done.`));
|
|
282
|
+
}
|
|
264
283
|
if (result.isError) {
|
|
265
284
|
process.exit(1);
|
|
266
285
|
}
|
|
@@ -281,10 +300,59 @@ cli
|
|
|
281
300
|
.command('session new', 'Create a new session and print the session ID')
|
|
282
301
|
.option('--host <host>', 'Remote relay server host')
|
|
283
302
|
.option('--token <token>', 'Authentication token (or use PLAYWRITER_TOKEN env var)')
|
|
284
|
-
.option('--browser <key>', 'Browser key when multiple browsers are available')
|
|
303
|
+
.option('--browser <key>', 'Browser key when multiple browsers are available. Special values: "headless" (launch headless Chrome, no extension), "cloud" (cloud browser with stealth/proxies)')
|
|
304
|
+
.option('--patchright', 'Use @playwriter/patchright-core for stealth mode (bypasses bot detection)')
|
|
285
305
|
.option('--direct [endpoint]', 'Use direct CDP connection without the extension. Enable debugging first at chrome://inspect/#remote-debugging or launch Chrome with --remote-debugging-port=9222. Auto-discovers instances or accepts an explicit ws:// endpoint')
|
|
306
|
+
.option('--proxy <region>', 'Enable residential proxy for cloud browser (e.g. us, de, jp). Disabled by default. Use for anti-detection or geo-targeting.')
|
|
307
|
+
.option('--custom-proxy <url>', 'Custom proxy for cloud browser (host:port or user:pass@host:port)')
|
|
308
|
+
.option('--timeout <minutes>', 'Cloud browser timeout in minutes (1-240, default 60)')
|
|
309
|
+
.option('--disable-proxy-bandwidth-acceleration', 'Allow loading images, video, and fonts when proxy is enabled (they are blocked by default to save proxy bandwidth)')
|
|
286
310
|
.action(async (options) => {
|
|
311
|
+
if (options.patchright) {
|
|
312
|
+
process.env.PLAYWRITER_PATCHRIGHT = '1';
|
|
313
|
+
}
|
|
287
314
|
const isLocal = !options.host && !process.env.PLAYWRITER_HOST;
|
|
315
|
+
// --browser headless: launch headless Chrome via chromium.launch(), no extension
|
|
316
|
+
if (options.browser === 'headless') {
|
|
317
|
+
try {
|
|
318
|
+
await ensureRelayForSessionCreation(isLocal);
|
|
319
|
+
const serverUrl = await getServerUrl(options.host);
|
|
320
|
+
const response = await fetch(`${serverUrl}/cli/session/new`, {
|
|
321
|
+
method: 'POST',
|
|
322
|
+
headers: buildAuthHeaders({ token: options.token, json: true }),
|
|
323
|
+
body: JSON.stringify({ headless: true, cwd: process.cwd() }),
|
|
324
|
+
});
|
|
325
|
+
if (!response.ok) {
|
|
326
|
+
const text = await response.text();
|
|
327
|
+
if (text.includes('Could not find a supported browser binary')) {
|
|
328
|
+
console.error('No Chrome browser found. Install one first:');
|
|
329
|
+
console.error('');
|
|
330
|
+
console.error(' playwriter browser install');
|
|
331
|
+
console.error('');
|
|
332
|
+
console.error('This downloads Chrome for Testing from Google.');
|
|
333
|
+
process.exit(1);
|
|
334
|
+
}
|
|
335
|
+
console.error(`Error: ${response.status} ${text}`);
|
|
336
|
+
process.exit(1);
|
|
337
|
+
}
|
|
338
|
+
const result = (await response.json());
|
|
339
|
+
console.log(`Session ${result.id} created (headless). Use with: playwriter -s ${result.id} -e "..."`);
|
|
340
|
+
console.log(pc.dim('NOTE: Recording unavailable in headless mode.'));
|
|
341
|
+
}
|
|
342
|
+
catch (error) {
|
|
343
|
+
if (error.message?.includes('Could not find a supported browser binary')) {
|
|
344
|
+
console.error('No Chrome browser found. Install one first:');
|
|
345
|
+
console.error('');
|
|
346
|
+
console.error(' playwriter browser install');
|
|
347
|
+
console.error('');
|
|
348
|
+
console.error('This downloads Chrome for Testing from Google.');
|
|
349
|
+
process.exit(1);
|
|
350
|
+
}
|
|
351
|
+
console.error(`Error: ${error.message}`);
|
|
352
|
+
process.exit(1);
|
|
353
|
+
}
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
288
356
|
// goke 6.6: optional-value flags are string | undefined
|
|
289
357
|
// `--direct ws://...` → 'ws://...' (explicit endpoint)
|
|
290
358
|
// `--direct` → '' (bare flag, auto-discover)
|
|
@@ -344,6 +412,7 @@ cli
|
|
|
344
412
|
return opt.key === options.browser;
|
|
345
413
|
});
|
|
346
414
|
if (!selected) {
|
|
415
|
+
await handleCloudBrowserNotFound(options.browser, { hasCloudOptions: false });
|
|
347
416
|
console.error(`Browser not found: ${options.browser}`);
|
|
348
417
|
console.error('Available: ' + directOptions.map((opt) => opt.key).join(', '));
|
|
349
418
|
process.exit(1);
|
|
@@ -361,7 +430,7 @@ cli
|
|
|
361
430
|
// Default mode: extension-based (existing behavior)
|
|
362
431
|
let extensions = [];
|
|
363
432
|
if (isLocal) {
|
|
364
|
-
await ensureRelayServer({ logger: console
|
|
433
|
+
await ensureRelayServer({ logger: console });
|
|
365
434
|
extensions = await waitForConnectedExtensions({
|
|
366
435
|
timeoutMs: 12000,
|
|
367
436
|
pollIntervalMs: 250,
|
|
@@ -380,8 +449,54 @@ cli
|
|
|
380
449
|
extensions = await fetchExtensionsStatus({ host: options.host, token: options.token });
|
|
381
450
|
}
|
|
382
451
|
if (extensions.length === 0) {
|
|
452
|
+
// Before giving up, check if cloud browsers are available
|
|
453
|
+
const cloudOptions = await discoverCloudBrowsers();
|
|
454
|
+
if (cloudOptions.length > 0) {
|
|
455
|
+
// Cloud-only user: skip extension requirement, show cloud options
|
|
456
|
+
await ensureRelayForSessionCreation(isLocal);
|
|
457
|
+
const allOptions = [...cloudOptions];
|
|
458
|
+
if (options.browser) {
|
|
459
|
+
const selected = allOptions.find((opt) => { return opt.key === options.browser; });
|
|
460
|
+
if (!selected) {
|
|
461
|
+
await handleCloudBrowserNotFound(options.browser, { hasCloudOptions: true });
|
|
462
|
+
console.error(`Browser not found: ${options.browser}`);
|
|
463
|
+
console.error('Available: ' + allOptions.map((opt) => opt.key).join(', '));
|
|
464
|
+
process.exit(1);
|
|
465
|
+
}
|
|
466
|
+
const serverUrl = await getServerUrl(options.host);
|
|
467
|
+
// Reuse existing running VM if selected, otherwise create new
|
|
468
|
+
const result = selected.activeCloudSessionId
|
|
469
|
+
? await attachExistingCloudSession({
|
|
470
|
+
serverUrl,
|
|
471
|
+
cloudSessionId: selected.activeCloudSessionId,
|
|
472
|
+
blockProxyResources: computeBlockProxyResources(options),
|
|
473
|
+
token: options.token,
|
|
474
|
+
})
|
|
475
|
+
: await createCloudSession({
|
|
476
|
+
serverUrl,
|
|
477
|
+
proxyRegion: options.proxy,
|
|
478
|
+
customProxy: options.customProxy,
|
|
479
|
+
timeout: parseCloudTimeout(options.timeout),
|
|
480
|
+
blockProxyResources: computeBlockProxyResources(options),
|
|
481
|
+
token: options.token,
|
|
482
|
+
});
|
|
483
|
+
console.log(`Session ${result.id} created (cloud). Use with: playwriter -s ${result.id} -e "..."`);
|
|
484
|
+
if (result.liveUrl) {
|
|
485
|
+
console.log(pc.dim(`Live view: ${result.liveUrl}`));
|
|
486
|
+
}
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
console.log('\nNo local browsers detected, but cloud browsers are available:\n');
|
|
490
|
+
printBrowserTable(allOptions);
|
|
491
|
+
console.log('\nRun again with --browser <key>.');
|
|
492
|
+
process.exit(1);
|
|
493
|
+
}
|
|
494
|
+
if (options.browser) {
|
|
495
|
+
await handleCloudBrowserNotFound(options.browser, { hasCloudOptions: false });
|
|
496
|
+
}
|
|
383
497
|
console.error('No connected browsers detected. Click the Playwriter extension icon.');
|
|
384
498
|
console.error(pc.dim('Tip: Use --direct to connect via Chrome DevTools Protocol instead.'));
|
|
499
|
+
console.error(pc.dim('Tip: Run `playwriter cloud login` to use cloud browsers.'));
|
|
385
500
|
process.exit(1);
|
|
386
501
|
}
|
|
387
502
|
// Warn if any connected extension was built with an older playwriter version
|
|
@@ -404,7 +519,7 @@ cli
|
|
|
404
519
|
const response = await fetch(`${serverUrl}/cli/session/new`, {
|
|
405
520
|
method: 'POST',
|
|
406
521
|
headers: buildAuthHeaders({ token: options.token, json: true }),
|
|
407
|
-
body: JSON.stringify({ extensionId, cwd
|
|
522
|
+
body: JSON.stringify({ extensionId, cwd }),
|
|
408
523
|
});
|
|
409
524
|
if (!response.ok) {
|
|
410
525
|
const text = await response.text();
|
|
@@ -413,6 +528,7 @@ cli
|
|
|
413
528
|
}
|
|
414
529
|
const result = (await response.json());
|
|
415
530
|
console.log(`Session ${result.id} created. Use with: playwriter -s ${result.id} -e "..."`);
|
|
531
|
+
printCloudTip();
|
|
416
532
|
}
|
|
417
533
|
catch (error) {
|
|
418
534
|
console.error(`Error: ${error.message}`);
|
|
@@ -420,12 +536,14 @@ cli
|
|
|
420
536
|
}
|
|
421
537
|
return;
|
|
422
538
|
}
|
|
423
|
-
// Multiple extensions: also discover direct CDP instances and
|
|
424
|
-
//
|
|
539
|
+
// Multiple extensions: also discover direct CDP instances and cloud browsers.
|
|
540
|
+
// Direct discovery only works locally — remote relay can't reach local Chrome debug ports.
|
|
425
541
|
const directInstances = isLocal ? await (async () => {
|
|
426
542
|
console.log(pc.dim('Discovering additional Chrome instances...'));
|
|
427
543
|
return await discoverChromeInstances();
|
|
428
544
|
})() : [];
|
|
545
|
+
// Fetch cloud browser slots if user is logged in
|
|
546
|
+
const cloudOptions = await discoverCloudBrowsers();
|
|
429
547
|
const allOptions = [
|
|
430
548
|
...extensions.map((ext) => {
|
|
431
549
|
return {
|
|
@@ -439,19 +557,43 @@ cli
|
|
|
439
557
|
...directInstances.map((instance) => {
|
|
440
558
|
return instanceToBrowserOption(instance);
|
|
441
559
|
}),
|
|
560
|
+
...cloudOptions,
|
|
442
561
|
];
|
|
443
562
|
if (options.browser) {
|
|
444
563
|
const selected = allOptions.find((opt) => {
|
|
445
564
|
return opt.key === options.browser;
|
|
446
565
|
});
|
|
447
566
|
if (!selected) {
|
|
567
|
+
await handleCloudBrowserNotFound(options.browser, { hasCloudOptions: cloudOptions.length > 0 });
|
|
448
568
|
console.error(`Browser not found: ${options.browser}`);
|
|
449
569
|
console.error('Available: ' + allOptions.map((opt) => opt.key).join(', '));
|
|
450
570
|
process.exit(1);
|
|
451
571
|
}
|
|
452
572
|
try {
|
|
453
573
|
const serverUrl = await getServerUrl(options.host);
|
|
454
|
-
if (selected.type === '
|
|
574
|
+
if (selected.type === 'cloud') {
|
|
575
|
+
// Reuse existing running VM if selected, otherwise create new
|
|
576
|
+
const result = selected.activeCloudSessionId
|
|
577
|
+
? await attachExistingCloudSession({
|
|
578
|
+
serverUrl,
|
|
579
|
+
cloudSessionId: selected.activeCloudSessionId,
|
|
580
|
+
blockProxyResources: computeBlockProxyResources(options),
|
|
581
|
+
token: options.token,
|
|
582
|
+
})
|
|
583
|
+
: await createCloudSession({
|
|
584
|
+
serverUrl,
|
|
585
|
+
proxyRegion: options.proxy,
|
|
586
|
+
customProxy: options.customProxy,
|
|
587
|
+
timeout: parseCloudTimeout(options.timeout),
|
|
588
|
+
blockProxyResources: computeBlockProxyResources(options),
|
|
589
|
+
token: options.token,
|
|
590
|
+
});
|
|
591
|
+
console.log(`Session ${result.id} created (cloud). Use with: playwriter -s ${result.id} -e "..."`);
|
|
592
|
+
if (result.liveUrl) {
|
|
593
|
+
console.log(pc.dim(`Live view: ${result.liveUrl}`));
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
else if (selected.type === 'direct') {
|
|
455
597
|
const result = await createDirectSession({ serverUrl, cdpEndpoint: selected.wsUrl, browser: selected.browser, profiles: selected.profiles, token: options.token });
|
|
456
598
|
console.log(`Session ${result.id} created (direct CDP). Use with: playwriter -s ${result.id} -e "..."`);
|
|
457
599
|
console.log(pc.dim('NOTE: Recording unavailable in direct CDP mode.'));
|
|
@@ -461,7 +603,7 @@ cli
|
|
|
461
603
|
const response = await fetch(`${serverUrl}/cli/session/new`, {
|
|
462
604
|
method: 'POST',
|
|
463
605
|
headers: buildAuthHeaders({ token: options.token, json: true }),
|
|
464
|
-
body: JSON.stringify({ extensionId: selected.extensionId, cwd
|
|
606
|
+
body: JSON.stringify({ extensionId: selected.extensionId, cwd }),
|
|
465
607
|
});
|
|
466
608
|
if (!response.ok) {
|
|
467
609
|
const text = await response.text();
|
|
@@ -470,6 +612,7 @@ cli
|
|
|
470
612
|
}
|
|
471
613
|
const result = (await response.json());
|
|
472
614
|
console.log(`Session ${result.id} created. Use with: playwriter -s ${result.id} -e "..."`);
|
|
615
|
+
printCloudTip();
|
|
473
616
|
}
|
|
474
617
|
}
|
|
475
618
|
catch (error) {
|
|
@@ -486,7 +629,7 @@ cli
|
|
|
486
629
|
});
|
|
487
630
|
async function ensureRelayForSessionCreation(isLocal) {
|
|
488
631
|
if (isLocal) {
|
|
489
|
-
await ensureRelayServer({ logger: console
|
|
632
|
+
await ensureRelayServer({ logger: console });
|
|
490
633
|
}
|
|
491
634
|
}
|
|
492
635
|
async function createDirectSession({ serverUrl, cdpEndpoint, browser, profiles, token, }) {
|
|
@@ -522,9 +665,248 @@ function formatInstanceProfiles(instance) {
|
|
|
522
665
|
})
|
|
523
666
|
.join(', ');
|
|
524
667
|
}
|
|
668
|
+
/** Discover cloud sessions from the website API, if logged in.
|
|
669
|
+
* Also adds a "cloud-new" option to create a new cloud browser. */
|
|
670
|
+
async function discoverCloudBrowsers() {
|
|
671
|
+
const client = getCloudClient();
|
|
672
|
+
if (!client)
|
|
673
|
+
return [];
|
|
674
|
+
try {
|
|
675
|
+
const { sessions } = await client.getStatus();
|
|
676
|
+
const options = sessions.map((s) => {
|
|
677
|
+
return {
|
|
678
|
+
key: `cloud-${s.index}`,
|
|
679
|
+
type: 'cloud',
|
|
680
|
+
browser: 'Chromium',
|
|
681
|
+
profile: `(running, expires ${new Date(s.timeoutAt).toLocaleTimeString()})`,
|
|
682
|
+
activeCloudSessionId: s.cloudSessionId,
|
|
683
|
+
};
|
|
684
|
+
});
|
|
685
|
+
// Always offer a "cloud-new" option to spin up a fresh VM
|
|
686
|
+
options.push({
|
|
687
|
+
key: 'cloud',
|
|
688
|
+
type: 'cloud',
|
|
689
|
+
browser: 'Chromium',
|
|
690
|
+
profile: '(new cloud browser)',
|
|
691
|
+
});
|
|
692
|
+
return options;
|
|
693
|
+
}
|
|
694
|
+
catch (error) {
|
|
695
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
696
|
+
console.error(pc.dim(`Cloud browser discovery failed: ${msg}`));
|
|
697
|
+
return [];
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
/** Compute whether to block images/video/fonts for proxy bandwidth savings.
|
|
701
|
+
* Enabled by default when proxy or custom-proxy is set, disabled via
|
|
702
|
+
* --disable-proxy-bandwidth-acceleration. */
|
|
703
|
+
function computeBlockProxyResources(options) {
|
|
704
|
+
const proxyEnabled = !!(options.proxy || options.customProxy);
|
|
705
|
+
if (!proxyEnabled)
|
|
706
|
+
return undefined; // no proxy, no blocking needed
|
|
707
|
+
if (options.disableProxyBandwidthAcceleration)
|
|
708
|
+
return false;
|
|
709
|
+
return true;
|
|
710
|
+
}
|
|
711
|
+
/** Check if user requested a cloud browser that isn't available.
|
|
712
|
+
* Shows helpful login/subscribe instructions instead of a generic "not found" error.
|
|
713
|
+
* @param hasCloudOptions whether any cloud options were discovered (to distinguish
|
|
714
|
+
* "not logged in" from "typo in cloud key") */
|
|
715
|
+
async function handleCloudBrowserNotFound(browserKey, { hasCloudOptions }) {
|
|
716
|
+
if (!browserKey.startsWith('cloud'))
|
|
717
|
+
return false;
|
|
718
|
+
// If cloud options exist, this is a typo (e.g. cloud-99) — let the
|
|
719
|
+
// generic "Browser not found" message show the available list instead.
|
|
720
|
+
if (hasCloudOptions)
|
|
721
|
+
return false;
|
|
722
|
+
const auth = loadCloudAuth();
|
|
723
|
+
if (!auth) {
|
|
724
|
+
console.error('Cloud browsers require authentication.');
|
|
725
|
+
console.error('');
|
|
726
|
+
console.error(' Option 1: Run `playwriter cloud login` (interactive browser flow)');
|
|
727
|
+
console.error(' Option 2: Set PLAYWRITER_API_KEY env var (create one at playwriter.dev/dashboard)');
|
|
728
|
+
console.error('');
|
|
729
|
+
console.error(' Then subscribe at playwriter.dev/dashboard and run `playwriter session new --browser cloud`');
|
|
730
|
+
}
|
|
731
|
+
else {
|
|
732
|
+
// Verify token is still valid with a quick API check
|
|
733
|
+
const client = getCloudClient();
|
|
734
|
+
const tokenValid = await (async () => {
|
|
735
|
+
if (!client)
|
|
736
|
+
return false;
|
|
737
|
+
try {
|
|
738
|
+
await client.getStatus();
|
|
739
|
+
return true;
|
|
740
|
+
}
|
|
741
|
+
catch {
|
|
742
|
+
return false;
|
|
743
|
+
}
|
|
744
|
+
})();
|
|
745
|
+
if (!tokenValid) {
|
|
746
|
+
console.error('Cloud authentication expired. Please re-authenticate.');
|
|
747
|
+
console.error('');
|
|
748
|
+
console.error(' Run `playwriter cloud login` or set PLAYWRITER_API_KEY env var.');
|
|
749
|
+
}
|
|
750
|
+
else {
|
|
751
|
+
console.error('No cloud browser sessions available.');
|
|
752
|
+
console.error('');
|
|
753
|
+
console.error(' You are logged in, but you may need an active subscription.');
|
|
754
|
+
console.error(' Run `playwriter cloud subscribe` to manage your plan.');
|
|
755
|
+
console.error(' Then run `playwriter session new --browser cloud` to start a cloud browser.');
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
process.exit(1);
|
|
759
|
+
}
|
|
760
|
+
function printCloudTip() {
|
|
761
|
+
console.log('');
|
|
762
|
+
console.log(pc.dim('Tip: Need stealth browsing, VPS control, or auto CAPTCHA solving? Run `playwriter cloud login` or set PLAYWRITER_API_KEY'));
|
|
763
|
+
console.log(pc.dim(' to control a browser in the cloud instead of local Chrome.'));
|
|
764
|
+
}
|
|
765
|
+
/** Parse a custom proxy string (host:port or user:pass@host:port) into an object. */
|
|
766
|
+
function parseCustomProxy(proxyStr) {
|
|
767
|
+
// Format: [user:pass@]host:port
|
|
768
|
+
const atIdx = proxyStr.lastIndexOf('@');
|
|
769
|
+
let hostPort;
|
|
770
|
+
let username;
|
|
771
|
+
let password;
|
|
772
|
+
if (atIdx !== -1) {
|
|
773
|
+
const userPass = proxyStr.slice(0, atIdx);
|
|
774
|
+
hostPort = proxyStr.slice(atIdx + 1);
|
|
775
|
+
const colonIdx = userPass.indexOf(':');
|
|
776
|
+
if (colonIdx !== -1) {
|
|
777
|
+
username = userPass.slice(0, colonIdx);
|
|
778
|
+
password = userPass.slice(colonIdx + 1);
|
|
779
|
+
}
|
|
780
|
+
else {
|
|
781
|
+
username = userPass;
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
else {
|
|
785
|
+
hostPort = proxyStr;
|
|
786
|
+
}
|
|
787
|
+
const lastColon = hostPort.lastIndexOf(':');
|
|
788
|
+
if (lastColon === -1) {
|
|
789
|
+
throw new Error(`Invalid proxy format: missing port in "${proxyStr}". Expected host:port or user:pass@host:port`);
|
|
790
|
+
}
|
|
791
|
+
const host = hostPort.slice(0, lastColon);
|
|
792
|
+
const port = parseInt(hostPort.slice(lastColon + 1), 10);
|
|
793
|
+
if (isNaN(port)) {
|
|
794
|
+
throw new Error(`Invalid proxy port in "${proxyStr}"`);
|
|
795
|
+
}
|
|
796
|
+
return { host, port, username, password };
|
|
797
|
+
}
|
|
798
|
+
/** Parse and validate the --timeout CLI option (integer 1-240). */
|
|
799
|
+
function parseCloudTimeout(value) {
|
|
800
|
+
if (value === undefined)
|
|
801
|
+
return undefined;
|
|
802
|
+
if (!/^\d+$/.test(value)) {
|
|
803
|
+
throw new Error('--timeout must be an integer from 1 to 240');
|
|
804
|
+
}
|
|
805
|
+
const timeout = Number(value);
|
|
806
|
+
if (timeout < 1 || timeout > 240) {
|
|
807
|
+
throw new Error('--timeout must be between 1 and 240 minutes');
|
|
808
|
+
}
|
|
809
|
+
return timeout;
|
|
810
|
+
}
|
|
811
|
+
/** Connect to a cloud browser and create a playwriter session via the relay. */
|
|
812
|
+
async function createCloudSession({ serverUrl, proxyRegion, customProxy, timeout, blockProxyResources, token, }) {
|
|
813
|
+
const client = getCloudClient();
|
|
814
|
+
if (!client) {
|
|
815
|
+
throw new Error('Not logged in to cloud. Run `playwriter cloud login` first.');
|
|
816
|
+
}
|
|
817
|
+
const connectResult = await client.connect({
|
|
818
|
+
proxyRegion,
|
|
819
|
+
customProxy: customProxy ? parseCustomProxy(customProxy) : undefined,
|
|
820
|
+
timeout,
|
|
821
|
+
});
|
|
822
|
+
if (!connectResult.cdpUrl) {
|
|
823
|
+
throw new Error('Cloud browser returned no CDP URL. The VM may have failed to start.');
|
|
824
|
+
}
|
|
825
|
+
// Normalize https:// CDP URL to wss:// for the relay
|
|
826
|
+
const cdpEndpoint = await resolveDirectInput(connectResult.cdpUrl);
|
|
827
|
+
// Create a playwriter session via the relay using the CDP URL (same as --direct).
|
|
828
|
+
// Also pass cloud metadata so the relay can track idle timeout and auto-disconnect.
|
|
829
|
+
const auth = loadCloudAuth();
|
|
830
|
+
const cwd = process.cwd();
|
|
831
|
+
let response;
|
|
832
|
+
try {
|
|
833
|
+
response = await fetch(`${serverUrl}/cli/session/new`, {
|
|
834
|
+
method: 'POST',
|
|
835
|
+
headers: buildAuthHeaders({ token, json: true }),
|
|
836
|
+
body: JSON.stringify({
|
|
837
|
+
cdpEndpoint,
|
|
838
|
+
cwd,
|
|
839
|
+
browser: 'Chromium (cloud)',
|
|
840
|
+
cloud: {
|
|
841
|
+
cloudSessionId: connectResult.cloudSessionId,
|
|
842
|
+
cloudBaseUrl: auth.baseUrl,
|
|
843
|
+
cloudToken: auth.token,
|
|
844
|
+
timeoutAt: connectResult.timeoutAt,
|
|
845
|
+
blockProxyResources,
|
|
846
|
+
},
|
|
847
|
+
}),
|
|
848
|
+
});
|
|
849
|
+
}
|
|
850
|
+
catch (cause) {
|
|
851
|
+
// Relay session creation failed — stop the cloud VM so we don't leak a paid resource
|
|
852
|
+
await client.disconnect(connectResult.cloudSessionId).catch(() => { });
|
|
853
|
+
throw new Error('Failed to create relay session', { cause });
|
|
854
|
+
}
|
|
855
|
+
if (!response.ok) {
|
|
856
|
+
await client.disconnect(connectResult.cloudSessionId).catch(() => { });
|
|
857
|
+
const text = await response.text();
|
|
858
|
+
throw new Error(`${response.status} ${text}`);
|
|
859
|
+
}
|
|
860
|
+
const result = (await response.json());
|
|
861
|
+
return { id: result.id, liveUrl: connectResult.cdpUrl ? buildLiveUrl(connectResult.cdpUrl, auth.baseUrl) : null };
|
|
862
|
+
}
|
|
863
|
+
/** Reattach to an existing running cloud browser VM instead of creating a new one.
|
|
864
|
+
* Fetches the session's cdpUrl from the cloud API and creates a relay session. */
|
|
865
|
+
async function attachExistingCloudSession({ serverUrl, cloudSessionId, blockProxyResources, token, }) {
|
|
866
|
+
const client = getCloudClient();
|
|
867
|
+
if (!client) {
|
|
868
|
+
throw new Error('Not logged in to cloud. Run `playwriter cloud login` first.');
|
|
869
|
+
}
|
|
870
|
+
const session = await client.getSessionStatus(cloudSessionId);
|
|
871
|
+
if (!session || session.status !== 'active') {
|
|
872
|
+
throw new Error('Cloud session is no longer active. It may have timed out.');
|
|
873
|
+
}
|
|
874
|
+
if (!session.cdpUrl) {
|
|
875
|
+
throw new Error('Cloud session has no CDP URL available.');
|
|
876
|
+
}
|
|
877
|
+
const cdpEndpoint = await resolveDirectInput(session.cdpUrl);
|
|
878
|
+
const auth = loadCloudAuth();
|
|
879
|
+
const cwd = process.cwd();
|
|
880
|
+
const response = await fetch(`${serverUrl}/cli/session/new`, {
|
|
881
|
+
method: 'POST',
|
|
882
|
+
headers: buildAuthHeaders({ token, json: true }),
|
|
883
|
+
body: JSON.stringify({
|
|
884
|
+
cdpEndpoint,
|
|
885
|
+
cwd,
|
|
886
|
+
browser: 'Chromium (cloud)',
|
|
887
|
+
cloud: {
|
|
888
|
+
cloudSessionId,
|
|
889
|
+
cloudBaseUrl: auth.baseUrl,
|
|
890
|
+
cloudToken: auth.token,
|
|
891
|
+
timeoutAt: session.timeoutAt,
|
|
892
|
+
blockProxyResources,
|
|
893
|
+
},
|
|
894
|
+
}),
|
|
895
|
+
});
|
|
896
|
+
if (!response.ok) {
|
|
897
|
+
const text = await response.text();
|
|
898
|
+
throw new Error(`${response.status} ${text}`);
|
|
899
|
+
}
|
|
900
|
+
const result = (await response.json());
|
|
901
|
+
return { id: result.id, liveUrl: session.cdpUrl ? buildLiveUrl(session.cdpUrl, auth.baseUrl) : null };
|
|
902
|
+
}
|
|
525
903
|
function printBrowserTable(options) {
|
|
526
904
|
const typeLabels = options.map((opt) => {
|
|
527
|
-
|
|
905
|
+
if (opt.type === 'direct')
|
|
906
|
+
return '--direct';
|
|
907
|
+
if (opt.type === 'cloud')
|
|
908
|
+
return 'cloud';
|
|
909
|
+
return opt.type;
|
|
528
910
|
});
|
|
529
911
|
const keyWidth = Math.max(3, ...options.map((opt) => opt.key.length));
|
|
530
912
|
const typeWidth = Math.max(4, ...typeLabels.map((t) => t.length));
|
|
@@ -548,7 +930,7 @@ cli
|
|
|
548
930
|
.option('--token <token>', 'Authentication token (or use PLAYWRITER_TOKEN env var)')
|
|
549
931
|
.action(async (options) => {
|
|
550
932
|
if (!options.host && !process.env.PLAYWRITER_HOST) {
|
|
551
|
-
await ensureRelayServer({ logger: console
|
|
933
|
+
await ensureRelayServer({ logger: console });
|
|
552
934
|
}
|
|
553
935
|
const serverUrl = await getServerUrl(options.host);
|
|
554
936
|
let sessions = [];
|
|
@@ -614,7 +996,7 @@ cli
|
|
|
614
996
|
.action(async (sessionId, options) => {
|
|
615
997
|
const serverUrl = await getServerUrl(options.host);
|
|
616
998
|
if (!options.host && !process.env.PLAYWRITER_HOST) {
|
|
617
|
-
await ensureRelayServer({ logger: console
|
|
999
|
+
await ensureRelayServer({ logger: console });
|
|
618
1000
|
}
|
|
619
1001
|
try {
|
|
620
1002
|
const response = await fetch(`${serverUrl}/cli/session/delete`, {
|
|
@@ -642,7 +1024,7 @@ cli
|
|
|
642
1024
|
const cwd = process.cwd();
|
|
643
1025
|
const serverUrl = await getServerUrl(options.host);
|
|
644
1026
|
if (!options.host && !process.env.PLAYWRITER_HOST) {
|
|
645
|
-
await ensureRelayServer({ logger: console
|
|
1027
|
+
await ensureRelayServer({ logger: console });
|
|
646
1028
|
}
|
|
647
1029
|
try {
|
|
648
1030
|
const response = await fetch(`${serverUrl}/cli/reset`, {
|
|
@@ -759,7 +1141,7 @@ cli
|
|
|
759
1141
|
const isLocal = !options.host && !process.env.PLAYWRITER_HOST;
|
|
760
1142
|
// Start relay if local so the extension can connect, then fetch in parallel
|
|
761
1143
|
if (isLocal) {
|
|
762
|
-
await ensureRelayServer({ logger: console
|
|
1144
|
+
await ensureRelayServer({ logger: console });
|
|
763
1145
|
}
|
|
764
1146
|
const [extensions, directInstances] = await Promise.all([
|
|
765
1147
|
isLocal
|
|
@@ -767,6 +1149,23 @@ cli
|
|
|
767
1149
|
: fetchExtensionsStatus({ host: options.host, token: options.token }),
|
|
768
1150
|
isLocal ? discoverChromeInstances() : Promise.resolve([]),
|
|
769
1151
|
]);
|
|
1152
|
+
const cloudOptions = await discoverCloudBrowsers();
|
|
1153
|
+
// Check if a Chrome binary is available for headless mode
|
|
1154
|
+
const headlessOption = await (async () => {
|
|
1155
|
+
try {
|
|
1156
|
+
const { resolveBrowserExecutablePath } = await import('./browser-config.js');
|
|
1157
|
+
resolveBrowserExecutablePath();
|
|
1158
|
+
return [{
|
|
1159
|
+
key: 'headless',
|
|
1160
|
+
type: 'headless',
|
|
1161
|
+
browser: 'Chrome (Headless)',
|
|
1162
|
+
profile: '-',
|
|
1163
|
+
}];
|
|
1164
|
+
}
|
|
1165
|
+
catch {
|
|
1166
|
+
return [];
|
|
1167
|
+
}
|
|
1168
|
+
})();
|
|
770
1169
|
const allOptions = [
|
|
771
1170
|
...extensions.map((ext) => {
|
|
772
1171
|
return {
|
|
@@ -778,11 +1177,15 @@ cli
|
|
|
778
1177
|
};
|
|
779
1178
|
}),
|
|
780
1179
|
...directInstances.map(instanceToBrowserOption),
|
|
1180
|
+
...headlessOption,
|
|
1181
|
+
...cloudOptions,
|
|
781
1182
|
];
|
|
782
1183
|
if (allOptions.length === 0) {
|
|
783
1184
|
console.log('No browsers detected.\n');
|
|
784
1185
|
console.log(' Extension: click the Playwriter icon on a tab to connect');
|
|
785
1186
|
console.log(' Direct: open chrome://inspect/#remote-debugging in Chrome');
|
|
1187
|
+
console.log(' Headless: run `playwriter browser install` then `--browser headless`');
|
|
1188
|
+
console.log(' Cloud: run `playwriter cloud login` to connect cloud browsers');
|
|
786
1189
|
return;
|
|
787
1190
|
}
|
|
788
1191
|
printBrowserTable(allOptions);
|
|
@@ -797,6 +1200,164 @@ cli
|
|
|
797
1200
|
else {
|
|
798
1201
|
console.log(pc.dim('Use with: playwriter session new [--browser <key>]'));
|
|
799
1202
|
}
|
|
1203
|
+
const hasCloud = allOptions.some((opt) => {
|
|
1204
|
+
return opt.type === 'cloud';
|
|
1205
|
+
});
|
|
1206
|
+
if (!hasCloud) {
|
|
1207
|
+
printCloudTip();
|
|
1208
|
+
}
|
|
1209
|
+
});
|
|
1210
|
+
// ── Cloud commands ──────────────────────────────────────────────────
|
|
1211
|
+
cli
|
|
1212
|
+
.command('cloud login', 'Authenticate with playwriter.dev to use cloud browsers')
|
|
1213
|
+
.option('--base-url <url>', 'Website base URL (default: https://playwriter.dev)')
|
|
1214
|
+
.action(async (options) => {
|
|
1215
|
+
const baseUrl = options.baseUrl || process.env.PLAYWRITER_CLOUD_URL || 'https://playwriter.dev';
|
|
1216
|
+
// Use the better-auth client SDK so we don't hardcode endpoint URLs.
|
|
1217
|
+
// Hardcoded URLs broke before when better-auth changed paths between versions.
|
|
1218
|
+
const { createAuthClient } = await import('better-auth/client');
|
|
1219
|
+
const { deviceAuthorizationClient } = await import('better-auth/client/plugins');
|
|
1220
|
+
const client = createAuthClient({
|
|
1221
|
+
baseURL: baseUrl,
|
|
1222
|
+
plugins: [deviceAuthorizationClient()],
|
|
1223
|
+
});
|
|
1224
|
+
console.log('Requesting device authorization...');
|
|
1225
|
+
const { data: deviceData, error: requestError } = await client.device.code({
|
|
1226
|
+
client_id: 'playwriter-cli',
|
|
1227
|
+
});
|
|
1228
|
+
if (requestError || !deviceData) {
|
|
1229
|
+
console.error(`Error: failed to request device code — ${requestError?.error_description || requestError?.error || 'unknown error'}`);
|
|
1230
|
+
process.exit(1);
|
|
1231
|
+
}
|
|
1232
|
+
const verificationUrl = deviceData.verification_uri_complete || `${baseUrl}/device?user_code=${deviceData.user_code}`;
|
|
1233
|
+
console.log(`\nOpen this URL in your browser:\n ${verificationUrl}\n`);
|
|
1234
|
+
console.log(`Code: ${deviceData.user_code}\n`);
|
|
1235
|
+
await openInBrowser(verificationUrl);
|
|
1236
|
+
console.log('Waiting for approval...');
|
|
1237
|
+
const pollInterval = (deviceData.interval || 5) * 1000;
|
|
1238
|
+
const deadline = Date.now() + (deviceData.expires_in || 300) * 1000;
|
|
1239
|
+
while (Date.now() < deadline) {
|
|
1240
|
+
await new Promise((r) => { setTimeout(r, pollInterval); });
|
|
1241
|
+
const { data: tokenData, error: pollError } = await client.device.token({
|
|
1242
|
+
grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
|
|
1243
|
+
device_code: deviceData.device_code,
|
|
1244
|
+
client_id: 'playwriter-cli',
|
|
1245
|
+
});
|
|
1246
|
+
if (tokenData?.access_token) {
|
|
1247
|
+
saveCloudAuth({ token: tokenData.access_token, baseUrl });
|
|
1248
|
+
console.log(pc.green('\nLogged in successfully!'));
|
|
1249
|
+
console.log('Cloud browsers will now appear in `playwriter session new`.');
|
|
1250
|
+
return;
|
|
1251
|
+
}
|
|
1252
|
+
if (pollError?.error === 'authorization_pending' || pollError?.error === 'slow_down') {
|
|
1253
|
+
continue;
|
|
1254
|
+
}
|
|
1255
|
+
if (pollError) {
|
|
1256
|
+
console.error(`\nError: Device authorization failed — ${pollError.error_description || pollError.error}`);
|
|
1257
|
+
process.exit(1);
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
1260
|
+
console.error('\nError: Device authorization timed out.');
|
|
1261
|
+
process.exit(1);
|
|
1262
|
+
});
|
|
1263
|
+
cli
|
|
1264
|
+
.command('cloud subscribe', 'Open the subscription page to purchase cloud browser sessions')
|
|
1265
|
+
.action(async () => {
|
|
1266
|
+
const auth = loadCloudAuth();
|
|
1267
|
+
if (!auth) {
|
|
1268
|
+
console.error('Not logged in. Run `playwriter cloud login` first.');
|
|
1269
|
+
process.exit(1);
|
|
1270
|
+
}
|
|
1271
|
+
const subscribeUrl = new URL('/dashboard', auth.baseUrl).toString();
|
|
1272
|
+
console.log(`Open your browser to manage your subscription:\n ${subscribeUrl}\n`);
|
|
1273
|
+
await openInBrowser(subscribeUrl);
|
|
1274
|
+
});
|
|
1275
|
+
cli
|
|
1276
|
+
.command('cloud status', 'Show active cloud browser sessions')
|
|
1277
|
+
.action(async () => {
|
|
1278
|
+
const client = getCloudClient();
|
|
1279
|
+
if (!client) {
|
|
1280
|
+
console.error('Not logged in. Run `playwriter cloud login` first.');
|
|
1281
|
+
process.exit(1);
|
|
1282
|
+
}
|
|
1283
|
+
try {
|
|
1284
|
+
const { sessions } = await client.getStatus();
|
|
1285
|
+
if (sessions.length === 0) {
|
|
1286
|
+
console.log('No active cloud sessions.');
|
|
1287
|
+
console.log(pc.dim('Start one with: playwriter session new --browser cloud'));
|
|
1288
|
+
return;
|
|
1289
|
+
}
|
|
1290
|
+
const keyWidth = Math.max(3, ...sessions.map((s) => `cloud-${s.index}`.length));
|
|
1291
|
+
console.log('KEY'.padEnd(keyWidth) + ' ' + 'STATUS'.padEnd(10) + ' ' + 'DETAILS');
|
|
1292
|
+
console.log('-'.repeat(keyWidth + 30));
|
|
1293
|
+
for (const s of sessions) {
|
|
1294
|
+
const key = `cloud-${s.index}`;
|
|
1295
|
+
const timeoutAt = new Date(s.timeoutAt).toLocaleTimeString();
|
|
1296
|
+
console.log(key.padEnd(keyWidth) +
|
|
1297
|
+
' ' +
|
|
1298
|
+
pc.green('running'.padEnd(10)) +
|
|
1299
|
+
' ' +
|
|
1300
|
+
`expires ${timeoutAt}`);
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1303
|
+
catch (error) {
|
|
1304
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
1305
|
+
console.error(`Error: ${msg}`);
|
|
1306
|
+
process.exit(1);
|
|
1307
|
+
}
|
|
1308
|
+
});
|
|
1309
|
+
cli
|
|
1310
|
+
.command('cloud live [key]', 'Open a live browser view for an active cloud session')
|
|
1311
|
+
.action(async (key) => {
|
|
1312
|
+
const client = getCloudClient();
|
|
1313
|
+
if (!client) {
|
|
1314
|
+
console.error('Not logged in. Run `playwriter cloud login` first.');
|
|
1315
|
+
process.exit(1);
|
|
1316
|
+
}
|
|
1317
|
+
try {
|
|
1318
|
+
const { sessions } = await client.getStatus();
|
|
1319
|
+
if (sessions.length === 0) {
|
|
1320
|
+
console.log('No active cloud sessions.');
|
|
1321
|
+
console.log(pc.dim('Start one with: playwriter session new --browser cloud'));
|
|
1322
|
+
process.exit(1);
|
|
1323
|
+
}
|
|
1324
|
+
let session;
|
|
1325
|
+
if (key) {
|
|
1326
|
+
// Match by cloud-N key or by cloudSessionId
|
|
1327
|
+
session = sessions.find((s) => {
|
|
1328
|
+
return `cloud-${s.index}` === key || s.cloudSessionId === key || s.browserUseSessionId === key;
|
|
1329
|
+
});
|
|
1330
|
+
if (!session) {
|
|
1331
|
+
console.error(`No active session matching "${key}".`);
|
|
1332
|
+
console.error('Active sessions: ' + sessions.map((s) => { return `cloud-${s.index}`; }).join(', '));
|
|
1333
|
+
process.exit(1);
|
|
1334
|
+
}
|
|
1335
|
+
}
|
|
1336
|
+
else if (sessions.length === 1) {
|
|
1337
|
+
session = sessions[0];
|
|
1338
|
+
}
|
|
1339
|
+
else {
|
|
1340
|
+
console.log('Multiple active sessions. Specify one:\n');
|
|
1341
|
+
for (const s of sessions) {
|
|
1342
|
+
console.log(` cloud-${s.index} (expires ${new Date(s.timeoutAt).toLocaleTimeString()})`);
|
|
1343
|
+
}
|
|
1344
|
+
console.log(`\nUsage: playwriter cloud live cloud-1`);
|
|
1345
|
+
process.exit(1);
|
|
1346
|
+
}
|
|
1347
|
+
if (!session.cdpUrl) {
|
|
1348
|
+
console.error('Session has no CDP URL — it may still be starting.');
|
|
1349
|
+
process.exit(1);
|
|
1350
|
+
}
|
|
1351
|
+
const auth = loadCloudAuth();
|
|
1352
|
+
const liveUrl = buildLiveUrl(session.cdpUrl, auth.baseUrl);
|
|
1353
|
+
console.log(liveUrl);
|
|
1354
|
+
await openInBrowser(liveUrl);
|
|
1355
|
+
}
|
|
1356
|
+
catch (error) {
|
|
1357
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
1358
|
+
console.error(`Error: ${msg}`);
|
|
1359
|
+
process.exit(1);
|
|
1360
|
+
}
|
|
800
1361
|
});
|
|
801
1362
|
cli.command('logfile', 'Print the path to the relay server log file').action(() => {
|
|
802
1363
|
console.log(`relay: ${LOG_FILE_PATH}`);
|