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/src/cli.ts
CHANGED
|
@@ -4,7 +4,7 @@ import fs from 'node:fs'
|
|
|
4
4
|
import path from 'node:path'
|
|
5
5
|
import util from 'node:util'
|
|
6
6
|
import { fileURLToPath } from 'node:url'
|
|
7
|
-
import { goke } from 'goke'
|
|
7
|
+
import { goke, openInBrowser } from 'goke'
|
|
8
8
|
import { z } from 'zod'
|
|
9
9
|
import pc from 'picocolors'
|
|
10
10
|
|
|
@@ -24,11 +24,10 @@ import {
|
|
|
24
24
|
type ExtensionStatus,
|
|
25
25
|
} from './relay-client.js'
|
|
26
26
|
import { discoverChromeInstances, resolveDirectInput, type DiscoveredInstance } from './chrome-discovery.js'
|
|
27
|
+
import { getCloudClient, loadCloudAuth, saveCloudAuth, CloudClient, buildLiveUrl } from './cloud-client.js'
|
|
27
28
|
|
|
28
29
|
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
|
29
30
|
|
|
30
|
-
const cliRelayEnv = { PLAYWRITER_AUTO_ENABLE: '1' }
|
|
31
|
-
|
|
32
31
|
const cli = goke('playwriter')
|
|
33
32
|
|
|
34
33
|
cli
|
|
@@ -53,7 +52,7 @@ cli
|
|
|
53
52
|
import('./package-paths.js'),
|
|
54
53
|
])
|
|
55
54
|
|
|
56
|
-
await ensureRelayServer({ logger: console
|
|
55
|
+
await ensureRelayServer({ logger: console })
|
|
57
56
|
|
|
58
57
|
const browserPath = resolveBrowserExecutablePath({ browserPath: binaryPath })
|
|
59
58
|
const extensionPath = getBundledExtensionPath()
|
|
@@ -99,6 +98,18 @@ cli
|
|
|
99
98
|
},
|
|
100
99
|
)
|
|
101
100
|
|
|
101
|
+
cli
|
|
102
|
+
.command('browser install', 'Download Chrome for Testing for headless browser automation')
|
|
103
|
+
.action(async () => {
|
|
104
|
+
try {
|
|
105
|
+
const { installChrome } = await import('./browser-install.js')
|
|
106
|
+
await installChrome()
|
|
107
|
+
} catch (error: any) {
|
|
108
|
+
console.error(`Error: ${error.message}`)
|
|
109
|
+
process.exit(1)
|
|
110
|
+
}
|
|
111
|
+
})
|
|
112
|
+
|
|
102
113
|
cli
|
|
103
114
|
.command('', 'Start the MCP server or controls the browser with -e')
|
|
104
115
|
.option('--host <host>', 'Remote relay server host to connect to (or use PLAYWRITER_HOST env var)')
|
|
@@ -106,8 +117,13 @@ cli
|
|
|
106
117
|
.option('-s, --session <name>', 'Session ID (required for -e, get one with `playwriter session new`)')
|
|
107
118
|
.option('-e, --eval <code>', 'Execute JavaScript code and exit, read https://playwriter.dev/SKILL.md for usage')
|
|
108
119
|
.option('-f, --file <path>', 'Execute JavaScript from a file and exit')
|
|
120
|
+
.option('--patchright', 'Use @playwriter/patchright-core for stealth mode (bypasses bot detection)')
|
|
109
121
|
.option('--timeout [ms]', z.number().default(10000).describe('Execution timeout in milliseconds'))
|
|
110
122
|
.action(async (options) => {
|
|
123
|
+
if (options.patchright) {
|
|
124
|
+
process.env.PLAYWRITER_PATCHRIGHT = '1'
|
|
125
|
+
}
|
|
126
|
+
|
|
111
127
|
if (options.eval && options.file) {
|
|
112
128
|
console.error('Error: -e and -f cannot be used together.')
|
|
113
129
|
process.exit(1)
|
|
@@ -237,7 +253,7 @@ async function executeCode(options: {
|
|
|
237
253
|
|
|
238
254
|
// Ensure relay server is running (only for local)
|
|
239
255
|
if (!host && !process.env.PLAYWRITER_HOST) {
|
|
240
|
-
const restarted = await ensureRelayServer({ logger: console
|
|
256
|
+
const restarted = await ensureRelayServer({ logger: console })
|
|
241
257
|
if (restarted) {
|
|
242
258
|
const connectedExtensions = await waitForConnectedExtensions({
|
|
243
259
|
logger: console,
|
|
@@ -278,6 +294,7 @@ async function executeCode(options: {
|
|
|
278
294
|
images: Array<{ data: string; mimeType: string }>
|
|
279
295
|
screenshots: Array<{ path: string; base64: string; snapshot: string; labelCount: number }>
|
|
280
296
|
isError: boolean
|
|
297
|
+
isCloud?: boolean
|
|
281
298
|
}
|
|
282
299
|
|
|
283
300
|
// Print output
|
|
@@ -321,6 +338,10 @@ async function executeCode(options: {
|
|
|
321
338
|
}
|
|
322
339
|
}
|
|
323
340
|
|
|
341
|
+
if (result.isCloud) {
|
|
342
|
+
console.error(pc.dim(`\nCloud session. Run \`playwriter session delete ${sessionId}\` when done.`))
|
|
343
|
+
}
|
|
344
|
+
|
|
324
345
|
if (result.isError) {
|
|
325
346
|
process.exit(1)
|
|
326
347
|
}
|
|
@@ -340,7 +361,7 @@ async function executeCode(options: {
|
|
|
340
361
|
// Unified browser option type used in the multi-browser selection table
|
|
341
362
|
interface BrowserOption {
|
|
342
363
|
key: string
|
|
343
|
-
type: 'extension' | 'direct'
|
|
364
|
+
type: 'extension' | 'direct' | 'cloud' | 'headless'
|
|
344
365
|
browser: string
|
|
345
366
|
profile: string
|
|
346
367
|
/** For extension entries */
|
|
@@ -349,16 +370,68 @@ interface BrowserOption {
|
|
|
349
370
|
wsUrl?: string
|
|
350
371
|
/** Raw profile data from discovery (for passing to relay) */
|
|
351
372
|
profiles?: Array<{ name: string; email: string }>
|
|
373
|
+
/** For cloud entries — active BU session's cloud session ID (if VM is running) */
|
|
374
|
+
activeCloudSessionId?: string
|
|
352
375
|
}
|
|
353
376
|
|
|
354
377
|
cli
|
|
355
378
|
.command('session new', 'Create a new session and print the session ID')
|
|
356
379
|
.option('--host <host>', 'Remote relay server host')
|
|
357
380
|
.option('--token <token>', 'Authentication token (or use PLAYWRITER_TOKEN env var)')
|
|
358
|
-
.option('--browser <key>', 'Browser key when multiple browsers are available')
|
|
381
|
+
.option('--browser <key>', 'Browser key when multiple browsers are available. Special values: "headless" (launch headless Chrome, no extension), "cloud" (cloud browser with stealth/proxies)')
|
|
382
|
+
.option('--patchright', 'Use @playwriter/patchright-core for stealth mode (bypasses bot detection)')
|
|
359
383
|
.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')
|
|
384
|
+
.option('--proxy <region>', 'Enable residential proxy for cloud browser (e.g. us, de, jp). Disabled by default. Use for anti-detection or geo-targeting.')
|
|
385
|
+
.option('--custom-proxy <url>', 'Custom proxy for cloud browser (host:port or user:pass@host:port)')
|
|
386
|
+
.option('--timeout <minutes>', 'Cloud browser timeout in minutes (1-240, default 60)')
|
|
387
|
+
.option('--disable-proxy-bandwidth-acceleration', 'Allow loading images, video, and fonts when proxy is enabled (they are blocked by default to save proxy bandwidth)')
|
|
360
388
|
.action(async (options) => {
|
|
389
|
+
if (options.patchright) {
|
|
390
|
+
process.env.PLAYWRITER_PATCHRIGHT = '1'
|
|
391
|
+
}
|
|
392
|
+
|
|
361
393
|
const isLocal = !options.host && !process.env.PLAYWRITER_HOST
|
|
394
|
+
|
|
395
|
+
// --browser headless: launch headless Chrome via chromium.launch(), no extension
|
|
396
|
+
if (options.browser === 'headless') {
|
|
397
|
+
try {
|
|
398
|
+
await ensureRelayForSessionCreation(isLocal)
|
|
399
|
+
const serverUrl = await getServerUrl(options.host)
|
|
400
|
+
const response = await fetch(`${serverUrl}/cli/session/new`, {
|
|
401
|
+
method: 'POST',
|
|
402
|
+
headers: buildAuthHeaders({ token: options.token, json: true }),
|
|
403
|
+
body: JSON.stringify({ headless: true, cwd: process.cwd() }),
|
|
404
|
+
})
|
|
405
|
+
if (!response.ok) {
|
|
406
|
+
const text = await response.text()
|
|
407
|
+
if (text.includes('Could not find a supported browser binary')) {
|
|
408
|
+
console.error('No Chrome browser found. Install one first:')
|
|
409
|
+
console.error('')
|
|
410
|
+
console.error(' playwriter browser install')
|
|
411
|
+
console.error('')
|
|
412
|
+
console.error('This downloads Chrome for Testing from Google.')
|
|
413
|
+
process.exit(1)
|
|
414
|
+
}
|
|
415
|
+
console.error(`Error: ${response.status} ${text}`)
|
|
416
|
+
process.exit(1)
|
|
417
|
+
}
|
|
418
|
+
const result = (await response.json()) as { id: string }
|
|
419
|
+
console.log(`Session ${result.id} created (headless). Use with: playwriter -s ${result.id} -e "..."`)
|
|
420
|
+
console.log(pc.dim('NOTE: Recording unavailable in headless mode.'))
|
|
421
|
+
} catch (error: any) {
|
|
422
|
+
if (error.message?.includes('Could not find a supported browser binary')) {
|
|
423
|
+
console.error('No Chrome browser found. Install one first:')
|
|
424
|
+
console.error('')
|
|
425
|
+
console.error(' playwriter browser install')
|
|
426
|
+
console.error('')
|
|
427
|
+
console.error('This downloads Chrome for Testing from Google.')
|
|
428
|
+
process.exit(1)
|
|
429
|
+
}
|
|
430
|
+
console.error(`Error: ${error.message}`)
|
|
431
|
+
process.exit(1)
|
|
432
|
+
}
|
|
433
|
+
return
|
|
434
|
+
}
|
|
362
435
|
// goke 6.6: optional-value flags are string | undefined
|
|
363
436
|
// `--direct ws://...` → 'ws://...' (explicit endpoint)
|
|
364
437
|
// `--direct` → '' (bare flag, auto-discover)
|
|
@@ -425,6 +498,7 @@ cli
|
|
|
425
498
|
return opt.key === options.browser
|
|
426
499
|
})
|
|
427
500
|
if (!selected) {
|
|
501
|
+
await handleCloudBrowserNotFound(options.browser, { hasCloudOptions: false })
|
|
428
502
|
console.error(`Browser not found: ${options.browser}`)
|
|
429
503
|
console.error('Available: ' + directOptions.map((opt) => opt.key).join(', '))
|
|
430
504
|
process.exit(1)
|
|
@@ -445,7 +519,7 @@ cli
|
|
|
445
519
|
let extensions: ExtensionStatus[] = []
|
|
446
520
|
|
|
447
521
|
if (isLocal) {
|
|
448
|
-
await ensureRelayServer({ logger: console
|
|
522
|
+
await ensureRelayServer({ logger: console })
|
|
449
523
|
extensions = await waitForConnectedExtensions({
|
|
450
524
|
timeoutMs: 12000,
|
|
451
525
|
pollIntervalMs: 250,
|
|
@@ -465,8 +539,57 @@ cli
|
|
|
465
539
|
}
|
|
466
540
|
|
|
467
541
|
if (extensions.length === 0) {
|
|
542
|
+
// Before giving up, check if cloud browsers are available
|
|
543
|
+
const cloudOptions = await discoverCloudBrowsers()
|
|
544
|
+
if (cloudOptions.length > 0) {
|
|
545
|
+
// Cloud-only user: skip extension requirement, show cloud options
|
|
546
|
+
await ensureRelayForSessionCreation(isLocal)
|
|
547
|
+
const allOptions: BrowserOption[] = [...cloudOptions]
|
|
548
|
+
|
|
549
|
+
if (options.browser) {
|
|
550
|
+
const selected = allOptions.find((opt) => { return opt.key === options.browser })
|
|
551
|
+
if (!selected) {
|
|
552
|
+
await handleCloudBrowserNotFound(options.browser, { hasCloudOptions: true })
|
|
553
|
+
console.error(`Browser not found: ${options.browser}`)
|
|
554
|
+
console.error('Available: ' + allOptions.map((opt) => opt.key).join(', '))
|
|
555
|
+
process.exit(1)
|
|
556
|
+
}
|
|
557
|
+
const serverUrl = await getServerUrl(options.host)
|
|
558
|
+
// Reuse existing running VM if selected, otherwise create new
|
|
559
|
+
const result = selected.activeCloudSessionId
|
|
560
|
+
? await attachExistingCloudSession({
|
|
561
|
+
serverUrl,
|
|
562
|
+
cloudSessionId: selected.activeCloudSessionId,
|
|
563
|
+
blockProxyResources: computeBlockProxyResources(options),
|
|
564
|
+
token: options.token,
|
|
565
|
+
})
|
|
566
|
+
: await createCloudSession({
|
|
567
|
+
serverUrl,
|
|
568
|
+
proxyRegion: options.proxy,
|
|
569
|
+
customProxy: options.customProxy,
|
|
570
|
+
timeout: parseCloudTimeout(options.timeout),
|
|
571
|
+
blockProxyResources: computeBlockProxyResources(options),
|
|
572
|
+
token: options.token,
|
|
573
|
+
})
|
|
574
|
+
console.log(`Session ${result.id} created (cloud). Use with: playwriter -s ${result.id} -e "..."`)
|
|
575
|
+
if (result.liveUrl) {
|
|
576
|
+
console.log(pc.dim(`Live view: ${result.liveUrl}`))
|
|
577
|
+
}
|
|
578
|
+
return
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
console.log('\nNo local browsers detected, but cloud browsers are available:\n')
|
|
582
|
+
printBrowserTable(allOptions)
|
|
583
|
+
console.log('\nRun again with --browser <key>.')
|
|
584
|
+
process.exit(1)
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
if (options.browser) {
|
|
588
|
+
await handleCloudBrowserNotFound(options.browser, { hasCloudOptions: false })
|
|
589
|
+
}
|
|
468
590
|
console.error('No connected browsers detected. Click the Playwriter extension icon.')
|
|
469
591
|
console.error(pc.dim('Tip: Use --direct to connect via Chrome DevTools Protocol instead.'))
|
|
592
|
+
console.error(pc.dim('Tip: Run `playwriter cloud login` to use cloud browsers.'))
|
|
470
593
|
process.exit(1)
|
|
471
594
|
}
|
|
472
595
|
|
|
@@ -492,7 +615,7 @@ cli
|
|
|
492
615
|
const response = await fetch(`${serverUrl}/cli/session/new`, {
|
|
493
616
|
method: 'POST',
|
|
494
617
|
headers: buildAuthHeaders({ token: options.token, json: true }),
|
|
495
|
-
body: JSON.stringify({ extensionId, cwd
|
|
618
|
+
body: JSON.stringify({ extensionId, cwd }),
|
|
496
619
|
})
|
|
497
620
|
if (!response.ok) {
|
|
498
621
|
const text = await response.text()
|
|
@@ -501,6 +624,7 @@ cli
|
|
|
501
624
|
}
|
|
502
625
|
const result = (await response.json()) as { id: string; extensionId: string | null }
|
|
503
626
|
console.log(`Session ${result.id} created. Use with: playwriter -s ${result.id} -e "..."`)
|
|
627
|
+
printCloudTip()
|
|
504
628
|
} catch (error: any) {
|
|
505
629
|
console.error(`Error: ${error.message}`)
|
|
506
630
|
process.exit(1)
|
|
@@ -508,13 +632,16 @@ cli
|
|
|
508
632
|
return
|
|
509
633
|
}
|
|
510
634
|
|
|
511
|
-
// Multiple extensions: also discover direct CDP instances and
|
|
512
|
-
//
|
|
635
|
+
// Multiple extensions: also discover direct CDP instances and cloud browsers.
|
|
636
|
+
// Direct discovery only works locally — remote relay can't reach local Chrome debug ports.
|
|
513
637
|
const directInstances = isLocal ? await (async () => {
|
|
514
638
|
console.log(pc.dim('Discovering additional Chrome instances...'))
|
|
515
639
|
return await discoverChromeInstances()
|
|
516
640
|
})() : []
|
|
517
641
|
|
|
642
|
+
// Fetch cloud browser slots if user is logged in
|
|
643
|
+
const cloudOptions = await discoverCloudBrowsers()
|
|
644
|
+
|
|
518
645
|
const allOptions: BrowserOption[] = [
|
|
519
646
|
...extensions.map((ext) => {
|
|
520
647
|
return {
|
|
@@ -528,6 +655,7 @@ cli
|
|
|
528
655
|
...directInstances.map((instance) => {
|
|
529
656
|
return instanceToBrowserOption(instance)
|
|
530
657
|
}),
|
|
658
|
+
...cloudOptions,
|
|
531
659
|
]
|
|
532
660
|
|
|
533
661
|
if (options.browser) {
|
|
@@ -535,6 +663,7 @@ cli
|
|
|
535
663
|
return opt.key === options.browser
|
|
536
664
|
})
|
|
537
665
|
if (!selected) {
|
|
666
|
+
await handleCloudBrowserNotFound(options.browser, { hasCloudOptions: cloudOptions.length > 0 })
|
|
538
667
|
console.error(`Browser not found: ${options.browser}`)
|
|
539
668
|
console.error('Available: ' + allOptions.map((opt) => opt.key).join(', '))
|
|
540
669
|
process.exit(1)
|
|
@@ -542,7 +671,28 @@ cli
|
|
|
542
671
|
|
|
543
672
|
try {
|
|
544
673
|
const serverUrl = await getServerUrl(options.host)
|
|
545
|
-
if (selected.type === '
|
|
674
|
+
if (selected.type === 'cloud') {
|
|
675
|
+
// Reuse existing running VM if selected, otherwise create new
|
|
676
|
+
const result = selected.activeCloudSessionId
|
|
677
|
+
? await attachExistingCloudSession({
|
|
678
|
+
serverUrl,
|
|
679
|
+
cloudSessionId: selected.activeCloudSessionId,
|
|
680
|
+
blockProxyResources: computeBlockProxyResources(options),
|
|
681
|
+
token: options.token,
|
|
682
|
+
})
|
|
683
|
+
: await createCloudSession({
|
|
684
|
+
serverUrl,
|
|
685
|
+
proxyRegion: options.proxy,
|
|
686
|
+
customProxy: options.customProxy,
|
|
687
|
+
timeout: parseCloudTimeout(options.timeout),
|
|
688
|
+
blockProxyResources: computeBlockProxyResources(options),
|
|
689
|
+
token: options.token,
|
|
690
|
+
})
|
|
691
|
+
console.log(`Session ${result.id} created (cloud). Use with: playwriter -s ${result.id} -e "..."`)
|
|
692
|
+
if (result.liveUrl) {
|
|
693
|
+
console.log(pc.dim(`Live view: ${result.liveUrl}`))
|
|
694
|
+
}
|
|
695
|
+
} else if (selected.type === 'direct') {
|
|
546
696
|
const result = await createDirectSession({ serverUrl, cdpEndpoint: selected.wsUrl!, browser: selected.browser, profiles: selected.profiles, token: options.token })
|
|
547
697
|
console.log(`Session ${result.id} created (direct CDP). Use with: playwriter -s ${result.id} -e "..."`)
|
|
548
698
|
console.log(pc.dim('NOTE: Recording unavailable in direct CDP mode.'))
|
|
@@ -551,7 +701,7 @@ cli
|
|
|
551
701
|
const response = await fetch(`${serverUrl}/cli/session/new`, {
|
|
552
702
|
method: 'POST',
|
|
553
703
|
headers: buildAuthHeaders({ token: options.token, json: true }),
|
|
554
|
-
body: JSON.stringify({ extensionId: selected.extensionId, cwd
|
|
704
|
+
body: JSON.stringify({ extensionId: selected.extensionId, cwd }),
|
|
555
705
|
})
|
|
556
706
|
if (!response.ok) {
|
|
557
707
|
const text = await response.text()
|
|
@@ -560,6 +710,7 @@ cli
|
|
|
560
710
|
}
|
|
561
711
|
const result = (await response.json()) as { id: string }
|
|
562
712
|
console.log(`Session ${result.id} created. Use with: playwriter -s ${result.id} -e "..."`)
|
|
713
|
+
printCloudTip()
|
|
563
714
|
}
|
|
564
715
|
} catch (error: any) {
|
|
565
716
|
console.error(`Error: ${error.message}`)
|
|
@@ -577,7 +728,7 @@ cli
|
|
|
577
728
|
|
|
578
729
|
async function ensureRelayForSessionCreation(isLocal: boolean): Promise<void> {
|
|
579
730
|
if (isLocal) {
|
|
580
|
-
await ensureRelayServer({ logger: console
|
|
731
|
+
await ensureRelayServer({ logger: console })
|
|
581
732
|
}
|
|
582
733
|
}
|
|
583
734
|
|
|
@@ -629,9 +780,286 @@ function formatInstanceProfiles(instance: DiscoveredInstance): string {
|
|
|
629
780
|
.join(', ')
|
|
630
781
|
}
|
|
631
782
|
|
|
783
|
+
/** Discover cloud sessions from the website API, if logged in.
|
|
784
|
+
* Also adds a "cloud-new" option to create a new cloud browser. */
|
|
785
|
+
async function discoverCloudBrowsers(): Promise<BrowserOption[]> {
|
|
786
|
+
const client = getCloudClient()
|
|
787
|
+
if (!client) return []
|
|
788
|
+
|
|
789
|
+
try {
|
|
790
|
+
const { sessions } = await client.getStatus()
|
|
791
|
+
const options: BrowserOption[] = sessions.map((s) => {
|
|
792
|
+
return {
|
|
793
|
+
key: `cloud-${s.index}`,
|
|
794
|
+
type: 'cloud' as const,
|
|
795
|
+
browser: 'Chromium',
|
|
796
|
+
profile: `(running, expires ${new Date(s.timeoutAt).toLocaleTimeString()})`,
|
|
797
|
+
activeCloudSessionId: s.cloudSessionId,
|
|
798
|
+
}
|
|
799
|
+
})
|
|
800
|
+
// Always offer a "cloud-new" option to spin up a fresh VM
|
|
801
|
+
options.push({
|
|
802
|
+
key: 'cloud',
|
|
803
|
+
type: 'cloud' as const,
|
|
804
|
+
browser: 'Chromium',
|
|
805
|
+
profile: '(new cloud browser)',
|
|
806
|
+
})
|
|
807
|
+
return options
|
|
808
|
+
} catch (error) {
|
|
809
|
+
const msg = error instanceof Error ? error.message : String(error)
|
|
810
|
+
console.error(pc.dim(`Cloud browser discovery failed: ${msg}`))
|
|
811
|
+
return []
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
/** Compute whether to block images/video/fonts for proxy bandwidth savings.
|
|
816
|
+
* Enabled by default when proxy or custom-proxy is set, disabled via
|
|
817
|
+
* --disable-proxy-bandwidth-acceleration. */
|
|
818
|
+
function computeBlockProxyResources(options: { proxy?: string; customProxy?: string; disableProxyBandwidthAcceleration?: boolean }): boolean | undefined {
|
|
819
|
+
const proxyEnabled = !!(options.proxy || options.customProxy)
|
|
820
|
+
if (!proxyEnabled) return undefined // no proxy, no blocking needed
|
|
821
|
+
if (options.disableProxyBandwidthAcceleration) return false
|
|
822
|
+
return true
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
/** Check if user requested a cloud browser that isn't available.
|
|
826
|
+
* Shows helpful login/subscribe instructions instead of a generic "not found" error.
|
|
827
|
+
* @param hasCloudOptions whether any cloud options were discovered (to distinguish
|
|
828
|
+
* "not logged in" from "typo in cloud key") */
|
|
829
|
+
async function handleCloudBrowserNotFound(browserKey: string, { hasCloudOptions }: { hasCloudOptions: boolean }): Promise<boolean> {
|
|
830
|
+
if (!browserKey.startsWith('cloud')) return false
|
|
831
|
+
// If cloud options exist, this is a typo (e.g. cloud-99) — let the
|
|
832
|
+
// generic "Browser not found" message show the available list instead.
|
|
833
|
+
if (hasCloudOptions) return false
|
|
834
|
+
const auth = loadCloudAuth()
|
|
835
|
+
if (!auth) {
|
|
836
|
+
console.error('Cloud browsers require authentication.')
|
|
837
|
+
console.error('')
|
|
838
|
+
console.error(' Option 1: Run `playwriter cloud login` (interactive browser flow)')
|
|
839
|
+
console.error(' Option 2: Set PLAYWRITER_API_KEY env var (create one at playwriter.dev/dashboard)')
|
|
840
|
+
console.error('')
|
|
841
|
+
console.error(' Then subscribe at playwriter.dev/dashboard and run `playwriter session new --browser cloud`')
|
|
842
|
+
} else {
|
|
843
|
+
// Verify token is still valid with a quick API check
|
|
844
|
+
const client = getCloudClient()
|
|
845
|
+
const tokenValid = await (async () => {
|
|
846
|
+
if (!client) return false
|
|
847
|
+
try {
|
|
848
|
+
await client.getStatus()
|
|
849
|
+
return true
|
|
850
|
+
} catch {
|
|
851
|
+
return false
|
|
852
|
+
}
|
|
853
|
+
})()
|
|
854
|
+
|
|
855
|
+
if (!tokenValid) {
|
|
856
|
+
console.error('Cloud authentication expired. Please re-authenticate.')
|
|
857
|
+
console.error('')
|
|
858
|
+
console.error(' Run `playwriter cloud login` or set PLAYWRITER_API_KEY env var.')
|
|
859
|
+
} else {
|
|
860
|
+
console.error('No cloud browser sessions available.')
|
|
861
|
+
console.error('')
|
|
862
|
+
console.error(' You are logged in, but you may need an active subscription.')
|
|
863
|
+
console.error(' Run `playwriter cloud subscribe` to manage your plan.')
|
|
864
|
+
console.error(' Then run `playwriter session new --browser cloud` to start a cloud browser.')
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
process.exit(1)
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
function printCloudTip(): void {
|
|
871
|
+
console.log('')
|
|
872
|
+
console.log(
|
|
873
|
+
pc.dim('Tip: Need stealth browsing, VPS control, or auto CAPTCHA solving? Run `playwriter cloud login` or set PLAYWRITER_API_KEY'),
|
|
874
|
+
)
|
|
875
|
+
console.log(
|
|
876
|
+
pc.dim(' to control a browser in the cloud instead of local Chrome.'),
|
|
877
|
+
)
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
/** Parse a custom proxy string (host:port or user:pass@host:port) into an object. */
|
|
881
|
+
function parseCustomProxy(proxyStr: string): { host: string; port: number; username?: string; password?: string } {
|
|
882
|
+
// Format: [user:pass@]host:port
|
|
883
|
+
const atIdx = proxyStr.lastIndexOf('@')
|
|
884
|
+
let hostPort: string
|
|
885
|
+
let username: string | undefined
|
|
886
|
+
let password: string | undefined
|
|
887
|
+
|
|
888
|
+
if (atIdx !== -1) {
|
|
889
|
+
const userPass = proxyStr.slice(0, atIdx)
|
|
890
|
+
hostPort = proxyStr.slice(atIdx + 1)
|
|
891
|
+
const colonIdx = userPass.indexOf(':')
|
|
892
|
+
if (colonIdx !== -1) {
|
|
893
|
+
username = userPass.slice(0, colonIdx)
|
|
894
|
+
password = userPass.slice(colonIdx + 1)
|
|
895
|
+
} else {
|
|
896
|
+
username = userPass
|
|
897
|
+
}
|
|
898
|
+
} else {
|
|
899
|
+
hostPort = proxyStr
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
const lastColon = hostPort.lastIndexOf(':')
|
|
903
|
+
if (lastColon === -1) {
|
|
904
|
+
throw new Error(`Invalid proxy format: missing port in "${proxyStr}". Expected host:port or user:pass@host:port`)
|
|
905
|
+
}
|
|
906
|
+
const host = hostPort.slice(0, lastColon)
|
|
907
|
+
const port = parseInt(hostPort.slice(lastColon + 1), 10)
|
|
908
|
+
if (isNaN(port)) {
|
|
909
|
+
throw new Error(`Invalid proxy port in "${proxyStr}"`)
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
return { host, port, username, password }
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
/** Parse and validate the --timeout CLI option (integer 1-240). */
|
|
916
|
+
function parseCloudTimeout(value: string | undefined): number | undefined {
|
|
917
|
+
if (value === undefined) return undefined
|
|
918
|
+
if (!/^\d+$/.test(value)) {
|
|
919
|
+
throw new Error('--timeout must be an integer from 1 to 240')
|
|
920
|
+
}
|
|
921
|
+
const timeout = Number(value)
|
|
922
|
+
if (timeout < 1 || timeout > 240) {
|
|
923
|
+
throw new Error('--timeout must be between 1 and 240 minutes')
|
|
924
|
+
}
|
|
925
|
+
return timeout
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
/** Connect to a cloud browser and create a playwriter session via the relay. */
|
|
929
|
+
async function createCloudSession({
|
|
930
|
+
serverUrl,
|
|
931
|
+
proxyRegion,
|
|
932
|
+
customProxy,
|
|
933
|
+
timeout,
|
|
934
|
+
blockProxyResources,
|
|
935
|
+
token,
|
|
936
|
+
}: {
|
|
937
|
+
serverUrl: string
|
|
938
|
+
proxyRegion?: string
|
|
939
|
+
customProxy?: string
|
|
940
|
+
/** Cloud browser timeout in minutes (1-240, default 60) */
|
|
941
|
+
timeout?: number
|
|
942
|
+
/** Block images/video/fonts to save proxy bandwidth (default: true when proxy is enabled) */
|
|
943
|
+
blockProxyResources?: boolean
|
|
944
|
+
token?: string
|
|
945
|
+
}): Promise<{ id: string; liveUrl: string | null }> {
|
|
946
|
+
const client = getCloudClient()
|
|
947
|
+
if (!client) {
|
|
948
|
+
throw new Error('Not logged in to cloud. Run `playwriter cloud login` first.')
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
const connectResult = await client.connect({
|
|
952
|
+
proxyRegion,
|
|
953
|
+
customProxy: customProxy ? parseCustomProxy(customProxy) : undefined,
|
|
954
|
+
timeout,
|
|
955
|
+
})
|
|
956
|
+
|
|
957
|
+
if (!connectResult.cdpUrl) {
|
|
958
|
+
throw new Error('Cloud browser returned no CDP URL. The VM may have failed to start.')
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
// Normalize https:// CDP URL to wss:// for the relay
|
|
962
|
+
const cdpEndpoint = await resolveDirectInput(connectResult.cdpUrl)
|
|
963
|
+
|
|
964
|
+
// Create a playwriter session via the relay using the CDP URL (same as --direct).
|
|
965
|
+
// Also pass cloud metadata so the relay can track idle timeout and auto-disconnect.
|
|
966
|
+
const auth = loadCloudAuth()!
|
|
967
|
+
const cwd = process.cwd()
|
|
968
|
+
let response: Response
|
|
969
|
+
try {
|
|
970
|
+
response = await fetch(`${serverUrl}/cli/session/new`, {
|
|
971
|
+
method: 'POST',
|
|
972
|
+
headers: buildAuthHeaders({ token, json: true }),
|
|
973
|
+
body: JSON.stringify({
|
|
974
|
+
cdpEndpoint,
|
|
975
|
+
cwd,
|
|
976
|
+
browser: 'Chromium (cloud)',
|
|
977
|
+
cloud: {
|
|
978
|
+
cloudSessionId: connectResult.cloudSessionId,
|
|
979
|
+
cloudBaseUrl: auth.baseUrl,
|
|
980
|
+
cloudToken: auth.token,
|
|
981
|
+
timeoutAt: connectResult.timeoutAt,
|
|
982
|
+
blockProxyResources,
|
|
983
|
+
},
|
|
984
|
+
}),
|
|
985
|
+
})
|
|
986
|
+
} catch (cause) {
|
|
987
|
+
// Relay session creation failed — stop the cloud VM so we don't leak a paid resource
|
|
988
|
+
await client.disconnect(connectResult.cloudSessionId).catch(() => {})
|
|
989
|
+
throw new Error('Failed to create relay session', { cause })
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
if (!response.ok) {
|
|
993
|
+
await client.disconnect(connectResult.cloudSessionId).catch(() => {})
|
|
994
|
+
const text = await response.text()
|
|
995
|
+
throw new Error(`${response.status} ${text}`)
|
|
996
|
+
}
|
|
997
|
+
const result = (await response.json()) as { id: string }
|
|
998
|
+
|
|
999
|
+
return { id: result.id, liveUrl: connectResult.cdpUrl ? buildLiveUrl(connectResult.cdpUrl, auth.baseUrl) : null }
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
/** Reattach to an existing running cloud browser VM instead of creating a new one.
|
|
1003
|
+
* Fetches the session's cdpUrl from the cloud API and creates a relay session. */
|
|
1004
|
+
async function attachExistingCloudSession({
|
|
1005
|
+
serverUrl,
|
|
1006
|
+
cloudSessionId,
|
|
1007
|
+
blockProxyResources,
|
|
1008
|
+
token,
|
|
1009
|
+
}: {
|
|
1010
|
+
serverUrl: string
|
|
1011
|
+
cloudSessionId: string
|
|
1012
|
+
blockProxyResources?: boolean
|
|
1013
|
+
token?: string
|
|
1014
|
+
}): Promise<{ id: string; liveUrl: string | null }> {
|
|
1015
|
+
const client = getCloudClient()
|
|
1016
|
+
if (!client) {
|
|
1017
|
+
throw new Error('Not logged in to cloud. Run `playwriter cloud login` first.')
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
const session = await client.getSessionStatus(cloudSessionId)
|
|
1021
|
+
if (!session || session.status !== 'active') {
|
|
1022
|
+
throw new Error('Cloud session is no longer active. It may have timed out.')
|
|
1023
|
+
}
|
|
1024
|
+
if (!session.cdpUrl) {
|
|
1025
|
+
throw new Error('Cloud session has no CDP URL available.')
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
const cdpEndpoint = await resolveDirectInput(session.cdpUrl)
|
|
1029
|
+
const auth = loadCloudAuth()!
|
|
1030
|
+
const cwd = process.cwd()
|
|
1031
|
+
|
|
1032
|
+
const response = await fetch(`${serverUrl}/cli/session/new`, {
|
|
1033
|
+
method: 'POST',
|
|
1034
|
+
headers: buildAuthHeaders({ token, json: true }),
|
|
1035
|
+
body: JSON.stringify({
|
|
1036
|
+
cdpEndpoint,
|
|
1037
|
+
cwd,
|
|
1038
|
+
browser: 'Chromium (cloud)',
|
|
1039
|
+
cloud: {
|
|
1040
|
+
cloudSessionId,
|
|
1041
|
+
cloudBaseUrl: auth.baseUrl,
|
|
1042
|
+
cloudToken: auth.token,
|
|
1043
|
+
timeoutAt: session.timeoutAt,
|
|
1044
|
+
blockProxyResources,
|
|
1045
|
+
},
|
|
1046
|
+
}),
|
|
1047
|
+
})
|
|
1048
|
+
|
|
1049
|
+
if (!response.ok) {
|
|
1050
|
+
const text = await response.text()
|
|
1051
|
+
throw new Error(`${response.status} ${text}`)
|
|
1052
|
+
}
|
|
1053
|
+
const result = (await response.json()) as { id: string }
|
|
1054
|
+
|
|
1055
|
+
return { id: result.id, liveUrl: session.cdpUrl ? buildLiveUrl(session.cdpUrl, auth.baseUrl) : null }
|
|
1056
|
+
}
|
|
1057
|
+
|
|
632
1058
|
function printBrowserTable(options: BrowserOption[]): void {
|
|
633
1059
|
const typeLabels = options.map((opt) => {
|
|
634
|
-
|
|
1060
|
+
if (opt.type === 'direct') return '--direct'
|
|
1061
|
+
if (opt.type === 'cloud') return 'cloud'
|
|
1062
|
+
return opt.type
|
|
635
1063
|
})
|
|
636
1064
|
const keyWidth = Math.max(3, ...options.map((opt) => opt.key.length))
|
|
637
1065
|
const typeWidth = Math.max(4, ...typeLabels.map((t) => t.length))
|
|
@@ -661,7 +1089,7 @@ cli
|
|
|
661
1089
|
.option('--token <token>', 'Authentication token (or use PLAYWRITER_TOKEN env var)')
|
|
662
1090
|
.action(async (options) => {
|
|
663
1091
|
if (!options.host && !process.env.PLAYWRITER_HOST) {
|
|
664
|
-
await ensureRelayServer({ logger: console
|
|
1092
|
+
await ensureRelayServer({ logger: console })
|
|
665
1093
|
}
|
|
666
1094
|
|
|
667
1095
|
const serverUrl = await getServerUrl(options.host)
|
|
@@ -754,7 +1182,7 @@ cli
|
|
|
754
1182
|
const serverUrl = await getServerUrl(options.host)
|
|
755
1183
|
|
|
756
1184
|
if (!options.host && !process.env.PLAYWRITER_HOST) {
|
|
757
|
-
await ensureRelayServer({ logger: console
|
|
1185
|
+
await ensureRelayServer({ logger: console })
|
|
758
1186
|
}
|
|
759
1187
|
|
|
760
1188
|
try {
|
|
@@ -786,7 +1214,7 @@ cli
|
|
|
786
1214
|
const serverUrl = await getServerUrl(options.host)
|
|
787
1215
|
|
|
788
1216
|
if (!options.host && !process.env.PLAYWRITER_HOST) {
|
|
789
|
-
await ensureRelayServer({ logger: console
|
|
1217
|
+
await ensureRelayServer({ logger: console })
|
|
790
1218
|
}
|
|
791
1219
|
|
|
792
1220
|
try {
|
|
@@ -926,7 +1354,7 @@ cli
|
|
|
926
1354
|
|
|
927
1355
|
// Start relay if local so the extension can connect, then fetch in parallel
|
|
928
1356
|
if (isLocal) {
|
|
929
|
-
await ensureRelayServer({ logger: console
|
|
1357
|
+
await ensureRelayServer({ logger: console })
|
|
930
1358
|
}
|
|
931
1359
|
|
|
932
1360
|
const [extensions, directInstances] = await Promise.all([
|
|
@@ -936,6 +1364,24 @@ cli
|
|
|
936
1364
|
isLocal ? discoverChromeInstances() : Promise.resolve([] as DiscoveredInstance[]),
|
|
937
1365
|
])
|
|
938
1366
|
|
|
1367
|
+
const cloudOptions = await discoverCloudBrowsers()
|
|
1368
|
+
|
|
1369
|
+
// Check if a Chrome binary is available for headless mode
|
|
1370
|
+
const headlessOption: BrowserOption[] = await (async () => {
|
|
1371
|
+
try {
|
|
1372
|
+
const { resolveBrowserExecutablePath } = await import('./browser-config.js')
|
|
1373
|
+
resolveBrowserExecutablePath()
|
|
1374
|
+
return [{
|
|
1375
|
+
key: 'headless',
|
|
1376
|
+
type: 'headless' as const,
|
|
1377
|
+
browser: 'Chrome (Headless)',
|
|
1378
|
+
profile: '-',
|
|
1379
|
+
}]
|
|
1380
|
+
} catch {
|
|
1381
|
+
return []
|
|
1382
|
+
}
|
|
1383
|
+
})()
|
|
1384
|
+
|
|
939
1385
|
const allOptions: BrowserOption[] = [
|
|
940
1386
|
...extensions.map((ext) => {
|
|
941
1387
|
return {
|
|
@@ -947,12 +1393,16 @@ cli
|
|
|
947
1393
|
}
|
|
948
1394
|
}),
|
|
949
1395
|
...directInstances.map(instanceToBrowserOption),
|
|
1396
|
+
...headlessOption,
|
|
1397
|
+
...cloudOptions,
|
|
950
1398
|
]
|
|
951
1399
|
|
|
952
1400
|
if (allOptions.length === 0) {
|
|
953
1401
|
console.log('No browsers detected.\n')
|
|
954
1402
|
console.log(' Extension: click the Playwriter icon on a tab to connect')
|
|
955
1403
|
console.log(' Direct: open chrome://inspect/#remote-debugging in Chrome')
|
|
1404
|
+
console.log(' Headless: run `playwriter browser install` then `--browser headless`')
|
|
1405
|
+
console.log(' Cloud: run `playwriter cloud login` to connect cloud browsers')
|
|
956
1406
|
return
|
|
957
1407
|
}
|
|
958
1408
|
|
|
@@ -968,6 +1418,182 @@ cli
|
|
|
968
1418
|
} else {
|
|
969
1419
|
console.log(pc.dim('Use with: playwriter session new [--browser <key>]'))
|
|
970
1420
|
}
|
|
1421
|
+
|
|
1422
|
+
const hasCloud = allOptions.some((opt) => {
|
|
1423
|
+
return opt.type === 'cloud'
|
|
1424
|
+
})
|
|
1425
|
+
if (!hasCloud) {
|
|
1426
|
+
printCloudTip()
|
|
1427
|
+
}
|
|
1428
|
+
})
|
|
1429
|
+
|
|
1430
|
+
// ── Cloud commands ──────────────────────────────────────────────────
|
|
1431
|
+
|
|
1432
|
+
cli
|
|
1433
|
+
.command('cloud login', 'Authenticate with playwriter.dev to use cloud browsers')
|
|
1434
|
+
.option('--base-url <url>', 'Website base URL (default: https://playwriter.dev)')
|
|
1435
|
+
.action(async (options) => {
|
|
1436
|
+
const baseUrl = options.baseUrl || process.env.PLAYWRITER_CLOUD_URL || 'https://playwriter.dev'
|
|
1437
|
+
|
|
1438
|
+
// Use the better-auth client SDK so we don't hardcode endpoint URLs.
|
|
1439
|
+
// Hardcoded URLs broke before when better-auth changed paths between versions.
|
|
1440
|
+
const { createAuthClient } = await import('better-auth/client')
|
|
1441
|
+
const { deviceAuthorizationClient } = await import('better-auth/client/plugins')
|
|
1442
|
+
const client = createAuthClient({
|
|
1443
|
+
baseURL: baseUrl,
|
|
1444
|
+
plugins: [deviceAuthorizationClient()],
|
|
1445
|
+
})
|
|
1446
|
+
|
|
1447
|
+
console.log('Requesting device authorization...')
|
|
1448
|
+
const { data: deviceData, error: requestError } = await client.device.code({
|
|
1449
|
+
client_id: 'playwriter-cli',
|
|
1450
|
+
})
|
|
1451
|
+
if (requestError || !deviceData) {
|
|
1452
|
+
console.error(`Error: failed to request device code — ${requestError?.error_description || requestError?.error || 'unknown error'}`)
|
|
1453
|
+
process.exit(1)
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
const verificationUrl = deviceData.verification_uri_complete || `${baseUrl}/device?user_code=${deviceData.user_code}`
|
|
1457
|
+
console.log(`\nOpen this URL in your browser:\n ${verificationUrl}\n`)
|
|
1458
|
+
console.log(`Code: ${deviceData.user_code}\n`)
|
|
1459
|
+
|
|
1460
|
+
await openInBrowser(verificationUrl)
|
|
1461
|
+
|
|
1462
|
+
console.log('Waiting for approval...')
|
|
1463
|
+
const pollInterval = (deviceData.interval || 5) * 1000
|
|
1464
|
+
const deadline = Date.now() + (deviceData.expires_in || 300) * 1000
|
|
1465
|
+
|
|
1466
|
+
while (Date.now() < deadline) {
|
|
1467
|
+
await new Promise((r) => { setTimeout(r, pollInterval) })
|
|
1468
|
+
const { data: tokenData, error: pollError } = await client.device.token({
|
|
1469
|
+
grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
|
|
1470
|
+
device_code: deviceData.device_code,
|
|
1471
|
+
client_id: 'playwriter-cli',
|
|
1472
|
+
})
|
|
1473
|
+
if (tokenData?.access_token) {
|
|
1474
|
+
saveCloudAuth({ token: tokenData.access_token, baseUrl })
|
|
1475
|
+
console.log(pc.green('\nLogged in successfully!'))
|
|
1476
|
+
console.log('Cloud browsers will now appear in `playwriter session new`.')
|
|
1477
|
+
return
|
|
1478
|
+
}
|
|
1479
|
+
if (pollError?.error === 'authorization_pending' || pollError?.error === 'slow_down') {
|
|
1480
|
+
continue
|
|
1481
|
+
}
|
|
1482
|
+
if (pollError) {
|
|
1483
|
+
console.error(`\nError: Device authorization failed — ${pollError.error_description || pollError.error}`)
|
|
1484
|
+
process.exit(1)
|
|
1485
|
+
}
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1488
|
+
console.error('\nError: Device authorization timed out.')
|
|
1489
|
+
process.exit(1)
|
|
1490
|
+
})
|
|
1491
|
+
|
|
1492
|
+
cli
|
|
1493
|
+
.command('cloud subscribe', 'Open the subscription page to purchase cloud browser sessions')
|
|
1494
|
+
.action(async () => {
|
|
1495
|
+
const auth = loadCloudAuth()
|
|
1496
|
+
if (!auth) {
|
|
1497
|
+
console.error('Not logged in. Run `playwriter cloud login` first.')
|
|
1498
|
+
process.exit(1)
|
|
1499
|
+
}
|
|
1500
|
+
const subscribeUrl = new URL('/dashboard', auth.baseUrl).toString()
|
|
1501
|
+
console.log(`Open your browser to manage your subscription:\n ${subscribeUrl}\n`)
|
|
1502
|
+
await openInBrowser(subscribeUrl)
|
|
1503
|
+
})
|
|
1504
|
+
|
|
1505
|
+
cli
|
|
1506
|
+
.command('cloud status', 'Show active cloud browser sessions')
|
|
1507
|
+
.action(async () => {
|
|
1508
|
+
const client = getCloudClient()
|
|
1509
|
+
if (!client) {
|
|
1510
|
+
console.error('Not logged in. Run `playwriter cloud login` first.')
|
|
1511
|
+
process.exit(1)
|
|
1512
|
+
}
|
|
1513
|
+
|
|
1514
|
+
try {
|
|
1515
|
+
const { sessions } = await client.getStatus()
|
|
1516
|
+
|
|
1517
|
+
if (sessions.length === 0) {
|
|
1518
|
+
console.log('No active cloud sessions.')
|
|
1519
|
+
console.log(pc.dim('Start one with: playwriter session new --browser cloud'))
|
|
1520
|
+
return
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1523
|
+
const keyWidth = Math.max(3, ...sessions.map((s) => `cloud-${s.index}`.length))
|
|
1524
|
+
console.log('KEY'.padEnd(keyWidth) + ' ' + 'STATUS'.padEnd(10) + ' ' + 'DETAILS')
|
|
1525
|
+
console.log('-'.repeat(keyWidth + 30))
|
|
1526
|
+
|
|
1527
|
+
for (const s of sessions) {
|
|
1528
|
+
const key = `cloud-${s.index}`
|
|
1529
|
+
const timeoutAt = new Date(s.timeoutAt).toLocaleTimeString()
|
|
1530
|
+
console.log(
|
|
1531
|
+
key.padEnd(keyWidth) +
|
|
1532
|
+
' ' +
|
|
1533
|
+
pc.green('running'.padEnd(10)) +
|
|
1534
|
+
' ' +
|
|
1535
|
+
`expires ${timeoutAt}`,
|
|
1536
|
+
)
|
|
1537
|
+
}
|
|
1538
|
+
} catch (error) {
|
|
1539
|
+
const msg = error instanceof Error ? error.message : String(error)
|
|
1540
|
+
console.error(`Error: ${msg}`)
|
|
1541
|
+
process.exit(1)
|
|
1542
|
+
}
|
|
1543
|
+
})
|
|
1544
|
+
|
|
1545
|
+
cli
|
|
1546
|
+
.command('cloud live [key]', 'Open a live browser view for an active cloud session')
|
|
1547
|
+
.action(async (key) => {
|
|
1548
|
+
const client = getCloudClient()
|
|
1549
|
+
if (!client) {
|
|
1550
|
+
console.error('Not logged in. Run `playwriter cloud login` first.')
|
|
1551
|
+
process.exit(1)
|
|
1552
|
+
}
|
|
1553
|
+
|
|
1554
|
+
try {
|
|
1555
|
+
const { sessions } = await client.getStatus()
|
|
1556
|
+
if (sessions.length === 0) {
|
|
1557
|
+
console.log('No active cloud sessions.')
|
|
1558
|
+
console.log(pc.dim('Start one with: playwriter session new --browser cloud'))
|
|
1559
|
+
process.exit(1)
|
|
1560
|
+
}
|
|
1561
|
+
|
|
1562
|
+
let session: (typeof sessions)[number] | undefined
|
|
1563
|
+
if (key) {
|
|
1564
|
+
// Match by cloud-N key or by cloudSessionId
|
|
1565
|
+
session = sessions.find((s) => {
|
|
1566
|
+
return `cloud-${s.index}` === key || s.cloudSessionId === key || s.browserUseSessionId === key
|
|
1567
|
+
})
|
|
1568
|
+
if (!session) {
|
|
1569
|
+
console.error(`No active session matching "${key}".`)
|
|
1570
|
+
console.error('Active sessions: ' + sessions.map((s) => { return `cloud-${s.index}` }).join(', '))
|
|
1571
|
+
process.exit(1)
|
|
1572
|
+
}
|
|
1573
|
+
} else if (sessions.length === 1) {
|
|
1574
|
+
session = sessions[0]!
|
|
1575
|
+
} else {
|
|
1576
|
+
console.log('Multiple active sessions. Specify one:\n')
|
|
1577
|
+
for (const s of sessions) {
|
|
1578
|
+
console.log(` cloud-${s.index} (expires ${new Date(s.timeoutAt).toLocaleTimeString()})`)
|
|
1579
|
+
}
|
|
1580
|
+
console.log(`\nUsage: playwriter cloud live cloud-1`)
|
|
1581
|
+
process.exit(1)
|
|
1582
|
+
}
|
|
1583
|
+
|
|
1584
|
+
if (!session.cdpUrl) {
|
|
1585
|
+
console.error('Session has no CDP URL — it may still be starting.')
|
|
1586
|
+
process.exit(1)
|
|
1587
|
+
}
|
|
1588
|
+
const auth = loadCloudAuth()!
|
|
1589
|
+
const liveUrl = buildLiveUrl(session.cdpUrl, auth.baseUrl)
|
|
1590
|
+
console.log(liveUrl)
|
|
1591
|
+
await openInBrowser(liveUrl)
|
|
1592
|
+
} catch (error) {
|
|
1593
|
+
const msg = error instanceof Error ? error.message : String(error)
|
|
1594
|
+
console.error(`Error: ${msg}`)
|
|
1595
|
+
process.exit(1)
|
|
1596
|
+
}
|
|
971
1597
|
})
|
|
972
1598
|
|
|
973
1599
|
cli.command('logfile', 'Print the path to the relay server log file').action(() => {
|