@beryl-so/cli 0.1.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/LICENSE +21 -0
- package/README.md +270 -0
- package/dist/adapters/cli.js +270 -0
- package/dist/adapters/mcp.js +106 -0
- package/dist/commands/account.js +89 -0
- package/dist/commands/auth.js +135 -0
- package/dist/commands/config-vars.js +166 -0
- package/dist/commands/credentials.js +144 -0
- package/dist/commands/environments.js +147 -0
- package/dist/commands/explorations.js +50 -0
- package/dist/commands/init.js +189 -0
- package/dist/commands/mcp.js +15 -0
- package/dist/commands/projects.js +177 -0
- package/dist/commands/runs.js +126 -0
- package/dist/commands/tests.js +239 -0
- package/dist/commands/util.js +43 -0
- package/dist/commands/watch.js +113 -0
- package/dist/commands/workspaces.js +220 -0
- package/dist/config.js +63 -0
- package/dist/context.js +101 -0
- package/dist/errors.js +21 -0
- package/dist/http.js +98 -0
- package/dist/index.js +8 -0
- package/dist/output.js +80 -0
- package/dist/registry/index.js +74 -0
- package/dist/registry/types.js +1 -0
- package/dist/sse.js +98 -0
- package/package.json +42 -0
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import { UsageError } from "../errors.js";
|
|
3
|
+
import { arg, argList, flagBool, flagNum, flagStr, projectPath, readJsonFlag } from "./util.js";
|
|
4
|
+
const testPath = (ws, p, id) => `${projectPath(ws, p)}/tests/${id}`;
|
|
5
|
+
export const testCommands = [
|
|
6
|
+
{
|
|
7
|
+
name: "tests list",
|
|
8
|
+
summary: "List the project's tests with their latest result",
|
|
9
|
+
scope: "project",
|
|
10
|
+
flags: [{ name: "env", type: "string", description: "Filter by environment id" }],
|
|
11
|
+
async run(ctx, input) {
|
|
12
|
+
const { workspaceId, projectId } = await ctx.requireProject(input);
|
|
13
|
+
return {
|
|
14
|
+
data: await ctx.client.get(`${projectPath(workspaceId, projectId)}/tests`, {
|
|
15
|
+
environment_id: flagStr(input, "env"),
|
|
16
|
+
}),
|
|
17
|
+
};
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
name: "tests get",
|
|
22
|
+
summary: "Show one test",
|
|
23
|
+
scope: "project",
|
|
24
|
+
args: [{ name: "test-id", description: "Test id", required: true }],
|
|
25
|
+
async run(ctx, input) {
|
|
26
|
+
const { workspaceId, projectId } = await ctx.requireProject(input);
|
|
27
|
+
return { data: await ctx.client.get(testPath(workspaceId, projectId, arg(input, "test-id"))) };
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
name: "tests plan",
|
|
32
|
+
summary: "Print a test's current step plan (JSON)",
|
|
33
|
+
scope: "project",
|
|
34
|
+
args: [{ name: "test-id", description: "Test id", required: true }],
|
|
35
|
+
async run(ctx, input) {
|
|
36
|
+
const { workspaceId, projectId } = await ctx.requireProject(input);
|
|
37
|
+
const base = testPath(workspaceId, projectId, arg(input, "test-id"));
|
|
38
|
+
const versions = (await ctx.client.get(`${base}/versions`, { limit: 1 }));
|
|
39
|
+
const version = (await ctx.client.get(`${base}/versions/${versions.current_version_no}`));
|
|
40
|
+
return { data: version.json_plan, human: JSON.stringify(version.json_plan, null, 2) };
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
name: "tests create",
|
|
45
|
+
summary: "Create a test case from a JSON action plan — for tests authored locally, e.g. by your coding agent",
|
|
46
|
+
description: "The plan is a JSON object whose steps are {action, selector, url, value, ...}: the first " +
|
|
47
|
+
"step must be a goto, and at least one step must be an expect. By default the plan is " +
|
|
48
|
+
"verified in a real browser before the test is accepted.",
|
|
49
|
+
scope: "project",
|
|
50
|
+
flags: [
|
|
51
|
+
{ name: "title", type: "string", required: true, description: "Title for the new test" },
|
|
52
|
+
{ name: "file", type: "string", required: true, description: "Plan JSON file, or - for stdin" },
|
|
53
|
+
{
|
|
54
|
+
name: "no-verify",
|
|
55
|
+
type: "boolean",
|
|
56
|
+
description: "Skip the compile-time browser/AI verification — trust the authored plan as-is",
|
|
57
|
+
},
|
|
58
|
+
],
|
|
59
|
+
examples: ['beryl tests create --title "Checkout happy path" --file plan.json'],
|
|
60
|
+
async run(ctx, input) {
|
|
61
|
+
const title = flagStr(input, "title");
|
|
62
|
+
if (!title)
|
|
63
|
+
throw new UsageError("--title is required");
|
|
64
|
+
const { workspaceId, projectId } = await ctx.requireProject(input);
|
|
65
|
+
return {
|
|
66
|
+
data: await ctx.client.post(`${projectPath(workspaceId, projectId)}/tests`, {
|
|
67
|
+
title,
|
|
68
|
+
plan: readJsonFlag(input, "file"),
|
|
69
|
+
verify: !flagBool(input, "no-verify"),
|
|
70
|
+
}),
|
|
71
|
+
};
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
name: "tests set-plan",
|
|
76
|
+
summary: "Replace a test's step plan from a JSON file (creates a new version)",
|
|
77
|
+
scope: "project",
|
|
78
|
+
args: [{ name: "test-id", description: "Test id", required: true }],
|
|
79
|
+
flags: [
|
|
80
|
+
{ name: "file", type: "string", required: true, description: "Plan JSON file, or - for stdin" },
|
|
81
|
+
],
|
|
82
|
+
examples: ["beryl tests plan 4f… > plan.json # edit, then:", "beryl tests set-plan 4f… --file plan.json"],
|
|
83
|
+
async run(ctx, input) {
|
|
84
|
+
const { workspaceId, projectId } = await ctx.requireProject(input);
|
|
85
|
+
return {
|
|
86
|
+
data: await ctx.client.patch(`${testPath(workspaceId, projectId, arg(input, "test-id"))}/plan`, { json_plan: readJsonFlag(input, "file") }),
|
|
87
|
+
};
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
name: "tests recompile",
|
|
92
|
+
summary: "Validate + verify an edited plan against the live site before persisting",
|
|
93
|
+
scope: "project",
|
|
94
|
+
args: [{ name: "test-id", description: "Test id", required: true }],
|
|
95
|
+
flags: [
|
|
96
|
+
{ name: "file", type: "string", required: true, description: "Plan JSON file, or - for stdin" },
|
|
97
|
+
],
|
|
98
|
+
async run(ctx, input) {
|
|
99
|
+
const { workspaceId, projectId } = await ctx.requireProject(input);
|
|
100
|
+
return {
|
|
101
|
+
data: await ctx.client.post(`${testPath(workspaceId, projectId, arg(input, "test-id"))}/plan/recompile`, { json_plan: readJsonFlag(input, "file") }),
|
|
102
|
+
};
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
name: "tests versions",
|
|
107
|
+
summary: "List a test's version history",
|
|
108
|
+
scope: "project",
|
|
109
|
+
args: [{ name: "test-id", description: "Test id", required: true }],
|
|
110
|
+
flags: [
|
|
111
|
+
{ name: "limit", type: "number", description: "Page size" },
|
|
112
|
+
{ name: "cursor", type: "number", description: "Continue from a previous next_cursor" },
|
|
113
|
+
],
|
|
114
|
+
async run(ctx, input) {
|
|
115
|
+
const { workspaceId, projectId } = await ctx.requireProject(input);
|
|
116
|
+
return {
|
|
117
|
+
data: await ctx.client.get(`${testPath(workspaceId, projectId, arg(input, "test-id"))}/versions`, { limit: flagNum(input, "limit"), cursor: flagNum(input, "cursor") }),
|
|
118
|
+
};
|
|
119
|
+
},
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
name: "tests version",
|
|
123
|
+
summary: "Show one specific version of a test (including its plan)",
|
|
124
|
+
scope: "project",
|
|
125
|
+
args: [
|
|
126
|
+
{ name: "test-id", description: "Test id", required: true },
|
|
127
|
+
{ name: "version-no", description: "Version number", required: true },
|
|
128
|
+
],
|
|
129
|
+
async run(ctx, input) {
|
|
130
|
+
const { workspaceId, projectId } = await ctx.requireProject(input);
|
|
131
|
+
return {
|
|
132
|
+
data: await ctx.client.get(`${testPath(workspaceId, projectId, arg(input, "test-id"))}/versions/${arg(input, "version-no")}`),
|
|
133
|
+
};
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
name: "tests diff",
|
|
138
|
+
summary: "Diff two versions of a test's plan",
|
|
139
|
+
scope: "project",
|
|
140
|
+
args: [
|
|
141
|
+
{ name: "test-id", description: "Test id", required: true },
|
|
142
|
+
{ name: "from", description: "From version number", required: true },
|
|
143
|
+
{ name: "to", description: "To version number", required: true },
|
|
144
|
+
],
|
|
145
|
+
async run(ctx, input) {
|
|
146
|
+
const { workspaceId, projectId } = await ctx.requireProject(input);
|
|
147
|
+
return {
|
|
148
|
+
data: await ctx.client.get(`${testPath(workspaceId, projectId, arg(input, "test-id"))}/versions/${arg(input, "from")}/diff/${arg(input, "to")}`),
|
|
149
|
+
};
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
name: "tests restore",
|
|
154
|
+
summary: "Restore a test to an earlier version",
|
|
155
|
+
scope: "project",
|
|
156
|
+
args: [
|
|
157
|
+
{ name: "test-id", description: "Test id", required: true },
|
|
158
|
+
{ name: "version-no", description: "Version number to restore", required: true },
|
|
159
|
+
],
|
|
160
|
+
async run(ctx, input) {
|
|
161
|
+
const { workspaceId, projectId } = await ctx.requireProject(input);
|
|
162
|
+
return {
|
|
163
|
+
data: await ctx.client.post(`${testPath(workspaceId, projectId, arg(input, "test-id"))}/restore`, { version_no: Number(arg(input, "version-no")) }),
|
|
164
|
+
};
|
|
165
|
+
},
|
|
166
|
+
},
|
|
167
|
+
{
|
|
168
|
+
name: "tests reset",
|
|
169
|
+
summary: "Discard user edits and return the test to its latest system-authored version",
|
|
170
|
+
scope: "project",
|
|
171
|
+
args: [{ name: "test-id", description: "Test id", required: true }],
|
|
172
|
+
async run(ctx, input) {
|
|
173
|
+
const { workspaceId, projectId } = await ctx.requireProject(input);
|
|
174
|
+
return {
|
|
175
|
+
data: await ctx.client.post(`${testPath(workspaceId, projectId, arg(input, "test-id"))}/system-reset`),
|
|
176
|
+
};
|
|
177
|
+
},
|
|
178
|
+
},
|
|
179
|
+
{
|
|
180
|
+
name: "tests heal",
|
|
181
|
+
summary: "Turn self-healing on or off for a test",
|
|
182
|
+
scope: "project",
|
|
183
|
+
args: [
|
|
184
|
+
{ name: "test-id", description: "Test id", required: true },
|
|
185
|
+
{ name: "state", description: "on or off", required: true },
|
|
186
|
+
],
|
|
187
|
+
async run(ctx, input) {
|
|
188
|
+
const state = arg(input, "state").toLowerCase();
|
|
189
|
+
if (state !== "on" && state !== "off")
|
|
190
|
+
throw new UsageError("<state> must be on or off");
|
|
191
|
+
const { workspaceId, projectId } = await ctx.requireProject(input);
|
|
192
|
+
return {
|
|
193
|
+
data: await ctx.client.patch(`${testPath(workspaceId, projectId, arg(input, "test-id"))}/heal-eligibility`, { self_heal_enabled: state === "on" }),
|
|
194
|
+
};
|
|
195
|
+
},
|
|
196
|
+
},
|
|
197
|
+
{
|
|
198
|
+
name: "tests history",
|
|
199
|
+
summary: "Pass/fail history, streak, and stability for a test",
|
|
200
|
+
scope: "project",
|
|
201
|
+
args: [{ name: "test-id", description: "Test id", required: true }],
|
|
202
|
+
flags: [
|
|
203
|
+
{ name: "limit", type: "number", description: "How many runs of history" },
|
|
204
|
+
{ name: "env", type: "string", description: "Filter by environment id" },
|
|
205
|
+
],
|
|
206
|
+
async run(ctx, input) {
|
|
207
|
+
const { workspaceId, projectId } = await ctx.requireProject(input);
|
|
208
|
+
return {
|
|
209
|
+
data: await ctx.client.get(`${testPath(workspaceId, projectId, arg(input, "test-id"))}/history`, { limit: flagNum(input, "limit"), environment_id: flagStr(input, "env") }),
|
|
210
|
+
};
|
|
211
|
+
},
|
|
212
|
+
},
|
|
213
|
+
{
|
|
214
|
+
name: "tests script",
|
|
215
|
+
summary: "Print the rendered Playwright spec for a test",
|
|
216
|
+
scope: "project",
|
|
217
|
+
args: [{ name: "test-id", description: "Test id", required: true }],
|
|
218
|
+
async run(ctx, input) {
|
|
219
|
+
const { workspaceId, projectId } = await ctx.requireProject(input);
|
|
220
|
+
const data = (await ctx.client.get(`${testPath(workspaceId, projectId, arg(input, "test-id"))}/script`));
|
|
221
|
+
const script = typeof data === "string" ? data : (data.script ?? data.content ?? data);
|
|
222
|
+
return { data, human: typeof script === "string" ? script : JSON.stringify(data, null, 2) };
|
|
223
|
+
},
|
|
224
|
+
},
|
|
225
|
+
{
|
|
226
|
+
name: "tests export",
|
|
227
|
+
summary: "Export tests as Playwright .spec.ts files in a ZIP",
|
|
228
|
+
scope: "project",
|
|
229
|
+
args: [{ name: "test-ids", description: "One or more test ids", required: true, variadic: true }],
|
|
230
|
+
flags: [{ name: "out", type: "string", description: "Output file (default beryl-tests.zip)" }],
|
|
231
|
+
async run(ctx, input) {
|
|
232
|
+
const { workspaceId, projectId } = await ctx.requireProject(input);
|
|
233
|
+
const response = (await ctx.client.request("POST", `${projectPath(workspaceId, projectId)}/tests/export`, { body: { test_case_ids: argList(input, "test-ids") }, raw: true }));
|
|
234
|
+
const out = flagStr(input, "out") ?? "beryl-tests.zip";
|
|
235
|
+
fs.writeFileSync(out, Buffer.from(await response.arrayBuffer()));
|
|
236
|
+
return { data: { written: out }, human: `Wrote ${out}` };
|
|
237
|
+
},
|
|
238
|
+
},
|
|
239
|
+
];
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import { UsageError } from "../errors.js";
|
|
3
|
+
export const projectPath = (ws, p) => `/workspaces/${ws}/projects/${p}`;
|
|
4
|
+
export function arg(input, name) {
|
|
5
|
+
const value = input.args[name];
|
|
6
|
+
if (typeof value !== "string" || !value)
|
|
7
|
+
throw new UsageError(`Missing <${name}>`);
|
|
8
|
+
return value;
|
|
9
|
+
}
|
|
10
|
+
export function argList(input, name) {
|
|
11
|
+
const value = input.args[name];
|
|
12
|
+
if (!Array.isArray(value) || value.length === 0)
|
|
13
|
+
throw new UsageError(`Missing <${name}>`);
|
|
14
|
+
return value;
|
|
15
|
+
}
|
|
16
|
+
export function flagStr(input, name) {
|
|
17
|
+
const value = input.flags[name];
|
|
18
|
+
return value === undefined ? undefined : String(value);
|
|
19
|
+
}
|
|
20
|
+
export function flagBool(input, name) {
|
|
21
|
+
return input.flags[name] === true;
|
|
22
|
+
}
|
|
23
|
+
export function flagNum(input, name) {
|
|
24
|
+
const value = input.flags[name];
|
|
25
|
+
if (value === undefined)
|
|
26
|
+
return undefined;
|
|
27
|
+
const n = Number(value);
|
|
28
|
+
if (Number.isNaN(n))
|
|
29
|
+
throw new UsageError(`--${name} must be a number`);
|
|
30
|
+
return n;
|
|
31
|
+
}
|
|
32
|
+
export function readJsonFlag(input, name) {
|
|
33
|
+
const file = flagStr(input, name);
|
|
34
|
+
if (!file)
|
|
35
|
+
throw new UsageError(`--${name} <file> is required`);
|
|
36
|
+
const text = file === "-" ? fs.readFileSync(0, "utf8") : fs.readFileSync(file, "utf8");
|
|
37
|
+
try {
|
|
38
|
+
return JSON.parse(text);
|
|
39
|
+
}
|
|
40
|
+
catch (err) {
|
|
41
|
+
throw new UsageError(`--${name}: ${file} is not valid JSON (${err.message})`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { CliError } from "../errors.js";
|
|
2
|
+
import { dim, green, red, statusColor, yellow } from "../output.js";
|
|
3
|
+
import { sseStream } from "../sse.js";
|
|
4
|
+
import { projectPath } from "./util.js";
|
|
5
|
+
function withTimeout(minutes) {
|
|
6
|
+
if (!minutes)
|
|
7
|
+
return undefined;
|
|
8
|
+
return AbortSignal.timeout(minutes * 60_000);
|
|
9
|
+
}
|
|
10
|
+
export async function watchRun(ctx, ws, project, runId, timeoutMinutes) {
|
|
11
|
+
const signal = withTimeout(timeoutMinutes);
|
|
12
|
+
let finalStatus = "unknown";
|
|
13
|
+
let counters = {};
|
|
14
|
+
const seen = new Set();
|
|
15
|
+
for await (const ev of sseStream(ctx.client, `${projectPath(ws, project)}/runs/${runId}/stream`, signal)) {
|
|
16
|
+
if (ctx.json) {
|
|
17
|
+
ctx.out(JSON.stringify(ev));
|
|
18
|
+
}
|
|
19
|
+
if (ev.counters)
|
|
20
|
+
counters = ev.counters;
|
|
21
|
+
const kind = ev.event;
|
|
22
|
+
if (!ctx.json) {
|
|
23
|
+
if (kind === "run_started") {
|
|
24
|
+
ctx.err(dim(`run ${runId} started`));
|
|
25
|
+
}
|
|
26
|
+
else if (kind === "test_completed") {
|
|
27
|
+
const key = `${ev.test_result_id}:${ev.status}`;
|
|
28
|
+
if (!seen.has(key)) {
|
|
29
|
+
seen.add(key);
|
|
30
|
+
const ok = ev.status === "passed";
|
|
31
|
+
const mark = ok ? green("✓") : red("✗");
|
|
32
|
+
const duration = ev.duration_ms ? dim(` ${Math.round(Number(ev.duration_ms) / 1000)}s`) : "";
|
|
33
|
+
const error = ev.error_message ? red(` — ${String(ev.error_message).slice(0, 120)}`) : "";
|
|
34
|
+
ctx.err(`${mark} ${statusColor(String(ev.status))} ${dim(String(ev.test_result_id))}${duration}${error}`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
if (kind === "run_completed" || kind === "run_cancelled") {
|
|
39
|
+
finalStatus = ev.status ?? kind;
|
|
40
|
+
break;
|
|
41
|
+
}
|
|
42
|
+
if (kind === "error") {
|
|
43
|
+
finalStatus = "error";
|
|
44
|
+
break;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
if (signal?.aborted)
|
|
48
|
+
throw new CliError(`Timed out after ${timeoutMinutes} minutes`, 1);
|
|
49
|
+
const failed = (counters.failed ?? 0) + (counters.errored ?? 0);
|
|
50
|
+
const passed = counters.passed ?? 0;
|
|
51
|
+
const summary = failed > 0
|
|
52
|
+
? red(`${failed} failed`) + `, ${passed} passed`
|
|
53
|
+
: green(`${passed} passed`) + (counters.cancelled ? yellow(`, ${counters.cancelled} cancelled`) : "");
|
|
54
|
+
if (!ctx.json)
|
|
55
|
+
ctx.err(`\n${statusColor(finalStatus)}: ${summary}`);
|
|
56
|
+
const exitCode = finalStatus === "completed" && failed === 0 ? 0 : 1;
|
|
57
|
+
return { data: { run_id: runId, status: finalStatus, ...counters }, exitCode };
|
|
58
|
+
}
|
|
59
|
+
export async function watchExploration(ctx, ws, project, explorationId, timeoutMinutes) {
|
|
60
|
+
const signal = withTimeout(timeoutMinutes);
|
|
61
|
+
let finalStatus = "unknown";
|
|
62
|
+
let handedOffRunId = null;
|
|
63
|
+
let error = null;
|
|
64
|
+
const seen = new Set();
|
|
65
|
+
for await (const ev of sseStream(ctx.client, `${projectPath(ws, project)}/explorations/${explorationId}/stream`, signal)) {
|
|
66
|
+
if (ctx.json)
|
|
67
|
+
ctx.out(JSON.stringify(ev));
|
|
68
|
+
const kind = ev.event;
|
|
69
|
+
if (kind === "exploration_step" && !ctx.json) {
|
|
70
|
+
const seq = Number(ev.seq);
|
|
71
|
+
if (!seen.has(seq)) {
|
|
72
|
+
seen.add(seq);
|
|
73
|
+
const mark = ev.ok === false ? red("✗") : green("·");
|
|
74
|
+
const tool = ev.tool ? `${ev.tool}` : "";
|
|
75
|
+
const text = (ev.summary ?? ev.rationale ?? "");
|
|
76
|
+
ctx.err(`${mark} ${dim(`#${seq}`)} ${tool} ${text.replace(/\s+/g, " ").slice(0, 140)}`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
else if (kind === "exploration_completed") {
|
|
80
|
+
finalStatus = ev.status ?? "completed";
|
|
81
|
+
handedOffRunId = ev.test_run_id ?? null;
|
|
82
|
+
if (ev.error)
|
|
83
|
+
error = String(ev.error);
|
|
84
|
+
break;
|
|
85
|
+
}
|
|
86
|
+
else if (kind === "error") {
|
|
87
|
+
finalStatus = "failed";
|
|
88
|
+
error = ev.error ?? "exploration failed";
|
|
89
|
+
break;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
if (signal?.aborted)
|
|
93
|
+
throw new CliError(`Timed out after ${timeoutMinutes} minutes`, 1);
|
|
94
|
+
if (!ctx.json) {
|
|
95
|
+
ctx.err(`\nexploration ${statusColor(finalStatus)}${error ? red(` — ${error}`) : ""}`);
|
|
96
|
+
if (handedOffRunId)
|
|
97
|
+
ctx.err(dim(`handed off to run ${handedOffRunId}`));
|
|
98
|
+
}
|
|
99
|
+
return {
|
|
100
|
+
data: { exploration_id: explorationId, status: finalStatus, test_run_id: handedOffRunId, error },
|
|
101
|
+
exitCode: finalStatus === "failed" ? 1 : 0,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
export async function pollCurrentExploration(ctx, ws, project, timeoutSeconds = 180) {
|
|
105
|
+
const deadline = Date.now() + timeoutSeconds * 1000;
|
|
106
|
+
while (Date.now() < deadline) {
|
|
107
|
+
const detail = (await ctx.client.get(projectPath(ws, project)));
|
|
108
|
+
if (detail.current_exploration_id)
|
|
109
|
+
return detail.current_exploration_id;
|
|
110
|
+
await new Promise((r) => setTimeout(r, 3000));
|
|
111
|
+
}
|
|
112
|
+
throw new CliError("No exploration started within the wait window");
|
|
113
|
+
}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { saveGlobalConfig } from "../config.js";
|
|
2
|
+
import { green } from "../output.js";
|
|
3
|
+
import { arg, flagBool, flagStr } from "./util.js";
|
|
4
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
5
|
+
export const workspaceCommands = [
|
|
6
|
+
{
|
|
7
|
+
name: "workspaces list",
|
|
8
|
+
summary: "List workspaces you belong to",
|
|
9
|
+
async run(ctx) {
|
|
10
|
+
const rows = (await ctx.client.get("/workspaces/"));
|
|
11
|
+
return { data: rows };
|
|
12
|
+
},
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
name: "workspaces get",
|
|
16
|
+
summary: "Show one workspace",
|
|
17
|
+
scope: "workspace",
|
|
18
|
+
async run(ctx, input) {
|
|
19
|
+
const ws = await ctx.requireWorkspace(input);
|
|
20
|
+
return { data: await ctx.client.get(`/workspaces/${ws}`) };
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
name: "workspaces create",
|
|
25
|
+
summary: "Create a workspace",
|
|
26
|
+
args: [{ name: "name", description: "Workspace name", required: true }],
|
|
27
|
+
flags: [
|
|
28
|
+
{ name: "domain", type: "string", description: "Company domain to associate" },
|
|
29
|
+
{
|
|
30
|
+
name: "visible-to-org",
|
|
31
|
+
type: "boolean",
|
|
32
|
+
description: "Let anyone on your email domain discover and join it",
|
|
33
|
+
},
|
|
34
|
+
],
|
|
35
|
+
async run(ctx, input) {
|
|
36
|
+
const created = await ctx.client.post("/workspaces/", {
|
|
37
|
+
name: arg(input, "name"),
|
|
38
|
+
domain: flagStr(input, "domain") ?? null,
|
|
39
|
+
visible_to_org: flagBool(input, "visible-to-org"),
|
|
40
|
+
});
|
|
41
|
+
return { data: created };
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
name: "workspaces update",
|
|
46
|
+
summary: "Rename a workspace or change its visibility",
|
|
47
|
+
scope: "workspace",
|
|
48
|
+
flags: [
|
|
49
|
+
{ name: "name", type: "string", description: "New name" },
|
|
50
|
+
{ name: "visible-to-org", type: "boolean", description: "Toggle org discoverability" },
|
|
51
|
+
{ name: "autofix", type: "boolean", description: "Toggle autofix" },
|
|
52
|
+
],
|
|
53
|
+
async run(ctx, input) {
|
|
54
|
+
const ws = await ctx.requireWorkspace(input);
|
|
55
|
+
const body = {};
|
|
56
|
+
if (flagStr(input, "name") !== undefined)
|
|
57
|
+
body.name = flagStr(input, "name");
|
|
58
|
+
if (input.flags["visible-to-org"] !== undefined)
|
|
59
|
+
body.visible_to_org = input.flags["visible-to-org"];
|
|
60
|
+
if (input.flags.autofix !== undefined)
|
|
61
|
+
body.autofix_enabled = input.flags.autofix;
|
|
62
|
+
return { data: await ctx.client.patch(`/workspaces/${ws}`, body) };
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
name: "workspaces delete",
|
|
67
|
+
summary: "Delete a workspace and everything in it",
|
|
68
|
+
scope: "workspace",
|
|
69
|
+
flags: [{ name: "force", type: "boolean", description: "Skip the confirmation prompt" }],
|
|
70
|
+
async run(ctx, input) {
|
|
71
|
+
const ws = await ctx.requireWorkspace(input);
|
|
72
|
+
await ctx.confirm(`Delete workspace ${ws} and ALL its projects?`, flagBool(input, "force"));
|
|
73
|
+
await ctx.client.del(`/workspaces/${ws}`);
|
|
74
|
+
return { human: "Deleted." };
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
name: "workspaces use",
|
|
79
|
+
summary: "Set the default workspace for future commands",
|
|
80
|
+
args: [{ name: "workspace", description: "Workspace id or name", required: true }],
|
|
81
|
+
async run(ctx, input) {
|
|
82
|
+
const value = arg(input, "workspace");
|
|
83
|
+
const id = UUID_RE.test(value)
|
|
84
|
+
? value
|
|
85
|
+
: await ctx.requireWorkspace({ args: {}, flags: { workspace: value } });
|
|
86
|
+
const detail = (await ctx.client.get(`/workspaces/${id}`));
|
|
87
|
+
saveGlobalConfig({ workspace: id, project: undefined });
|
|
88
|
+
return {
|
|
89
|
+
data: { workspace: id },
|
|
90
|
+
human: `${green("Default workspace set")}: ${detail.name} (${id})`,
|
|
91
|
+
};
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
name: "workspaces history",
|
|
96
|
+
summary: "Show the workspace's action history (who did what, when)",
|
|
97
|
+
scope: "workspace",
|
|
98
|
+
async run(ctx, input) {
|
|
99
|
+
const ws = await ctx.requireWorkspace(input);
|
|
100
|
+
return { data: await ctx.client.get(`/workspaces/${ws}/action-history`) };
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
name: "workspaces leave",
|
|
105
|
+
summary: "Leave a workspace you are a member of",
|
|
106
|
+
scope: "workspace",
|
|
107
|
+
flags: [{ name: "force", type: "boolean", description: "Skip the confirmation prompt" }],
|
|
108
|
+
async run(ctx, input) {
|
|
109
|
+
const ws = await ctx.requireWorkspace(input);
|
|
110
|
+
await ctx.confirm(`Leave workspace ${ws}?`, flagBool(input, "force"));
|
|
111
|
+
await ctx.client.post(`/workspaces/${ws}/leave`);
|
|
112
|
+
return { human: "Left the workspace." };
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
name: "members list",
|
|
117
|
+
summary: "List workspace members",
|
|
118
|
+
scope: "workspace",
|
|
119
|
+
async run(ctx, input) {
|
|
120
|
+
const ws = await ctx.requireWorkspace(input);
|
|
121
|
+
return { data: await ctx.client.get(`/workspaces/${ws}/members`) };
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
name: "members set-role",
|
|
126
|
+
summary: "Change a member's role",
|
|
127
|
+
scope: "workspace",
|
|
128
|
+
args: [
|
|
129
|
+
{ name: "user-id", description: "Member's user id", required: true },
|
|
130
|
+
{ name: "role", description: "OWNER or USER", required: true },
|
|
131
|
+
],
|
|
132
|
+
async run(ctx, input) {
|
|
133
|
+
const ws = await ctx.requireWorkspace(input);
|
|
134
|
+
const role = arg(input, "role").toUpperCase();
|
|
135
|
+
return {
|
|
136
|
+
data: await ctx.client.patch(`/workspaces/${ws}/members/${arg(input, "user-id")}`, { role }),
|
|
137
|
+
};
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
name: "members remove",
|
|
142
|
+
summary: "Remove a member from the workspace",
|
|
143
|
+
scope: "workspace",
|
|
144
|
+
args: [{ name: "user-id", description: "Member's user id", required: true }],
|
|
145
|
+
flags: [{ name: "force", type: "boolean", description: "Skip the confirmation prompt" }],
|
|
146
|
+
async run(ctx, input) {
|
|
147
|
+
const ws = await ctx.requireWorkspace(input);
|
|
148
|
+
const userId = arg(input, "user-id");
|
|
149
|
+
await ctx.confirm(`Remove ${userId} from workspace ${ws}?`, flagBool(input, "force"));
|
|
150
|
+
await ctx.client.del(`/workspaces/${ws}/members/${userId}`);
|
|
151
|
+
return { human: "Removed." };
|
|
152
|
+
},
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
name: "invites send",
|
|
156
|
+
summary: "Invite someone to the workspace by email",
|
|
157
|
+
scope: "workspace",
|
|
158
|
+
args: [{ name: "email", description: "Invitee email", required: true }],
|
|
159
|
+
flags: [
|
|
160
|
+
{ name: "role", type: "string", enum: ["OWNER", "USER"], description: "Role (default USER)" },
|
|
161
|
+
],
|
|
162
|
+
async run(ctx, input) {
|
|
163
|
+
const ws = await ctx.requireWorkspace(input);
|
|
164
|
+
return {
|
|
165
|
+
data: await ctx.client.post(`/workspaces/${ws}/invitations`, {
|
|
166
|
+
email: arg(input, "email"),
|
|
167
|
+
role: (flagStr(input, "role") ?? "USER").toUpperCase(),
|
|
168
|
+
}),
|
|
169
|
+
};
|
|
170
|
+
},
|
|
171
|
+
},
|
|
172
|
+
{
|
|
173
|
+
name: "invites list",
|
|
174
|
+
summary: "List the workspace's outstanding invitations",
|
|
175
|
+
scope: "workspace",
|
|
176
|
+
async run(ctx, input) {
|
|
177
|
+
const ws = await ctx.requireWorkspace(input);
|
|
178
|
+
return { data: await ctx.client.get(`/workspaces/${ws}/invitations`) };
|
|
179
|
+
},
|
|
180
|
+
},
|
|
181
|
+
{
|
|
182
|
+
name: "invites revoke",
|
|
183
|
+
summary: "Revoke a pending invitation",
|
|
184
|
+
scope: "workspace",
|
|
185
|
+
args: [{ name: "invitation-id", description: "Invitation id", required: true }],
|
|
186
|
+
async run(ctx, input) {
|
|
187
|
+
const ws = await ctx.requireWorkspace(input);
|
|
188
|
+
await ctx.client.del(`/workspaces/${ws}/invitations/${arg(input, "invitation-id")}`);
|
|
189
|
+
return { human: "Revoked." };
|
|
190
|
+
},
|
|
191
|
+
},
|
|
192
|
+
{
|
|
193
|
+
name: "invites mine",
|
|
194
|
+
summary: "List invitations sent to you",
|
|
195
|
+
async run(ctx) {
|
|
196
|
+
return { data: await ctx.client.get("/invitations") };
|
|
197
|
+
},
|
|
198
|
+
},
|
|
199
|
+
{
|
|
200
|
+
name: "invites accept",
|
|
201
|
+
summary: "Accept an invitation (by id, or by the token from the invite email)",
|
|
202
|
+
args: [{ name: "invitation", description: "Invitation id or emailed token", required: true }],
|
|
203
|
+
async run(ctx, input) {
|
|
204
|
+
const value = arg(input, "invitation");
|
|
205
|
+
const path = UUID_RE.test(value)
|
|
206
|
+
? `/invitations/${value}/accept`
|
|
207
|
+
: `/invitations/token/${value}/accept`;
|
|
208
|
+
return { data: await ctx.client.post(path) };
|
|
209
|
+
},
|
|
210
|
+
},
|
|
211
|
+
{
|
|
212
|
+
name: "invites decline",
|
|
213
|
+
summary: "Decline an invitation",
|
|
214
|
+
args: [{ name: "invitation-id", description: "Invitation id", required: true }],
|
|
215
|
+
async run(ctx, input) {
|
|
216
|
+
await ctx.client.post(`/invitations/${arg(input, "invitation-id")}/decline`);
|
|
217
|
+
return { human: "Declined." };
|
|
218
|
+
},
|
|
219
|
+
},
|
|
220
|
+
];
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
export const DEFAULT_API_URL = "https://api.beryl.so";
|
|
5
|
+
export const LOCAL_CONFIG_FILENAME = ".beryl.json";
|
|
6
|
+
export function globalConfigPath(env = process.env) {
|
|
7
|
+
const base = env.BERYL_CONFIG_DIR ??
|
|
8
|
+
(env.XDG_CONFIG_HOME ? path.join(env.XDG_CONFIG_HOME, "beryl") : undefined) ??
|
|
9
|
+
path.join(os.homedir(), ".config", "beryl");
|
|
10
|
+
return path.join(base, "config.json");
|
|
11
|
+
}
|
|
12
|
+
function readJson(file) {
|
|
13
|
+
try {
|
|
14
|
+
return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return undefined;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
export function findLocalConfig(cwd) {
|
|
21
|
+
let dir = path.resolve(cwd);
|
|
22
|
+
for (;;) {
|
|
23
|
+
const candidate = path.join(dir, LOCAL_CONFIG_FILENAME);
|
|
24
|
+
if (fs.existsSync(candidate))
|
|
25
|
+
return candidate;
|
|
26
|
+
const parent = path.dirname(dir);
|
|
27
|
+
if (parent === dir)
|
|
28
|
+
return undefined;
|
|
29
|
+
dir = parent;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
export function loadConfig(env = process.env, cwd = process.cwd()) {
|
|
33
|
+
const globalPath = globalConfigPath(env);
|
|
34
|
+
const global = readJson(globalPath) ?? {};
|
|
35
|
+
const localPath = findLocalConfig(cwd);
|
|
36
|
+
const local = localPath ? (readJson(localPath) ?? {}) : {};
|
|
37
|
+
return {
|
|
38
|
+
apiUrl: env.BERYL_API_URL ?? local.api_url ?? global.api_url ?? DEFAULT_API_URL,
|
|
39
|
+
token: env.BERYL_API_KEY ?? env.BERYL_TOKEN ?? local.token ?? global.token,
|
|
40
|
+
workspace: env.BERYL_WORKSPACE ?? local.workspace ?? global.workspace,
|
|
41
|
+
project: env.BERYL_PROJECT ?? local.project ?? global.project,
|
|
42
|
+
globalConfigPath: globalPath,
|
|
43
|
+
localConfigPath: localPath,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
export function saveGlobalConfig(patch, env = process.env) {
|
|
47
|
+
const file = globalConfigPath(env);
|
|
48
|
+
const current = readJson(file) ?? {};
|
|
49
|
+
const next = { ...current, ...patch };
|
|
50
|
+
for (const key of Object.keys(next)) {
|
|
51
|
+
if (next[key] === undefined)
|
|
52
|
+
delete next[key];
|
|
53
|
+
}
|
|
54
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
55
|
+
fs.writeFileSync(file, JSON.stringify(next, null, 2) + "\n", { mode: 0o600 });
|
|
56
|
+
try {
|
|
57
|
+
fs.chmodSync(file, 0o600);
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
// best effort on non-POSIX filesystems
|
|
61
|
+
}
|
|
62
|
+
return file;
|
|
63
|
+
}
|