@beryl-so/cli 0.34.0 → 0.34.3

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/README.md CHANGED
@@ -252,7 +252,7 @@ Trigger a run of a project's tests (e.g. in CI), then watch, inspect, and downlo
252
252
  | Command | Summary | MCP tool |
253
253
  | --- | --- | --- |
254
254
  | `beryl runs trigger` | Trigger a test run (whole suite, a subset, or one environment) | `runs_trigger` |
255
- | `beryl runs local [test-ids...]` | Run tests on your machine with your own Playwright; results sync to Beryl | `runs_local` |
255
+ | `beryl runs local [test-ids...]` | Run tests on your machine with Playwright; results sync to Beryl | `runs_local` |
256
256
  | `beryl runs list` | List recent runs | `runs_list` |
257
257
  | `beryl runs get <run-id>` | Show one run with its per-test results | `runs_get` |
258
258
  | `beryl runs watch <run-id>` | Attach to a run and stream progress until it finishes | `runs_watch` |
@@ -73,7 +73,7 @@ exception are in \`tests create\`'s own description.
73
73
  Before the first plan:
74
74
 
75
75
  \`\`\`
76
- npm i -D @playwright/test && npx playwright install chromium # once, per project
76
+ npx @beryl-so/cli@latest init # once per machine: installs the Chromium browser (the runner ships with the CLI)
77
77
  beryl envs list # the root URL gotos resolve against (§2)
78
78
  beryl envs update <id> --url https://app.example.com # set it if root_url is empty — required
79
79
  beryl accounts list # who do authenticated tests sign in as? (§1)
@@ -370,7 +370,7 @@ Weak (describes steps + asserts nothing meaningful):
370
370
  ## 4. The local run-fix loop
371
371
 
372
372
  Iterate on your machine before you rely on the cloud. \`beryl runs local\` runs banked
373
- tests with your local \`@playwright/test\` — no cloud, no waiting for a scheduled run.
373
+ tests with the Playwright runner bundled in the CLI — no cloud, no waiting for a scheduled run.
374
374
 
375
375
  \`\`\`
376
376
  beryl runs local <test-id> --no-sync --url-override http://localhost:3000 --dir ./beryl-local
@@ -175,7 +175,7 @@ export const authCommands = [
175
175
  const human = `${green("Logged in")} as ${me.name} <${me.email}>` +
176
176
  `\n${dim(`Token saved to ${saved}`)}`;
177
177
  return {
178
- data: { email: me.email, name: me.name, config: saved },
178
+ data: { id: me.id, email: me.email, name: me.name, config: saved },
179
179
  human,
180
180
  };
181
181
  },
@@ -8,6 +8,7 @@ import { AuthError, CliError } from "../errors.js";
8
8
  import { ApiClient } from "../http.js";
9
9
  import { bold, cyan, dim, green, red, yellow } from "../output.js";
10
10
  import { anyGap, describeGaps, installCommandsFor, installPlaywright, playwrightGaps, } from "../playwright-install.js";
11
+ import { aliasSessionToAccount, captureCliEvent, flushTelemetry, shutdownTelemetry, } from "../telemetry.js";
11
12
  import { cliVersion, warnIfStale } from "../version-check.js";
12
13
  import { authCommands } from "./auth.js";
13
14
  import { flagStr } from "./util.js";
@@ -117,16 +118,16 @@ function writeSkills(cwd, scope) {
117
118
  writeSkillFile(skillLeaf(path.join(root, ".claude", "skills"))),
118
119
  ];
119
120
  }
120
- // Local authoring drives a real browser via `@playwright/test` + chromium, and the whole
121
- // authoring workflow (walk the flow first, then bank the plan) depends on it — so the
122
- // install is mandatory, not offered: missing means install now, no prompt, no opt-out.
123
- // Both halves count: the npm package without the browser binary is a real state, and it
124
- // fails every local run with the same launch error. Never throws a failed install must not
125
- // fail `init`, which has already done its wiring; it prints the exact commands instead.
121
+ // Local authoring drives a real browser, and the whole authoring workflow (walk the flow
122
+ // first, then bank the plan) depends on it — so the install is mandatory, not offered:
123
+ // missing means install now, no prompt, no opt-out. The runner ships with the CLI; the
124
+ // browser is the per-machine half, fetched for the runner's own revision. Never throws a
125
+ // failed install must not fail `init`, which has already done its wiring; it prints the
126
+ // exact commands instead.
126
127
  async function ensureLocalPlaywright(ctx, cwd) {
127
128
  const gaps = playwrightGaps(cwd);
128
129
  if (!anyGap(gaps)) {
129
- ctx.err(`${green("✓")} @playwright/test + Chromium already installed ${dim("(local runs ready)")}`);
130
+ ctx.err(`${green("✓")} Playwright + Chromium already installed ${dim("(local runs ready)")}`);
130
131
  return;
131
132
  }
132
133
  try {
@@ -141,7 +142,7 @@ async function ensureLocalPlaywright(ctx, cwd) {
141
142
  catch (err) {
142
143
  ctx.err(`${red("✗")} Playwright install failed: ${err.message}`);
143
144
  ctx.err(`${dim("•")} Finish the install by hand — local runs and browser authoring need it:\n` +
144
- ` ${cyan(installCommandsFor(gaps))}`);
145
+ ` ${cyan(installCommandsFor(gaps, cwd))}`);
145
146
  }
146
147
  }
147
148
  export const initCommands = [
@@ -153,8 +154,9 @@ export const initCommands = [
153
154
  "agent. Nothing is detected and nothing is conditional: every run wires the beryl AND " +
154
155
  "playwright MCP servers, writes the authoring skill (user scope: your home " +
155
156
  ".agents/skills/ + .claude/skills/, so it follows you into every session; --scope " +
156
- "project: the repo's own dirs, committed for teammates), and installs @playwright/test " +
157
- "+ chromium if missing, since browser authoring and local runs depend on it. By default the " +
157
+ "project: the repo's own dirs, committed for teammates), and installs the Chromium " +
158
+ "browser if missing (the Playwright runner ships with the CLI, so any repo can run tests " +
159
+ "locally), since browser authoring and local runs depend on it. By default the " +
158
160
  "servers are wired per-user (matching where your login token lives) via `claude mcp add " +
159
161
  "-s user`; pass --scope project to write a committed .mcp.json for a shared repo " +
160
162
  "instead. No workspace/project pin and no URL prompt: ask Claude to write tests for " +
@@ -187,6 +189,10 @@ export const initCommands = [
187
189
  async run(ctx, input) {
188
190
  const cwd = process.cwd();
189
191
  let { client, config } = ctx;
192
+ const scope = (flagStr(input, "scope") ?? "user");
193
+ captureCliEvent("cli_init_started", config.apiUrl, { scope });
194
+ flushTelemetry();
195
+ let accountId;
190
196
  const signIn = async () => {
191
197
  // Non-interactive skips the picker and lets `login` fail with its own
192
198
  // "interactive input required" message, exactly as before.
@@ -194,7 +200,11 @@ export const initCommands = [
194
200
  ? await ctx.prompt("Sign in: [1] Browser (opens beryl.so) [2] Email me a code — [1]: ")
195
201
  : "";
196
202
  const login = authCommands.find((c) => c.name === "login");
197
- await login.run(ctx, { args: {}, flags: answer.trim() === "2" ? { otp: true } : {} });
203
+ const result = await login.run(ctx, {
204
+ args: {},
205
+ flags: answer.trim() === "2" ? { otp: true } : {},
206
+ });
207
+ accountId = (result ?? {}).data?.id;
198
208
  config = loadConfig();
199
209
  if (!config.token)
200
210
  throw new CliError("Login did not persist a token");
@@ -203,6 +213,7 @@ export const initCommands = [
203
213
  if (config.token) {
204
214
  try {
205
215
  const me = (await client.get("/account/"));
216
+ accountId = me.id;
206
217
  ctx.err(`${green("✓")} Signed in as ${me.email}`);
207
218
  }
208
219
  catch (err) {
@@ -215,7 +226,10 @@ export const initCommands = [
215
226
  else {
216
227
  await signIn();
217
228
  }
218
- const scope = (flagStr(input, "scope") ?? "user");
229
+ if (accountId) {
230
+ aliasSessionToAccount(accountId);
231
+ flushTelemetry();
232
+ }
219
233
  const report = (label, fresh, where) => ctx.err(`${green("✓")} ${label} ${fresh ? "configured" : "already configured"} ${dim(where)}`);
220
234
  if (scope === "user") {
221
235
  // Claude Code owns ~/.claude.json — shell out to `claude mcp add` rather than write it.
@@ -267,6 +281,7 @@ export const initCommands = [
267
281
  // The .mcp.json entry is pinned to @latest, but a global install / old npx cache
268
282
  // still wins resolution — so tell the user when the CLI they just ran is stale.
269
283
  await warnIfStale(cliVersion(), (msg) => ctx.err(yellow(msg)));
284
+ await shutdownTelemetry();
270
285
  return {
271
286
  data: {
272
287
  scope,
@@ -5,7 +5,7 @@ import { CliError, UsageError } from "../errors.js";
5
5
  import { buildImportForm, establishAccountSession, executeLocalSpec, IMPORT_MAX_ERROR_LEN, toRunEntry, } from "../local-exec.js";
6
6
  import { countPlannedFrames, countWrittenFrames, PlaywrightMissingError, } from "../local-run.js";
7
7
  import { dim, green, red, yellow } from "../output.js";
8
- import { anyGap, confirmInstall, describeGaps, installCommandsFor, installPlaywright, playwrightGaps, } from "../playwright-install.js";
8
+ import { anyGap, confirmInstall, describeGaps, installCommandsFor, installHint, installPlaywright, playwrightGaps, } from "../playwright-install.js";
9
9
  import { ProgressBar } from "../progress.js";
10
10
  import { arg, flagBool, flagNum, flagStr, idsInGroup, projectPath } from "./util.js";
11
11
  import { watchRun } from "./watch.js";
@@ -17,8 +17,8 @@ async function ensureRunnableLocally(ctx) {
17
17
  const gaps = playwrightGaps(process.cwd());
18
18
  if (!anyGap(gaps))
19
19
  return;
20
- const commands = installCommandsFor(gaps);
21
- const hint = `Local runs need ${describeGaps(gaps)}. Install it, then re-run:\n ${commands}`;
20
+ const commands = installCommandsFor(gaps, process.cwd());
21
+ const hint = installHint(process.cwd(), gaps);
22
22
  if (!ctx.interactive || !(await confirmInstall(ctx.prompt, commands)))
23
23
  throw new CliError(hint);
24
24
  try {
@@ -115,12 +115,13 @@ export const runCommands = [
115
115
  },
116
116
  {
117
117
  name: "runs local",
118
- summary: "Run tests on your machine with your own Playwright; results sync to Beryl",
118
+ summary: "Run tests on your machine with Playwright; results sync to Beryl",
119
119
  description: "Unlike `runs trigger`, the browser runs on YOUR machine: each test's rendered spec is " +
120
- "fetched and run with your local @playwright/test and the Chromium binary it drives. " +
120
+ "fetched and run with the Playwright runner bundled in the CLI (your repo's own " +
121
+ "@playwright/test wins when present) and the Chromium binary it drives. " +
121
122
  "Both are checked once before any spec is fetched, so a machine that can't run tests " +
122
123
  "says so once instead of failing every test (on a terminal the CLI offers to install " +
123
- "whichever half is missing; over MCP it prints the exact install commands). " +
124
+ "the missing browser; over MCP it prints the exact install command). " +
124
125
  "Signup/OTP flows work: the CLI answers the spec's await_email steps over the API " +
125
126
  "against the same mailbox the cloud runner uses. Authenticated tests work too: for a plan " +
126
127
  "that signs itself in with {{login_email}}/{{login_password}}, the email is baked into " +
@@ -412,7 +413,9 @@ export const runCommands = [
412
413
  if (err instanceof PlaywrightMissingError && ctx.interactive && !installOffered) {
413
414
  installOffered = true;
414
415
  bar.clear();
415
- if (!(await confirmInstall(ctx.prompt)))
416
+ const cwd = process.cwd();
417
+ const commands = installCommandsFor(playwrightGaps(cwd), cwd);
418
+ if (!(await confirmInstall(ctx.prompt, commands)))
416
419
  throw err;
417
420
  try {
418
421
  await installPlaywright(process.cwd(), (line) => ctx.err(dim(line)));
@@ -5,7 +5,7 @@ import { lintPlan } from "../lint.js";
5
5
  import { buildImportForm, establishAccountSession, executeLocalSpec, toRunEntry, } from "../local-exec.js";
6
6
  import { PlaywrightMissingError } from "../local-run.js";
7
7
  import { dim, green, red, table, yellow } from "../output.js";
8
- import { confirmInstall, installPlaywright } from "../playwright-install.js";
8
+ import { confirmInstall, installCommandsFor, installPlaywright, playwrightGaps, } from "../playwright-install.js";
9
9
  import { ACTION_PLAN_SCHEMA } from "../schema.generated.js";
10
10
  import { arg, argList, flagBool, flagNum, flagStr, flagStrings, inGroup, projectPath, readJsonFlag, } from "./util.js";
11
11
  const testPath = (ws, p, id) => `${projectPath(ws, p)}/tests/${id}`;
@@ -207,13 +207,14 @@ export const testCommands = [
207
207
  summary: "Create a test case from a JSON action plan (for tests authored locally, e.g. by your coding agent)",
208
208
  description: "The plan is a JSON object whose steps are {action, selector, url, value, ...}: the first " +
209
209
  "EXECUTED step must be a goto, and at least one step must be an expect. Before anything is " +
210
- "banked, the plan is proven by replaying it in a browser ON YOUR MACHINE with your local " +
211
- "@playwright/test: the server renders the spec (`tests/compile`), the CLI runs it (minting " +
210
+ "banked, the plan is proven by replaying it in a browser ON YOUR MACHINE with the " +
211
+ "Playwright runner bundled in the CLI (your repo's own @playwright/test wins when " +
212
+ "present): the server renders the spec (`tests/compile`), the CLI runs it (minting " +
212
213
  "a run inbox for await_email steps and resolving the saved login exactly as a cloud run " +
213
214
  "would), and only a green replay creates the test (bound to the replayed plan by its hash). " +
214
215
  "This holds over MCP too: the replay runs on the machine hosting the MCP server, never on " +
215
- "Beryl's; if @playwright/test is missing there the tool returns the install commands (on a " +
216
- "terminal the CLI offers to install it). " +
216
+ "Beryl's; if the Chromium browser is missing there the tool returns the install command " +
217
+ "(`beryl init` installs it; on a terminal the CLI offers to). " +
217
218
  "A red replay banks NOTHING: the failure evidence comes back (over MCP the screenshot is " +
218
219
  "image content), you fix the plan file and re-run. The proving run is imported as the " +
219
220
  "test's first run (--no-sync to skip). A plan that signs in with a session Beryl captured " +
@@ -399,8 +400,10 @@ export const testCommands = [
399
400
  // pipe the hint is the answer.
400
401
  if (ctx.interactive && !installOffered) {
401
402
  installOffered = true;
402
- if (await confirmInstall(ctx.prompt)) {
403
- await installPlaywright(process.cwd(), (line) => ctx.err(dim(line)));
403
+ const cwd = process.cwd();
404
+ const commands = installCommandsFor(playwrightGaps(cwd), cwd);
405
+ if (await confirmInstall(ctx.prompt, commands)) {
406
+ await installPlaywright(cwd, (line) => ctx.err(dim(line)));
404
407
  attempt -= 1;
405
408
  continue;
406
409
  }
package/dist/local-run.js CHANGED
@@ -1,11 +1,9 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
- import { PLAYWRIGHT_INSTALL_COMMANDS } from "./playwright-install.js";
5
- // We invoke @playwright/test as an external rather than bundling it — the CLI's zero-runtime-dep
6
- // rule. In a node_modules install `playwright` is on PATH; otherwise fall back to `npx playwright`,
7
- // exactly as the cloud runner does. The install commands come from playwright-install.ts so the
8
- // hint text and the actual installer never drift apart.
4
+ import { installHint, PLAYWRIGHT_INSTALL_COMMANDS, resolvePlaywrightRunner, } from "./playwright-install.js";
5
+ // @playwright/test is invoked as an external, never bundled — the CLI's zero-runtime-dep rule.
6
+ // The install commands come from playwright-install.ts so hint and installer never drift apart.
9
7
  export const PLAYWRIGHT_INSTALL_HINT = "Local Playwright not found. Install it in this project, then re-run:\n" +
10
8
  ` ${PLAYWRIGHT_INSTALL_COMMANDS}`;
11
9
  // Isolate the run from any playwright.config.ts in the customer's repo: a stray `testMatch`
@@ -51,25 +49,36 @@ function runProcess(command, args, cwd, env) {
51
49
  child.on("close", (code) => resolve({ code, stdout, stderr }));
52
50
  });
53
51
  }
54
- // `npx playwright` is the portable fallback when no local binary is on PATHit resolves
55
- // the project's @playwright/test without us hard-coding a node_modules path.
56
- function playwrightBase(cwd) {
57
- const binName = process.platform === "win32" ? "playwright.cmd" : "playwright";
58
- const local = path.join(cwd, "node_modules", ".bin", binName);
59
- if (fs.existsSync(local))
60
- return { command: local, args: ["test"] };
52
+ // Resolved from the run dir the exact place the spec imports from so the process we
53
+ // spawn and the module the spec loads are the same copy. `npx playwright` is the last
54
+ // resort for a CLI install that somehow lost its dependencies.
55
+ export function playwrightBase(runDir) {
56
+ const runner = resolvePlaywrightRunner(runDir);
57
+ if (runner?.source === "project") {
58
+ return { command: process.execPath, args: [runner.cli, "test"], label: "playwright test" };
59
+ }
60
+ if (runner) {
61
+ return {
62
+ command: process.execPath,
63
+ args: [runner.cli, "test"],
64
+ label: "playwright test (bundled with the CLI)",
65
+ nodePath: path.resolve(path.dirname(runner.cli), "..", ".."),
66
+ };
67
+ }
61
68
  const npx = process.platform === "win32" ? "npx.cmd" : "npx";
62
- return { command: npx, args: ["playwright", "test"] };
69
+ return { command: npx, args: ["playwright", "test"], label: "npx playwright test" };
63
70
  }
64
71
  // "unknown command 'test'" is the Python `playwright` shim (no `test` subcommand); the module
65
- // errors mean @playwright/test isn't installed. Either way the actionable answer is the same
66
- // install hint, not a stack trace.
72
+ // errors mean @playwright/test isn't installed; "Executable doesn't exist" is the runner
73
+ // launching a browser revision that was never downloaded. Either way the actionable answer
74
+ // is the same install hint, not a stack trace.
67
75
  function looksLikePlaywrightMissing(r) {
68
76
  if (r.spawnError?.code === "ENOENT")
69
77
  return true;
70
78
  const blob = `${r.stdout}\n${r.stderr}`;
71
79
  return (/unknown command ['"]?test/i.test(blob) ||
72
80
  /Cannot find module ['"]@playwright\/test/i.test(blob) ||
81
+ /Executable doesn't exist/i.test(blob) ||
73
82
  /npm ERR!.*could not determine executable|npx.*not found/i.test(blob));
74
83
  }
75
84
  export function parsePlaywrightReport(data) {
@@ -249,12 +258,15 @@ export async function runSpecLocally(opts) {
249
258
  fs.writeFileSync(configPath, ISOLATING_CONFIG(runDir, artifactsDir));
250
259
  const reportPath = path.join(runDir, "report.json");
251
260
  const cleanup = () => fs.rmSync(runDir, { recursive: true, force: true });
252
- const { command, args: base } = playwrightBase(cwd);
261
+ const { command, args: base, label, nodePath } = playwrightBase(runDir);
253
262
  const args = [...base, `--config=${configPath}`, "--reporter=json"];
254
- opts.onProgress?.(`Running ${command} ${base.join(" ")} on ${opts.testName}…`);
263
+ opts.onProgress?.(`Running ${label} on ${opts.testName}…`);
255
264
  await opts.setup?.(runDir);
256
265
  const stop = opts.during?.(runDir);
257
266
  const env = { ...process.env, PLAYWRIGHT_JSON_OUTPUT_NAME: reportPath };
267
+ if (nodePath) {
268
+ env.NODE_PATH = [nodePath, process.env.NODE_PATH].filter(Boolean).join(path.delimiter);
269
+ }
258
270
  // cwd is the RUN dir, not the project: the spec's relative writes (frames/,
259
271
  // email-inbox.json, dom-snapshot.html, phase.json) must land where we harvest and
260
272
  // clean up, exactly as the cloud runner keys them to its per-run workdir.
@@ -267,7 +279,7 @@ export async function runSpecLocally(opts) {
267
279
  }
268
280
  if (looksLikePlaywrightMissing(result)) {
269
281
  cleanup();
270
- throw new PlaywrightMissingError(PLAYWRIGHT_INSTALL_HINT);
282
+ throw new PlaywrightMissingError(installHint(cwd));
271
283
  }
272
284
  let results;
273
285
  try {
@@ -12,15 +12,57 @@ export const PLAYWRIGHT_INSTALL_COMMANDS = `${INSTALL_TEST_RUNNER.join(" ")} &&
12
12
  // Resolve from the project tree, not from wherever the globally-installed CLI happens to
13
13
  // live — `createRequire` rooted at cwd walks up the same node_modules chain Playwright will.
14
14
  const projectRequire = (cwd) => createRequire(path.join(cwd, "package.json"));
15
- export function hasPlaywrightTest(cwd) {
15
+ // @playwright/test is a dependency of the CLI, so a repo needs nothing installed to run
16
+ // locally: the runner comes with `npx @beryl-so/cli`, and only the browser is per-machine.
17
+ // A repo's own @playwright/test still wins when present (its version, its browser revision).
18
+ const cliRequire = createRequire(import.meta.url);
19
+ // cli.js is the package's bin, not an export, so it is located via package.json.
20
+ function runnerAt(pkgPath, source) {
21
+ const { version } = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
22
+ return { cli: path.join(path.dirname(pkgPath), "cli.js"), version, source };
23
+ }
24
+ function projectRunner(dir) {
16
25
  try {
17
- projectRequire(cwd).resolve("@playwright/test");
18
- return true;
26
+ return runnerAt(projectRequire(dir).resolve("@playwright/test/package.json"), "project");
19
27
  }
20
28
  catch {
21
- return false;
29
+ return undefined;
30
+ }
31
+ }
32
+ function bundledRunner() {
33
+ try {
34
+ return runnerAt(cliRequire.resolve("@playwright/test/package.json"), "bundled");
35
+ }
36
+ catch {
37
+ return undefined;
22
38
  }
23
39
  }
40
+ // The single answer to "which @playwright/test": what `import "@playwright/test"` from `dir`
41
+ // resolves to (any ancestor's node_modules), else the bundled copy. Runner and spec must load
42
+ // the same one or Playwright refuses to run ("did not expect test.use() to be called here").
43
+ export function resolvePlaywrightRunner(dir) {
44
+ return projectRunner(dir) ?? bundledRunner();
45
+ }
46
+ export const bundledRunnerCli = () => bundledRunner()?.cli;
47
+ // By hand, the browser must be fetched for the runner that will launch it: the repo's own
48
+ // (`npx playwright` resolves it) or, in a bare repo, the CLI's pinned version. An unpinned
49
+ // `npx playwright install` there downloads the registry's latest revision, which is not it.
50
+ export function browserInstallCommand(cwd) {
51
+ const bundled = bundledRunner();
52
+ if (hasPlaywrightTest(cwd) || !bundled)
53
+ return INSTALL_CHROMIUM.join(" ");
54
+ const [npx, pkg, ...rest] = INSTALL_CHROMIUM;
55
+ return [npx, `${pkg}@${bundled.version}`, ...rest].join(" ");
56
+ }
57
+ function bundledCoreDir() {
58
+ try {
59
+ return path.dirname(cliRequire.resolve("playwright-core/package.json"));
60
+ }
61
+ catch {
62
+ return undefined;
63
+ }
64
+ }
65
+ export const hasPlaywrightTest = (cwd) => projectRunner(cwd) !== undefined;
24
66
  // A local run launches chromium headless, which Playwright serves from a separate
25
67
  // `chromium_headless_shell` build (`playwright install chromium` fetches both). That is the
26
68
  // only engine a run ever launches, so it is the only one we install or check for.
@@ -89,7 +131,7 @@ const isComplete = (dir) => fs.existsSync(path.join(dir, INSTALL_MARKER));
89
131
  * problem that reads as a broken suite.
90
132
  */
91
133
  export function hasChromiumBrowser(cwd) {
92
- const coreDir = playwrightCoreDir(cwd);
134
+ const coreDir = hasPlaywrightTest(cwd) ? playwrightCoreDir(cwd) : bundledCoreDir();
93
135
  const root = browsersRoot(coreDir);
94
136
  // Nowhere known to look — say nothing rather than block; the run surfaces Playwright's
95
137
  // own error if it really is missing.
@@ -110,7 +152,7 @@ export function hasChromiumBrowser(cwd) {
110
152
  }
111
153
  }
112
154
  export function playwrightGaps(cwd) {
113
- const runner = !hasPlaywrightTest(cwd);
155
+ const runner = !hasPlaywrightTest(cwd) && !bundledRunnerCli();
114
156
  // With no runner there is no revision to judge a cached browser against, so the honest
115
157
  // answer is the full install — not the npm half and a second failure right after it.
116
158
  return { runner, browser: runner || !hasChromiumBrowser(cwd) };
@@ -122,10 +164,10 @@ export function describeGaps(gaps) {
122
164
  return gaps.browser ? "the Chromium browser" : "@playwright/test";
123
165
  }
124
166
  /** Only the commands the missing halves need — a present @playwright/test isn't reinstalled. */
125
- export function installCommandsFor(gaps) {
167
+ export function installCommandsFor(gaps, cwd = process.cwd()) {
126
168
  const commands = [
127
169
  ...(gaps.runner ? [INSTALL_TEST_RUNNER.join(" ")] : []),
128
- ...(gaps.browser ? [INSTALL_CHROMIUM.join(" ")] : []),
170
+ ...(gaps.browser ? [browserInstallCommand(cwd)] : []),
129
171
  ];
130
172
  return commands.length > 0 ? commands.join(" && ") : PLAYWRIGHT_INSTALL_COMMANDS;
131
173
  }
@@ -160,10 +202,19 @@ function runInherit(command, args, cwd) {
160
202
  * the browser binary — an installed runner with no browser still fails a real run.
161
203
  */
162
204
  export async function installPlaywright(cwd, onStep) {
163
- if (!hasPlaywrightTest(cwd)) {
205
+ const bundled = bundledRunnerCli();
206
+ if (!hasPlaywrightTest(cwd) && !bundled) {
164
207
  onStep?.(`Installing @playwright/test — ${INSTALL_TEST_RUNNER.join(" ")}`);
165
208
  await runInherit(INSTALL_TEST_RUNNER[0], INSTALL_TEST_RUNNER.slice(1), cwd);
166
209
  }
167
- onStep?.(`Installing the Chromium browser — ${INSTALL_CHROMIUM.join(" ")}`);
210
+ onStep?.(`Installing the Chromium browser — ${browserInstallCommand(cwd)}`);
211
+ if (!hasPlaywrightTest(cwd) && bundled) {
212
+ await runInherit(process.execPath, [bundled, ...INSTALL_CHROMIUM.slice(2)], cwd);
213
+ return;
214
+ }
168
215
  await runInherit(INSTALL_CHROMIUM[0], INSTALL_CHROMIUM.slice(1), cwd);
169
216
  }
217
+ export function installHint(cwd, gaps = playwrightGaps(cwd)) {
218
+ const commands = installCommandsFor(gaps, cwd);
219
+ return `Local runs need ${describeGaps(gaps)}. Install it, then re-run:\n ${commands}`;
220
+ }
package/dist/telemetry.js CHANGED
@@ -233,6 +233,24 @@ export function createPostHogClient() {
233
233
  posthog ??= new PostHog(POSTHOG_KEY, { host: POSTHOG_HOST, disableGeoip: false });
234
234
  return posthog;
235
235
  }
236
+ // `beryl init` starts before there is an account to attribute to: its events land on the
237
+ // process session id, and the session is aliased onto the account once init has signed in,
238
+ // so the install joins the same person as the signup and the later MCP work.
239
+ export function captureCliEvent(event, apiUrl, properties = {}) {
240
+ createPostHogClient().capture({
241
+ distinctId: sessionId,
242
+ event,
243
+ properties: { ...baseEventProperties(apiUrl), ...properties },
244
+ });
245
+ }
246
+ export function aliasSessionToAccount(userId) {
247
+ createPostHogClient().alias({ distinctId: userId, alias: sessionId });
248
+ }
249
+ export function flushTelemetry() {
250
+ // Fire-and-forget: init keeps running (login, installs) while the batch goes out, and a
251
+ // failed send must never surface to the user.
252
+ void posthog?.flush().catch(() => { });
253
+ }
236
254
  export function analyticsOptions(apiUrl, identify) {
237
255
  const properties = baseEventProperties(apiUrl);
238
256
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@beryl-so/cli",
3
- "version": "0.34.0",
3
+ "version": "0.34.3",
4
4
  "description": "Beryl on the command line — projects, runs, the exploring agent, and an MCP server over the same commands.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -32,11 +32,11 @@
32
32
  },
33
33
  "dependencies": {
34
34
  "@modelcontextprotocol/sdk": "^1.29.0",
35
+ "@playwright/test": "^1.61.1",
35
36
  "@posthog/mcp": "^0.11.6",
36
37
  "posthog-node": "^5.49.1"
37
38
  },
38
39
  "devDependencies": {
39
- "@playwright/test": "^1.61.1",
40
40
  "@types/node": "^26.1.1",
41
41
  "tsx": "^4.23.1",
42
42
  "typescript": "^7.0.2",