@beryl-so/cli 0.34.4 → 0.36.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -30,6 +30,9 @@ From the monorepo (development): `cd cli && npm install && npm run build && npm
30
30
  latest npm release; a stale MCP server silently exposes fewer tools. Set
31
31
  `BERYL_NO_UPDATE_CHECK=1` to opt out.
32
32
 
33
+ The CLI reports which commands run (never their arguments or output) so onboarding can
34
+ be measured; set `BERYL_TELEMETRY=0` or `DO_NOT_TRACK=1` to send nothing.
35
+
33
36
  ## Authenticate
34
37
 
35
38
  ```bash
@@ -329,7 +332,7 @@ Durable identities on the site under test: what an authenticated test signs in a
329
332
  | --- | --- | --- |
330
333
  | `beryl accounts list` | List the test accounts an environment's tests sign in as | `accounts_list` |
331
334
  | `beryl accounts create` | Add a test account: the customer's own, or one Beryl signs up | `accounts_create` |
332
- | `beryl accounts provision <account-id>` | Prove a test account can get in, by replaying a plan that ends logged in | `accounts_provision` |
335
+ | `beryl accounts signup <account-id>` | Store a test account's signup plan and sign it up on its environment | `accounts_signup` |
333
336
  | `beryl accounts set-login <account-id>` | Store the sign-in plan a run replays once, plus the probe that proves it | `accounts_set_login` |
334
337
  | `beryl accounts get-login <account-id>` | Read the stored sign-in plan, its probe, and the hash a safe write must cite | `accounts_get_login` |
335
338
  | `beryl accounts check <account-id>` | Sign in now and prove the session survives into a fresh browser | `accounts_check` |
@@ -4,7 +4,7 @@ import { CliError, EXIT_OK, EXIT_USAGE, UsageError } from "../errors.js";
4
4
  import { ApiClient } from "../http.js";
5
5
  import { autoFormat, bold, cyan, dim } from "../output.js";
6
6
  import { commandGroups, findCommand, groupSummary } from "../registry/index.js";
7
- import { withTool } from "../telemetry.js";
7
+ import { captureCliEvent, shutdownTelemetry, withTool, } from "../telemetry.js";
8
8
  import { cliVersion } from "../version-check.js";
9
9
  import { mcpToolFor } from "./mcp.js";
10
10
  export const GLOBAL_FLAGS = [
@@ -324,6 +324,10 @@ export async function runCli(argv) {
324
324
  config.token = parsed.token;
325
325
  const client = new ApiClient(config.apiUrl, config.token);
326
326
  const ctx = createContext({ client, config, json: parsed.json, mcp: false });
327
+ // The MCP server reports its own tool calls; every other invocation is one command.
328
+ if (spec.name !== "mcp") {
329
+ captureCliEvent("cli_command", config.apiUrl, { command: spec.name });
330
+ }
327
331
  try {
328
332
  const result = (await withTool(spec.name, () => spec.run(ctx, parsed.input))) ?? {};
329
333
  if (parsed.json) {
@@ -345,4 +349,7 @@ export async function runCli(argv) {
345
349
  }
346
350
  throw err;
347
351
  }
352
+ finally {
353
+ await shutdownTelemetry();
354
+ }
348
355
  }
@@ -250,14 +250,7 @@ export function identifyUser(baseCtx) {
250
250
  if (me?.id) {
251
251
  identity = {
252
252
  distinctId: me.id,
253
- // Internal dogfooding is stamped rather than dropped: unlike the webapp we
254
- // want our own MCP sessions visible when debugging, so dashboards exclude
255
- // them by cohort instead.
256
- properties: {
257
- email: me.email,
258
- name: me.name,
259
- is_internal: Boolean(me.is_vibemonitor),
260
- },
253
+ properties: { email: me.email, name: me.name },
261
254
  };
262
255
  }
263
256
  }
@@ -154,13 +154,14 @@ An empty \`accounts list\` means nothing is set up. In order of preference:
154
154
  \`beryl accounts create --email <email> --password <password>\`. A DEDICATED test
155
155
  account only — never a real user's.
156
156
  2. **No credentials, but the app has a signup form** → let Beryl make one.
157
- \`beryl accounts create --type beryl\`, then prove it with
158
- \`beryl accounts provision <id> --file <plan.json>\`. The plan is any ActionPlan that
159
- ends LOGGED IN a signup filling \`{{mailbox_address}}\` and \`{{login_password}}\`
160
- for a new account, or a **sign-in** for one that already exists (hand-made, or the
161
- record was lost); both prove the same thing. No password sign-in? Pass
162
- \`--login-method otp\` (or \`magic_link\`) and let the plan \`await_email\` through —
163
- later sign-ins read the same mailbox.
157
+ \`beryl accounts create --type beryl\`, then sign it up with
158
+ \`beryl accounts signup <id> --file <signup-plan.json>\`. The plan is the app's own
159
+ signup form as an ActionPlan, ending LOGGED IN, filling \`{{mailbox_address}}\` and
160
+ \`{{login_password}}\`. It stays on the account, so a new environment signs the same
161
+ identity up again by itself. An account that already exists on the site (hand-made,
162
+ or the record was lost) skips signup: store its sign-in with \`accounts set-login\`.
163
+ No password sign-in? Pass \`--login-method otp\` (or \`magic_link\`) and let the plan
164
+ \`await_email\` through; later sign-ins read the same mailbox.
164
165
  3. **Neither** → the flow is not testable authenticated. Say so rather than guessing.
165
166
 
166
167
  A project with no accounts falls back to the \`LOGIN_EMAIL\` variable +
@@ -63,7 +63,7 @@ export const testAccountCommands = [
63
63
  description: "Two kinds. `--type user_provided` records a dedicated account you already have on " +
64
64
  "the site: pass --email and --password, and it is usable immediately. `--type beryl` " +
65
65
  "reserves one Beryl will sign up itself, addressed at the project mailbox. It starts " +
66
- "`pending` and becomes usable after `beryl accounts provision`. " +
66
+ "`pending` and becomes usable after `beryl accounts signup`. " +
67
67
  "The first account an environment gets is its default whatever you pass.",
68
68
  scope: "project",
69
69
  flags: [
@@ -111,8 +111,8 @@ export const testAccountCommands = [
111
111
  is_default: flagBool(input, "default"),
112
112
  }));
113
113
  const next = account.status === "pending"
114
- ? yellow(`\nNot proven yet — run:\n beryl accounts provision ${account.id} --file <plan.json>\n` +
115
- `with a plan that ends logged in (a signup, or a sign-in if it already exists).`)
114
+ ? yellow(`\nNot signed up yet. Run:\n beryl accounts signup ${account.id} --file <signup-plan.json>\n` +
115
+ `with the app's signup form as a plan that ends logged in.`)
116
116
  : "";
117
117
  return {
118
118
  data: account,
@@ -121,15 +121,15 @@ export const testAccountCommands = [
121
121
  },
122
122
  },
123
123
  {
124
- name: "accounts provision",
125
- summary: "Prove a test account can get in, by replaying a plan that ends logged in",
126
- description: "Runs the plan once in a real browser and, if it passes, marks the account ready. " +
127
- "The plan is an ordinary ActionPlan that ends logged in: a SIGNUP when the account " +
128
- "does not exist yet, or a SIGN-IN when it already does (you created it by hand, or " +
129
- "the site already had it). Either proves the same thing, and a sign-in is what every " +
130
- "later test will do anyway. Type {{mailbox_address}} into the email field and " +
131
- "{{login_password}} into the password field. Both resolve at replay time, and an " +
132
- "`await_email` step reads the project mailbox, so a verification code works.",
124
+ name: "accounts signup",
125
+ summary: "Store a test account's signup plan and sign it up on its environment",
126
+ description: "Runs the app's signup form once in a real browser and, if it ends logged in, marks " +
127
+ "the account ready. The plan is an ordinary ActionPlan of the SIGNUP flow. Type " +
128
+ "{{mailbox_address}} into the email field and {{login_password}} into the password " +
129
+ "field. Both resolve at replay time, and an `await_email` step reads the project " +
130
+ "mailbox, so a verification code works. The plan is kept on the account, so the " +
131
+ "same identity can be signed up again on a new environment. For an account that " +
132
+ "already exists on the site, store its sign-in with `accounts set-login` instead.",
133
133
  scope: "project",
134
134
  args: [{ name: "account-id", description: "Account id from `beryl accounts list`", required: true }],
135
135
  flags: [
@@ -137,20 +137,17 @@ export const testAccountCommands = [
137
137
  name: "file",
138
138
  type: "string",
139
139
  required: true,
140
- description: "ActionPlan that ends logged in, as JSON (path, or - for stdin)",
140
+ description: "The signup ActionPlan, ending logged in, as JSON (path, or - for stdin)",
141
141
  },
142
142
  ],
143
- examples: [
144
- "beryl accounts provision acc_123 --file signup-plan.json",
145
- "beryl accounts provision acc_123 --file signin-plan.json",
146
- ],
143
+ examples: ["beryl accounts signup acc_123 --file signup-plan.json"],
147
144
  async run(ctx, input) {
148
145
  const { workspaceId, projectId } = await ctx.requireProject(input);
149
- const account = (await ctx.client.post(`${accountsPath(workspaceId, projectId)}/${arg(input, "account-id")}/provision`, { plan: readJsonFlag(input, "file") }));
146
+ const account = (await ctx.client.post(`${accountsPath(workspaceId, projectId)}/${arg(input, "account-id")}/signup`, { plan: readJsonFlag(input, "file") }));
150
147
  if (account.status !== "ready")
151
148
  return {
152
149
  data: account,
153
- human: `${yellow("The account could not get in")}: ${account.last_error ?? account.status}`,
150
+ human: `${yellow("The signup did not get in")}: ${account.last_error ?? account.status}`,
154
151
  exitCode: 1,
155
152
  };
156
153
  return {
@@ -5,6 +5,7 @@ import { deviceLogin, SWITCH_TO_OTP } from "../device-login.js";
5
5
  import { CliError, UsageError } from "../errors.js";
6
6
  import { ApiClient } from "../http.js";
7
7
  import { dim, green } from "../output.js";
8
+ import { aliasMachineToAccount, flushTelemetry } from "../telemetry.js";
8
9
  import { arg, flagBool, flagStr } from "./util.js";
9
10
  function gitEmail() {
10
11
  try {
@@ -172,6 +173,8 @@ export const authCommands = [
172
173
  token,
173
174
  api_url: apiUrl === "https://api.beryl.so" ? undefined : apiUrl,
174
175
  });
176
+ aliasMachineToAccount(me.id);
177
+ flushTelemetry();
175
178
  const human = `${green("Logged in")} as ${me.name} <${me.email}>` +
176
179
  `\n${dim(`Token saved to ${saved}`)}`;
177
180
  return {
@@ -8,7 +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
+ import { aliasMachineToAccount, captureCliEvent, flushTelemetry, shutdownTelemetry, } from "../telemetry.js";
12
12
  import { cliVersion, warnIfStale } from "../version-check.js";
13
13
  import { authCommands } from "./auth.js";
14
14
  import { flagStr } from "./util.js";
@@ -227,7 +227,7 @@ export const initCommands = [
227
227
  await signIn();
228
228
  }
229
229
  if (accountId) {
230
- aliasSessionToAccount(accountId);
230
+ aliasMachineToAccount(accountId);
231
231
  flushTelemetry();
232
232
  }
233
233
  const report = (label, fresh, where) => ctx.err(`${green("✓")} ${label} ${fresh ? "configured" : "already configured"} ${dim(where)}`);
package/dist/config.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { randomUUID } from "node:crypto";
1
2
  import fs from "node:fs";
2
3
  import os from "node:os";
3
4
  import path from "node:path";
@@ -45,3 +46,13 @@ export function saveGlobalConfig(patch, env = process.env) {
45
46
  }
46
47
  return file;
47
48
  }
49
+ // One id per install: every CLI event from this machine lands on it, and a login aliases
50
+ // it onto the account, so an install joins the same PostHog person as the signup.
51
+ export function machineId(env = process.env) {
52
+ const current = readJson(globalConfigPath(env)) ?? {};
53
+ if (current.machine_id)
54
+ return current.machine_id;
55
+ const id = randomUUID();
56
+ saveGlobalConfig({ machine_id: id }, env);
57
+ return id;
58
+ }
package/dist/telemetry.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { AsyncLocalStorage } from "node:async_hooks";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import { PostHog } from "posthog-node";
4
+ import { machineId } from "./config.js";
4
5
  import { cliVersion } from "./version-check.js";
5
6
  // The webapp already ships this public (write-only) project key in its JS bundle, and
6
7
  // dev/prod deploys share it — environments are separated by the `environment` property
@@ -229,22 +230,38 @@ export function baseEventProperties(apiUrl) {
229
230
  };
230
231
  }
231
232
  let posthog;
233
+ export function telemetryDisabled(env = process.env) {
234
+ return env.BERYL_TELEMETRY === "0" || env.DO_NOT_TRACK === "1";
235
+ }
232
236
  export function createPostHogClient() {
233
- posthog ??= new PostHog(POSTHOG_KEY, { host: POSTHOG_HOST, disableGeoip: false });
237
+ posthog ??= new PostHog(POSTHOG_KEY, {
238
+ host: POSTHOG_HOST,
239
+ disableGeoip: false,
240
+ disabled: telemetryDisabled(),
241
+ });
234
242
  return posthog;
235
243
  }
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.
244
+ // CLI events land on the install's machine id, which a login aliases onto the account:
245
+ // an install made before there is an account still joins the person who later signs up.
239
246
  export function captureCliEvent(event, apiUrl, properties = {}) {
240
- createPostHogClient().capture({
241
- distinctId: sessionId,
242
- event,
243
- properties: { ...baseEventProperties(apiUrl), ...properties },
244
- });
247
+ try {
248
+ createPostHogClient().capture({
249
+ distinctId: machineId(),
250
+ event,
251
+ properties: { ...baseEventProperties(apiUrl), ...properties },
252
+ });
253
+ }
254
+ catch {
255
+ // telemetry must never break a command
256
+ }
245
257
  }
246
- export function aliasSessionToAccount(userId) {
247
- createPostHogClient().alias({ distinctId: userId, alias: sessionId });
258
+ export function aliasMachineToAccount(userId) {
259
+ try {
260
+ createPostHogClient().alias({ distinctId: userId, alias: machineId() });
261
+ }
262
+ catch {
263
+ // telemetry must never break a command
264
+ }
248
265
  }
249
266
  export function flushTelemetry() {
250
267
  // Fire-and-forget: init keeps running (login, installs) while the batch goes out, and a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@beryl-so/cli",
3
- "version": "0.34.4",
3
+ "version": "0.36.0",
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",