@beryl-so/cli 0.54.1 → 0.61.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.
@@ -1,14 +1,37 @@
1
1
  import fs from "node:fs";
2
2
  import { CliError, UsageError } from "../errors.js";
3
3
  import { ApiError } from "../http.js";
4
- import { lintPlan } from "../lint.js";
5
- import { establishAccountSession, executeLocalSpec, } from "../local-exec.js";
4
+ import { lintPlan, planUsesThirdParty } from "../lint.js";
5
+ import { establishAccountSession, executeLocalSpec, THIRD_PARTY_CLOUD_ONLY, } from "../local-exec.js";
6
6
  import { PlaywrightMissingError } from "../local-run.js";
7
7
  import { dim, green, red, table, yellow } from "../output.js";
8
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}`;
12
+ const EVENT_VERBS = {
13
+ QUARANTINED: "quarantined",
14
+ UNQUARANTINED: "released from quarantine",
15
+ GROUPS_ADDED: "added to",
16
+ GROUPS_REMOVED: "removed from",
17
+ RENAMED: "renamed",
18
+ };
19
+ function eventLine(e) {
20
+ const when = e.created_at.slice(0, 19).replace("T", " ");
21
+ const verb = EVENT_VERBS[e.action] ?? e.action.toLowerCase();
22
+ const who = e.actor_name ? `by ${e.actor_name}` : "automatically";
23
+ const d = e.detail ?? {};
24
+ const groups = Array.isArray(d.groups) ? ` ${d.groups.join(", ")}` : "";
25
+ // The reason is the point: an automatic mute has to say what triggered it.
26
+ const why = d.failures !== undefined
27
+ ? ` (${d.failures} failures in ${d.days} days, run ${d.run_id})`
28
+ : d.passes !== undefined
29
+ ? ` (${d.passes} clean passes)`
30
+ : d.from !== undefined
31
+ ? ` ("${d.from}" to "${d.to}")`
32
+ : "";
33
+ return `${when} ${verb}${groups} ${who}${why}`;
34
+ }
12
35
  // A duplicate-title 409 after a green replay is idempotent when the existing row
13
36
  // holds the same plan (a retry after a lost response) — success, not an error.
14
37
  // A different plan under the same title is a real conflict.
@@ -165,7 +188,8 @@ export const testCommands = [
165
188
  {
166
189
  name: "status",
167
190
  type: "string",
168
- description: "With --page, show only this bucket: passed, failed, or quarantined",
191
+ description: "With --page, show only this bucket: passed, failed, errored, cancelled, " +
192
+ "quarantined, or unstable (failed no longer includes errored)",
169
193
  },
170
194
  {
171
195
  name: "ungrouped",
@@ -257,7 +281,8 @@ export const testCommands = [
257
281
  "tests is banked, run their ids together once (`runs local`, or `runs trigger` for the " +
258
282
  "cloud) so they land as ONE run with real first results. A plan that signs in with a session Beryl captured " +
259
283
  "server-side cannot replay locally (that session never leaves Beryl's cloud). It falls " +
260
- "back to server-side verification automatically, and says so. A session-mode plan replays locally " +
284
+ "back to server-side verification automatically, and says so; so does a plan with third party " +
285
+ "integration steps, which run only in Beryl's cloud. A session-mode plan replays locally " +
261
286
  "fine: the server renders it with its account's stored sign-in steps in front, so " +
262
287
  "the same identity is exercised on your machine. Optional `before` and `after` arrays hold setup and teardown " +
263
288
  "steps: `after` runs even when a main step fails, which is how a create/update/delete test " +
@@ -346,6 +371,23 @@ export const testCommands = [
346
371
  environment_id: env,
347
372
  });
348
373
  let compiled = await compile();
374
+ const verifyInCloud = async (reason) => {
375
+ ctx.err(yellow(`! ${reason}, so this plan is verified server-side instead.`));
376
+ try {
377
+ return { data: await bank({ verify: true, plan_hash: compiled.plan_hash }) };
378
+ }
379
+ catch (err) {
380
+ const failure = verifyFailureResult(err);
381
+ if (failure)
382
+ return failure;
383
+ const conflict = createConflictResult(err, compiled.plan_hash);
384
+ if (conflict)
385
+ return conflict;
386
+ throw err;
387
+ }
388
+ };
389
+ if (planUsesThirdParty(plan))
390
+ return verifyInCloud(THIRD_PARTY_CLOUD_ONLY);
349
391
  // Session-mode plan: establish the account's session once (server-side, reused
350
392
  // from the stored one when it still proves live) and replay against it — the
351
393
  // cloud shape. An unsupported account is re-compiled to sign itself in.
@@ -367,20 +409,7 @@ export const testCommands = [
367
409
  // A captured session lives encrypted in Beryl's cloud and is never handed to
368
410
  // this machine — the cloud is the only place this plan can be proven.
369
411
  if (compiled.requires_auth && !session) {
370
- ctx.err(yellow("! This plan signs in with a captured session, so it can only be verified " +
371
- "in Beryl's cloud — verifying server-side instead."));
372
- try {
373
- return { data: await bank({ verify: true, plan_hash: compiled.plan_hash }) };
374
- }
375
- catch (err) {
376
- const failure = verifyFailureResult(err);
377
- if (failure)
378
- return failure;
379
- const conflict = createConflictResult(err, compiled.plan_hash);
380
- if (conflict)
381
- return conflict;
382
- throw err;
383
- }
412
+ return verifyInCloud("A captured session never leaves Beryl's cloud");
384
413
  }
385
414
  if (compiled.login_config_error)
386
415
  throw new CliError(compiled.login_config_error);
@@ -637,7 +666,7 @@ export const testCommands = [
637
666
  },
638
667
  {
639
668
  name: "tests quarantine",
640
- summary: "Mute a flaky test: it keeps running, but its failures stop failing the run",
669
+ summary: "Mute flaky tests: they keep running, but their failures stop failing the run",
641
670
  description: "A quarantined test still executes and its result is still recorded and visible. Its " +
642
671
  "red lands in the run's quarantined_count and gates neither the run's verdict nor exit " +
643
672
  "codes, so it can't red-light a deploy. Use " +
@@ -645,24 +674,63 @@ export const testCommands = [
645
674
  "asking support to deactivate it (which stops it running at all). After the project's " +
646
675
  "release threshold of consecutive clean passes (default 5) the test reports " +
647
676
  "rehab_ready: a suggestion while auto quarantine is off, the actual release once " +
648
- "`projects quarantine on` has turned it on. `off` un-quarantines.",
677
+ "`projects quarantine on` has turned it on. `off` un-quarantines. Several ids are one " +
678
+ "call; tests already in the requested state are left alone rather than rejected. Who " +
679
+ "quarantined what is recorded, readable with `beryl tests events`.",
649
680
  scope: "project",
650
681
  args: [
651
- { name: "test-id", description: "Test id", required: true },
652
682
  { name: "state", description: "on | off", required: true },
683
+ { name: "test-ids", description: "One or more test ids", required: true, variadic: true },
653
684
  ],
654
- examples: ["beryl tests quarantine 4f… on", "beryl tests quarantine 4f… off"],
685
+ examples: ["beryl tests quarantine on 4f…", "beryl tests quarantine on 4f… 9a… 1c…", "beryl tests quarantine off 4f…"],
655
686
  async run(ctx, input) {
656
687
  const { workspaceId, projectId } = await ctx.requireProject(input);
657
- const state = arg(input, "state").toLowerCase();
688
+ let state = arg(input, "state").toLowerCase();
689
+ let ids = argList(input, "test-ids");
690
+ // Older scripts wrote `tests quarantine <id> on`; keep them working.
658
691
  if (state !== "on" && state !== "off") {
659
- throw new UsageError(`Expected "on" or "off", got "${arg(input, "state")}"`);
692
+ const trailing = ids[ids.length - 1]?.toLowerCase();
693
+ if (trailing === "on" || trailing === "off") {
694
+ ids = [arg(input, "state"), ...ids.slice(0, -1)];
695
+ state = trailing;
696
+ }
697
+ else {
698
+ throw new UsageError(`Expected "on" or "off", got "${arg(input, "state")}"`);
699
+ }
660
700
  }
701
+ const quarantined = state === "on";
702
+ const only = ids.length === 1 ? ids[0] : undefined;
703
+ if (only !== undefined) {
704
+ return {
705
+ data: await ctx.client.patch(`${testPath(workspaceId, projectId, only)}/quarantine`, { quarantined }),
706
+ };
707
+ }
708
+ const data = (await ctx.client.post(`${projectPath(workspaceId, projectId)}/tests/bulk-quarantine`, { test_case_ids: ids, quarantined }));
661
709
  return {
662
- data: await ctx.client.patch(`${testPath(workspaceId, projectId, arg(input, "test-id"))}/quarantine`, { quarantined: state === "on" }),
710
+ data,
711
+ human: `${quarantined ? "Quarantined" : "Released"} ${data.updated} of ${ids.length} tests (the rest were already ${quarantined ? "quarantined" : "not"}).`,
663
712
  };
664
713
  },
665
714
  },
715
+ {
716
+ name: "tests events",
717
+ summary: "Who quarantined, grouped or renamed a test, and why",
718
+ description: "Lifecycle history for one test, newest first: quarantine and release (by a person or " +
719
+ "by auto-quarantine, with the run and failure streak behind it), group changes, and " +
720
+ "renames. Plan edits are NOT here: those are versions, and `beryl tests versions` can " +
721
+ "restore them.",
722
+ scope: "project",
723
+ args: [{ name: "test-id", description: "Test id", required: true }],
724
+ flags: [{ name: "limit", type: "number", description: "How many events (default 100, max 500)" }],
725
+ examples: ["beryl tests events 4f…", "beryl tests events 4f… --limit 20"],
726
+ async run(ctx, input) {
727
+ const { workspaceId, projectId } = await ctx.requireProject(input);
728
+ const limit = flagNum(input, "limit");
729
+ const path = `${testPath(workspaceId, projectId, arg(input, "test-id"))}/events`;
730
+ const data = (await ctx.client.get(limit === undefined ? path : `${path}?limit=${limit}`));
731
+ return { data, human: data.length ? data.map(eventLine).join("\n") : "No changes recorded." };
732
+ },
733
+ },
666
734
  {
667
735
  name: "tests delete",
668
736
  summary: "Delete tests, their version history, and their results",
@@ -0,0 +1,59 @@
1
+ import { UsageError } from "../errors.js";
2
+ import { dim, table } from "../output.js";
3
+ import { flagStr, projectPath } from "./util.js";
4
+ const integrationsPath = (ws, p) => `${projectPath(ws, p)}/third-party-integrations`;
5
+ export const thirdPartyIntegrationCommands = [
6
+ {
7
+ name: "third-party-integrations list",
8
+ summary: "The third party providers this project's tests can connect to",
9
+ groupDefault: true,
10
+ groupSummary: "Test a site's own integrations (Connect Slack) against Beryl's accounts on those providers.",
11
+ description: "Lists the providers set up on this Beryl deployment. A test may use " +
12
+ "`third_party_authorize` / `await_third_party` only for a provider listed here; " +
13
+ "anything else is refused at save time.",
14
+ scope: "project",
15
+ async run(ctx, input) {
16
+ const { workspaceId, projectId } = await ctx.requireProject(input);
17
+ const { providers } = (await ctx.client.get(integrationsPath(workspaceId, projectId)));
18
+ return {
19
+ data: providers,
20
+ human: providers.length
21
+ ? table(providers.map((p) => ({ key: p.key, label: p.label })))
22
+ : dim("No third party providers are set up on this deployment."),
23
+ };
24
+ },
25
+ },
26
+ {
27
+ name: "third-party-integrations target",
28
+ summary: "The channel this project's tests pick when the app asks where to post",
29
+ description: "Returns the project's own destination on Beryl's provider account (for Slack, a " +
30
+ "channel), creating it on first ask. When a test connects the app, it picks this " +
31
+ "channel in the app's settings; `await_third_party` then waits for the app's message " +
32
+ "there. `recorded_app_id` is the app whose first message was seen, null until then. " +
33
+ "A 409 means the provider is not set up on this deployment (see " +
34
+ "`third-party-integrations list`).",
35
+ scope: "project",
36
+ flags: [
37
+ {
38
+ name: "provider",
39
+ type: "string",
40
+ required: true,
41
+ description: "Provider key from `third-party-integrations list`, e.g. slack",
42
+ },
43
+ ],
44
+ examples: ["beryl third-party-integrations target --provider slack"],
45
+ async run(ctx, input) {
46
+ const provider = flagStr(input, "provider");
47
+ if (!provider)
48
+ throw new UsageError("--provider is required");
49
+ const { workspaceId, projectId } = await ctx.requireProject(input);
50
+ const target = (await ctx.client.post(`${integrationsPath(workspaceId, projectId)}/${encodeURIComponent(provider)}/target`));
51
+ return {
52
+ data: target,
53
+ human: `#${target.external_name}\n\n` +
54
+ dim(`Pick this channel in the app when the test connects ${target.provider}.` +
55
+ (target.recorded_app_id ? ` Recorded app: ${target.recorded_app_id}` : "")),
56
+ };
57
+ },
58
+ },
59
+ ];
@@ -0,0 +1,295 @@
1
+ import { UsageError } from "../errors.js";
2
+ import { flagNum, flagStr, flagStrings, projectPath } from "./util.js";
3
+ const PROVIDERS = ["linear"];
4
+ const SEVERITIES = ["high", "medium", "low"];
5
+ const PRIORITIES = ["urgent", "high", "medium", "low", "none"];
6
+ const ON_OFF = ["on", "off"];
7
+ const ENV_FLAG = {
8
+ name: "env",
9
+ type: "string",
10
+ description: "Environment id (defaults to the project's default environment)",
11
+ };
12
+ const PROVIDER_FLAG = {
13
+ name: "tracker",
14
+ type: "string",
15
+ enum: PROVIDERS,
16
+ description: "Issue tracker (default linear)",
17
+ };
18
+ /** `envs update` flags for the Site Health issue tracker settings. */
19
+ export const TRACKER_SETTING_FLAGS = [
20
+ {
21
+ name: "tracker",
22
+ type: "string",
23
+ enum: [...PROVIDERS, "off"],
24
+ description: "Tracker Site Health files tickets in; `off` stops filing for this environment " +
25
+ "(connect it first with `health tracker connect`)",
26
+ },
27
+ { name: "tracker-team", type: "string", description: "Tracker team id (see `health tracker teams`)" },
28
+ {
29
+ name: "tracker-project",
30
+ type: "string",
31
+ description: "Tracker project id inside the team (see `health tracker projects`); `none` clears it",
32
+ },
33
+ {
34
+ name: "tracker-label",
35
+ type: "strings",
36
+ description: "Label put on every ticket, repeatable; replaces the set (default beryl)",
37
+ },
38
+ {
39
+ name: "tracker-priority",
40
+ type: "strings",
41
+ description: "Severity to tracker priority, repeatable, e.g. high=urgent. Priorities: " +
42
+ PRIORITIES.join(", ") +
43
+ " (default high=high, medium=medium, low=low)",
44
+ },
45
+ {
46
+ name: "tracker-threshold",
47
+ type: "string",
48
+ enum: SEVERITIES,
49
+ description: "Lowest severity filed automatically; the rest go to the digest (default high)",
50
+ },
51
+ {
52
+ name: "tracker-digest",
53
+ type: "string",
54
+ enum: ["off", "daily", "weekly"],
55
+ description: "Digest of findings that were not filed (default weekly)",
56
+ },
57
+ {
58
+ name: "tracker-digest-to",
59
+ type: "string",
60
+ enum: ["ticket", "slack"],
61
+ description: "Where the digest goes: one digest ticket, or the Slack alert channel (default ticket)",
62
+ },
63
+ {
64
+ name: "tracker-auto-close",
65
+ type: "string",
66
+ enum: ON_OFF,
67
+ description: "Close a ticket once a run no longer sees its finding (default on)",
68
+ },
69
+ {
70
+ name: "tracker-reopen",
71
+ type: "string",
72
+ enum: ON_OFF,
73
+ description: "Reopen the same ticket when a fixed finding comes back; off files a new one (default on)",
74
+ },
75
+ {
76
+ name: "tracker-reopen-closed",
77
+ type: "string",
78
+ enum: ON_OFF,
79
+ description: "Reopen a ticket closed in the tracker while the check still fails (default on)",
80
+ },
81
+ {
82
+ name: "tracker-max-tickets",
83
+ type: "number",
84
+ description: "New tickets one run may file, 0-100; the rest go to the digest (default 10)",
85
+ },
86
+ {
87
+ name: "tracker-assignee",
88
+ type: "string",
89
+ description: "Tracker user id (a person or an agent user) new tickets go to; `none` clears it",
90
+ },
91
+ {
92
+ name: "tracker-min-runs",
93
+ type: "number",
94
+ description: "Runs in a row a finding must show in before a manual run files it (default 1)",
95
+ },
96
+ {
97
+ name: "tracker-min-runs-scheduled",
98
+ type: "number",
99
+ description: "Same, for scheduled and CI runs (default 2)",
100
+ },
101
+ {
102
+ name: "tracker-pause",
103
+ type: "string",
104
+ enum: ON_OFF,
105
+ description: "Pause all ticket creation and updates without disconnecting",
106
+ },
107
+ ];
108
+ function onOff(input, name) {
109
+ const v = flagStr(input, name);
110
+ if (v === undefined)
111
+ return undefined;
112
+ if (!ON_OFF.includes(v))
113
+ throw new UsageError(`--${name} must be on or off`);
114
+ return v === "on";
115
+ }
116
+ function orNull(value) {
117
+ return value === "none" ? null : value;
118
+ }
119
+ function priorityMap(entries) {
120
+ const map = {};
121
+ for (const entry of entries) {
122
+ const [severity, priority] = entry.split("=").map((s) => s.trim().toLowerCase());
123
+ if (!severity || !priority || !SEVERITIES.includes(severity) || !PRIORITIES.includes(priority)) {
124
+ throw new UsageError(`--tracker-priority takes severity=priority, e.g. high=urgent (severities: ${SEVERITIES.join(", ")}; priorities: ${PRIORITIES.join(", ")})`);
125
+ }
126
+ map[severity] = priority;
127
+ }
128
+ return map;
129
+ }
130
+ /** The `site_health_tracker` PATCH body from `envs update` flags; undefined when none were given. */
131
+ export function trackerSettingsFromFlags(input) {
132
+ const body = {};
133
+ const provider = flagStr(input, "tracker");
134
+ if (provider !== undefined)
135
+ body.provider = provider === "off" ? null : provider;
136
+ const team = flagStr(input, "tracker-team");
137
+ if (team !== undefined) {
138
+ body.team_id = team;
139
+ if (provider === undefined)
140
+ body.provider = "linear";
141
+ }
142
+ const project = orNull(flagStr(input, "tracker-project"));
143
+ if (project !== undefined)
144
+ body.project_id = project;
145
+ const labels = flagStrings(input, "tracker-label");
146
+ if (labels)
147
+ body.labels = labels;
148
+ const priorities = flagStrings(input, "tracker-priority");
149
+ if (priorities)
150
+ body.priority_map = priorityMap(priorities);
151
+ const threshold = flagStr(input, "tracker-threshold");
152
+ if (threshold !== undefined)
153
+ body.auto_file_threshold = threshold;
154
+ const digest = flagStr(input, "tracker-digest");
155
+ if (digest !== undefined)
156
+ body.digest = digest;
157
+ const digestTo = flagStr(input, "tracker-digest-to");
158
+ if (digestTo !== undefined)
159
+ body.digest_target = digestTo;
160
+ const toggles = [
161
+ ["tracker-auto-close", "auto_close"],
162
+ ["tracker-reopen", "reopen_on_regression"],
163
+ ["tracker-reopen-closed", "reopen_if_closed_while_failing"],
164
+ ["tracker-pause", "paused"],
165
+ ];
166
+ for (const [flag, field] of toggles) {
167
+ const v = onOff(input, flag);
168
+ if (v !== undefined)
169
+ body[field] = v;
170
+ }
171
+ const numbers = [
172
+ ["tracker-max-tickets", "max_tickets_per_run"],
173
+ ["tracker-min-runs", "min_consecutive_runs_manual"],
174
+ ["tracker-min-runs-scheduled", "min_consecutive_runs_scheduled"],
175
+ ];
176
+ for (const [flag, field] of numbers) {
177
+ const v = flagNum(input, flag);
178
+ if (v !== undefined)
179
+ body[field] = v;
180
+ }
181
+ const assignee = orNull(flagStr(input, "tracker-assignee"));
182
+ if (assignee !== undefined)
183
+ body.assignee_id = assignee;
184
+ return Object.keys(body).length ? body : undefined;
185
+ }
186
+ function provider(input) {
187
+ return flagStr(input, "tracker") ?? "linear";
188
+ }
189
+ export const trackerCommands = [
190
+ {
191
+ name: "health tracker status",
192
+ summary: "Show how Site Health files findings as issue tracker tickets for an environment",
193
+ description: "Returns the environment's tracker settings with every default filled in, the " +
194
+ "workspace's connection (`status: disconnected` means the token expired or was " +
195
+ "revoked; ticket changes queue until you reconnect), the number of queued changes, " +
196
+ "open tickets (filed in the tracker), `unfiled_tickets` the tracker hasn't accepted " +
197
+ "yet with its `last_sync_error`, digest findings, and `last_sync`: what the latest run filed, " +
198
+ "updated, closed or skipped. Change settings with `envs update --tracker-*`.",
199
+ scope: "project",
200
+ flags: [ENV_FLAG],
201
+ async run(ctx, input) {
202
+ const { workspaceId, projectId } = await ctx.requireProject(input);
203
+ return {
204
+ data: await ctx.client.get(`${projectPath(workspaceId, projectId)}/site-health/tracker`, {
205
+ environment_id: flagStr(input, "env"),
206
+ }),
207
+ };
208
+ },
209
+ },
210
+ {
211
+ name: "health tracker tickets",
212
+ summary: "List the findings Site Health tracks in the issue tracker, with their tickets",
213
+ description: "One row per finding: `status` watching (seen, waiting for enough runs in a row), " +
214
+ "digest (not filed), open, closed (with `close_reason` fixed, intended or tracker) " +
215
+ "or stale (its page is gone), plus the ticket key and URL once filed and `queued` " +
216
+ "when a change is waiting to reach the tracker.",
217
+ scope: "project",
218
+ flags: [
219
+ ENV_FLAG,
220
+ {
221
+ name: "status",
222
+ type: "string",
223
+ enum: ["watching", "digest", "open", "closed", "stale"],
224
+ description: "Only rows in this status",
225
+ },
226
+ ],
227
+ async run(ctx, input) {
228
+ const { workspaceId, projectId } = await ctx.requireProject(input);
229
+ return {
230
+ data: await ctx.client.get(`${projectPath(workspaceId, projectId)}/site-health/tickets`, {
231
+ environment_id: flagStr(input, "env"),
232
+ status: flagStr(input, "status"),
233
+ }),
234
+ };
235
+ },
236
+ },
237
+ {
238
+ name: "health tracker connect",
239
+ summary: "Get the link that connects an issue tracker to the workspace",
240
+ description: "Returns an authorize URL for a workspace admin to open in a browser. Once approved, " +
241
+ "Beryl files tickets as its own app user, and any changes queued while disconnected " +
242
+ "are applied. Then pick a team with `envs update --tracker-team`.",
243
+ scope: "workspace",
244
+ flags: [PROVIDER_FLAG],
245
+ async run(ctx, input) {
246
+ const ws = await ctx.requireWorkspace(input);
247
+ const res = (await ctx.client.get(`/workspaces/${ws}/trackers/${provider(input)}/connect`));
248
+ return { data: res, human: `Open this link to connect:\n${res.url}` };
249
+ },
250
+ },
251
+ {
252
+ name: "health tracker disconnect",
253
+ summary: "Remove the workspace's issue tracker connection",
254
+ description: "Deletes the stored tokens. Tickets already filed stay in the tracker; to stop " +
255
+ "filing for one environment only, use `envs update --tracker off` or `--tracker-pause on`.",
256
+ scope: "workspace",
257
+ flags: [PROVIDER_FLAG, { name: "force", type: "boolean", description: "Skip the confirmation prompt" }],
258
+ async run(ctx, input) {
259
+ const ws = await ctx.requireWorkspace(input);
260
+ await ctx.confirm(`Disconnect ${provider(input)}?`, input.flags.force === true);
261
+ await ctx.client.del(`/workspaces/${ws}/trackers/${provider(input)}`);
262
+ return { human: "Disconnected." };
263
+ },
264
+ },
265
+ {
266
+ name: "health tracker teams",
267
+ summary: "List the teams of the connected issue tracker",
268
+ scope: "workspace",
269
+ flags: [PROVIDER_FLAG],
270
+ async run(ctx, input) {
271
+ const ws = await ctx.requireWorkspace(input);
272
+ return { data: await ctx.client.get(`/workspaces/${ws}/trackers/${provider(input)}/teams`) };
273
+ },
274
+ },
275
+ {
276
+ name: "health tracker projects",
277
+ summary: "List a tracker team's projects",
278
+ scope: "workspace",
279
+ flags: [
280
+ PROVIDER_FLAG,
281
+ { name: "team", type: "string", description: "Team id (see `health tracker teams`)", required: true },
282
+ ],
283
+ async run(ctx, input) {
284
+ const ws = await ctx.requireWorkspace(input);
285
+ const team = flagStr(input, "team");
286
+ if (!team)
287
+ throw new UsageError("--team is required");
288
+ return {
289
+ data: await ctx.client.get(`/workspaces/${ws}/trackers/${provider(input)}/projects`, {
290
+ team_id: team,
291
+ }),
292
+ };
293
+ },
294
+ },
295
+ ];
@@ -1,3 +1,4 @@
1
+ import { autoFormat } from "../output.js";
1
2
  import { arg, flagBool, flagStr } from "./util.js";
2
3
  const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
3
4
  export const workspaceCommands = [
@@ -145,11 +146,16 @@ export const workspaceCommands = [
145
146
  ],
146
147
  async run(ctx, input) {
147
148
  const ws = await ctx.requireWorkspace(input);
149
+ const email = arg(input, "email");
150
+ const invitation = (await ctx.client.post(`/workspaces/${ws}/invitations`, {
151
+ email,
152
+ role: (flagStr(input, "role") ?? "USER").toUpperCase(),
153
+ }));
154
+ if (!invitation.email_delivery_failed)
155
+ return { data: invitation };
148
156
  return {
149
- data: await ctx.client.post(`/workspaces/${ws}/invitations`, {
150
- email: arg(input, "email"),
151
- role: (flagStr(input, "role") ?? "USER").toUpperCase(),
152
- }),
157
+ data: invitation,
158
+ human: `${autoFormat(invitation)}\nWarning: the invitation email to ${email} was not delivered. Check the address, or revoke and re-invite later.`,
153
159
  };
154
160
  },
155
161
  },
package/dist/http.js CHANGED
@@ -97,8 +97,8 @@ export class ApiClient {
97
97
  patch(path, body) {
98
98
  return this.request("PATCH", path, { body });
99
99
  }
100
- put(path, body) {
101
- return this.request("PUT", path, { body });
100
+ put(path, body, query) {
101
+ return this.request("PUT", path, { body, query });
102
102
  }
103
103
  del(path, body) {
104
104
  return this.request("DELETE", path, { body });
package/dist/lint.js CHANGED
@@ -16,6 +16,9 @@ export const WRITE_NEEDS_CLOSING_TEARDOWN = (detail) => "A 'write' test must end
16
16
  export const OPTIONAL_TEARDOWN_ACTION_MUST_BE_LAST = (label, action) => `${label} (${action}) is optional but not the last step of 'after'. An optional step ` +
17
17
  "that misses ends the teardown, so the steps after it would silently not run. Guard " +
18
18
  "them with an optional 'wait_for' or 'expect' instead, or move this step to the end.";
19
+ export const THIRD_PARTY_UNDECLARED = (p) => `third_party_authorize approves the install on ${p}'s consent page, which needs ` +
20
+ `Beryl's ${p} login: add "${p}" to third_party_integrations`;
21
+ export const THIRD_PARTY_UNUSED = (p) => `third_party_integrations declares "${p}" but no third_party_authorize step uses it; remove it`;
19
22
  const typeOf = (value) => {
20
23
  if (value === null)
21
24
  return "null";
@@ -174,6 +177,37 @@ function identityIssues(plan, provisioning) {
174
177
  ? [{ path: "(root)", message: MINTED_IDENTITY_NEEDS_TEARDOWN }]
175
178
  : [];
176
179
  }
180
+ export const THIRD_PARTY_AUTHORIZE = "third_party_authorize";
181
+ export const AWAIT_THIRD_PARTY = "await_third_party";
182
+ const executed = (plan) => [
183
+ ...steps(plan.before),
184
+ ...steps(plan.steps),
185
+ ...steps(plan.after),
186
+ ];
187
+ export function planUsesThirdParty(plan) {
188
+ if (typeof plan !== "object" || plan === null)
189
+ return false;
190
+ return executed(plan).some((s) => s.action === THIRD_PARTY_AUTHORIZE || s.action === AWAIT_THIRD_PARTY);
191
+ }
192
+ // Mirrors the server's ActionPlan validator, which raises the first sorted mismatch only.
193
+ function thirdPartyIssues(plan) {
194
+ if (typeof plan !== "object" || plan === null)
195
+ return [];
196
+ const record = plan;
197
+ const declared = new Set(Array.isArray(record.third_party_integrations)
198
+ ? record.third_party_integrations.map(String)
199
+ : []);
200
+ const authorized = new Set(executed(record)
201
+ .filter((s) => s.action === THIRD_PARTY_AUTHORIZE && typeof s.provider === "string")
202
+ .map((s) => s.provider));
203
+ const [undeclared] = [...authorized].filter((p) => !declared.has(p)).sort();
204
+ if (undeclared)
205
+ return [{ path: "(root)", message: THIRD_PARTY_UNDECLARED(undeclared) }];
206
+ const [unused] = [...declared].filter((p) => !authorized.has(p)).sort();
207
+ if (unused)
208
+ return [{ path: "third_party_integrations", message: THIRD_PARTY_UNUSED(unused) }];
209
+ return [];
210
+ }
177
211
  const TEARDOWN_PROOF_KINDS = new Set([
178
212
  "gone",
179
213
  "hidden",
@@ -212,6 +246,7 @@ export function lintPlan(plan, opts = {}) {
212
246
  if (issues.length > 0)
213
247
  return issues;
214
248
  return [
249
+ ...thirdPartyIssues(plan),
215
250
  ...identityIssues(plan, opts.provisioning === true),
216
251
  ...(opts.access === undefined ? [] : accessIssues(plan, opts.access)),
217
252
  ];
@@ -8,6 +8,11 @@ export const IMPORT_MAX_ERROR_LEN = 5000;
8
8
  export const IMPORT_MAX_FILE_BYTES = 14 * 1024 * 1024;
9
9
  export const IMPORT_MAX_TOTAL_BYTES = 180 * 1024 * 1024;
10
10
  export const IMPORT_MAX_FILES = 3600;
11
+ export const THIRD_PARTY_CLOUD_ONLY = "third party integration steps run only in Beryl's cloud";
12
+ export const THIRD_PARTY_USE_TRIGGER = `${THIRD_PARTY_CLOUD_ONLY}; use \`beryl runs trigger\``;
13
+ // The rendered await_third_party helper, and the authorize step's wait to leave the provider's host.
14
+ const THIRD_PARTY_SPEC_MARKER = /__vmThirdParty\(|\.test\(u\.hostname\)/;
15
+ export const specUsesThirdParty = (content) => THIRD_PARTY_SPEC_MARKER.test(content);
11
16
  export function toRunEntry(spec, outcome, runError, startedAt, ordinal, redact) {
12
17
  // Mirrors the cloud runner's redact_result: the spec types secrets into the page
13
18
  // and an injected session authenticates its requests, so error text and the DOM
@@ -12,6 +12,8 @@ import { mcpCommands } from "../commands/mcp.js";
12
12
  import { projectCommands } from "../commands/projects.js";
13
13
  import { runCommands } from "../commands/runs.js";
14
14
  import { testCommands } from "../commands/tests.js";
15
+ import { thirdPartyIntegrationCommands } from "../commands/third-party-integrations.js";
16
+ import { trackerCommands } from "../commands/tracker.js";
15
17
  import { workspaceCommands } from "../commands/workspaces.js";
16
18
  export const WORKSPACE_FLAG = {
17
19
  name: "workspace",
@@ -65,9 +67,11 @@ export const commands = [
65
67
  ...groupCommands,
66
68
  ...runCommands,
67
69
  ...healthCommands,
70
+ ...trackerCommands,
68
71
  ...explorationCommands,
69
72
  ...configCommands,
70
73
  ...mailboxCommands,
74
+ ...thirdPartyIntegrationCommands,
71
75
  ...testAccountCommands,
72
76
  ...accountCommands,
73
77
  ...mcpCommands,