playwriter 0.3.1 → 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.
Files changed (51) hide show
  1. package/dist/bippy.js +5 -5
  2. package/dist/browser-config.d.ts.map +1 -1
  3. package/dist/browser-config.js +8 -2
  4. package/dist/browser-config.js.map +1 -1
  5. package/dist/browser-install.d.ts +16 -0
  6. package/dist/browser-install.d.ts.map +1 -0
  7. package/dist/browser-install.js +237 -0
  8. package/dist/browser-install.js.map +1 -0
  9. package/dist/cdp-relay.d.ts.map +1 -1
  10. package/dist/cdp-relay.js +254 -18
  11. package/dist/cdp-relay.js.map +1 -1
  12. package/dist/chrome-discovery.d.ts.map +1 -1
  13. package/dist/chrome-discovery.js +8 -0
  14. package/dist/chrome-discovery.js.map +1 -1
  15. package/dist/cli.js +568 -6
  16. package/dist/cli.js.map +1 -1
  17. package/dist/cloud-client.d.ts +56 -0
  18. package/dist/cloud-client.d.ts.map +1 -0
  19. package/dist/cloud-client.js +120 -0
  20. package/dist/cloud-client.js.map +1 -0
  21. package/dist/executor.d.ts +46 -2
  22. package/dist/executor.d.ts.map +1 -1
  23. package/dist/executor.js +245 -22
  24. package/dist/executor.js.map +1 -1
  25. package/dist/extension/background.js +106 -23
  26. package/dist/extension/manifest.json +1 -1
  27. package/dist/playwright-import.d.ts +19 -0
  28. package/dist/playwright-import.d.ts.map +1 -0
  29. package/dist/playwright-import.js +39 -0
  30. package/dist/playwright-import.js.map +1 -0
  31. package/dist/prompt.md +32 -0
  32. package/dist/readability.js +1 -1
  33. package/dist/relay-state.d.ts +1 -0
  34. package/dist/relay-state.d.ts.map +1 -1
  35. package/dist/relay-state.js +18 -0
  36. package/dist/relay-state.js.map +1 -1
  37. package/dist/relay-state.test.js +22 -0
  38. package/dist/relay-state.test.js.map +1 -1
  39. package/dist/selector-generator.js +1 -1
  40. package/package.json +3 -1
  41. package/src/browser-config.ts +11 -2
  42. package/src/browser-install.ts +283 -0
  43. package/src/cdp-relay.ts +300 -19
  44. package/src/chrome-discovery.ts +9 -0
  45. package/src/cli.ts +635 -7
  46. package/src/cloud-client.ts +172 -0
  47. package/src/executor.ts +291 -23
  48. package/src/playwright-import.ts +58 -0
  49. package/src/relay-state.test.ts +32 -0
  50. package/src/relay-state.ts +19 -1
  51. package/src/skill.md +154 -14
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,6 +24,7 @@ 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
 
@@ -97,6 +98,18 @@ cli
97
98
  },
98
99
  )
99
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
+
100
113
  cli
101
114
  .command('', 'Start the MCP server or controls the browser with -e')
102
115
  .option('--host <host>', 'Remote relay server host to connect to (or use PLAYWRITER_HOST env var)')
@@ -104,8 +117,13 @@ cli
104
117
  .option('-s, --session <name>', 'Session ID (required for -e, get one with `playwriter session new`)')
105
118
  .option('-e, --eval <code>', 'Execute JavaScript code and exit, read https://playwriter.dev/SKILL.md for usage')
106
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)')
107
121
  .option('--timeout [ms]', z.number().default(10000).describe('Execution timeout in milliseconds'))
108
122
  .action(async (options) => {
123
+ if (options.patchright) {
124
+ process.env.PLAYWRITER_PATCHRIGHT = '1'
125
+ }
126
+
109
127
  if (options.eval && options.file) {
110
128
  console.error('Error: -e and -f cannot be used together.')
111
129
  process.exit(1)
@@ -276,6 +294,7 @@ async function executeCode(options: {
276
294
  images: Array<{ data: string; mimeType: string }>
277
295
  screenshots: Array<{ path: string; base64: string; snapshot: string; labelCount: number }>
278
296
  isError: boolean
297
+ isCloud?: boolean
279
298
  }
280
299
 
281
300
  // Print output
@@ -319,6 +338,10 @@ async function executeCode(options: {
319
338
  }
320
339
  }
321
340
 
341
+ if (result.isCloud) {
342
+ console.error(pc.dim(`\nCloud session. Run \`playwriter session delete ${sessionId}\` when done.`))
343
+ }
344
+
322
345
  if (result.isError) {
323
346
  process.exit(1)
324
347
  }
@@ -338,7 +361,7 @@ async function executeCode(options: {
338
361
  // Unified browser option type used in the multi-browser selection table
339
362
  interface BrowserOption {
340
363
  key: string
341
- type: 'extension' | 'direct'
364
+ type: 'extension' | 'direct' | 'cloud' | 'headless'
342
365
  browser: string
343
366
  profile: string
344
367
  /** For extension entries */
@@ -347,16 +370,68 @@ interface BrowserOption {
347
370
  wsUrl?: string
348
371
  /** Raw profile data from discovery (for passing to relay) */
349
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
350
375
  }
351
376
 
352
377
  cli
353
378
  .command('session new', 'Create a new session and print the session ID')
354
379
  .option('--host <host>', 'Remote relay server host')
355
380
  .option('--token <token>', 'Authentication token (or use PLAYWRITER_TOKEN env var)')
356
- .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)')
357
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)')
358
388
  .action(async (options) => {
389
+ if (options.patchright) {
390
+ process.env.PLAYWRITER_PATCHRIGHT = '1'
391
+ }
392
+
359
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
+ }
360
435
  // goke 6.6: optional-value flags are string | undefined
361
436
  // `--direct ws://...` → 'ws://...' (explicit endpoint)
362
437
  // `--direct` → '' (bare flag, auto-discover)
@@ -423,6 +498,7 @@ cli
423
498
  return opt.key === options.browser
424
499
  })
425
500
  if (!selected) {
501
+ await handleCloudBrowserNotFound(options.browser, { hasCloudOptions: false })
426
502
  console.error(`Browser not found: ${options.browser}`)
427
503
  console.error('Available: ' + directOptions.map((opt) => opt.key).join(', '))
428
504
  process.exit(1)
@@ -463,8 +539,57 @@ cli
463
539
  }
464
540
 
465
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
+ }
466
590
  console.error('No connected browsers detected. Click the Playwriter extension icon.')
467
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.'))
468
593
  process.exit(1)
469
594
  }
470
595
 
@@ -499,6 +624,7 @@ cli
499
624
  }
500
625
  const result = (await response.json()) as { id: string; extensionId: string | null }
501
626
  console.log(`Session ${result.id} created. Use with: playwriter -s ${result.id} -e "..."`)
627
+ printCloudTip()
502
628
  } catch (error: any) {
503
629
  console.error(`Error: ${error.message}`)
504
630
  process.exit(1)
@@ -506,13 +632,16 @@ cli
506
632
  return
507
633
  }
508
634
 
509
- // Multiple extensions: also discover direct CDP instances and show unified table.
510
- // Only discover locally — remote relay can't reach local Chrome debug ports.
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.
511
637
  const directInstances = isLocal ? await (async () => {
512
638
  console.log(pc.dim('Discovering additional Chrome instances...'))
513
639
  return await discoverChromeInstances()
514
640
  })() : []
515
641
 
642
+ // Fetch cloud browser slots if user is logged in
643
+ const cloudOptions = await discoverCloudBrowsers()
644
+
516
645
  const allOptions: BrowserOption[] = [
517
646
  ...extensions.map((ext) => {
518
647
  return {
@@ -526,6 +655,7 @@ cli
526
655
  ...directInstances.map((instance) => {
527
656
  return instanceToBrowserOption(instance)
528
657
  }),
658
+ ...cloudOptions,
529
659
  ]
530
660
 
531
661
  if (options.browser) {
@@ -533,6 +663,7 @@ cli
533
663
  return opt.key === options.browser
534
664
  })
535
665
  if (!selected) {
666
+ await handleCloudBrowserNotFound(options.browser, { hasCloudOptions: cloudOptions.length > 0 })
536
667
  console.error(`Browser not found: ${options.browser}`)
537
668
  console.error('Available: ' + allOptions.map((opt) => opt.key).join(', '))
538
669
  process.exit(1)
@@ -540,7 +671,28 @@ cli
540
671
 
541
672
  try {
542
673
  const serverUrl = await getServerUrl(options.host)
543
- if (selected.type === 'direct') {
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') {
544
696
  const result = await createDirectSession({ serverUrl, cdpEndpoint: selected.wsUrl!, browser: selected.browser, profiles: selected.profiles, token: options.token })
545
697
  console.log(`Session ${result.id} created (direct CDP). Use with: playwriter -s ${result.id} -e "..."`)
546
698
  console.log(pc.dim('NOTE: Recording unavailable in direct CDP mode.'))
@@ -558,6 +710,7 @@ cli
558
710
  }
559
711
  const result = (await response.json()) as { id: string }
560
712
  console.log(`Session ${result.id} created. Use with: playwriter -s ${result.id} -e "..."`)
713
+ printCloudTip()
561
714
  }
562
715
  } catch (error: any) {
563
716
  console.error(`Error: ${error.message}`)
@@ -627,9 +780,286 @@ function formatInstanceProfiles(instance: DiscoveredInstance): string {
627
780
  .join(', ')
628
781
  }
629
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
+
630
1058
  function printBrowserTable(options: BrowserOption[]): void {
631
1059
  const typeLabels = options.map((opt) => {
632
- return opt.type === 'direct' ? '--direct' : opt.type
1060
+ if (opt.type === 'direct') return '--direct'
1061
+ if (opt.type === 'cloud') return 'cloud'
1062
+ return opt.type
633
1063
  })
634
1064
  const keyWidth = Math.max(3, ...options.map((opt) => opt.key.length))
635
1065
  const typeWidth = Math.max(4, ...typeLabels.map((t) => t.length))
@@ -934,6 +1364,24 @@ cli
934
1364
  isLocal ? discoverChromeInstances() : Promise.resolve([] as DiscoveredInstance[]),
935
1365
  ])
936
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
+
937
1385
  const allOptions: BrowserOption[] = [
938
1386
  ...extensions.map((ext) => {
939
1387
  return {
@@ -945,12 +1393,16 @@ cli
945
1393
  }
946
1394
  }),
947
1395
  ...directInstances.map(instanceToBrowserOption),
1396
+ ...headlessOption,
1397
+ ...cloudOptions,
948
1398
  ]
949
1399
 
950
1400
  if (allOptions.length === 0) {
951
1401
  console.log('No browsers detected.\n')
952
1402
  console.log(' Extension: click the Playwriter icon on a tab to connect')
953
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')
954
1406
  return
955
1407
  }
956
1408
 
@@ -966,6 +1418,182 @@ cli
966
1418
  } else {
967
1419
  console.log(pc.dim('Use with: playwriter session new [--browser <key>]'))
968
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
+ }
969
1597
  })
970
1598
 
971
1599
  cli.command('logfile', 'Print the path to the relay server log file').action(() => {