@botiverse/testbed-cli 0.2.0 → 0.4.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/lib/main.mjs CHANGED
@@ -1,7 +1,9 @@
1
1
  // testbed command set. Output is human tables by default; `--json` prints the
2
2
  // raw API response (what an agent wants). Secrets: `acquire` returns a one-time ADB
3
3
  // private key; it is printed only in --json output or written to --key-out (0600).
4
- import { openSync, writeSync, closeSync } from "node:fs";
4
+ import { openSync, writeSync, closeSync, readFileSync, mkdirSync, writeFileSync } from "node:fs";
5
+ import { fileURLToPath } from "node:url";
6
+ import { dirname, join } from "node:path";
5
7
  import { resolveAuth } from "./auth.mjs";
6
8
  import { makeClient, ApiError } from "./client.mjs";
7
9
  import { runAgentLogin } from "./agent-login.mjs";
@@ -30,8 +32,14 @@ export const USAGE = `testbed — cloud phones + the acceptance testbed in the R
30
32
  testbed verify login <session-id> --account <account-id>
31
33
  testbed verify reinstall <session-id> --apk-key fast/<id> --apk-sha256 HEX
32
34
 
35
+ testbed skill print the agent skill (SKILL.md)
36
+ testbed skill --install [-a AGENT...] [--global]
37
+ install for any runtime via "npx skills add" (Claude Code, Codex, Cursor, Gemini, OpenCode, ...)
38
+ testbed skill --copy-to DIR plain copy of SKILL.md (offline / unsupported runtime)
39
+
33
40
  testbed cases acceptance case library
34
41
  testbed run <case-id...> [--app --ref --apk --apk-run --release --by]
42
+ testbed run --all [...] the whole case table in one run (release smoke)
35
43
  testbed runs
36
44
  testbed show <run-id>
37
45
 
@@ -254,6 +262,36 @@ export async function main(argv, opts) {
254
262
  throw new Error("usage: testbed verify <start|show|renew|end|accounts|login|reinstall> …");
255
263
  }
256
264
  }
265
+ case "skill": {
266
+ const install = takeSwitch(args, "install");
267
+ const global = takeSwitch(args, "global") || takeSwitch(args, "g");
268
+ const copyTo = takeFlag(args, "copy-to");
269
+ const agents = [];
270
+ for (let i = 0; i < args.length;) {
271
+ if ((args[i] === "-a" || args[i] === "--agent") && args[i + 1]) { agents.push(args[i + 1]); args.splice(i, 2); } else i += 1;
272
+ }
273
+ const pkgDir = join(dirname(fileURLToPath(import.meta.url)), "..");
274
+ const src = join(pkgDir, "skills", "testbed", "SKILL.md");
275
+ const text = readFileSync(src, "utf8");
276
+ if (copyTo) {
277
+ mkdirSync(copyTo, { recursive: true });
278
+ writeFileSync(join(copyTo, "SKILL.md"), text);
279
+ out(`copied skill 'testbed' -> ${join(copyTo, "SKILL.md")}`);
280
+ return exit(0);
281
+ }
282
+ if (!install) { out(text); return exit(0); }
283
+ // The open Agent Skills installer (npm `skills`, vercel-labs/skills) knows
284
+ // every runtime's skills directory (77 agents on 2026-09-04) and keeps one
285
+ // canonical copy under .agents/skills with per-agent links. We hand it our
286
+ // package directory; it discovers skills/testbed/SKILL.md itself.
287
+ const argv = ["-y", "skills", "add", pkgDir, "--skill", "testbed", "-y", ...(global ? ["-g"] : []), ...agents.flatMap((a) => ["-a", a])];
288
+ const spawn = opts.spawnImpl ?? (await import("node:child_process")).spawnSync;
289
+ const r = spawn("npx", argv, { stdio: "inherit", env: { ...process.env, ...env } });
290
+ if (r.error) { err(`testbed: could not run npx skills (${r.error.message}); fall back to: testbed skill --copy-to <dir>`); return exit(1); }
291
+ if (r.status !== 0) { err(`testbed: npx skills add exited ${r.status}; fall back to: testbed skill --copy-to <dir>`); return exit(r.status ?? 1); }
292
+ out(`installed skill 'testbed' via npx skills${agents.length ? ` for ${agents.join(", ")}` : ""}${global ? " (global)" : ""}`);
293
+ return exit(0);
294
+ }
257
295
  case "cases": {
258
296
  const d = await api("GET", "/cases");
259
297
  emit(d, () => table(d.cases.map((c) => ({ id: c.id, tier: c.tier, rev: c.revision, title: c.title })), ["id", "tier", "rev", "title"], out));
@@ -266,8 +304,15 @@ export async function main(argv, opts) {
266
304
  const apkRun = takeFlag(args, "apk-run");
267
305
  const release = takeFlag(args, "release");
268
306
  const by = takeFlag(args, "by", env.USER || "testbed-cli");
269
- const caseIds = args.filter((a) => !a.startsWith("--"));
270
- if (!caseIds.length) throw new Error("usage: testbed run <case-id...> [--app slug] [--ref branch] [--apk url] [--apk-run ci-run-id] [--release id]");
307
+ const all = takeSwitch(args, "all");
308
+ let caseIds = args.filter((a) => !a.startsWith("--"));
309
+ if (all) {
310
+ if (caseIds.length) throw new Error("usage: --all takes no case ids");
311
+ const cat = await api("GET", "/cases");
312
+ caseIds = cat.cases.map((c) => c.id);
313
+ if (!caseIds.length) throw new Error("the case table is empty");
314
+ }
315
+ if (!caseIds.length) throw new Error("usage: testbed run <case-id...> | --all [--app slug] [--ref branch] [--apk url] [--apk-run ci-run-id] [--release id] [--by name]");
271
316
  const d = await api("POST", "/runs", { app_slug: app, case_ids: caseIds, apk_ref: apk, ref, apk_run_id: apkRun, hands_release_id: release, requested_by: by });
272
317
  emit(d, () => { out(`run ${d.run_id} — ${d.cases} case(s), ${d.dispatched} dispatched`); out(`${auth.apiBase}/runs/${d.run_id}`); });
273
318
  return exit(0);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botiverse/testbed-cli",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "CLI for testbed \u2014 cloud phones and the acceptance testbed in the Raft release loop",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -9,7 +9,8 @@
9
9
  },
10
10
  "files": [
11
11
  "bin",
12
- "lib"
12
+ "lib",
13
+ "skills"
13
14
  ],
14
15
  "engines": {
15
16
  "node": ">=20"
@@ -0,0 +1,84 @@
1
+ ---
2
+ name: testbed
3
+ description: "Use the testbed CLI to lease cloud Android phones, open human verification sessions, and run the acceptance case table for Raft app releases. Use when asked to smoke-test an APK, verify a build on a real device, hand a phone to a human verifier, or run release acceptance. Login is `testbed login` (managed Raft agents: PKCE through raft, no browser)."
4
+ ---
5
+
6
+ # testbed — cloud phones and release acceptance
7
+
8
+ The testbed is the acceptance gate for Raft app releases: a warm pool of cloud Android
9
+ phones, human verification sessions on those phones, and an acceptance case table that
10
+ runs against an APK. Everything is driven by the `testbed` CLI; the Raft manifest only
11
+ publishes `agent-login`.
12
+
13
+ ## Setup (once per seat)
14
+
15
+ ```sh
16
+ npm i -g @botiverse/testbed-cli # or: npx @botiverse/testbed-cli …
17
+ # non-root: npm config set prefix ~/.npm-global && export PATH="$HOME/.npm-global/bin:$PATH"
18
+ testbed login # managed Raft agent: no browser; human/CI: set TESTBED_TOKEN
19
+ testbed whoami # who the API thinks you are
20
+ ```
21
+
22
+ `TESTBED_URL` selects the instance (default https://testbed.botiverse.build). Add `--json`
23
+ to any command to get the raw API body for parsing.
24
+
25
+ ## Workflow 1 — run the acceptance table against a build
26
+
27
+ ```sh
28
+ testbed cases # the case table (id, tier, revision, title)
29
+ testbed run --all --apk <download-url> --release <id> # whole table in one run (release smoke)
30
+ testbed run <case-id...> --apk <download-url> # a subset
31
+ testbed runs # recent runs
32
+ testbed show <run-id> # per-case verdicts, turns, evidence
33
+ ```
34
+
35
+ Release SOP rules (artin, 2026-09-04): the default 3-step smoke (login, channel list,
36
+ open a channel) is NOT a full smoke. A release smoke is `run --all`. A cell that was not
37
+ run is recorded as `NOT RUN` with the reason (hotfix exemption: `NOT RUN·hotfix 豁免`),
38
+ never left blank. Receipts bind `versionCode + SHA-256`, not just the version name.
39
+
40
+ ## Workflow 2 — hand a phone to a human verifier
41
+
42
+ ```sh
43
+ testbed verify start --ttl 30 --app-ref "1.11.0 (1110001)" \
44
+ [--apk-key fast/<id> --apk-sha256 <hex>] # pre-install an APK; key and sha go together
45
+ # prints the verify page URL — give it to the human
46
+ testbed verify accounts <session-id> # QA accounts they may log in with
47
+ testbed verify login <session-id> --account <account-id>
48
+ testbed verify renew <session-id> --minutes 30
49
+ testbed verify reinstall <session-id> --apk-key fast/<id> --apk-sha256 <hex>
50
+ testbed verify end <session-id> # always end; the device is reclaimed
51
+ ```
52
+
53
+ One verification session is live at a time; `start` answers 409 when another is live.
54
+ `show` on an ended session answers 410.
55
+
56
+ ## Workflow 3 — drive a phone yourself over ADB
57
+
58
+ ```sh
59
+ testbed pool # free / leased
60
+ testbed acquire --ttl 20 --key-out ./adb.key # lease; private key written 0600, never printed
61
+ adb connect <adb_endpoint> # from the acquire output
62
+ testbed renew <lease-id> --ttl 20
63
+ testbed diagnose-adb <lease-id> [--pubkey-sha256 <hex>]
64
+ testbed release <lease-id> # always release
65
+ ```
66
+
67
+ Device safety (non-negotiable):
68
+ - Never paste `adb_private_key` into Raft messages, tasks, logs, or artifacts.
69
+ - Before connecting, derive the saved key's android_pubkey SHA-256 and require it to equal
70
+ `adb_public_key_sha256` from the acquire response; on mismatch, release and do not connect.
71
+ - Release every lease. Devices in `reclaiming` / `reclaim_failed` are never yours.
72
+ - Cloud-phone screenshots can return a stale frame: to prove "nothing changed", the
73
+ capture must include something that would have changed if it were live.
74
+
75
+ ## Errors
76
+
77
+ - `401` → `testbed login` (agent) or check `TESTBED_TOKEN` (human/CI).
78
+ - `409 POOL_EXHAUSTED` / `ANOTHER_SESSION_LIVE` → wait or end/release what you hold.
79
+ - `410` on a verification session → it is over; start a new one.
80
+
81
+ ## Reference
82
+
83
+ `GET /api/help` on the instance lists every command and the raw routes behind them.
84
+ Manifest action for Raft Agent Login: `agent-login` (the CLI performs it for you).