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/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,6 +15,7 @@ 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
20
  const cli = goke('playwriter');
20
21
  cli
@@ -76,6 +77,18 @@ cli
76
77
  process.exit(1);
77
78
  }
78
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
+ });
79
92
  cli
80
93
  .command('', 'Start the MCP server or controls the browser with -e')
81
94
  .option('--host <host>', 'Remote relay server host to connect to (or use PLAYWRITER_HOST env var)')
@@ -83,8 +96,12 @@ cli
83
96
  .option('-s, --session <name>', 'Session ID (required for -e, get one with `playwriter session new`)')
84
97
  .option('-e, --eval <code>', 'Execute JavaScript code and exit, read https://playwriter.dev/SKILL.md for usage')
85
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)')
86
100
  .option('--timeout [ms]', z.number().default(10000).describe('Execution timeout in milliseconds'))
87
101
  .action(async (options) => {
102
+ if (options.patchright) {
103
+ process.env.PLAYWRITER_PATCHRIGHT = '1';
104
+ }
88
105
  if (options.eval && options.file) {
89
106
  console.error('Error: -e and -f cannot be used together.');
90
107
  process.exit(1);
@@ -260,6 +277,9 @@ async function executeCode(options) {
260
277
  }
261
278
  }
262
279
  }
280
+ if (result.isCloud) {
281
+ console.error(pc.dim(`\nCloud session. Run \`playwriter session delete ${sessionId}\` when done.`));
282
+ }
263
283
  if (result.isError) {
264
284
  process.exit(1);
265
285
  }
@@ -280,10 +300,59 @@ cli
280
300
  .command('session new', 'Create a new session and print the session ID')
281
301
  .option('--host <host>', 'Remote relay server host')
282
302
  .option('--token <token>', 'Authentication token (or use PLAYWRITER_TOKEN env var)')
283
- .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)')
284
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)')
285
310
  .action(async (options) => {
311
+ if (options.patchright) {
312
+ process.env.PLAYWRITER_PATCHRIGHT = '1';
313
+ }
286
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
+ }
287
356
  // goke 6.6: optional-value flags are string | undefined
288
357
  // `--direct ws://...` → 'ws://...' (explicit endpoint)
289
358
  // `--direct` → '' (bare flag, auto-discover)
@@ -343,6 +412,7 @@ cli
343
412
  return opt.key === options.browser;
344
413
  });
345
414
  if (!selected) {
415
+ await handleCloudBrowserNotFound(options.browser, { hasCloudOptions: false });
346
416
  console.error(`Browser not found: ${options.browser}`);
347
417
  console.error('Available: ' + directOptions.map((opt) => opt.key).join(', '));
348
418
  process.exit(1);
@@ -379,8 +449,54 @@ cli
379
449
  extensions = await fetchExtensionsStatus({ host: options.host, token: options.token });
380
450
  }
381
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
+ }
382
497
  console.error('No connected browsers detected. Click the Playwriter extension icon.');
383
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.'));
384
500
  process.exit(1);
385
501
  }
386
502
  // Warn if any connected extension was built with an older playwriter version
@@ -412,6 +528,7 @@ cli
412
528
  }
413
529
  const result = (await response.json());
414
530
  console.log(`Session ${result.id} created. Use with: playwriter -s ${result.id} -e "..."`);
531
+ printCloudTip();
415
532
  }
416
533
  catch (error) {
417
534
  console.error(`Error: ${error.message}`);
@@ -419,12 +536,14 @@ cli
419
536
  }
420
537
  return;
421
538
  }
422
- // Multiple extensions: also discover direct CDP instances and show unified table.
423
- // Only discover locally — remote relay can't reach local Chrome debug ports.
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.
424
541
  const directInstances = isLocal ? await (async () => {
425
542
  console.log(pc.dim('Discovering additional Chrome instances...'));
426
543
  return await discoverChromeInstances();
427
544
  })() : [];
545
+ // Fetch cloud browser slots if user is logged in
546
+ const cloudOptions = await discoverCloudBrowsers();
428
547
  const allOptions = [
429
548
  ...extensions.map((ext) => {
430
549
  return {
@@ -438,19 +557,43 @@ cli
438
557
  ...directInstances.map((instance) => {
439
558
  return instanceToBrowserOption(instance);
440
559
  }),
560
+ ...cloudOptions,
441
561
  ];
442
562
  if (options.browser) {
443
563
  const selected = allOptions.find((opt) => {
444
564
  return opt.key === options.browser;
445
565
  });
446
566
  if (!selected) {
567
+ await handleCloudBrowserNotFound(options.browser, { hasCloudOptions: cloudOptions.length > 0 });
447
568
  console.error(`Browser not found: ${options.browser}`);
448
569
  console.error('Available: ' + allOptions.map((opt) => opt.key).join(', '));
449
570
  process.exit(1);
450
571
  }
451
572
  try {
452
573
  const serverUrl = await getServerUrl(options.host);
453
- if (selected.type === 'direct') {
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') {
454
597
  const result = await createDirectSession({ serverUrl, cdpEndpoint: selected.wsUrl, browser: selected.browser, profiles: selected.profiles, token: options.token });
455
598
  console.log(`Session ${result.id} created (direct CDP). Use with: playwriter -s ${result.id} -e "..."`);
456
599
  console.log(pc.dim('NOTE: Recording unavailable in direct CDP mode.'));
@@ -469,6 +612,7 @@ cli
469
612
  }
470
613
  const result = (await response.json());
471
614
  console.log(`Session ${result.id} created. Use with: playwriter -s ${result.id} -e "..."`);
615
+ printCloudTip();
472
616
  }
473
617
  }
474
618
  catch (error) {
@@ -521,9 +665,248 @@ function formatInstanceProfiles(instance) {
521
665
  })
522
666
  .join(', ');
523
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
+ }
524
903
  function printBrowserTable(options) {
525
904
  const typeLabels = options.map((opt) => {
526
- return opt.type === 'direct' ? '--direct' : opt.type;
905
+ if (opt.type === 'direct')
906
+ return '--direct';
907
+ if (opt.type === 'cloud')
908
+ return 'cloud';
909
+ return opt.type;
527
910
  });
528
911
  const keyWidth = Math.max(3, ...options.map((opt) => opt.key.length));
529
912
  const typeWidth = Math.max(4, ...typeLabels.map((t) => t.length));
@@ -766,6 +1149,23 @@ cli
766
1149
  : fetchExtensionsStatus({ host: options.host, token: options.token }),
767
1150
  isLocal ? discoverChromeInstances() : Promise.resolve([]),
768
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
+ })();
769
1169
  const allOptions = [
770
1170
  ...extensions.map((ext) => {
771
1171
  return {
@@ -777,11 +1177,15 @@ cli
777
1177
  };
778
1178
  }),
779
1179
  ...directInstances.map(instanceToBrowserOption),
1180
+ ...headlessOption,
1181
+ ...cloudOptions,
780
1182
  ];
781
1183
  if (allOptions.length === 0) {
782
1184
  console.log('No browsers detected.\n');
783
1185
  console.log(' Extension: click the Playwriter icon on a tab to connect');
784
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');
785
1189
  return;
786
1190
  }
787
1191
  printBrowserTable(allOptions);
@@ -796,6 +1200,164 @@ cli
796
1200
  else {
797
1201
  console.log(pc.dim('Use with: playwriter session new [--browser <key>]'));
798
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
+ }
799
1361
  });
800
1362
  cli.command('logfile', 'Print the path to the relay server log file').action(() => {
801
1363
  console.log(`relay: ${LOG_FILE_PATH}`);