browse 0.11.0 → 0.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -59,24 +59,6 @@ export async function readDevToolsActivePort(userDataDir) {
59
59
  return null;
60
60
  }
61
61
  }
62
- function isPortReachable(port, timeoutMs = 500) {
63
- return new Promise((resolve) => {
64
- const socket = net.createConnection({ host: "127.0.0.1", port });
65
- const timer = setTimeout(() => {
66
- socket.destroy();
67
- resolve(false);
68
- }, timeoutMs);
69
- socket.on("connect", () => {
70
- clearTimeout(timer);
71
- socket.destroy();
72
- resolve(true);
73
- });
74
- socket.on("error", () => {
75
- clearTimeout(timer);
76
- resolve(false);
77
- });
78
- });
79
- }
80
62
  async function probeJsonVersion(port) {
81
63
  const controller = new AbortController();
82
64
  const timer = setTimeout(() => controller.abort(), 2000);
@@ -97,6 +79,46 @@ async function probeJsonVersion(port) {
97
79
  clearTimeout(timer);
98
80
  }
99
81
  }
82
+ /**
83
+ * Multiple candidates (several profile dirs, or a profile dir and a fallback
84
+ * port) can reference the same port within one discovery call. Memoize the
85
+ * live /json/version probe per port so that port is only actually hit once.
86
+ */
87
+ function createJsonVersionProbe() {
88
+ const cache = new Map();
89
+ return (port) => {
90
+ let pending = cache.get(port);
91
+ if (!pending) {
92
+ pending = probeJsonVersion(port);
93
+ cache.set(port, pending);
94
+ }
95
+ return pending;
96
+ };
97
+ }
98
+ function wsPathname(wsUrl) {
99
+ try {
100
+ return new URL(wsUrl).pathname;
101
+ }
102
+ catch {
103
+ return null;
104
+ }
105
+ }
106
+ /**
107
+ * A DevToolsActivePort file only reflects the browser instance that wrote it.
108
+ * If a different process later binds the same port (a very likely collision,
109
+ * since 9222 is the near-universal default debugging port), the cached
110
+ * port+wsPath pair points at a target that no longer exists. Confirm the
111
+ * port's live /json/version still reports this exact target before trusting
112
+ * it. Compare pathnames only (not the full URL) since Chrome can report the
113
+ * host as "127.0.0.1" or "localhost" depending on version/platform.
114
+ */
115
+ async function isDevToolsActivePortFresh(info, probe = probeJsonVersion) {
116
+ const liveWsUrl = await probe(info.port);
117
+ if (!liveWsUrl)
118
+ return false;
119
+ const expectedPathname = wsPathname(buildDevToolsWsUrl(info.port, info.wsPath));
120
+ return (expectedPathname !== null && wsPathname(liveWsUrl) === expectedPathname);
121
+ }
100
122
  async function verifyCdpWebSocket(wsUrl) {
101
123
  return new Promise((resolve) => {
102
124
  const url = new URL(wsUrl);
@@ -135,12 +157,12 @@ async function verifyCdpWebSocket(wsUrl) {
135
157
  });
136
158
  });
137
159
  }
138
- async function resolveDevToolsActivePortUrl(port, userDataDirs) {
160
+ async function resolveDevToolsActivePortUrl(port, userDataDirs, probe) {
139
161
  for (const dir of userDataDirs) {
140
162
  const info = await readDevToolsActivePort(dir);
141
163
  if (!info || info.port !== port)
142
164
  continue;
143
- if (!(await isPortReachable(info.port)))
165
+ if (!(await isDevToolsActivePortFresh(info, probe)))
144
166
  continue;
145
167
  return buildDevToolsWsUrl(info.port, info.wsPath);
146
168
  }
@@ -148,10 +170,11 @@ async function resolveDevToolsActivePortUrl(port, userDataDirs) {
148
170
  }
149
171
  export async function resolveWsTargetFromPort(port, options = {}) {
150
172
  const userDataDirs = options.userDataDirs ?? getChromeUserDataDirs();
151
- const devToolsUrl = await resolveDevToolsActivePortUrl(port, userDataDirs);
173
+ const probe = createJsonVersionProbe();
174
+ const devToolsUrl = await resolveDevToolsActivePortUrl(port, userDataDirs, probe);
152
175
  if (devToolsUrl)
153
176
  return devToolsUrl;
154
- const jsonVersionUrl = await probeJsonVersion(port);
177
+ const jsonVersionUrl = await probe(port);
155
178
  if (jsonVersionUrl)
156
179
  return jsonVersionUrl;
157
180
  const fallback = buildDevToolsWsUrl(port, "/devtools/browser");
@@ -162,9 +185,10 @@ export async function resolveWsTargetFromPort(port, options = {}) {
162
185
  export async function discoverLocalCdp(options = {}) {
163
186
  const candidates = [];
164
187
  const userDataDirs = options.userDataDirs ?? getChromeUserDataDirs();
188
+ const probe = createJsonVersionProbe();
165
189
  for (const dir of userDataDirs) {
166
190
  const info = await readDevToolsActivePort(dir);
167
- if (!info || !(await isPortReachable(info.port)))
191
+ if (!info || !(await isDevToolsActivePortFresh(info, probe)))
168
192
  continue;
169
193
  candidates.push({
170
194
  source: `DevToolsActivePort:${dir}`,
@@ -172,7 +196,7 @@ export async function discoverLocalCdp(options = {}) {
172
196
  });
173
197
  }
174
198
  for (const port of options.fallbackPorts ?? DEFAULT_FALLBACK_PORTS) {
175
- const wsUrl = await probeJsonVersion(port);
199
+ const wsUrl = await probe(port);
176
200
  if (!wsUrl)
177
201
  continue;
178
202
  if (!candidates.some((candidate) => candidate.wsUrl === wsUrl)) {
@@ -377,7 +377,9 @@ async function routeRequest(request, response, bridge, manifestStore, sessionMan
377
377
  ? body.params || {}
378
378
  : {};
379
379
  const session = await sessionManager.createSession(manifest.config?.sessionConfig);
380
- const accepted = bridge.triggerInvocation(functionName, params, { session }, corsHeaders, response);
380
+ const accepted = bridge.triggerInvocation(functionName, params,
381
+ // Match production, which always sends secrets.
382
+ { session, secrets: {} }, corsHeaders, response);
381
383
  if (!accepted) {
382
384
  await sessionManager.closeSession(session.id);
383
385
  sendJson(response, 503, { error: "No runtime is connected yet." }, corsHeaders);
@@ -1,5 +1,5 @@
1
1
  import { spawnSync } from "node:child_process";
2
- import { existsSync } from "node:fs";
2
+ import { existsSync, readFileSync } from "node:fs";
3
3
  import { mkdir, writeFile } from "node:fs/promises";
4
4
  import { join, resolve } from "node:path";
5
5
  import { fail } from "../errors.js";
@@ -17,24 +17,49 @@ dist/
17
17
  .DS_Store
18
18
  `;
19
19
  const starterFunctionTemplate = `import { defineFn } from "@browserbasehq/sdk-functions";
20
- import { chromium } from "playwright-core";
20
+ import { browserbase, Stagehand } from "@browserbasehq/stagehand";
21
+ import { z } from "zod/v4";
21
22
 
22
- defineFn("my-function", async (context) => {
23
- const browser = await chromium.connectOverCDP(context.session.connectUrl);
24
- const page = browser.contexts()[0]!.pages()[0]!;
25
-
26
- await page.goto("https://news.ycombinator.com");
27
- await page.waitForSelector(".athing", { timeout: 30_000 });
23
+ defineFn(
24
+ "my-function",
25
+ async (context) => {
26
+ const browser = await browserbase.connect({
27
+ // The local dev server has no secrets, so fall back to .env.
28
+ apiKey:
29
+ context.secrets.BROWSERBASE_API_KEY ?? process.env.BROWSERBASE_API_KEY!,
30
+ sessionId: context.session.id,
31
+ });
32
+ // In this example, Stagehand uses the Model Gateway where Browserbase charges for the tokens
33
+ const stagehand = await Stagehand.create({ browser });
34
+ const page = (await browser.context.activePage())!;
28
35
 
29
- const titles = await page.$$eval(".athing .titleline > a", (elements) =>
30
- elements.slice(0, 3).map((element) => element.textContent),
31
- );
36
+ await page.goto("https://news.ycombinator.com");
37
+ const { data } = await stagehand.extract(
38
+ "Extract the top 3 stories with their rank, title, and link URL.",
39
+ z.object({
40
+ stories: z
41
+ .array(z.object({ rank: z.number(), title: z.string(), url: z.string() }))
42
+ .max(3),
43
+ }),
44
+ );
32
45
 
33
- return {
34
- message: "Fetched top Hacker News titles",
35
- titles,
36
- };
37
- });
46
+ await stagehand.close();
47
+ return {
48
+ message: "Successfully fetched top Hacker News stories",
49
+ timestamp: new Date().toISOString(),
50
+ results: data.stories,
51
+ };
52
+ },
53
+ {
54
+ // Upload the Stagehand extension once, then paste its ID here:
55
+ // browse cloud extensions upload node_modules/@browserbasehq/stagehand/dist/assets/stagehand-extension.zip
56
+ sessionConfig: { extensionId: "your-extension-id" },
57
+ },
58
+ );
59
+ `;
60
+ // pnpm 11 and later fail installs when esbuild's build script is not approved.
61
+ const pnpmWorkspaceTemplate = `allowBuilds:
62
+ esbuild: true
38
63
  `;
39
64
  const tsconfigTemplate = `{
40
65
  "compilerOptions": {
@@ -71,9 +96,15 @@ export async function initFunctionsProject({ packageManager, projectName, }) {
71
96
  await writeFile(join(projectRoot, ".gitignore"), gitignoreTemplate);
72
97
  await writeFile(join(projectRoot, "index.ts"), starterFunctionTemplate);
73
98
  await writeFile(join(projectRoot, "tsconfig.json"), tsconfigTemplate);
99
+ if (packageManager === "pnpm") {
100
+ await writeFile(join(projectRoot, "pnpm-workspace.yaml"), pnpmWorkspaceTemplate);
101
+ }
74
102
  const install = packageManager === "pnpm" ? ["add"] : ["install"];
75
103
  const installDev = packageManager === "pnpm" ? ["add", "-D"] : ["install", "--save-dev"];
76
- runPackageManager(packageManager, [...install, "@browserbasehq/sdk-functions", "playwright-core"], projectRoot);
104
+ runPackageManager(packageManager, [...install, "@browserbasehq/sdk-functions", "@browserbasehq/stagehand"], projectRoot);
105
+ // Match Stagehand's version of zod in order to pass type checks.
106
+ const zodVersion = readStagehandZodVersion(projectRoot);
107
+ runPackageManager(packageManager, [...install, zodVersion ? `zod@${zodVersion}` : "zod"], projectRoot);
77
108
  runPackageManager(packageManager, [...installDev, "typescript", "@types/node"], projectRoot);
78
109
  if (!existsSync(join(projectRoot, ".git"))) {
79
110
  spawnSync("git", ["init"], {
@@ -88,8 +119,11 @@ export async function initFunctionsProject({ packageManager, projectName, }) {
88
119
  nextSteps: [
89
120
  `cd ${projectName}`,
90
121
  "Edit .env with your Browserbase API key",
91
- packageManager === "pnpm" ? "pnpm dev" : "npm run dev",
92
- packageManager === "pnpm" ? "pnpm run deploy" : "npm run deploy",
122
+ "browse cloud extensions upload node_modules/@browserbasehq/stagehand/dist/assets/stagehand-extension.zip",
123
+ "Paste the uploaded extension ID into index.ts",
124
+ "browse functions dev index.ts",
125
+ "browse functions publish index.ts",
126
+ "Create a BROWSERBASE_API_KEY secret with browse cloud secrets create, then attach it with browse functions secrets attach",
93
127
  ],
94
128
  }, null, 2));
95
129
  }
@@ -99,6 +133,15 @@ function ensureCommand(command) {
99
133
  fail(`${command} is required but was not found on PATH.`);
100
134
  }
101
135
  }
136
+ function readStagehandZodVersion(projectRoot) {
137
+ try {
138
+ const stagehandPackageJson = JSON.parse(readFileSync(join(projectRoot, "node_modules", "@browserbasehq", "stagehand", "package.json"), "utf8"));
139
+ return stagehandPackageJson.dependencies?.zod;
140
+ }
141
+ catch {
142
+ return undefined;
143
+ }
144
+ }
102
145
  function runPackageManager(packageManager, args, cwd) {
103
146
  const result = spawnSync(packageManager, args, {
104
147
  cwd,
@@ -122,14 +122,23 @@ function ensureArchiveLockfile(root, entries) {
122
122
  }
123
123
  const tempDir = join(tmpdir(), `bb-functions-lockgen-${randomUUID()}`);
124
124
  mkdirSync(tempDir, { recursive: true });
125
- copyFileSync(join(root, "package.json"), join(tempDir, "package.json"));
126
- const result = spawnSync("npm", ["install", "--package-lock-only"], {
125
+ // Copy the files that publish uploads, so npm resolves local dependencies the same way the build does.
126
+ for (const entry of entries) {
127
+ const target = join(tempDir, entry);
128
+ mkdirSync(dirname(target), { recursive: true });
129
+ copyFileSync(join(root, entry), target);
130
+ }
131
+ // Omit resolved URLs so the builder installs from its own registry rather than the one on your machine.
132
+ const result = spawnSync("npm", ["install", "--package-lock-only", "--omit-lockfile-registry-resolved"], {
127
133
  cwd: tempDir,
128
134
  stdio: "pipe",
129
135
  });
130
136
  if (result.status !== 0) {
131
137
  rmSync(tempDir, { recursive: true, force: true });
132
- fail("Failed to generate package-lock.json for the Functions build archive.");
138
+ const npmOutput = result.error
139
+ ? result.error.message
140
+ : result.stderr.toString().trim();
141
+ fail(`Failed to generate package-lock.json for the Functions build archive.${npmOutput ? `\n${npmOutput}` : ""}`);
133
142
  }
134
143
  return {
135
144
  entries: [...entries, "package-lock.json"].sort(),
@@ -5502,18 +5502,18 @@
5502
5502
  "update.js"
5503
5503
  ]
5504
5504
  },
5505
- "cloud:projects:get": {
5505
+ "cloud:extensions:delete": {
5506
5506
  "aliases": [],
5507
5507
  "args": {
5508
5508
  "id": {
5509
- "description": "Project ID.",
5509
+ "description": "Extension ID.",
5510
5510
  "name": "id",
5511
5511
  "required": true
5512
5512
  }
5513
5513
  },
5514
- "description": "Get a project by ID.",
5514
+ "description": "Delete a Chrome extension.",
5515
5515
  "examples": [
5516
- "browse cloud projects get <project-id>"
5516
+ "browse cloud extensions delete <extension-id>"
5517
5517
  ],
5518
5518
  "flags": {
5519
5519
  "api-key": {
@@ -5535,7 +5535,7 @@
5535
5535
  },
5536
5536
  "hasDynamicHelp": false,
5537
5537
  "hiddenAliases": [],
5538
- "id": "cloud:projects:get",
5538
+ "id": "cloud:extensions:delete",
5539
5539
  "pluginAlias": "browse",
5540
5540
  "pluginName": "browse",
5541
5541
  "pluginType": "core",
@@ -5545,17 +5545,22 @@
5545
5545
  "dist",
5546
5546
  "commands",
5547
5547
  "cloud",
5548
- "projects",
5549
- "get.js"
5548
+ "extensions",
5549
+ "delete.js"
5550
5550
  ]
5551
5551
  },
5552
- "cloud:projects:list": {
5552
+ "cloud:extensions:get": {
5553
5553
  "aliases": [],
5554
- "args": {},
5555
- "description": "List projects visible to the current API key.",
5554
+ "args": {
5555
+ "id": {
5556
+ "description": "Extension ID.",
5557
+ "name": "id",
5558
+ "required": true
5559
+ }
5560
+ },
5561
+ "description": "Get a Chrome extension by ID.",
5556
5562
  "examples": [
5557
- "browse cloud projects list",
5558
- "browse cloud projects list --json"
5563
+ "browse cloud extensions get <extension-id>"
5559
5564
  ],
5560
5565
  "flags": {
5561
5566
  "api-key": {
@@ -5573,35 +5578,11 @@
5573
5578
  "helpValue": "<baseUrl>",
5574
5579
  "multiple": false,
5575
5580
  "type": "option"
5576
- },
5577
- "format": {
5578
- "description": "Output format. Defaults to table in a terminal and JSON when piped.",
5579
- "name": "format",
5580
- "hasDynamicHelp": false,
5581
- "helpValue": "<format>",
5582
- "multiple": false,
5583
- "options": [
5584
- "table",
5585
- "json"
5586
- ],
5587
- "type": "option"
5588
- },
5589
- "json": {
5590
- "description": "Print raw JSON output.",
5591
- "name": "json",
5592
- "allowNo": false,
5593
- "type": "boolean"
5594
- },
5595
- "wide": {
5596
- "description": "Show full table values without truncation.",
5597
- "name": "wide",
5598
- "allowNo": false,
5599
- "type": "boolean"
5600
5581
  }
5601
5582
  },
5602
5583
  "hasDynamicHelp": false,
5603
5584
  "hiddenAliases": [],
5604
- "id": "cloud:projects:list",
5585
+ "id": "cloud:extensions:get",
5605
5586
  "pluginAlias": "browse",
5606
5587
  "pluginName": "browse",
5607
5588
  "pluginType": "core",
@@ -5611,22 +5592,22 @@
5611
5592
  "dist",
5612
5593
  "commands",
5613
5594
  "cloud",
5614
- "projects",
5615
- "list.js"
5595
+ "extensions",
5596
+ "get.js"
5616
5597
  ]
5617
5598
  },
5618
- "cloud:projects:usage": {
5599
+ "cloud:extensions:upload": {
5619
5600
  "aliases": [],
5620
5601
  "args": {
5621
- "id": {
5622
- "description": "Project ID.",
5623
- "name": "id",
5602
+ "file": {
5603
+ "description": "Path to extension ZIP file.",
5604
+ "name": "file",
5624
5605
  "required": true
5625
5606
  }
5626
5607
  },
5627
- "description": "Get project usage.",
5608
+ "description": "Upload a Chrome extension ZIP file.",
5628
5609
  "examples": [
5629
- "browse cloud projects usage <project-id>"
5610
+ "browse cloud extensions upload ./extension.zip"
5630
5611
  ],
5631
5612
  "flags": {
5632
5613
  "api-key": {
@@ -5648,7 +5629,7 @@
5648
5629
  },
5649
5630
  "hasDynamicHelp": false,
5650
5631
  "hiddenAliases": [],
5651
- "id": "cloud:projects:usage",
5632
+ "id": "cloud:extensions:upload",
5652
5633
  "pluginAlias": "browse",
5653
5634
  "pluginName": "browse",
5654
5635
  "pluginType": "core",
@@ -5658,22 +5639,22 @@
5658
5639
  "dist",
5659
5640
  "commands",
5660
5641
  "cloud",
5661
- "projects",
5662
- "usage.js"
5642
+ "extensions",
5643
+ "upload.js"
5663
5644
  ]
5664
5645
  },
5665
- "cloud:extensions:delete": {
5646
+ "cloud:projects:get": {
5666
5647
  "aliases": [],
5667
5648
  "args": {
5668
5649
  "id": {
5669
- "description": "Extension ID.",
5650
+ "description": "Project ID.",
5670
5651
  "name": "id",
5671
5652
  "required": true
5672
5653
  }
5673
5654
  },
5674
- "description": "Delete a Chrome extension.",
5655
+ "description": "Get a project by ID.",
5675
5656
  "examples": [
5676
- "browse cloud extensions delete <extension-id>"
5657
+ "browse cloud projects get <project-id>"
5677
5658
  ],
5678
5659
  "flags": {
5679
5660
  "api-key": {
@@ -5695,7 +5676,7 @@
5695
5676
  },
5696
5677
  "hasDynamicHelp": false,
5697
5678
  "hiddenAliases": [],
5698
- "id": "cloud:extensions:delete",
5679
+ "id": "cloud:projects:get",
5699
5680
  "pluginAlias": "browse",
5700
5681
  "pluginName": "browse",
5701
5682
  "pluginType": "core",
@@ -5705,22 +5686,17 @@
5705
5686
  "dist",
5706
5687
  "commands",
5707
5688
  "cloud",
5708
- "extensions",
5709
- "delete.js"
5689
+ "projects",
5690
+ "get.js"
5710
5691
  ]
5711
5692
  },
5712
- "cloud:extensions:get": {
5693
+ "cloud:projects:list": {
5713
5694
  "aliases": [],
5714
- "args": {
5715
- "id": {
5716
- "description": "Extension ID.",
5717
- "name": "id",
5718
- "required": true
5719
- }
5720
- },
5721
- "description": "Get a Chrome extension by ID.",
5695
+ "args": {},
5696
+ "description": "List projects visible to the current API key.",
5722
5697
  "examples": [
5723
- "browse cloud extensions get <extension-id>"
5698
+ "browse cloud projects list",
5699
+ "browse cloud projects list --json"
5724
5700
  ],
5725
5701
  "flags": {
5726
5702
  "api-key": {
@@ -5738,11 +5714,35 @@
5738
5714
  "helpValue": "<baseUrl>",
5739
5715
  "multiple": false,
5740
5716
  "type": "option"
5717
+ },
5718
+ "format": {
5719
+ "description": "Output format. Defaults to table in a terminal and JSON when piped.",
5720
+ "name": "format",
5721
+ "hasDynamicHelp": false,
5722
+ "helpValue": "<format>",
5723
+ "multiple": false,
5724
+ "options": [
5725
+ "table",
5726
+ "json"
5727
+ ],
5728
+ "type": "option"
5729
+ },
5730
+ "json": {
5731
+ "description": "Print raw JSON output.",
5732
+ "name": "json",
5733
+ "allowNo": false,
5734
+ "type": "boolean"
5735
+ },
5736
+ "wide": {
5737
+ "description": "Show full table values without truncation.",
5738
+ "name": "wide",
5739
+ "allowNo": false,
5740
+ "type": "boolean"
5741
5741
  }
5742
5742
  },
5743
5743
  "hasDynamicHelp": false,
5744
5744
  "hiddenAliases": [],
5745
- "id": "cloud:extensions:get",
5745
+ "id": "cloud:projects:list",
5746
5746
  "pluginAlias": "browse",
5747
5747
  "pluginName": "browse",
5748
5748
  "pluginType": "core",
@@ -5752,22 +5752,22 @@
5752
5752
  "dist",
5753
5753
  "commands",
5754
5754
  "cloud",
5755
- "extensions",
5756
- "get.js"
5755
+ "projects",
5756
+ "list.js"
5757
5757
  ]
5758
5758
  },
5759
- "cloud:extensions:upload": {
5759
+ "cloud:projects:usage": {
5760
5760
  "aliases": [],
5761
5761
  "args": {
5762
- "file": {
5763
- "description": "Path to extension ZIP file.",
5764
- "name": "file",
5762
+ "id": {
5763
+ "description": "Project ID.",
5764
+ "name": "id",
5765
5765
  "required": true
5766
5766
  }
5767
5767
  },
5768
- "description": "Upload a Chrome extension ZIP file.",
5768
+ "description": "Get project usage.",
5769
5769
  "examples": [
5770
- "browse cloud extensions upload ./extension.zip"
5770
+ "browse cloud projects usage <project-id>"
5771
5771
  ],
5772
5772
  "flags": {
5773
5773
  "api-key": {
@@ -5789,7 +5789,7 @@
5789
5789
  },
5790
5790
  "hasDynamicHelp": false,
5791
5791
  "hiddenAliases": [],
5792
- "id": "cloud:extensions:upload",
5792
+ "id": "cloud:projects:usage",
5793
5793
  "pluginAlias": "browse",
5794
5794
  "pluginName": "browse",
5795
5795
  "pluginType": "core",
@@ -5799,8 +5799,8 @@
5799
5799
  "dist",
5800
5800
  "commands",
5801
5801
  "cloud",
5802
- "extensions",
5803
- "upload.js"
5802
+ "projects",
5803
+ "usage.js"
5804
5804
  ]
5805
5805
  },
5806
5806
  "cloud:secrets:create": {
@@ -6903,5 +6903,5 @@
6903
6903
  ]
6904
6904
  }
6905
6905
  },
6906
- "version": "0.11.0"
6906
+ "version": "0.11.1"
6907
6907
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "browse",
3
- "version": "0.11.0",
3
+ "version": "0.11.1",
4
4
  "description": "Unified Browserbase CLI for browser automation and cloud APIs.",
5
5
  "type": "module",
6
6
  "private": false,