@beryl-so/cli 0.67.0 → 0.77.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 +26 -14
- package/dist/adapters/mcp.js +6 -4
- package/dist/beryl-test-skill.js +129 -107
- package/dist/commands/accounts.js +92 -10
- package/dist/commands/config-vars.js +87 -16
- package/dist/commands/environments.js +18 -1
- package/dist/commands/init.js +42 -108
- package/dist/commands/jobs.js +129 -0
- package/dist/commands/projects.js +68 -45
- package/dist/commands/runs.js +5 -432
- package/dist/commands/tests.js +34 -409
- package/dist/commands/third-party-integrations.js +26 -3
- package/dist/commands/watch.js +36 -0
- package/dist/lint.js +120 -6
- package/dist/registry/index.js +2 -0
- package/dist/schema.generated.js +152 -27
- package/dist/skill-tables.js +3 -0
- package/package.json +2 -3
- package/dist/email-pump.js +0 -138
- package/dist/local-exec.js +0 -239
- package/dist/local-run.js +0 -340
- package/dist/playwright-install.js +0 -220
- package/dist/progress.js +0 -44
package/dist/lint.js
CHANGED
|
@@ -142,6 +142,13 @@ function validate(value, raw, root, path) {
|
|
|
142
142
|
else
|
|
143
143
|
issues.push(...found);
|
|
144
144
|
}
|
|
145
|
+
// A branch can itself be an if/then/else (the first-step rule nests on device, then
|
|
146
|
+
// on whether `before` exists).
|
|
147
|
+
if (schema.if) {
|
|
148
|
+
const branch = matches(value, schema.if, root) ? schema.then : schema.else;
|
|
149
|
+
if (branch)
|
|
150
|
+
issues.push(...validate(value, branch, root, path));
|
|
151
|
+
}
|
|
145
152
|
return issues;
|
|
146
153
|
}
|
|
147
154
|
const steps = (section) => Array.isArray(section)
|
|
@@ -182,17 +189,11 @@ function identityIssues(plan, provisioning) {
|
|
|
182
189
|
: [];
|
|
183
190
|
}
|
|
184
191
|
export const THIRD_PARTY_AUTHORIZE = "third_party_authorize";
|
|
185
|
-
export const AWAIT_THIRD_PARTY = "await_third_party";
|
|
186
192
|
const executed = (plan) => [
|
|
187
193
|
...steps(plan.before),
|
|
188
194
|
...steps(plan.steps),
|
|
189
195
|
...steps(plan.after),
|
|
190
196
|
];
|
|
191
|
-
export function planUsesThirdParty(plan) {
|
|
192
|
-
if (typeof plan !== "object" || plan === null)
|
|
193
|
-
return false;
|
|
194
|
-
return executed(plan).some((s) => s.action === THIRD_PARTY_AUTHORIZE || s.action === AWAIT_THIRD_PARTY);
|
|
195
|
-
}
|
|
196
197
|
// Mirrors the server's ActionPlan validator, which raises the first sorted mismatch only.
|
|
197
198
|
function thirdPartyIssues(plan) {
|
|
198
199
|
if (typeof plan !== "object" || plan === null)
|
|
@@ -212,6 +213,118 @@ function thirdPartyIssues(plan) {
|
|
|
212
213
|
return [{ path: "third_party_integrations", message: THIRD_PARTY_UNUSED(unused) }];
|
|
213
214
|
return [];
|
|
214
215
|
}
|
|
216
|
+
// Mirrors ActionPlan._validate_nonempty and _assert_app_plan in
|
|
217
|
+
// api/app/ta_shared/schemas.py, which raise the first violation only.
|
|
218
|
+
const APP_DEVICES = ["android", "ios"];
|
|
219
|
+
const APP_PLATFORM_NAMES = {
|
|
220
|
+
android: "Android",
|
|
221
|
+
ios: "iOS",
|
|
222
|
+
};
|
|
223
|
+
export const APP_ACTIONS = [
|
|
224
|
+
"launch",
|
|
225
|
+
"back",
|
|
226
|
+
"fill",
|
|
227
|
+
"click",
|
|
228
|
+
"press",
|
|
229
|
+
"scroll",
|
|
230
|
+
"wait_for",
|
|
231
|
+
"expect",
|
|
232
|
+
"await_email",
|
|
233
|
+
];
|
|
234
|
+
export const APP_EXPECTS = [
|
|
235
|
+
"visible",
|
|
236
|
+
"hidden",
|
|
237
|
+
"checked",
|
|
238
|
+
"enabled",
|
|
239
|
+
"disabled",
|
|
240
|
+
"have_text",
|
|
241
|
+
"persisted",
|
|
242
|
+
"gone",
|
|
243
|
+
];
|
|
244
|
+
const APP_KEYS = {
|
|
245
|
+
android: ["Enter", "Backspace", "Tab"],
|
|
246
|
+
ios: ["Enter", "Backspace"],
|
|
247
|
+
};
|
|
248
|
+
const APP_ID_SELECTOR = {
|
|
249
|
+
android: "resource id",
|
|
250
|
+
ios: "accessibility identifier",
|
|
251
|
+
};
|
|
252
|
+
const APP_SELECTOR_RE = /^(id|text)=[\s\S]+$/;
|
|
253
|
+
const APP_STRING_FIELDS = [
|
|
254
|
+
"selector",
|
|
255
|
+
"value",
|
|
256
|
+
"key",
|
|
257
|
+
"expect_text",
|
|
258
|
+
"from_contains",
|
|
259
|
+
"subject_contains",
|
|
260
|
+
];
|
|
261
|
+
const sortedList = (values) => [...values].sort().join(", ");
|
|
262
|
+
const sectionSteps = (plan) => ["before", "steps", "after"].flatMap((section) => steps(plan[section]).map((step, i) => ({ path: `${section}[${i}]`, section, step })));
|
|
263
|
+
const appStepRules = (p) => [
|
|
264
|
+
(s) => p === "ios" && s.action === "back"
|
|
265
|
+
? "is not available on ios: iOS has no back button; tap the app's own back control."
|
|
266
|
+
: null,
|
|
267
|
+
(s) => APP_ACTIONS.includes(String(s.action))
|
|
268
|
+
? null
|
|
269
|
+
: `is not available on ${p}. ${APP_PLATFORM_NAMES[p]} steps: ${sortedList(APP_ACTIONS)}.`,
|
|
270
|
+
(s) => s.action !== "expect" || APP_EXPECTS.includes(String(s.expect_kind))
|
|
271
|
+
? null
|
|
272
|
+
: `expect_kind '${typeof s.expect_kind === "string" ? s.expect_kind : "?"}' is not ` +
|
|
273
|
+
`available on ${p}. ${APP_PLATFORM_NAMES[p]} expects: ${sortedList(APP_EXPECTS)}.`,
|
|
274
|
+
(s) => (s.iframe ? "sets iframe, which only exists on web pages." : null),
|
|
275
|
+
(s) => s.action === "scroll" && !s.selector
|
|
276
|
+
? `needs a selector: on ${p}, scroll moves until that element is on screen.`
|
|
277
|
+
: null,
|
|
278
|
+
(s) => typeof s.selector === "string" && !APP_SELECTOR_RE.test(s.selector)
|
|
279
|
+
? `selector ${pyRepr(s.selector)} is not an ${p} selector. Use ` +
|
|
280
|
+
`'id=<${APP_ID_SELECTOR[p]}>' or 'text=<visible text or accessibility label>'.`
|
|
281
|
+
: null,
|
|
282
|
+
(s) => s.expect_kind === "have_text" &&
|
|
283
|
+
typeof s.selector === "string" &&
|
|
284
|
+
s.selector &&
|
|
285
|
+
!s.selector.startsWith("id=")
|
|
286
|
+
? "checks text, so it needs an 'id=' selector; to assert that some text is on " +
|
|
287
|
+
"screen, use expect_kind 'visible' with 'text=<text>'."
|
|
288
|
+
: null,
|
|
289
|
+
(s) => s.action === "press" && !APP_KEYS[p].includes(String(s.key))
|
|
290
|
+
? `key ${pyRepr(s.key)} is not available on ${p}. Keys: ${APP_KEYS[p].join(", ")}.`
|
|
291
|
+
: null,
|
|
292
|
+
(s) => {
|
|
293
|
+
const field = APP_STRING_FIELDS.find((f) => {
|
|
294
|
+
const text = s[f];
|
|
295
|
+
return typeof text === "string" && text.includes("${");
|
|
296
|
+
});
|
|
297
|
+
return field
|
|
298
|
+
? `${field} contains '\${', which the app runner would evaluate as code. Remove it.`
|
|
299
|
+
: null;
|
|
300
|
+
},
|
|
301
|
+
];
|
|
302
|
+
function deviceIssues(plan) {
|
|
303
|
+
if (typeof plan !== "object" || plan === null)
|
|
304
|
+
return [];
|
|
305
|
+
const record = plan;
|
|
306
|
+
const issue = (path, message) => [{ path, message }];
|
|
307
|
+
const platform = String(record.device);
|
|
308
|
+
if (!APP_DEVICES.includes(platform)) {
|
|
309
|
+
const appOnly = [...new Set(executed(record).map((s) => String(s.action)))]
|
|
310
|
+
.filter((a) => a === "launch" || a === "back")
|
|
311
|
+
.sort();
|
|
312
|
+
return appOnly.length
|
|
313
|
+
? issue("(root)", `${appOnly.join(", ")} drives a native app, so it is only for devices 'android' ` +
|
|
314
|
+
"and 'ios'; a web plan starts with a goto")
|
|
315
|
+
: [];
|
|
316
|
+
}
|
|
317
|
+
// The server names a flat plan's steps `step[N]` and a sectioned one's `after[0]`.
|
|
318
|
+
const flat = steps(record.before).length === 0 && steps(record.after).length === 0;
|
|
319
|
+
for (const [i, { path, step }] of sectionSteps(record).entries()) {
|
|
320
|
+
for (const rule of appStepRules(platform)) {
|
|
321
|
+
const why = rule(step);
|
|
322
|
+
if (why)
|
|
323
|
+
return issue(path, `${flat ? `step[${i}]` : path} (${String(step.action)}) ${why}`);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
return [];
|
|
327
|
+
}
|
|
215
328
|
const TEARDOWN_PROOF_KINDS = new Set([
|
|
216
329
|
"gone",
|
|
217
330
|
"hidden",
|
|
@@ -301,6 +414,7 @@ export function lintPlan(plan, opts = {}) {
|
|
|
301
414
|
if (issues.length > 0)
|
|
302
415
|
return issues;
|
|
303
416
|
return [
|
|
417
|
+
...deviceIssues(plan),
|
|
304
418
|
...thirdPartyIssues(plan),
|
|
305
419
|
...identityIssues(plan, opts.provisioning === true),
|
|
306
420
|
...(opts.access === undefined ? [] : accessIssues(plan, opts.access)),
|
package/dist/registry/index.js
CHANGED
|
@@ -8,6 +8,7 @@ import { groupCommands } from "../commands/groups.js";
|
|
|
8
8
|
import { healthCommands } from "../commands/health.js";
|
|
9
9
|
import { mailboxCommands } from "../commands/mailboxes.js";
|
|
10
10
|
import { initCommands } from "../commands/init.js";
|
|
11
|
+
import { jobCommands } from "../commands/jobs.js";
|
|
11
12
|
import { mcpCommands } from "../commands/mcp.js";
|
|
12
13
|
import { projectCommands } from "../commands/projects.js";
|
|
13
14
|
import { runCommands } from "../commands/runs.js";
|
|
@@ -68,6 +69,7 @@ export const commands = [
|
|
|
68
69
|
...runCommands,
|
|
69
70
|
...healthCommands,
|
|
70
71
|
...trackerCommands,
|
|
72
|
+
...jobCommands,
|
|
71
73
|
...explorationCommands,
|
|
72
74
|
...configCommands,
|
|
73
75
|
...mailboxCommands,
|
package/dist/schema.generated.js
CHANGED
|
@@ -4,6 +4,8 @@ export const ACTION_PLAN_SCHEMA = {
|
|
|
4
4
|
"ActionType": {
|
|
5
5
|
"enum": [
|
|
6
6
|
"goto",
|
|
7
|
+
"launch",
|
|
8
|
+
"back",
|
|
7
9
|
"fill",
|
|
8
10
|
"click",
|
|
9
11
|
"press",
|
|
@@ -24,7 +26,8 @@ export const ACTION_PLAN_SCHEMA = {
|
|
|
24
26
|
"close_tab",
|
|
25
27
|
"drag",
|
|
26
28
|
"third_party_authorize",
|
|
27
|
-
"await_third_party"
|
|
29
|
+
"await_third_party",
|
|
30
|
+
"send_third_party"
|
|
28
31
|
],
|
|
29
32
|
"title": "ActionType",
|
|
30
33
|
"type": "string"
|
|
@@ -39,11 +42,13 @@ export const ACTION_PLAN_SCHEMA = {
|
|
|
39
42
|
"type": "string"
|
|
40
43
|
},
|
|
41
44
|
"DeviceClass": {
|
|
42
|
-
"description": "The viewport and device a test runs at. The AUTHOR sets it when a flow is specific\nto a form factor (a mobile-only nav, a hamburger, a stacked layout); omit it and the\ntest runs at the default desktop viewport.\n\n``mobile`` / ``tablet`` are full Chromium device emulation, not a bare resize: the\nviewport, a mobile Chrome user-agent, touch, and the device pixel ratio all change\ntogether, so a control gated behind a touch or mobile-UA media query actually appears.",
|
|
45
|
+
"description": "The viewport and device a test runs at. The AUTHOR sets it when a flow is specific\nto a form factor (a mobile-only nav, a hamburger, a stacked layout); omit it and the\ntest runs at the default desktop viewport.\n\n``mobile`` / ``tablet`` are full Chromium device emulation, not a bare resize: the\nviewport, a mobile Chrome user-agent, touch, and the device pixel ratio all change\ntogether, so a control gated behind a touch or mobile-UA media query actually appears.\n``android`` and ``ios`` are not browsers: the plan drives the environment's app on\nan Android emulator or an iOS simulator.",
|
|
43
46
|
"enum": [
|
|
44
47
|
"desktop",
|
|
45
48
|
"tablet",
|
|
46
|
-
"mobile"
|
|
49
|
+
"mobile",
|
|
50
|
+
"android",
|
|
51
|
+
"ios"
|
|
47
52
|
],
|
|
48
53
|
"title": "DeviceClass",
|
|
49
54
|
"type": "string"
|
|
@@ -110,6 +115,38 @@ export const ACTION_PLAN_SCHEMA = {
|
|
|
110
115
|
]
|
|
111
116
|
}
|
|
112
117
|
},
|
|
118
|
+
{
|
|
119
|
+
"if": {
|
|
120
|
+
"properties": {
|
|
121
|
+
"action": {
|
|
122
|
+
"const": "launch"
|
|
123
|
+
}
|
|
124
|
+
},
|
|
125
|
+
"required": [
|
|
126
|
+
"action"
|
|
127
|
+
]
|
|
128
|
+
},
|
|
129
|
+
"then": {
|
|
130
|
+
"properties": {},
|
|
131
|
+
"required": []
|
|
132
|
+
}
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
"if": {
|
|
136
|
+
"properties": {
|
|
137
|
+
"action": {
|
|
138
|
+
"const": "back"
|
|
139
|
+
}
|
|
140
|
+
},
|
|
141
|
+
"required": [
|
|
142
|
+
"action"
|
|
143
|
+
]
|
|
144
|
+
},
|
|
145
|
+
"then": {
|
|
146
|
+
"properties": {},
|
|
147
|
+
"required": []
|
|
148
|
+
}
|
|
149
|
+
},
|
|
113
150
|
{
|
|
114
151
|
"if": {
|
|
115
152
|
"properties": {
|
|
@@ -496,6 +533,36 @@ export const ACTION_PLAN_SCHEMA = {
|
|
|
496
533
|
]
|
|
497
534
|
}
|
|
498
535
|
},
|
|
536
|
+
{
|
|
537
|
+
"if": {
|
|
538
|
+
"properties": {
|
|
539
|
+
"action": {
|
|
540
|
+
"const": "send_third_party"
|
|
541
|
+
}
|
|
542
|
+
},
|
|
543
|
+
"required": [
|
|
544
|
+
"action"
|
|
545
|
+
]
|
|
546
|
+
},
|
|
547
|
+
"then": {
|
|
548
|
+
"properties": {
|
|
549
|
+
"provider": {
|
|
550
|
+
"not": {
|
|
551
|
+
"type": "null"
|
|
552
|
+
}
|
|
553
|
+
},
|
|
554
|
+
"value": {
|
|
555
|
+
"not": {
|
|
556
|
+
"type": "null"
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
},
|
|
560
|
+
"required": [
|
|
561
|
+
"provider",
|
|
562
|
+
"value"
|
|
563
|
+
]
|
|
564
|
+
}
|
|
565
|
+
},
|
|
499
566
|
{
|
|
500
567
|
"if": {
|
|
501
568
|
"properties": {
|
|
@@ -1292,45 +1359,103 @@ export const ACTION_PLAN_SCHEMA = {
|
|
|
1292
1359
|
}
|
|
1293
1360
|
},
|
|
1294
1361
|
{
|
|
1295
|
-
"$comment": "The first executed step must be a goto, so the test loads a page before acting (that is before[0] when there is a setup section, else steps[0]).",
|
|
1362
|
+
"$comment": "The first executed step must be a goto, so the test loads a page before acting (that is before[0] when there is a setup section, else steps[0]). An android or ios plan opens its app instead, so its first executed step is a launch.",
|
|
1296
1363
|
"else": {
|
|
1297
|
-
"
|
|
1298
|
-
"
|
|
1299
|
-
"
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
"
|
|
1303
|
-
"
|
|
1364
|
+
"else": {
|
|
1365
|
+
"properties": {
|
|
1366
|
+
"steps": {
|
|
1367
|
+
"prefixItems": [
|
|
1368
|
+
{
|
|
1369
|
+
"properties": {
|
|
1370
|
+
"action": {
|
|
1371
|
+
"const": "goto"
|
|
1372
|
+
}
|
|
1304
1373
|
}
|
|
1305
1374
|
}
|
|
1306
|
-
|
|
1307
|
-
|
|
1375
|
+
]
|
|
1376
|
+
}
|
|
1377
|
+
}
|
|
1378
|
+
},
|
|
1379
|
+
"if": {
|
|
1380
|
+
"properties": {
|
|
1381
|
+
"before": {
|
|
1382
|
+
"minItems": 1,
|
|
1383
|
+
"type": "array"
|
|
1384
|
+
}
|
|
1385
|
+
},
|
|
1386
|
+
"required": [
|
|
1387
|
+
"before"
|
|
1388
|
+
]
|
|
1389
|
+
},
|
|
1390
|
+
"then": {
|
|
1391
|
+
"properties": {
|
|
1392
|
+
"before": {
|
|
1393
|
+
"prefixItems": [
|
|
1394
|
+
{
|
|
1395
|
+
"properties": {
|
|
1396
|
+
"action": {
|
|
1397
|
+
"const": "goto"
|
|
1398
|
+
}
|
|
1399
|
+
}
|
|
1400
|
+
}
|
|
1401
|
+
]
|
|
1402
|
+
}
|
|
1308
1403
|
}
|
|
1309
1404
|
}
|
|
1310
1405
|
},
|
|
1311
1406
|
"if": {
|
|
1312
1407
|
"properties": {
|
|
1313
|
-
"
|
|
1314
|
-
"
|
|
1315
|
-
|
|
1408
|
+
"device": {
|
|
1409
|
+
"enum": [
|
|
1410
|
+
"android",
|
|
1411
|
+
"ios"
|
|
1412
|
+
]
|
|
1316
1413
|
}
|
|
1317
1414
|
},
|
|
1318
1415
|
"required": [
|
|
1319
|
-
"
|
|
1416
|
+
"device"
|
|
1320
1417
|
]
|
|
1321
1418
|
},
|
|
1322
1419
|
"then": {
|
|
1323
|
-
"
|
|
1324
|
-
"
|
|
1325
|
-
"
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
"
|
|
1329
|
-
"
|
|
1420
|
+
"else": {
|
|
1421
|
+
"properties": {
|
|
1422
|
+
"steps": {
|
|
1423
|
+
"prefixItems": [
|
|
1424
|
+
{
|
|
1425
|
+
"properties": {
|
|
1426
|
+
"action": {
|
|
1427
|
+
"const": "launch"
|
|
1428
|
+
}
|
|
1330
1429
|
}
|
|
1331
1430
|
}
|
|
1332
|
-
|
|
1333
|
-
|
|
1431
|
+
]
|
|
1432
|
+
}
|
|
1433
|
+
}
|
|
1434
|
+
},
|
|
1435
|
+
"if": {
|
|
1436
|
+
"properties": {
|
|
1437
|
+
"before": {
|
|
1438
|
+
"minItems": 1,
|
|
1439
|
+
"type": "array"
|
|
1440
|
+
}
|
|
1441
|
+
},
|
|
1442
|
+
"required": [
|
|
1443
|
+
"before"
|
|
1444
|
+
]
|
|
1445
|
+
},
|
|
1446
|
+
"then": {
|
|
1447
|
+
"properties": {
|
|
1448
|
+
"before": {
|
|
1449
|
+
"prefixItems": [
|
|
1450
|
+
{
|
|
1451
|
+
"properties": {
|
|
1452
|
+
"action": {
|
|
1453
|
+
"const": "launch"
|
|
1454
|
+
}
|
|
1455
|
+
}
|
|
1456
|
+
}
|
|
1457
|
+
]
|
|
1458
|
+
}
|
|
1334
1459
|
}
|
|
1335
1460
|
}
|
|
1336
1461
|
}
|
|
@@ -1417,7 +1542,7 @@ export const ACTION_PLAN_SCHEMA = {
|
|
|
1417
1542
|
"device": {
|
|
1418
1543
|
"$ref": "#/$defs/DeviceClass",
|
|
1419
1544
|
"default": "desktop",
|
|
1420
|
-
"description": "Viewport and device to run at. 'mobile' and 'tablet' apply full Chromium device emulation (viewport, mobile user-agent, touch, pixel ratio); 'desktop' (default) runs at the standard desktop viewport."
|
|
1545
|
+
"description": "Viewport and device to run at. 'mobile' and 'tablet' apply full Chromium device emulation (viewport, mobile user-agent, touch, pixel ratio); 'desktop' (default) runs at the standard desktop viewport; 'android' and 'ios' run the environment's app on an Android emulator or iOS simulator."
|
|
1421
1546
|
},
|
|
1422
1547
|
"requires_auth": {
|
|
1423
1548
|
"default": false,
|
package/dist/skill-tables.js
CHANGED
|
@@ -17,6 +17,8 @@ function expectRequires(kind) {
|
|
|
17
17
|
}
|
|
18
18
|
export const ACTION_DOCS = {
|
|
19
19
|
goto: "Navigate to `url` — a path on your app, absolute only for another origin",
|
|
20
|
+
launch: "App plans only (android, ios), and their first step: open the app (a clean start unless the test rides a session, a relaunch keeping its data after)",
|
|
21
|
+
back: "Android only: press the system back button (iOS has none)",
|
|
20
22
|
fill: "Type `value` into the `selector` element",
|
|
21
23
|
click: "Click the `selector` element",
|
|
22
24
|
press: "Press keyboard `key` (e.g. `Enter`) on the `selector` element",
|
|
@@ -38,6 +40,7 @@ export const ACTION_DOCS = {
|
|
|
38
40
|
drag: "Drag the `selector` element onto the `value` TARGET selector",
|
|
39
41
|
third_party_authorize: "Approve the `provider` install on its consent page as Beryl's account (cloud only, see third party integration tests)",
|
|
40
42
|
await_third_party: "Wait for the connected app's message on Beryl's `provider` account (cloud only)",
|
|
43
|
+
send_third_party: "Mention the connected app's bot with `value` as Beryl's `provider` account (cloud only)",
|
|
41
44
|
};
|
|
42
45
|
export const EXPECT_DOCS = {
|
|
43
46
|
visible: "The element is visible",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@beryl-so/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.77.1",
|
|
4
4
|
"description": "Beryl on the command line: projects, tests, runs, and an MCP server over the same commands.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -31,8 +31,7 @@
|
|
|
31
31
|
"docs": "tsx scripts/gen-docs.ts"
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@modelcontextprotocol/sdk": "^1.
|
|
35
|
-
"@playwright/test": "^1.61.1",
|
|
34
|
+
"@modelcontextprotocol/sdk": "^1.31.0",
|
|
36
35
|
"@posthog/mcp": "^0.11.6",
|
|
37
36
|
"posthog-node": "^5.49.1"
|
|
38
37
|
},
|
package/dist/email-pump.js
DELETED
|
@@ -1,138 +0,0 @@
|
|
|
1
|
-
import fs from "node:fs";
|
|
2
|
-
import { ApiError } from "./http.js";
|
|
3
|
-
const SIDECAR_POLL_MS = 500;
|
|
4
|
-
// The server caps one blocking wait at 50s; stay under it and loop.
|
|
5
|
-
const SERVER_WAIT_MAX_S = 45;
|
|
6
|
-
const DEFAULT_WAIT_S = 30;
|
|
7
|
-
const EXTRACT_RETRY_MS = 1000;
|
|
8
|
-
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
9
|
-
export function startEmailPump(opts) {
|
|
10
|
-
let stopped = false;
|
|
11
|
-
const served = new Set();
|
|
12
|
-
const consumed = new Set();
|
|
13
|
-
let storedAfter = opts.storedAfter;
|
|
14
|
-
let since;
|
|
15
|
-
const fetchLatest = async (req, timeoutS) => {
|
|
16
|
-
try {
|
|
17
|
-
return (await opts.client.get(`/workspaces/${opts.workspaceId}/mailboxes/${opts.inboxId}/emails/latest`, {
|
|
18
|
-
timeout_s: timeoutS,
|
|
19
|
-
since,
|
|
20
|
-
stored_after: storedAfter,
|
|
21
|
-
from_contains: req.from_contains,
|
|
22
|
-
subject_contains: req.subject_contains,
|
|
23
|
-
recipient_contains: opts.recipientContains,
|
|
24
|
-
}));
|
|
25
|
-
}
|
|
26
|
-
catch (err) {
|
|
27
|
-
if (err instanceof ApiError && err.status === 404)
|
|
28
|
-
return null;
|
|
29
|
-
throw err;
|
|
30
|
-
}
|
|
31
|
-
};
|
|
32
|
-
// A 422 is a real verdict — "this email carries no artifact" — and sends the wait on
|
|
33
|
-
// to the next email; anything else is transport trouble, retried until the step's own
|
|
34
|
-
// deadline does the failing.
|
|
35
|
-
const extractOnServer = async (emailId, req, deadline) => {
|
|
36
|
-
while (!stopped && Date.now() < deadline) {
|
|
37
|
-
try {
|
|
38
|
-
const res = (await opts.client.post(`/workspaces/${opts.workspaceId}/mailboxes/${opts.inboxId}/emails/${emailId}/extract`, { extract: req.extract ?? "code", extract_pattern: req.pattern }));
|
|
39
|
-
return { ok: true, value: res.value };
|
|
40
|
-
}
|
|
41
|
-
catch (err) {
|
|
42
|
-
// 424 is fatal; 422 walks on; the rest retries.
|
|
43
|
-
if (err instanceof ApiError && (err.status === 422 || err.status === 424)) {
|
|
44
|
-
const detail = typeof err.detail === "string" ? err.detail : err.message;
|
|
45
|
-
return {
|
|
46
|
-
ok: false,
|
|
47
|
-
fatal: err.status === 424,
|
|
48
|
-
error: detail.replace(/^EMAIL_(EXTRACTION_FAILED|EXTRACTOR_UNAVAILABLE):\s*/, ""),
|
|
49
|
-
};
|
|
50
|
-
}
|
|
51
|
-
await sleep(EXTRACT_RETRY_MS);
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
return { ok: false, error: "extraction did not complete before the wait deadline" };
|
|
55
|
-
};
|
|
56
|
-
const resolveRequest = async (req) => {
|
|
57
|
-
// Wait exactly wait_s, like the cloud resolver — the spec's own grace window
|
|
58
|
-
// covers this side's poll tick + round trip.
|
|
59
|
-
const waitS = Math.max(1, Number(req.wait_s) || DEFAULT_WAIT_S);
|
|
60
|
-
const deadline = Date.now() + waitS * 1000;
|
|
61
|
-
let lastExtractError;
|
|
62
|
-
while (!stopped && Date.now() < deadline) {
|
|
63
|
-
const remainingS = Math.ceil((deadline - Date.now()) / 1000);
|
|
64
|
-
const email = await fetchLatest(req, Math.min(SERVER_WAIT_MAX_S, remainingS));
|
|
65
|
-
if (email === null)
|
|
66
|
-
continue;
|
|
67
|
-
if (consumed.has(email.id)) {
|
|
68
|
-
// The fence rides received_at, which comes from the sender's Date header — a
|
|
69
|
-
// lagging mailer clock can re-serve a consumed mail. Nudge past it instead.
|
|
70
|
-
since = new Date(new Date(email.received_at).getTime() + 1000).toISOString();
|
|
71
|
-
continue;
|
|
72
|
-
}
|
|
73
|
-
consumed.add(email.id);
|
|
74
|
-
since = email.received_at;
|
|
75
|
-
opts.onEvent?.(`await_email: matched "${email.subject ?? "(no subject)"}"`);
|
|
76
|
-
const extracted = await extractOnServer(email.id, req, deadline);
|
|
77
|
-
if (extracted.ok)
|
|
78
|
-
return extracted.value;
|
|
79
|
-
if (extracted.fatal)
|
|
80
|
-
throw new Error(extracted.error);
|
|
81
|
-
// Same move as the cloud resolver: an email without the artifact (a welcome mail
|
|
82
|
-
// racing the code mail) is consumed and the wait continues for the next one.
|
|
83
|
-
lastExtractError = extracted.error;
|
|
84
|
-
opts.onEvent?.(`await_email: ${extracted.error} — waiting for the next email`);
|
|
85
|
-
}
|
|
86
|
-
throw new Error(lastExtractError ?? `no matching email arrived in the run inbox within ${waitS}s`);
|
|
87
|
-
};
|
|
88
|
-
const writeResponse = (response) => {
|
|
89
|
-
let box = {};
|
|
90
|
-
try {
|
|
91
|
-
box = JSON.parse(fs.readFileSync(opts.sidecarPath, "utf8"));
|
|
92
|
-
}
|
|
93
|
-
catch {
|
|
94
|
-
// keep at least the response readable
|
|
95
|
-
}
|
|
96
|
-
box.response = response;
|
|
97
|
-
// Write-then-rename so the spec never reads a half-written response.
|
|
98
|
-
const tmp = `${opts.sidecarPath}.tmp`;
|
|
99
|
-
fs.writeFileSync(tmp, JSON.stringify(box), "utf8");
|
|
100
|
-
fs.renameSync(tmp, opts.sidecarPath);
|
|
101
|
-
};
|
|
102
|
-
void (async () => {
|
|
103
|
-
while (!stopped) {
|
|
104
|
-
await sleep(SIDECAR_POLL_MS);
|
|
105
|
-
let box;
|
|
106
|
-
try {
|
|
107
|
-
box = JSON.parse(fs.readFileSync(opts.sidecarPath, "utf8"));
|
|
108
|
-
}
|
|
109
|
-
catch {
|
|
110
|
-
continue; // torn read or not yet written — the spec re-polls
|
|
111
|
-
}
|
|
112
|
-
const req = box.request;
|
|
113
|
-
if (!req || typeof req.id !== "string" || served.has(req.id))
|
|
114
|
-
continue;
|
|
115
|
-
served.add(req.id);
|
|
116
|
-
const response = { id: req.id };
|
|
117
|
-
try {
|
|
118
|
-
response.value = await resolveRequest(req);
|
|
119
|
-
}
|
|
120
|
-
catch (err) {
|
|
121
|
-
response.error = err instanceof Error ? err.message : String(err);
|
|
122
|
-
}
|
|
123
|
-
if (stopped)
|
|
124
|
-
return;
|
|
125
|
-
try {
|
|
126
|
-
writeResponse(response);
|
|
127
|
-
}
|
|
128
|
-
catch {
|
|
129
|
-
// run dir vanished mid-write — the run is over anyway
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
})();
|
|
133
|
-
return {
|
|
134
|
-
stop: () => {
|
|
135
|
-
stopped = true;
|
|
136
|
-
},
|
|
137
|
-
};
|
|
138
|
-
}
|