@beryl-so/cli 0.78.0 → 0.92.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.
@@ -1,6 +1,8 @@
1
+ import { SESSION_CAPTURE_EXTENSION_URL } from "../config.js";
2
+ import { UsageError } from "../errors.js";
1
3
  import { ApiError } from "../http.js";
2
4
  import { dim, green, table, yellow } from "../output.js";
3
- import { arg, flagBool, flagStr, projectPath, readJsonFlag } from "./util.js";
5
+ import { arg, flagBool, flagNum, flagStr, jsonFileOrInline, jsonFlag, projectPath, } from "./util.js";
4
6
  // The stored login plan heals, so a blind overwrite could silently clobber a repair the
5
7
  // caller never read. A write cites the hash it read; this turns the server's refusal into
6
8
  // the two-step recovery, and treats "you already wrote exactly this" as success.
@@ -32,6 +34,17 @@ function accountTable(rows) {
32
34
  id: a.id,
33
35
  })));
34
36
  }
37
+ function throwawayTable(rows) {
38
+ return table(rows.map((a) => ({
39
+ email: a.email,
40
+ minted_by: a.mint_source ?? "",
41
+ run: a.test_run_id ?? "",
42
+ test: a.test_case_id ?? "",
43
+ teardown: a.teardown_outcome ?? (a.mint_source === "run" ? "not reported" : "not tracked"),
44
+ created: a.created_at ?? "",
45
+ })));
46
+ }
47
+ const THROWAWAY_LIMIT = 50;
35
48
  export const testAccountCommands = [
36
49
  {
37
50
  name: "accounts list",
@@ -40,13 +53,26 @@ export const testAccountCommands = [
40
53
  groupSummary: "Durable identities on the site under test: what an authenticated test signs in as.",
41
54
  description: "Every test account on the project, with the identity label a plan's `auth_label` " +
42
55
  "names. The row marked * is the default: what a plan gets when it sets " +
43
- "requires_auth without an auth_label.",
56
+ "requires_auth without an auth_label. Below them, grouped as throwaway, the newest " +
57
+ "accounts test executions created for themselves through {{inbox_address}}: which " +
58
+ "run and test minted each, and how its teardown ended. They are kept for 30 days.",
44
59
  scope: "project",
45
- flags: [{ name: "env", type: "string", description: "Only accounts on this environment id" }],
60
+ flags: [
61
+ { name: "env", type: "string", description: "Only accounts on this environment id" },
62
+ {
63
+ name: "throwaway-limit",
64
+ type: "number",
65
+ description: `How many of the newest throwaway accounts to show (default ${THROWAWAY_LIMIT}, max 500, 0 hides them)`,
66
+ },
67
+ ],
46
68
  async run(ctx, input) {
47
69
  const { workspaceId, projectId } = await ctx.requireProject(input);
70
+ const throwawayLimit = flagNum(input, "throwaway-limit") ?? THROWAWAY_LIMIT;
71
+ if (!Number.isInteger(throwawayLimit) || throwawayLimit < 0 || throwawayLimit > 500)
72
+ throw new UsageError("--throwaway-limit must be a whole number from 0 to 500");
48
73
  const rows = (await ctx.client.get(accountsPath(workspaceId, projectId), {
49
74
  environment_id: flagStr(input, "env"),
75
+ throwaway_limit: throwawayLimit,
50
76
  }));
51
77
  if (rows.length === 0)
52
78
  return {
@@ -55,7 +81,14 @@ export const testAccountCommands = [
55
81
  "resolve to. Add one with `beryl accounts create`. A flow that must sign up " +
56
82
  "fresh cites {{inbox_address}} and deletes the account in `after`."),
57
83
  };
58
- return { data: rows, human: accountTable(rows) };
84
+ const durable = rows.filter((a) => !a.mint_source);
85
+ const throwaway = rows.filter((a) => a.mint_source);
86
+ const parts = [durable.length ? accountTable(durable) : dim("No durable test accounts.")];
87
+ if (throwaway.length) {
88
+ const capped = throwaway.length >= throwawayLimit;
89
+ parts.push("", `Throwaway (created by test executions${capped ? `, newest ${throwawayLimit} shown` : ""}):`, throwawayTable(throwaway));
90
+ }
91
+ return { data: rows, human: parts.join("\n") };
59
92
  },
60
93
  },
61
94
  {
@@ -82,7 +115,8 @@ export const testAccountCommands = [
82
115
  "mailbox; `--lifetime` says whether it is kept (permanent, the default) or a " +
83
116
  "throwaway. It starts `pending` and becomes usable after `beryl accounts signup`. " +
84
117
  "A captured account (a signed-in session from the Beryl Chrome extension) is not " +
85
- "created here: capture it with the extension, then give it a probe with " +
118
+ `created here: capture it with the extension (${SESSION_CAPTURE_EXTENSION_URL}), ` +
119
+ "then give it a probe with " +
86
120
  "`beryl accounts set-login --probe`. " +
87
121
  "Works for Google-only apps too: bank a dedicated Google test account as provided " +
88
122
  "and author its sign-in plan through Google's form. " +
@@ -153,8 +187,9 @@ export const testAccountCommands = [
153
187
  {
154
188
  name: "accounts capture",
155
189
  summary: "Store a signed-in session as a captured test account",
156
- description: "Usually done by the Beryl Chrome extension from a signed-in tab; this takes the " +
157
- "same session JSON from a file: {schema_version: 2, final_url, cookies (CDP " +
190
+ description: `Usually done by the Beryl Chrome extension (${SESSION_CAPTURE_EXTENSION_URL}) ` +
191
+ "from a signed-in tab; this takes the " +
192
+ "same session JSON from a file or inline: {schema_version: 2, final_url, cookies (CDP " +
158
193
  "shape), local_storage, session_storage, indexed_db}. Upserts by --label on the " +
159
194
  "environment, so capturing an existing label replaces its session. Cookies for " +
160
195
  "other domains are dropped and listed. A captured account cannot sign itself in: " +
@@ -165,9 +200,13 @@ export const testAccountCommands = [
165
200
  {
166
201
  name: "file",
167
202
  type: "string",
168
- required: true,
169
203
  description: "The captured session, as JSON (path, or - for stdin)",
170
204
  },
205
+ {
206
+ name: "session",
207
+ type: "json",
208
+ description: "The captured session inline, instead of --file",
209
+ },
171
210
  {
172
211
  name: "label",
173
212
  type: "string",
@@ -179,9 +218,10 @@ export const testAccountCommands = [
179
218
  ],
180
219
  examples: ["beryl accounts capture --file session.json --label admin"],
181
220
  async run(ctx, input) {
221
+ const session = jsonFileOrInline(input, "file", "session", true);
182
222
  const { workspaceId, projectId } = await ctx.requireProject(input);
183
223
  const account = (await ctx.client.put(`${accountsPath(workspaceId, projectId)}/captured`, {
184
- session: readJsonFlag(input, "file"),
224
+ session,
185
225
  identity_label: flagStr(input, "label") ?? "default",
186
226
  email: flagStr(input, "email") ?? null,
187
227
  environment_id: flagStr(input, "env") ?? null,
@@ -214,14 +254,19 @@ export const testAccountCommands = [
214
254
  {
215
255
  name: "file",
216
256
  type: "string",
217
- required: true,
218
257
  description: "The signup ActionPlan, ending logged in, as JSON (path, or - for stdin)",
219
258
  },
259
+ {
260
+ name: "plan",
261
+ type: "json",
262
+ description: "The signup ActionPlan inline, instead of --file",
263
+ },
220
264
  ],
221
265
  examples: ["beryl accounts signup acc_123 --file signup-plan.json"],
222
266
  async run(ctx, input) {
267
+ const plan = jsonFileOrInline(input, "file", "plan", true);
223
268
  const { workspaceId, projectId } = await ctx.requireProject(input);
224
- const account = (await ctx.client.post(`${accountsPath(workspaceId, projectId)}/${arg(input, "account-id")}/signup`, { plan: readJsonFlag(input, "file") }));
269
+ const account = (await ctx.client.post(`${accountsPath(workspaceId, projectId)}/${arg(input, "account-id")}/signup`, { plan }));
225
270
  if (account.status !== "ready")
226
271
  return {
227
272
  data: account,
@@ -240,8 +285,8 @@ export const testAccountCommands = [
240
285
  description: "Session mode: instead of every authenticated test signing in for itself, the " +
241
286
  "account signs in ONCE at the start of a run and every test rides that session. " +
242
287
  "This stores the two plans that makes possible.\n\n" +
243
- "--file is the SIGN-IN plan (not the signup): it must end logged in. Type " +
244
- "{{login_email}} into the email field and {{login_password}} into the password " +
288
+ "--file (or --plan inline) is the SIGN-IN plan (not the signup): it must end " +
289
+ "logged in. Type {{login_email}} into the email field and {{login_password}} into the password " +
245
290
  "field; an emailed code or magic link arrives at the account's own address, so an " +
246
291
  "`await_email` step reads it with no human involved.\n\n" +
247
292
  "--probe is the liveness check: a two-step plan (goto a gated page, then a POSITIVE " +
@@ -252,7 +297,7 @@ export const testAccountCommands = [
252
297
  "signal a dead session would run every test logged-out and still report the run " +
253
298
  "green.\n\n" +
254
299
  "A captured account (from the Beryl Chrome extension) takes --probe alone: it rides " +
255
- "the captured session and cannot sign itself in, so there is no --file.\n\n" +
300
+ "the captured session and cannot sign itself in, so there is no --file or --plan.\n\n" +
256
301
  "--surface android stores the sign-in Android tests use instead, beside the web one; " +
257
302
  "Beryl proves it on an Android worker the first time a signed-in android test runs. " +
258
303
  "A test-creation job saves one itself when it meets the app's login screen, so this " +
@@ -269,12 +314,19 @@ export const testAccountCommands = [
269
314
  description: "The sign-in ActionPlan, as JSON (path, or - for stdin). Required except for a " +
270
315
  "captured account",
271
316
  },
317
+ {
318
+ name: "plan",
319
+ type: "json",
320
+ description: "The sign-in ActionPlan inline, instead of --file",
321
+ },
272
322
  {
273
323
  name: "probe",
274
- type: "string",
324
+ type: "json",
275
325
  required: true,
276
- description: "The liveness probe plan, as JSON (path, or - for stdin): goto a gated page, " +
326
+ description: "The liveness probe plan (JSON, a path, or - for stdin): goto a gated page, " +
277
327
  "then assert something only a signed-in user sees",
328
+ mcpDescription: "The liveness probe plan, as a JSON object: goto a gated page, then assert " +
329
+ "something only a signed-in user sees",
278
330
  },
279
331
  {
280
332
  name: "base-hash",
@@ -296,13 +348,17 @@ export const testAccountCommands = [
296
348
  "beryl accounts set-login acc_123 --file signin.json --probe probe.json --base-hash 9f2c…",
297
349
  "beryl accounts set-login acc_456 --probe probe.json # captured account",
298
350
  "beryl accounts set-login acc_123 --surface android --file app-signin.json --probe app-probe.json",
351
+ `beryl accounts set-login acc_456 --probe '{"steps":[{"action":"goto","url":"/account"},` +
352
+ `{"action":"expect","selector":"text=Sign out","expect_kind":"visible"}]}'`,
299
353
  ],
300
354
  async run(ctx, input) {
355
+ const plan = jsonFileOrInline(input, "file", "plan", false) ?? null;
356
+ const probe = jsonFlag(input, "probe");
301
357
  const { workspaceId, projectId } = await ctx.requireProject(input);
302
358
  try {
303
359
  const account = (await ctx.client.put(`${accountsPath(workspaceId, projectId)}/${arg(input, "account-id")}/login`, {
304
- plan: flagStr(input, "file") ? readJsonFlag(input, "file") : null,
305
- probe: readJsonFlag(input, "probe"),
360
+ plan,
361
+ probe,
306
362
  base_hash: flagStr(input, "base-hash") ?? null,
307
363
  ...(flagStr(input, "surface") ? { surface: flagStr(input, "surface") } : {}),
308
364
  }));
@@ -3,10 +3,90 @@ import path from "node:path";
3
3
  import { CliError, UsageError } from "../errors.js";
4
4
  import { ApiError } from "../http.js";
5
5
  import { dim, green, table, timeAgo } from "../output.js";
6
- import { arg, flagBool, flagStr, projectPath } from "./util.js";
7
- const APK_RE = /\.apk$/i;
6
+ import { arg, flagBool, flagNum, flagStr, projectPath } from "./util.js";
7
+ const ANDROID_BUILD_RE = /\.(apk|aab)$/i;
8
8
  const BUILD_KINDS = new Set(["android_build", "ios_build"]);
9
+ // A bundle conversion takes a minute or three; poll gently, and faster for a short wait.
10
+ const BUILD_POLL_MS = 5000;
11
+ const BUILD_WAIT_DEFAULT_S = 900;
12
+ const SIGNER_LABEL = {
13
+ as_uploaded: "as uploaded (your own certificate)",
14
+ beryl_key: "Beryl's key for this project",
15
+ customer_keystore: "your keystore",
16
+ };
17
+ const SIGNER_SHORT = {
18
+ as_uploaded: "as uploaded",
19
+ beryl_key: "Beryl key",
20
+ customer_keystore: "your keystore",
21
+ };
9
22
  const buildLabel = (f) => `${f.app_id ?? "?"} ${f.app_version ?? "?"} (${f.build_number ?? "?"})`;
23
+ const signerCell = (f) => f.signer_source
24
+ ? `${SIGNER_SHORT[String(f.signer_source)] ?? f.signer_source} ${String(f.signer_sha256 ?? "").slice(0, 8)}`
25
+ : "";
26
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
27
+ const signingPath = (ws, p) => `${configPath(ws, p)}/signing/android`;
28
+ // stdin is read once and handed out one line per `-` flag, in flag order, so
29
+ // `--store-pass - --key-pass -` takes two lines instead of the first flag eating both.
30
+ let stdinLines = null;
31
+ function secretFlag(value) {
32
+ if (value !== "-")
33
+ return value;
34
+ stdinLines ??= fs.readFileSync(0, "utf8").split(/\r?\n/);
35
+ const line = stdinLines.shift();
36
+ if (line === undefined || line === "")
37
+ throw new UsageError("stdin had no line left for a - password");
38
+ return line.trim();
39
+ }
40
+ function signingHuman(record) {
41
+ const key = (k) => ` SHA-1 ${k.sha1}\n SHA-256 ${k.sha256}`;
42
+ if (record.mode === "customer_keystore" && record.customer_keystore) {
43
+ return [
44
+ `Converted Android builds are signed with your keystore` +
45
+ (record.customer_keystore.key_alias ? ` (key ${record.customer_keystore.key_alias})` : ""),
46
+ key(record.customer_keystore),
47
+ dim("Beryl's key for this project stays available: `beryl config signing clear`"),
48
+ ].join("\n");
49
+ }
50
+ return [
51
+ "Converted Android builds are signed with Beryl's key for this project",
52
+ key(record.beryl_key),
53
+ dim("Register these with Google Sign-In, Firebase or Play Integrity if the app uses them, " +
54
+ "or sign with your own keystore: `beryl config signing set`"),
55
+ ].join("\n");
56
+ }
57
+ function buildSummary(done) {
58
+ const lines = [`${green("Build uploaded")}: ${buildLabel(done)} ${dim(String(done.id))}`];
59
+ if (done.signer_source) {
60
+ lines.push(`Signed ${SIGNER_LABEL[String(done.signer_source)] ?? done.signer_source}`);
61
+ lines.push(` SHA-1 ${done.signer_sha1}`);
62
+ lines.push(` SHA-256 ${done.signer_sha256}`);
63
+ if (done.signer_source === "beryl_key")
64
+ lines.push(dim(" Register these with Google Sign-In, Firebase or Play Integrity if the app uses them."));
65
+ }
66
+ if (done.note)
67
+ lines.push(String(done.note));
68
+ if (done.app_id_warning)
69
+ lines.push(`Warning: ${String(done.app_id_warning)}`);
70
+ return lines.join("\n");
71
+ }
72
+ async function waitForBuild(ctx, filesPath, fileId, waitS) {
73
+ const deadline = Date.now() + waitS * 1000;
74
+ const pollMs = Math.min(BUILD_POLL_MS, Math.max(50, (waitS * 1000) / 4));
75
+ for (;;) {
76
+ await sleep(pollMs);
77
+ const rows = (await ctx.client.get(filesPath));
78
+ const row = rows.find((r) => r.id === fileId);
79
+ if (!row)
80
+ throw new CliError(`Build ${fileId} disappeared while converting`);
81
+ if (row.status === "ready")
82
+ return row;
83
+ if (row.status === "failed")
84
+ throw new CliError(`Build refused: ${String(row.failure_reason ?? "conversion failed")}`);
85
+ if (Date.now() >= deadline)
86
+ throw new CliError(`Still converting after ${waitS}s. Check later with \`beryl config files get ${fileId}\`; ` +
87
+ "tests install it once it is ready.");
88
+ }
89
+ }
10
90
  const configPath = (ws, p) => `${projectPath(ws, p)}/config`;
11
91
  function findByKey(rows, value, kind) {
12
92
  const byId = rows.find((r) => r.id === value);
@@ -168,6 +248,86 @@ export const configCommands = [
168
248
  return { human: "Deleted." };
169
249
  },
170
250
  },
251
+ {
252
+ name: "config signing get",
253
+ summary: "Which key signs Android builds converted from an .aab, with its fingerprints",
254
+ description: "An .apk keeps your signature; a converted .aab is signed with this key: Beryl's key for " +
255
+ "the project unless you set your keystore. Register the fingerprints with Google " +
256
+ "Sign-In, Firebase or Play Integrity when the app uses them.",
257
+ scope: "project",
258
+ async run(ctx, input) {
259
+ const { workspaceId, projectId } = await ctx.requireProject(input);
260
+ const record = (await ctx.client.get(signingPath(workspaceId, projectId)));
261
+ return { data: record, human: signingHuman(record) };
262
+ },
263
+ },
264
+ {
265
+ name: "config signing set",
266
+ summary: "Sign converted Android builds with your own keystore",
267
+ description: "A JKS or PKCS12 keystore with its passwords, checked now (a wrong password or alias " +
268
+ "fails here), encrypted at rest and never returned. Pass - as a password to read it " +
269
+ "from stdin, one line per - flag in flag order.",
270
+ scope: "project",
271
+ flags: [
272
+ { name: "keystore", type: "string", required: true, description: "Path to the .jks or .p12" },
273
+ {
274
+ name: "store-pass",
275
+ type: "string",
276
+ required: true,
277
+ description: "Keystore password (or - to read from stdin)",
278
+ },
279
+ { name: "alias", type: "string", description: "Key alias; needed when the store holds several" },
280
+ {
281
+ name: "key-pass",
282
+ type: "string",
283
+ description: "Key password when it differs from the store password (or -)",
284
+ },
285
+ ],
286
+ examples: [
287
+ "beryl config signing set --keystore release.jks --store-pass - --alias upload --key-pass -",
288
+ ],
289
+ async run(ctx, input) {
290
+ const { workspaceId, projectId } = await ctx.requireProject(input);
291
+ const keystore = flagStr(input, "keystore");
292
+ if (!keystore)
293
+ throw new UsageError("--keystore is required");
294
+ const storePass = flagStr(input, "store-pass");
295
+ if (!storePass)
296
+ throw new UsageError("--store-pass is required (or - to read it from stdin)");
297
+ let blob;
298
+ try {
299
+ blob = await fs.openAsBlob(keystore);
300
+ }
301
+ catch (err) {
302
+ throw new UsageError(`cannot read ${keystore} (${err.code ?? "error"})`);
303
+ }
304
+ const form = new FormData();
305
+ form.set("keystore", blob, path.basename(keystore));
306
+ form.set("store_password", secretFlag(storePass));
307
+ const alias = flagStr(input, "alias");
308
+ if (alias)
309
+ form.set("key_alias", alias);
310
+ const keyPass = flagStr(input, "key-pass");
311
+ if (keyPass)
312
+ form.set("key_password", secretFlag(keyPass));
313
+ const record = (await ctx.client.request("PUT", signingPath(workspaceId, projectId), {
314
+ form,
315
+ }));
316
+ return { data: record, human: signingHuman(record) };
317
+ },
318
+ },
319
+ {
320
+ name: "config signing clear",
321
+ summary: "Forget your keystore; converted builds go back to Beryl's key",
322
+ scope: "project",
323
+ flags: [{ name: "force", type: "boolean", description: "Skip the confirmation prompt" }],
324
+ async run(ctx, input) {
325
+ const { workspaceId, projectId } = await ctx.requireProject(input);
326
+ await ctx.confirm("Forget the project's keystore? Builds converted from now on are signed with Beryl's key.", flagBool(input, "force"));
327
+ const record = (await ctx.client.del(signingPath(workspaceId, projectId)));
328
+ return { data: record, human: signingHuman(record) };
329
+ },
330
+ },
171
331
  {
172
332
  name: "config files list",
173
333
  summary: "List uploaded files: fixtures tests attach and Android and iOS app builds",
@@ -179,6 +339,12 @@ export const configCommands = [
179
339
  name: f.name,
180
340
  kind: f.kind ?? "fixture",
181
341
  app: BUILD_KINDS.has(String(f.kind)) ? buildLabel(f) : "",
342
+ status: BUILD_KINDS.has(String(f.kind))
343
+ ? f.status === "failed"
344
+ ? `failed: ${String(f.failure_reason ?? "").slice(0, 60)}`
345
+ : String(f.status ?? "unknown")
346
+ : "",
347
+ signer: signerCell(f),
182
348
  env: f.environment_id ?? "",
183
349
  created: timeAgo(f.created_at),
184
350
  id: f.id,
@@ -210,15 +376,14 @@ export const configCommands = [
210
376
  {
211
377
  name: "config files upload",
212
378
  summary: "Upload a file: a fixture an upload step attaches, or an Android or iOS app build",
213
- description: "The bytes go straight to storage over a short-lived URL, never through the API, up to " +
214
- "1 GB. An .apk uploads as an Android build (or pass --kind android_build): Beryl reads " +
215
- "its package name, version and native ABIs, refuses one the emulators cannot run (an " +
216
- ".aab, a split APK, a too-new minSdk, ARM-only native code on x86 emulators). An iOS " +
217
- "simulator build (the .app folder zipped, or EAS's .tar.gz) uploads with --kind " +
218
- "ios_build: Beryl reads its bundle id and version and refuses an .ipa, a device build " +
219
- "or one with no arm64 slice. App tests of an environment whose app id for that surface " +
220
- "matches install the newest build. Beryl keeps the newest 5 builds per app per " +
221
- "environment. Upload after each build, e.g. in CI.",
379
+ description: "The bytes go straight to storage over a short-lived URL, up to 1 GB. An .apk or .aab " +
380
+ "uploads as an Android build: an APK is read at once (package, version, ABIs, signing " +
381
+ "certificate) and refused if the emulators cannot run it; an .aab is converted on an " +
382
+ "Android worker to a universal APK signed with the project's key (`config signing get`), " +
383
+ "and the command waits until it is ready or failed (--no-wait returns at once). An iOS " +
384
+ "simulator build (the .app zipped, or EAS's .tar.gz) uploads with --kind ios_build; an " +
385
+ ".ipa is refused. A ready build names the app for environments that name none yet. " +
386
+ "Beryl keeps the newest 5 builds per app per environment.",
222
387
  scope: "project",
223
388
  args: [{ name: "file", description: "Path to the local file", required: true }],
224
389
  flags: [
@@ -227,20 +392,31 @@ export const configCommands = [
227
392
  name: "kind",
228
393
  type: "string",
229
394
  enum: ["fixture", "android_build", "ios_build"],
230
- description: "fixture: a file tests attach. android_build: an Android APK (the default for .apk). " +
231
- "ios_build: a zipped or tarred iOS simulator .app",
395
+ description: "fixture: a file tests attach. android_build: an Android APK or App Bundle (the " +
396
+ "default for .apk and .aab). ios_build: a zipped or tarred iOS simulator .app",
397
+ },
398
+ {
399
+ name: "wait-s",
400
+ type: "number",
401
+ description: `Seconds to wait for an .aab to convert (default ${BUILD_WAIT_DEFAULT_S})`,
402
+ },
403
+ {
404
+ name: "no-wait",
405
+ type: "boolean",
406
+ description: "Return as soon as an .aab is queued; `config files get <id>` shows when it is ready",
232
407
  },
233
408
  ],
234
409
  examples: [
235
410
  "beryl config files upload ./fixtures/avatar.png",
236
411
  "beryl config files upload ./android/app/build/outputs/apk/release/app-release.apk",
412
+ "beryl config files upload ./app-release.aab",
237
413
  "beryl config files upload ./MyApp.zip --kind ios_build",
238
414
  ],
239
415
  async run(ctx, input) {
240
416
  const { workspaceId, projectId } = await ctx.requireProject(input);
241
417
  const file = arg(input, "file");
242
418
  const explicitKind = flagStr(input, "kind");
243
- const kind = explicitKind ?? (APK_RE.test(file) ? "android_build" : "fixture");
419
+ const kind = explicitKind ?? (ANDROID_BUILD_RE.test(file) ? "android_build" : "fixture");
244
420
  const envId = flagStr(input, "env");
245
421
  let blob;
246
422
  try {
@@ -287,12 +463,23 @@ export const configCommands = [
287
463
  throw new CliError(`Build refused: ${err.detail}`);
288
464
  throw err;
289
465
  }
290
- return {
291
- data: done,
292
- human: BUILD_KINDS.has(String(done.kind))
293
- ? `${green("Build uploaded")}: ${buildLabel(done)} ${dim(String(done.id))}`
294
- : `${green("Uploaded")}: ${String(done.name)} ${dim(String(done.id))}`,
295
- };
466
+ if (!BUILD_KINDS.has(String(done.kind))) {
467
+ return { data: done, human: `${green("Uploaded")}: ${String(done.name)} ${dim(String(done.id))}` };
468
+ }
469
+ if (done.status === "converting") {
470
+ const fileId = String(done.id);
471
+ if (flagBool(input, "no-wait")) {
472
+ return {
473
+ data: done,
474
+ human: `Queued: ${path.basename(file)} is being converted to an APK ${dim(fileId)}\n` +
475
+ `${String(done.note ?? "")}\nCheck with \`beryl config files get ${fileId}\`.`,
476
+ };
477
+ }
478
+ const waitS = flagNum(input, "wait-s") ?? BUILD_WAIT_DEFAULT_S;
479
+ ctx.err(dim(`Converting ${path.basename(file)} to a universal APK on an Android worker…`));
480
+ done = await waitForBuild(ctx, base, fileId, waitS);
481
+ }
482
+ return { data: done, human: buildSummary(done) };
296
483
  },
297
484
  },
298
485
  {
@@ -1,4 +1,5 @@
1
1
  import { UsageError } from "../errors.js";
2
+ import { autoFormat } from "../output.js";
2
3
  import { HEALTH_RUN_SETTING_FLAGS, VIEWPORTS, runSettingsFromFlags } from "./health.js";
3
4
  import { TRACKER_SETTING_FLAGS, trackerSettingsFromFlags } from "./tracker.js";
4
5
  import { arg, findGroup, flagBool, flagNum, flagStr, flagStrings, projectPath } from "./util.js";
@@ -15,6 +16,16 @@ const SCHEDULE_CADENCE_FLAGS = [
15
16
  { name: "minute", type: "number", description: "Minute 0-59" },
16
17
  { name: "tz", type: "string", description: "IANA timezone (e.g. America/Los_Angeles)" },
17
18
  ];
19
+ // A matching build can be uploaded later, so a mismatch warns instead of failing.
20
+ export function withAppBuildWarnings(env) {
21
+ const { app_build_warnings: warnings, ...rest } = env;
22
+ if (!warnings?.length)
23
+ return { data: env };
24
+ return {
25
+ data: env,
26
+ human: [autoFormat(rest), ...warnings.map((w) => `Warning: ${w.message}`)].join("\n"),
27
+ };
28
+ }
18
29
  export const environmentCommands = [
19
30
  {
20
31
  name: "envs list",
@@ -38,9 +49,7 @@ export const environmentCommands = [
38
49
  args: [{ name: "env-id", description: "Environment id", required: true }],
39
50
  async run(ctx, input) {
40
51
  const { workspaceId, projectId } = await ctx.requireProject(input);
41
- return {
42
- data: await ctx.client.get(`${projectPath(workspaceId, projectId)}/environments/${arg(input, "env-id")}`),
43
- };
52
+ return withAppBuildWarnings(await ctx.client.get(`${projectPath(workspaceId, projectId)}/environments/${arg(input, "env-id")}`));
44
53
  },
45
54
  },
46
55
  {
@@ -81,7 +90,7 @@ export const environmentCommands = [
81
90
  {
82
91
  name: "app-id",
83
92
  type: "strings",
84
- description: 'App id for an app surface, as surface=id (android=com.acme.app, ios=com.acme.App), repeatable: that surface\'s tests install the newest uploaded build of it; "ios=" clears it',
93
+ description: 'Usually not needed: a ready build fills it in. Set it by hand when the project has several apps (flavours), as surface=id (android=com.acme.app, ios=com.acme.App), repeatable: that surface\'s tests install the newest uploaded build of it; "ios=" clears it. Warns, naming the uploaded ids, when no uploaded build has it',
85
94
  },
86
95
  { name: "auth", type: "string", enum: ["public", "gated"], description: "New auth choice" },
87
96
  {
@@ -139,9 +148,7 @@ export const environmentCommands = [
139
148
  body.site_health_runs = runs;
140
149
  if (Object.keys(body).length === 0)
141
150
  throw new UsageError("Nothing to update");
142
- return {
143
- data: await ctx.client.patch(`${projectPath(workspaceId, projectId)}/environments/${arg(input, "env-id")}`, body),
144
- };
151
+ return withAppBuildWarnings(await ctx.client.patch(`${projectPath(workspaceId, projectId)}/environments/${arg(input, "env-id")}`, body));
145
152
  },
146
153
  },
147
154
  {
@@ -461,8 +461,8 @@ export const initCommands = [
461
461
  ` ${dim("Some clients name that key `servers` or `mcp`.")}\n` +
462
462
  ` ${yellow(`Details: ${MANUAL_SETUP_DOCS}`)}\n`
463
463
  : `\n${bold(`Beryl is set up. Now ${ed.open} and ask ${ed.agent} to create tests.`)}\n` +
464
- ` ${dim(`• Say: "create tests for https://example.com from docs/checkout.md" and ${ed.agent} starts`)}\n` +
465
- ` ${dim(" a cloud test-creation job (jobs create) that authors them from that document.")}\n` +
464
+ ` ${dim(`• Say: "create tests for the checkout flow on https://example.com" and ${ed.agent} starts`)}\n` +
465
+ ` ${dim(" a cloud test-creation job (jobs create) that authors them from that prompt.")}\n` +
466
466
  ` ${dim(`• Then ask ${ed.agent} to run them in Beryl's cloud (runs trigger).`)}\n`;
467
467
  // The .mcp.json entry is pinned to @latest, but a global install / old npx cache
468
468
  // still wins resolution — so tell the user when the CLI they just ran is stale.