@botiverse/testbed-cli 0.4.0 → 0.5.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.
Files changed (2) hide show
  1. package/lib/main.mjs +75 -7
  2. package/package.json +1 -1
package/lib/main.mjs CHANGED
@@ -15,8 +15,10 @@ export const USAGE = `testbed — cloud phones + the acceptance testbed in the R
15
15
  testbed whoami who the API thinks you are
16
16
 
17
17
  testbed pool warm-pool devices and free/leased state
18
- testbed acquire [--ttl MIN] [--instance ID] [--request-id ID] [--key-out FILE]
19
- lease a phone; the ADB private key goes to --key-out (0600) or --json
18
+ testbed acquire [--ttl MIN] [--instance ID] [--request-id ID] [--key-out FILE] [--wait-ms N]
19
+ lease a phone; waits through in_flight (same request id) up to --wait-ms (90000);
20
+ the ADB private key goes to --key-out (0600) or --json
21
+ testbed <command> --help usage only — never sends a request
20
22
  testbed devices your leases
21
23
  testbed renew <lease-id> [--ttl MIN]
22
24
  testbed release <lease-id>
@@ -37,6 +39,12 @@ export const USAGE = `testbed — cloud phones + the acceptance testbed in the R
37
39
  install for any runtime via "npx skills add" (Claude Code, Codex, Cursor, Gemini, OpenCode, ...)
38
40
  testbed skill --copy-to DIR plain copy of SKILL.md (offline / unsupported runtime)
39
41
 
42
+ testbed qa-account list QA account pool (labels, environments; never secrets)
43
+ testbed qa-account get <label> [--env staging|production] [--out FILE]
44
+ one QA login for a device; credential goes to --out (0600) or --json
45
+ testbed qa-account add <label> --env staging|production --username EMAIL
46
+ owner only; password is read from stdin, never from an argument
47
+
40
48
  testbed cases acceptance case library
41
49
  testbed run <case-id...> [--app --ref --apk --apk-run --release --by]
42
50
  testbed run --all [...] the whole case table in one run (release smoke)
@@ -66,6 +74,11 @@ function table(rows, cols, out) {
66
74
  for (const r of rows) out(cols.map((c, i) => String(r[c] ?? "").padEnd(w[i])).join(" "));
67
75
  }
68
76
 
77
+ async function readStdin(stream = process.stdin) {
78
+ if (stream.isTTY) return "";
79
+ let data = ""; for await (const chunk of stream) data += chunk; return data;
80
+ }
81
+
69
82
  function writeKeyFile(path, key) {
70
83
  const fd = openSync(path, "wx", 0o600);
71
84
  try { writeSync(fd, key.endsWith("\n") ? key : key + "\n"); } finally { closeSync(fd); }
@@ -84,6 +97,10 @@ export async function main(argv, opts) {
84
97
  const env = opts.env ?? process.env;
85
98
  const exit = opts.exit ?? ((code) => process.exit(code));
86
99
  const json = takeSwitch(args, "json");
100
+ // `--help` / `-h` anywhere prints usage and NEVER reaches the API. 2026-09-05:
101
+ // `testbed acquire --help` acquired a device (HuaTuo) because flags were
102
+ // only consumed by the command that expected them.
103
+ if (args.includes("--help") || args.includes("-h")) { out(USAGE); return exit(0); }
87
104
  const cmd = args.shift();
88
105
  const emit = (data, human) => { if (json) out(JSON.stringify(data, null, 2)); else human(); };
89
106
 
@@ -137,11 +154,24 @@ export async function main(argv, opts) {
137
154
  const body = {};
138
155
  if (ttl !== undefined) body.ttl_minutes = Number(ttl);
139
156
  if (instance) body.instance_id = instance;
140
- if (requestId) body.request_id = requestId;
141
- const d = await api("POST", "/device-broker/leases", body);
142
- if (d.status === "in_flight") {
143
- emit(d, () => out(`in_flight: request ${d.request_id} on ${d.instance_id ?? "?"} retry acquire with --request-id ${d.request_id}`));
144
- return exit(2);
157
+ // Always send a request id so a retry never takes a second device: the
158
+ // broker answers 202 in_flight (retryable) while it is still preparing
159
+ // the lease, and the SAME request id retried returns the acquired lease
160
+ // with its one-time key. 2026-09-05 (HuaTuo): the old CLI printed the
161
+ // in_flight receipt and exited 2, leaving a lease held with no key.
162
+ body.request_id = requestId ?? `testbed-cli-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
163
+ const waitMs = Number(takeFlag(args, "wait-ms", "90000"));
164
+ const sleep = opts.sleepImpl ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
165
+ const started = Date.now();
166
+ let d = await api("POST", "/device-broker/leases", body);
167
+ while (d.status === "in_flight") {
168
+ if (Date.now() - started > waitMs) {
169
+ err(`testbed: acquire still in_flight after ${Math.round(waitMs / 1000)}s (request ${body.request_id}${d.instance_id ? ` on ${d.instance_id}` : ""}); the broker may still hold a lease for this request — run \`testbed devices\` and release it, or retry with --request-id ${body.request_id}`);
170
+ return exit(2);
171
+ }
172
+ if (!json) err(`testbed: in_flight${d.instance_id ? ` on ${d.instance_id}` : ""}, retrying with the same request id…`);
173
+ await sleep(2000);
174
+ d = await api("POST", "/device-broker/leases", body);
145
175
  }
146
176
  let keyPath = null;
147
177
  if (keyOut && d.adb_private_key) {
@@ -292,6 +322,44 @@ export async function main(argv, opts) {
292
322
  out(`installed skill 'testbed' via npx skills${agents.length ? ` for ${agents.join(", ")}` : ""}${global ? " (global)" : ""}`);
293
323
  return exit(0);
294
324
  }
325
+ case "qa-account": {
326
+ const sub = args.shift();
327
+ switch (sub) {
328
+ case "list": {
329
+ const d = await api("GET", "/device-broker/qa-accounts");
330
+ const rows = d.accounts ?? [];
331
+ emit(d, () => rows.length
332
+ ? table(rows.map((a) => ({ env: a.environment, label: a.label, email: a.login_email ?? "-", visibility: a.visibility ?? "-", status: a.status ?? "-" })), ["env", "label", "email", "visibility", "status"], out)
333
+ : out("no QA accounts visible to you"));
334
+ return exit(0);
335
+ }
336
+ case "get": {
337
+ const label = args[0]; if (!label) throw new Error("usage: testbed qa-account get <label> [--env ENV] [--out FILE]");
338
+ const envName = takeFlag(args, "env"); const outPath = takeFlag(args, "out");
339
+ const q = envName ? `?environment=${encodeURIComponent(envName)}` : "";
340
+ const d = await api("GET", `/device-broker/qa-account/${encodeURIComponent(label)}${q}`);
341
+ if (json) { out(JSON.stringify(d, null, 2)); return exit(0); }
342
+ if (outPath) {
343
+ writeKeyFile(outPath, `${d.username ?? d.login_email ?? ""}\n${d.password ?? ""}\n`);
344
+ out(`qa account ${d.label ?? label} (${d.environment ?? envName ?? "?"}): username ${d.username ?? d.login_email ?? "?"}; credential written to ${outPath} (0600)`);
345
+ } else {
346
+ out(`qa account ${d.label ?? label} (${d.environment ?? envName ?? "?"}): username ${d.username ?? d.login_email ?? "?"}; password not shown — pass --out FILE or --json`);
347
+ }
348
+ return exit(0);
349
+ }
350
+ case "add": {
351
+ const label = args[0]; const envName = takeFlag(args, "env"); const username = takeFlag(args, "username");
352
+ if (!label || !envName || !username) throw new Error("usage: testbed qa-account add <label> --env ENV --username EMAIL (password on stdin)");
353
+ const password = (await readStdin(opts.stdin)).replace(/\r?\n$/, "");
354
+ if (!password) throw new Error("password must be provided on stdin (e.g. printf '%s' \"$PW\" | testbed qa-account add …)");
355
+ const d = await api("PUT", `/device-broker/qa-account/${encodeURIComponent(label)}`, { environment: envName, username, password });
356
+ emit(d, () => out(`qa account ${d.label} (${d.environment}) ${d.secret_version > 1 ? "rotated" : "created"}, secret version ${d.secret_version}`));
357
+ return exit(0);
358
+ }
359
+ default:
360
+ throw new Error("usage: testbed qa-account <list|get|add> …");
361
+ }
362
+ }
295
363
  case "cases": {
296
364
  const d = await api("GET", "/cases");
297
365
  emit(d, () => table(d.cases.map((c) => ({ id: c.id, tier: c.tier, rev: c.revision, title: c.title })), ["id", "tier", "rev", "title"], out));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botiverse/testbed-cli",
3
- "version": "0.4.0",
3
+ "version": "0.5.1",
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",