@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.
- package/README.md +18 -7
- package/dist/adapters/mcp.js +23 -10
- package/dist/beryl-test-skill.js +177 -33
- package/dist/code-ref.js +81 -0
- package/dist/commands/accounts.js +74 -18
- package/dist/commands/config-vars.js +207 -20
- package/dist/commands/environments.js +14 -7
- package/dist/commands/init.js +2 -2
- package/dist/commands/jobs.js +152 -20
- package/dist/commands/projects.js +115 -20
- package/dist/commands/runs.js +68 -8
- package/dist/commands/tests.js +44 -9
- package/dist/commands/util.js +38 -6
- package/dist/commands/watch.js +3 -2
- package/dist/config.js +2 -0
- package/dist/lint.js +180 -3
- package/dist/schema.generated.js +178 -7
- package/dist/skill-tables.js +3 -0
- package/package.json +1 -1
|
@@ -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,
|
|
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: [
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
157
|
-
"
|
|
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
|
|
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
|
|
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
|
|
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: "
|
|
324
|
+
type: "json",
|
|
275
325
|
required: true,
|
|
276
|
-
description: "The liveness probe plan,
|
|
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
|
|
305
|
-
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
|
|
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,
|
|
214
|
-
"
|
|
215
|
-
"
|
|
216
|
-
"
|
|
217
|
-
"
|
|
218
|
-
"
|
|
219
|
-
"
|
|
220
|
-
"
|
|
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
|
|
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 ?? (
|
|
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
|
-
|
|
291
|
-
data: done,
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
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: '
|
|
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
|
{
|
package/dist/commands/init.js
CHANGED
|
@@ -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
|
|
465
|
-
` ${dim(" a cloud test-creation job (jobs create) that authors them from that
|
|
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.
|