@qaping/cli 0.1.5 → 0.3.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/bin/qaping +606 -5
- package/docs/COMMANDS.md +122 -0
- package/docs/QA-PLAN-FORMAT.md +53 -2
- package/docs/icon.svg +1 -0
- package/package.json +2 -2
- package/skill/SHIPPED-HASHES.json +19 -0
- package/skill/qaping/SKILL.md +246 -58
package/bin/qaping
CHANGED
|
@@ -11,7 +11,11 @@
|
|
|
11
11
|
// qaping setup [claude-code|cursor|codex] [--force] one-command onboarding
|
|
12
12
|
// qaping remove|uninstall [--client <c>] sweep ONLY qaping's state
|
|
13
13
|
// qaping publish-build <game.zip> --platform windows|macos
|
|
14
|
+
// qaping builds [rm <slug>] what is hosted, and free a slot
|
|
14
15
|
// qaping wait <ping_id> [--timeout <seconds>]
|
|
16
|
+
// qaping results <ping_id> one passive fetch, no blocking
|
|
17
|
+
// qaping rounds [add|rm] the parked rounds this repo is owed
|
|
18
|
+
// qaping issues <game> the game's issue board, read-only
|
|
15
19
|
// qaping whoami | version | help
|
|
16
20
|
"use strict";
|
|
17
21
|
const fs = require("fs");
|
|
@@ -47,9 +51,12 @@ function resolveKitDir() {
|
|
|
47
51
|
// writes it to ~/.claude/rules/qaping.md and ~/.cursor/rules/qaping.mdc.
|
|
48
52
|
const RULE_BODY = `This machine has qaping: automatic QA for games — real human playtesters play the developer's builds on their own hardware and report back, and the coding agent runs the whole loop.
|
|
49
53
|
Whenever the user asks to set up QA for a game, run QA on a patch, check whether a change broke anything, or playtest a game or build, load the qaping skill and follow it — it owns authoring/maintaining QA-PLAN.md in the game repo and the per-patch run.
|
|
50
|
-
Builds ship with \`qaping publish-build <game.zip> --platform windows|macos\`; the printed /b/<slug> URL is what a round is filed against (store-delivered games file with their Steam/TestFlight/App Store URL instead).
|
|
51
|
-
The MCP tools are qaping_playtest (file a round with real human playtesters), qaping_results (fetch a
|
|
52
|
-
|
|
54
|
+
Builds ship with \`qaping publish-build <game.zip> --platform windows|macos\`; the printed /b/<slug> URL is what a round is filed against (store-delivered games file with their Steam/TestFlight/App Store URL instead). Hosted builds are kept until you delete them (pass \`--expires-in-hours <n>\` to have one reaped instead) and capped per account — if a publish is refused for that cap, \`qaping builds\` lists them and \`qaping builds rm <slug>\` frees a slot; free one and carry on rather than stopping to ask, taking the ones marked as never-finished uploads first and never deleting a build a round you filed is still running against.
|
|
55
|
+
The MCP tools are qaping_playtest (file a round with real human playtesters), qaping_results (fetch a round, free), qaping_wait (continue a pending round) and qaping_issues (the game's issue board — file a bug, claim a fix, close or reopen; free).
|
|
56
|
+
Bugs live on that board, not in chat or in a report nobody re-reads: file EVERY playtest with \`game\` set to the QA plan's game_slug, so the game's open and claimed-fixed issues ride the round as the playtester's checklist. You can only ever CLAIM a fix (qaping_issues action:'fixed', with the exact build URL the next round will play, claimed BEFORE that round is filed) — only a playtester answering "Gone" on a later round marks one verified, and a "Still here" on a claimed fix regresses it. \`qaping issues <game>\` reads the same board from the shell.
|
|
57
|
+
Decide at filing time how the round ends: a short round waits in place — the moment filing returns pending, spawn a background task that loops qaping_wait and keep working (foreground polling is the fallback); a long or overnight round is PARKED — file it with an explicit long deadline_seconds, record it immediately with \`qaping rounds add <ping_id>\` (it writes qa-open-rounds.json beside QA-PLAN.md), tell the user exactly what is pending, and end the turn; a later session collects it (\`qaping rounds\` lists them with live status, qaping_results fetches one).
|
|
58
|
+
A recorded parked round is an honest pending end — never report pending as the answer without one, and never end with an unbacked promise to check later.
|
|
59
|
+
Playtests are duration-billed at 2 credits per minute of play per playtester — state the estimated cost before filing. Windows rounds return a recording, and a transcript only when the tester recorded with our recorder app (read transcript_status on each result — a Game Bar session has no transcript); web rounds return answers only (no recording, no transcript) — never promise evidence a session does not ship.
|
|
53
60
|
QA memory: whenever the developer mentions — in ANY conversation, not just QA runs — something that should always be tested, a fragile area, or a QA lesson learned, offer to record it as a check (or a note on an existing check) in QA-PLAN.md so it is tested from then on.
|
|
54
61
|
`;
|
|
55
62
|
|
|
@@ -217,6 +224,15 @@ function cmdPublishBuild(argv) {
|
|
|
217
224
|
.main(argv.slice(1), { brandCommand: "qaping publish-build", nextStepToolName: "qaping_playtest" });
|
|
218
225
|
}
|
|
219
226
|
|
|
227
|
+
// The management half of publish-build: an account holds a small number of
|
|
228
|
+
// hosted builds at once, and until this existed a QA loop that hit that cap
|
|
229
|
+
// could not even name the builds holding the slots — it stopped and asked a
|
|
230
|
+
// human. Same brand seam: every remedy this prints says `qaping`.
|
|
231
|
+
function cmdBuilds(argv) {
|
|
232
|
+
return require(path.join(resolveKitDir(), "harness", "builds.js"))
|
|
233
|
+
.main(argv.slice(1), { brandCommand: "qaping builds" });
|
|
234
|
+
}
|
|
235
|
+
|
|
220
236
|
function cmdPublish(argv) {
|
|
221
237
|
// WEB games: their "build" is a static directory, hosted (not zipped) — the
|
|
222
238
|
// kit's hosted-draft publisher, brand-seamed like publish-build above. The
|
|
@@ -225,14 +241,580 @@ function cmdPublish(argv) {
|
|
|
225
241
|
.main(argv.slice(1), { brandCommand: "qaping publish" });
|
|
226
242
|
}
|
|
227
243
|
|
|
244
|
+
// ─── parked rounds: the state a LATER session collects from ─────────────────
|
|
245
|
+
//
|
|
246
|
+
// A playtest legitimately runs for hours, so an agent that files one has two
|
|
247
|
+
// honest endings: wait in place (the background qaping_wait chain), or PARK —
|
|
248
|
+
// file with an explicit long deadline, record the round, end the turn. This
|
|
249
|
+
// file is that record: OPEN state, mutable, one entry per round still owed,
|
|
250
|
+
// sitting beside QA-PLAN.md at the game repo root. It is NOT qa-rounds.jsonl —
|
|
251
|
+
// that ledger is append-only history written AFTER results land, and the two
|
|
252
|
+
// are never merged. Signed URLs never go in here; they expire in days and the
|
|
253
|
+
// record has to outlive the session that wrote it.
|
|
254
|
+
const ROUNDS_FILE = "qa-open-rounds.json";
|
|
255
|
+
// A repo owed more open rounds than this is a runaway loop, not a QA plan. The
|
|
256
|
+
// listing spends one passive fetch per round, so this is also the ceiling on
|
|
257
|
+
// what `qaping rounds` costs in requests.
|
|
258
|
+
const MAX_OPEN_ROUNDS = 20;
|
|
259
|
+
const PING_ID_RE = /^[0-9a-f-]{36}$/i;
|
|
260
|
+
// The issue board's game slug. Mirrors lib/qa-issues.ts's GAME_SLUG_RE and
|
|
261
|
+
// migration 066's column check exactly — a slug this refuses is a slug the
|
|
262
|
+
// service refuses, and finding that out before the network is the point.
|
|
263
|
+
const GAME_SLUG_RE = /^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/;
|
|
264
|
+
const DEFAULT_APP_URL = "https://pingfusi.com";
|
|
265
|
+
|
|
266
|
+
const ROUNDS_USAGE = `usage:
|
|
267
|
+
qaping rounds [--json] [--offline] [--file <path>]
|
|
268
|
+
qaping rounds add <ping_id> [--platform windows|macos|ios] [--build <url>]
|
|
269
|
+
[--minutes N] [--deadline-seconds N]
|
|
270
|
+
[--game <slug>] [--checks a,b,c]
|
|
271
|
+
[--note "<patch>"] [--json]
|
|
272
|
+
qaping rounds rm <ping_id> [--json]`;
|
|
273
|
+
|
|
274
|
+
function flagValue(argv, name) {
|
|
275
|
+
const i = argv.indexOf(name);
|
|
276
|
+
return i >= 0 ? argv[i + 1] : undefined;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function intFlag(argv, name) {
|
|
280
|
+
const raw = flagValue(argv, name);
|
|
281
|
+
if (raw === undefined) return null;
|
|
282
|
+
const n = Number.parseInt(raw, 10);
|
|
283
|
+
return Number.isFinite(n) && n > 0 ? n : null;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function roundsFilePath(argv) {
|
|
287
|
+
const override = flagValue(argv, "--file");
|
|
288
|
+
return override ? path.resolve(override) : path.join(process.cwd(), ROUNDS_FILE);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Tolerant on read, strict on shape: an entry without a ping_id is not a round
|
|
292
|
+
// anyone can collect, so it is dropped rather than rendered as a mystery.
|
|
293
|
+
//
|
|
294
|
+
// A bare top-level ARRAY is normalized, not refused: the skill and the mount
|
|
295
|
+
// prose both sanction hand-writing this file, and a list of records is the
|
|
296
|
+
// likeliest thing a hand writes. ANY OTHER shape is a loud failure — reading an
|
|
297
|
+
// unrecognized document as "no rounds" makes the next `rounds add` overwrite
|
|
298
|
+
// records nobody ever saw, which is data loss dressed as a fresh start.
|
|
299
|
+
function parseRounds(raw) {
|
|
300
|
+
let data;
|
|
301
|
+
try { data = JSON.parse(raw); }
|
|
302
|
+
catch { throw new Error(`${ROUNDS_FILE} is not valid JSON — fix or delete it`); }
|
|
303
|
+
const rounds = Array.isArray(data) ? data
|
|
304
|
+
: (data && Array.isArray(data.rounds) ? data.rounds : null);
|
|
305
|
+
if (!rounds) {
|
|
306
|
+
throw new Error(`${ROUNDS_FILE} is valid JSON but not a rounds file — expected {"rounds": [ … ]} (a bare array of records is also read). Refusing to overwrite it; fix or delete it.`);
|
|
307
|
+
}
|
|
308
|
+
return { rounds: rounds.filter((r) => r && typeof r.ping_id === "string") };
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** null when the file does not exist — "no rounds recorded here" is not an error. */
|
|
312
|
+
function readRoundsFile(file) {
|
|
313
|
+
let raw;
|
|
314
|
+
try { raw = fs.readFileSync(file, "utf8"); }
|
|
315
|
+
catch { return null; }
|
|
316
|
+
return parseRounds(raw);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// Write via tmp-then-rename: rename(2) is atomic on POSIX, so a reader (or a
|
|
320
|
+
// crash) sees the whole old file or the whole new one, never a truncated one.
|
|
321
|
+
function writeRoundsFile(file, state) {
|
|
322
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
323
|
+
fs.writeFileSync(tmp, JSON.stringify({ rounds: state.rounds }, null, 2) + "\n");
|
|
324
|
+
try { fs.renameSync(tmp, file); }
|
|
325
|
+
catch (e) { try { fs.unlinkSync(tmp); } catch { /* best effort */ } throw e; }
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// ── the write lock ──────────────────────────────────────────────────────────
|
|
329
|
+
//
|
|
330
|
+
// Every mutation is a read-modify-write, and the two callers that run
|
|
331
|
+
// unattended — an agent recording a fresh round while a background poller
|
|
332
|
+
// removes a collected one — race. Unlocked, the loser's record is simply gone.
|
|
333
|
+
// A lockfile (O_EXCL create, the one atomic primitive every filesystem gives)
|
|
334
|
+
// serializes them; dependency-free, because a QA repo must not grow one.
|
|
335
|
+
const LOCK_RETRIES = 10;
|
|
336
|
+
const LOCK_RETRY_MS = 50;
|
|
337
|
+
const LOCK_STALE_MS = 5000;
|
|
338
|
+
|
|
339
|
+
/** Blocking sleep with no dependency and no busy-spin (allowed on Node's main thread). */
|
|
340
|
+
function sleepSync(ms) {
|
|
341
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function acquireRoundsLock(file) {
|
|
345
|
+
const lock = `${file}.lock`;
|
|
346
|
+
for (let attempt = 0; attempt < LOCK_RETRIES; attempt++) {
|
|
347
|
+
try { return fs.openSync(lock, "wx"); }
|
|
348
|
+
catch (e) {
|
|
349
|
+
if (!e || e.code !== "EEXIST") throw e;
|
|
350
|
+
// A killed writer must not wedge the repo forever — an old lock is stolen,
|
|
351
|
+
// loudly, because a stolen lock is also how a real concurrent write is lost.
|
|
352
|
+
let age = 0;
|
|
353
|
+
try { age = Date.now() - fs.statSync(lock).mtimeMs; } catch { age = 0; }
|
|
354
|
+
if (age > LOCK_STALE_MS) {
|
|
355
|
+
console.error(`⚠ stale lock on ${ROUNDS_FILE} (${Math.round(age / 1000)}s old) — taking it over`);
|
|
356
|
+
try { fs.unlinkSync(lock); } catch { /* someone else got there first */ }
|
|
357
|
+
continue;
|
|
358
|
+
}
|
|
359
|
+
sleepSync(LOCK_RETRY_MS);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
throw new Error(`${ROUNDS_FILE} is locked by another qaping process — nothing was written; try again in a moment`);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function releaseRoundsLock(file, fd) {
|
|
366
|
+
try { fs.closeSync(fd); } catch { /* best effort */ }
|
|
367
|
+
try { fs.unlinkSync(`${file}.lock`); } catch { /* best effort */ }
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* The ONE read-modify-write path. `mutate(state)` returns `{ state, …extras }`;
|
|
372
|
+
* a null/absent `state` means "decided not to write" (and never truncates).
|
|
373
|
+
* Both the read and the write happen inside the lock — the point of the lock.
|
|
374
|
+
*/
|
|
375
|
+
function mutateRoundsFile(file, mutate) {
|
|
376
|
+
const fd = acquireRoundsLock(file);
|
|
377
|
+
try {
|
|
378
|
+
const out = mutate(readRoundsFile(file) || { rounds: [] }) || {};
|
|
379
|
+
if (out.state) writeRoundsFile(file, out.state);
|
|
380
|
+
return out;
|
|
381
|
+
} finally {
|
|
382
|
+
releaseRoundsLock(file, fd);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function upsertRound(state, record) {
|
|
387
|
+
const rounds = state.rounds.slice();
|
|
388
|
+
const i = rounds.findIndex((r) => r.ping_id === record.ping_id);
|
|
389
|
+
if (i >= 0) rounds[i] = record; else rounds.push(record);
|
|
390
|
+
return { rounds };
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function removeRound(state, pingId) {
|
|
394
|
+
const rounds = state.rounds.filter((r) => r.ping_id !== pingId);
|
|
395
|
+
return { state: { rounds }, removed: rounds.length !== state.rounds.length };
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// How long the round stays offered to NEW players: the explicit deadline the
|
|
399
|
+
// filing set, counted from when it was filed. Absent either fact there is
|
|
400
|
+
// nothing honest to print, so nothing is printed.
|
|
401
|
+
function visibleUntil(record) {
|
|
402
|
+
const filed = Date.parse(record.filed_at || "");
|
|
403
|
+
if (!Number.isFinite(filed) || !record.deadline_seconds) return null;
|
|
404
|
+
return new Date(filed + record.deadline_seconds * 1000).toISOString();
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function roundStatusLine(record, live) {
|
|
408
|
+
const status = live.status || "pending";
|
|
409
|
+
if (status === "complete") return `READY — collect now: qaping results ${record.ping_id}`;
|
|
410
|
+
if (status === "pending") {
|
|
411
|
+
const seen = `${live.n_received ?? 0}/${live.n_target ?? "?"}`;
|
|
412
|
+
const until = visibleUntil(record);
|
|
413
|
+
return `still pending (${seen})${until ? ` — visible until ${until}` : ""}; keep waiting: qaping wait ${record.ping_id}`;
|
|
414
|
+
}
|
|
415
|
+
// One round's own failure, not the run's: the rest of the listing is real.
|
|
416
|
+
if (status === "unavailable") {
|
|
417
|
+
return `status unavailable (${live.error || "the service refused this id"}) — the other rounds are unaffected; retry: qaping results ${record.ping_id}`;
|
|
418
|
+
}
|
|
419
|
+
// Results are asker-scoped server-side, so a round filed under a DIFFERENT
|
|
420
|
+
// login reads exactly like one that never existed. Never prescribe deleting
|
|
421
|
+
// the only record of a round on that evidence.
|
|
422
|
+
if (status === "not_found") {
|
|
423
|
+
return `not visible from this login (wrong account?) or expired — verify before \`qaping rounds rm ${record.ping_id}\` (\`qaping whoami\` shows which login this is)`;
|
|
424
|
+
}
|
|
425
|
+
return `expired or unreadable (${status}) — report it, then: qaping rounds rm ${record.ping_id}`;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* Pure render of the listing. entries: [{ record, live }], live null when unfetched.
|
|
430
|
+
* opts.total is the TRUE recorded count — entries may be a capped slice of it, and
|
|
431
|
+
* a count line that reports the slice as the total is a lie a later session acts on.
|
|
432
|
+
*/
|
|
433
|
+
function renderRounds(entries, opts) {
|
|
434
|
+
const o = opts || {};
|
|
435
|
+
const total = Number.isFinite(o.total) ? o.total : entries.length;
|
|
436
|
+
const notice = o.notice || (o.offline ? "(live status unavailable — offline)" : null);
|
|
437
|
+
const lines = [total > entries.length
|
|
438
|
+
? `${total} open round(s) recorded in ${ROUNDS_FILE} — showing live status for first ${entries.length} of ${total}:`
|
|
439
|
+
: `${total} open round(s) recorded in ${ROUNDS_FILE}:`];
|
|
440
|
+
if (notice) lines.push(notice);
|
|
441
|
+
for (const { record, live } of entries) {
|
|
442
|
+
const facts = [
|
|
443
|
+
record.platform || null,
|
|
444
|
+
record.est_minutes ? `${record.est_minutes} min` : null,
|
|
445
|
+
record.filed_at ? `filed ${record.filed_at}` : null,
|
|
446
|
+
].filter(Boolean).join(" · ");
|
|
447
|
+
lines.push("");
|
|
448
|
+
lines.push(` ${record.ping_id}${facts ? ` — ${facts}` : ""}`);
|
|
449
|
+
if (record.build) lines.push(` build: ${record.build}`);
|
|
450
|
+
// Which board's issues rode this round — the later session that collects
|
|
451
|
+
// it needs the slug to read what moved (`qaping issues <game>`).
|
|
452
|
+
if (record.game) lines.push(` game: ${record.game}`);
|
|
453
|
+
if (Array.isArray(record.checks) && record.checks.length) lines.push(` checks: ${record.checks.join(", ")}`);
|
|
454
|
+
if (record.note) lines.push(` patch: ${record.note}`);
|
|
455
|
+
if (live) lines.push(` ${roundStatusLine(record, live)}`);
|
|
456
|
+
}
|
|
457
|
+
return lines.join("\n");
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// The app base these passive fetches use. `qaping wait` rides the vendored
|
|
461
|
+
// installer, which honors two kit-side env names besides this brand's own — so
|
|
462
|
+
// resolving a different base here would make two verbs disagree about which
|
|
463
|
+
// service a staging machine talks to. Same order as the vendor's. The second
|
|
464
|
+
// name is spelled from parts on purpose: it is a wire-only env name, and the
|
|
465
|
+
// reviewer-side brand never appears literally on a dev-facing surface (this
|
|
466
|
+
// package's own leak scan is what enforces that).
|
|
467
|
+
const LEGACY_APP_URL_ENVS = [["PING", "HUMANS", "_APP_URL"].join(""), "PINGFUSI_APP_URL"];
|
|
468
|
+
|
|
469
|
+
function resolveAppUrl() {
|
|
470
|
+
const fromEnv = LEGACY_APP_URL_ENVS.map((name) => process.env[name]).find(Boolean);
|
|
471
|
+
return (qapingWrapper().appUrl || fromEnv || DEFAULT_APP_URL).replace(/\/+$/, "");
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// wire.js's resolveToken reads ~/.claude.json under the WIRE CONTRACT's MCP
|
|
475
|
+
// server keys — a list that predates this brand and does not contain `qaping`.
|
|
476
|
+
// A machine whose only login is the qaping MCP entry (written by the client's
|
|
477
|
+
// own config, with no credentials stash) therefore reads as logged out. This
|
|
478
|
+
// mirrors the vendored installer's resolveLocalToken minimally, bin-local, so
|
|
479
|
+
// the kit stays untouched: the qaping key first, then the contract's own order
|
|
480
|
+
// — which supplies the legacy names without spelling a retired brand here.
|
|
481
|
+
function resolveTokenFromClientConfigs(kitDir) {
|
|
482
|
+
const keys = ["qaping"];
|
|
483
|
+
try {
|
|
484
|
+
const contract = require(path.join(kitDir, "packages", "core", "wire-contract.gen.js"));
|
|
485
|
+
for (const k of contract.MCP_SERVER_KEYS || []) if (!keys.includes(k)) keys.push(k);
|
|
486
|
+
} catch { keys.push("pingfusi"); }
|
|
487
|
+
const home = os.homedir();
|
|
488
|
+
const desktop = process.platform === "darwin"
|
|
489
|
+
? path.join(home, "Library", "Application Support", "Claude", "claude_desktop_config.json")
|
|
490
|
+
: process.platform === "win32"
|
|
491
|
+
? path.join(process.env.APPDATA || path.join(home, "AppData", "Roaming"), "Claude", "claude_desktop_config.json")
|
|
492
|
+
: path.join(home, ".config", "Claude", "claude_desktop_config.json");
|
|
493
|
+
for (const p of [path.join(home, ".claude.json"), desktop, path.join(home, ".cursor", "mcp.json")]) {
|
|
494
|
+
try {
|
|
495
|
+
const servers = (JSON.parse(fs.readFileSync(p, "utf8")) || {}).mcpServers || {};
|
|
496
|
+
for (const key of keys) {
|
|
497
|
+
const headers = (servers[key] || {}).headers || {};
|
|
498
|
+
const m = /Bearer\s+(\S+)/.exec(headers.Authorization || headers.authorization || "");
|
|
499
|
+
if (m) return m[1];
|
|
500
|
+
}
|
|
501
|
+
} catch { /* absent or unreadable config: just not a login source */ }
|
|
502
|
+
}
|
|
503
|
+
return null;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// One passive tools/call against the qaping mount, for ANY tool it registers —
|
|
507
|
+
// the same transport and the same JSON-or-SSE parse the kit's `wait` leg uses,
|
|
508
|
+
// with the mount read from the ONE wrapper object every other command is driven
|
|
509
|
+
// by. Passive: it never renews a round's lease, which is exactly why a parked
|
|
510
|
+
// round needs its own explicit deadline. Failures are TYPED for the caller:
|
|
511
|
+
// `authRejected` and `perRound` separate "this login is dead" from "the service
|
|
512
|
+
// refused this one call".
|
|
513
|
+
async function callQapingTool(name, args) {
|
|
514
|
+
const w = qapingWrapper();
|
|
515
|
+
const kitDir = resolveKitDir();
|
|
516
|
+
const { resolveToken } = require(path.join(kitDir, "packages", "core", "wire.js"));
|
|
517
|
+
const token = resolveToken() || resolveTokenFromClientConfigs(kitDir);
|
|
518
|
+
if (!token) throw new Error("no qaping login on this machine — run `qaping setup` first");
|
|
519
|
+
let res;
|
|
520
|
+
try {
|
|
521
|
+
res = await fetch(`${resolveAppUrl()}${w.mcpPath}`, {
|
|
522
|
+
method: "POST",
|
|
523
|
+
headers: {
|
|
524
|
+
"content-type": "application/json",
|
|
525
|
+
accept: "application/json, text/event-stream",
|
|
526
|
+
authorization: `Bearer ${token}`,
|
|
527
|
+
},
|
|
528
|
+
body: JSON.stringify({
|
|
529
|
+
jsonrpc: "2.0",
|
|
530
|
+
id: 1,
|
|
531
|
+
method: "tools/call",
|
|
532
|
+
params: { name, arguments: args },
|
|
533
|
+
}),
|
|
534
|
+
signal: AbortSignal.timeout(20_000),
|
|
535
|
+
});
|
|
536
|
+
} catch (e) {
|
|
537
|
+
throw new Error(`could not reach the qaping service (${(e && e.message) || e})`);
|
|
538
|
+
}
|
|
539
|
+
// A rejected login is a fact about the RUN, not about the round: the body is
|
|
540
|
+
// plain text, so without this it parses as garbage and reads as "offline",
|
|
541
|
+
// sending the developer to debug their network instead of their login.
|
|
542
|
+
if (res.status === 401 || res.status === 403) {
|
|
543
|
+
const e = new Error(`qaping login rejected (HTTP ${res.status}) — run \`qaping setup\` to re-link this machine`);
|
|
544
|
+
e.authRejected = true;
|
|
545
|
+
throw e;
|
|
546
|
+
}
|
|
547
|
+
const raw = await res.text();
|
|
548
|
+
const m = raw.match(/data: (.*)/);
|
|
549
|
+
let payload;
|
|
550
|
+
try { payload = JSON.parse(m ? m[1] : raw); }
|
|
551
|
+
catch { throw new Error(`unreadable response from the qaping service (HTTP ${res.status})`); }
|
|
552
|
+
if (payload.error) {
|
|
553
|
+
// The service answered — it just refused THIS id (a bad ping_id is -32602).
|
|
554
|
+
// Marked so a listing can degrade one row instead of the whole pass.
|
|
555
|
+
const e = new Error(payload.error.message || "MCP error");
|
|
556
|
+
e.perRound = true;
|
|
557
|
+
throw e;
|
|
558
|
+
}
|
|
559
|
+
const result = payload.result || {};
|
|
560
|
+
const structured = result.structuredContent || {};
|
|
561
|
+
return {
|
|
562
|
+
status: structured.status || "pending",
|
|
563
|
+
structured,
|
|
564
|
+
result,
|
|
565
|
+
// A tool that refuses inside a successful JSON-RPC result (the board at
|
|
566
|
+
// its cap, a seq that is not there) answers with isError + the same text.
|
|
567
|
+
isError: result.isError === true,
|
|
568
|
+
text: (result.content && result.content[0] && result.content[0].text) || "",
|
|
569
|
+
};
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
/** The results tool, by name — every caller below reads the same shape. */
|
|
573
|
+
function callResultsTool(pingId) {
|
|
574
|
+
return callQapingTool("qaping_results", { ping_id: pingId });
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
async function cmdResults(argv) {
|
|
578
|
+
const json = argv.includes("--json");
|
|
579
|
+
// Flags in any position: `qaping results --json <id>` is the natural thing to
|
|
580
|
+
// type, and reading argv[1] positionally made it a usage error.
|
|
581
|
+
const pingId = argv.slice(1).find((a) => !a.startsWith("--"));
|
|
582
|
+
if (!pingId || !PING_ID_RE.test(pingId)) {
|
|
583
|
+
console.error("usage: qaping results <ping_id> [--json]");
|
|
584
|
+
// 1, never 2: exit 2 is THIS command's "still pending" signal (a background
|
|
585
|
+
// harness reads it and waits), so a typo must not answer "pending". Same
|
|
586
|
+
// code the vendored `wait` exits on its own usage error.
|
|
587
|
+
process.exit(1);
|
|
588
|
+
}
|
|
589
|
+
const r = await callResultsTool(pingId);
|
|
590
|
+
if (r.status === "not_found") {
|
|
591
|
+
// Results are asker-scoped server-side, so this is nearly always a
|
|
592
|
+
// wrong-account token rather than a wrong id — say which.
|
|
593
|
+
console.error(`✗ no round ${pingId} on this account — results are asker-scoped: \`qaping results\` only reads rounds filed with the same qaping login (\`qaping whoami\` shows which one this is).`);
|
|
594
|
+
process.exit(1);
|
|
595
|
+
}
|
|
596
|
+
const payload = Object.keys(r.structured).length ? r.structured : r.result;
|
|
597
|
+
console.log(json ? JSON.stringify(payload, null, 2) : (r.text || JSON.stringify(payload, null, 2)));
|
|
598
|
+
if (r.status === "pending") {
|
|
599
|
+
// stderr, so --json stdout stays a parseable document.
|
|
600
|
+
console.error(`still pending — \`qaping wait ${pingId}\` to block, or leave it recorded and check later.`);
|
|
601
|
+
process.exit(2);
|
|
602
|
+
}
|
|
603
|
+
process.exit(0);
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
const ISSUES_USAGE = "usage: qaping issues <game> [--json] [--status open|fixed|verified|closed]";
|
|
607
|
+
const ISSUE_STATUSES = ["open", "fixed", "verified", "closed"];
|
|
608
|
+
|
|
609
|
+
/** argv after the command, minus flags and the values they consume — so a
|
|
610
|
+
* slug that happens to equal a flag's value is still the positional. */
|
|
611
|
+
function positionalArgs(argv, valueFlags) {
|
|
612
|
+
const out = [];
|
|
613
|
+
for (let i = 1; i < argv.length; i++) {
|
|
614
|
+
if (argv[i].startsWith("--")) {
|
|
615
|
+
if (valueFlags.includes(argv[i])) i++;
|
|
616
|
+
continue;
|
|
617
|
+
}
|
|
618
|
+
out.push(argv[i]);
|
|
619
|
+
}
|
|
620
|
+
return out;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
/**
|
|
624
|
+
* The game's issue board, read-only. The agent moves issues through the MCP
|
|
625
|
+
* tool; this is the human's window on the same board — and it prints the
|
|
626
|
+
* SERVICE's own rendering rather than a second one, so the board a developer
|
|
627
|
+
* reads in the shell and the board an agent reads mid-run cannot disagree.
|
|
628
|
+
*/
|
|
629
|
+
async function cmdIssues(argv) {
|
|
630
|
+
const json = argv.includes("--json");
|
|
631
|
+
const game = positionalArgs(argv, ["--status"])[0];
|
|
632
|
+
if (!game || !GAME_SLUG_RE.test(game)) {
|
|
633
|
+
console.error(`✗ not a game slug: ${game || "(missing)"} — lowercase letters, digits and hyphens.\n${ISSUES_USAGE}`);
|
|
634
|
+
// 1, like `results`: a typo must never reach the network, and 2 is
|
|
635
|
+
// reserved by the round verbs for "still pending".
|
|
636
|
+
process.exit(1);
|
|
637
|
+
}
|
|
638
|
+
const status = flagValue(argv, "--status");
|
|
639
|
+
if (status !== undefined && !ISSUE_STATUSES.includes(status)) {
|
|
640
|
+
console.error(`✗ --status must be one of ${ISSUE_STATUSES.join(", ")}.\n${ISSUES_USAGE}`);
|
|
641
|
+
process.exit(1);
|
|
642
|
+
}
|
|
643
|
+
const args = { game, action: "list" };
|
|
644
|
+
if (status) args.status_filter = status;
|
|
645
|
+
const r = await callQapingTool("qaping_issues", args);
|
|
646
|
+
console.log(json
|
|
647
|
+
? JSON.stringify(Object.keys(r.structured).length ? r.structured : r.result, null, 2)
|
|
648
|
+
: (r.text || JSON.stringify(r.result, null, 2)));
|
|
649
|
+
process.exit(r.isError ? 1 : 0);
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
async function cmdRoundsList(argv) {
|
|
653
|
+
const json = argv.includes("--json");
|
|
654
|
+
const state = readRoundsFile(roundsFilePath(argv));
|
|
655
|
+
if (!state || state.rounds.length === 0) {
|
|
656
|
+
console.log(json ? JSON.stringify({ rounds: [], total: 0, truncated: false, notice: null }, null, 2)
|
|
657
|
+
: `no open rounds recorded here (${ROUNDS_FILE})`);
|
|
658
|
+
process.exit(0);
|
|
659
|
+
}
|
|
660
|
+
// The cap bounds the FETCHES (one passive call each), not the truth: the
|
|
661
|
+
// count line and the JSON both report every recorded round, and say so when
|
|
662
|
+
// only the first N carry live status.
|
|
663
|
+
const total = state.rounds.length;
|
|
664
|
+
const rounds = state.rounds.slice(0, MAX_OPEN_ROUNDS);
|
|
665
|
+
const offlineFlag = argv.includes("--offline");
|
|
666
|
+
let notice = offlineFlag ? "(live status unavailable — offline)" : null;
|
|
667
|
+
let live = new Map();
|
|
668
|
+
if (!offlineFlag) {
|
|
669
|
+
for (const record of rounds) {
|
|
670
|
+
try {
|
|
671
|
+
const r = await callResultsTool(record.ping_id);
|
|
672
|
+
live.set(record.ping_id, {
|
|
673
|
+
status: r.status,
|
|
674
|
+
n_received: r.structured.n_received ?? null,
|
|
675
|
+
n_target: r.structured.n_target ?? null,
|
|
676
|
+
});
|
|
677
|
+
} catch (e) {
|
|
678
|
+
if (e && e.perRound) {
|
|
679
|
+
// The service answered and refused this id — that is this round's
|
|
680
|
+
// status, not the run's. The rest of the listing still gets fetched.
|
|
681
|
+
live.set(record.ping_id, { status: "unavailable", error: e.message });
|
|
682
|
+
continue;
|
|
683
|
+
}
|
|
684
|
+
// Login and transport fail for the whole pass; name WHICH, because the
|
|
685
|
+
// remedies are nothing alike.
|
|
686
|
+
notice = e && e.authRejected
|
|
687
|
+
? "(live status unavailable — login rejected: run `qaping setup`)"
|
|
688
|
+
: `(live status unavailable — ${(e && e.message) || "offline"})`;
|
|
689
|
+
live = new Map();
|
|
690
|
+
break;
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
const entries = rounds.map((record) => ({ record, live: live.get(record.ping_id) || null }));
|
|
695
|
+
console.log(json
|
|
696
|
+
? JSON.stringify({
|
|
697
|
+
rounds: entries.map((e) => ({ ...e.record, live: e.live })),
|
|
698
|
+
total,
|
|
699
|
+
truncated: total > rounds.length,
|
|
700
|
+
notice,
|
|
701
|
+
}, null, 2)
|
|
702
|
+
: renderRounds(entries, { total, notice }));
|
|
703
|
+
process.exit(0);
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
function cmdRoundsAdd(argv) {
|
|
707
|
+
const json = argv.includes("--json");
|
|
708
|
+
const pingId = argv[2];
|
|
709
|
+
if (!pingId || pingId.startsWith("--") || !PING_ID_RE.test(pingId)) {
|
|
710
|
+
console.error(`✗ not a ping id: ${pingId || "(missing)"}\n${ROUNDS_USAGE}`);
|
|
711
|
+
process.exit(2);
|
|
712
|
+
}
|
|
713
|
+
const platform = flagValue(argv, "--platform");
|
|
714
|
+
if (platform && !["windows", "macos", "ios"].includes(platform)) {
|
|
715
|
+
console.error(`✗ --platform must be windows, macos or ios — a web round has none, so omit it.\n${ROUNDS_USAGE}`);
|
|
716
|
+
process.exit(2);
|
|
717
|
+
}
|
|
718
|
+
// Validated here, not just recorded: a slug the service would refuse is a
|
|
719
|
+
// board the collecting session could never read.
|
|
720
|
+
const game = flagValue(argv, "--game");
|
|
721
|
+
if (game !== undefined && !GAME_SLUG_RE.test(game)) {
|
|
722
|
+
console.error(`✗ --game must be a slug: lowercase letters, digits and hyphens (the QA plan's game_slug).\n${ROUNDS_USAGE}`);
|
|
723
|
+
process.exit(2);
|
|
724
|
+
}
|
|
725
|
+
const file = roundsFilePath(argv);
|
|
726
|
+
const minutes = intFlag(argv, "--minutes");
|
|
727
|
+
const deadline = intFlag(argv, "--deadline-seconds");
|
|
728
|
+
const checks = (flagValue(argv, "--checks") || "").split(",").map((s) => s.trim()).filter(Boolean);
|
|
729
|
+
const note = flagValue(argv, "--note");
|
|
730
|
+
const build = flagValue(argv, "--build");
|
|
731
|
+
const patch = {};
|
|
732
|
+
if (platform) patch.platform = platform;
|
|
733
|
+
if (game) patch.game = game;
|
|
734
|
+
if (build) patch.build = build;
|
|
735
|
+
if (minutes != null) patch.est_minutes = minutes;
|
|
736
|
+
if (deadline != null) patch.deadline_seconds = deadline;
|
|
737
|
+
if (checks.length) patch.checks = checks;
|
|
738
|
+
if (note) patch.note = note;
|
|
739
|
+
// Read and write inside ONE lock: a background poller's `rounds rm` landing
|
|
740
|
+
// between this read and this write used to drop whichever record lost.
|
|
741
|
+
const { record, count, updated } = mutateRoundsFile(file, (state) => {
|
|
742
|
+
const existing = state.rounds.find((r) => r.ping_id === pingId);
|
|
743
|
+
// Re-adding the same id CORRECTS a record, never duplicates it — and keeps
|
|
744
|
+
// the original filed_at, which is what the visibility window counts from.
|
|
745
|
+
const rec = {
|
|
746
|
+
...(existing || {}),
|
|
747
|
+
ping_id: pingId,
|
|
748
|
+
filed_at: (existing && existing.filed_at) || new Date().toISOString(),
|
|
749
|
+
...patch,
|
|
750
|
+
};
|
|
751
|
+
const next = upsertRound(state, rec);
|
|
752
|
+
return { state: next, record: rec, count: next.rounds.length, updated: !!existing };
|
|
753
|
+
});
|
|
754
|
+
if (json) {
|
|
755
|
+
console.log(JSON.stringify({ file, recorded: record, rounds: count }, null, 2));
|
|
756
|
+
process.exit(0);
|
|
757
|
+
}
|
|
758
|
+
console.log(`✓ recorded round ${pingId} in ${ROUNDS_FILE}${updated ? " (updated)" : ""}`);
|
|
759
|
+
console.log(` collect it later: \`qaping rounds\` lists what this repo is owed; \`qaping results ${pingId}\` fetches this one.`);
|
|
760
|
+
console.log(` ${ROUNDS_FILE} is open state, not history — add it to .gitignore.`);
|
|
761
|
+
if (count > MAX_OPEN_ROUNDS) {
|
|
762
|
+
console.log(` ⚠ ${count} rounds recorded — \`qaping rounds\` fetches live status for the first ${MAX_OPEN_ROUNDS}. Collect some.`);
|
|
763
|
+
}
|
|
764
|
+
process.exit(0);
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
function cmdRoundsRm(argv) {
|
|
768
|
+
const json = argv.includes("--json");
|
|
769
|
+
const pingId = argv[2];
|
|
770
|
+
if (!pingId || pingId.startsWith("--")) {
|
|
771
|
+
console.error(`✗ qaping rounds rm needs a ping id.\n${ROUNDS_USAGE}`);
|
|
772
|
+
process.exit(2);
|
|
773
|
+
}
|
|
774
|
+
const file = roundsFilePath(argv);
|
|
775
|
+
// Same lock as `add`: this is the verb a background poller runs while the
|
|
776
|
+
// agent that filed the next round is recording it.
|
|
777
|
+
const { removed, count } = mutateRoundsFile(file, (state) => {
|
|
778
|
+
const { state: next, removed: didRemove } = removeRound(state, pingId);
|
|
779
|
+
// No write at all when nothing matched — a no-op must not rewrite the file.
|
|
780
|
+
return { state: didRemove ? next : null, removed: didRemove, count: next.rounds.length };
|
|
781
|
+
});
|
|
782
|
+
if (!removed) {
|
|
783
|
+
console.error(`✗ no round ${pingId} recorded in ${ROUNDS_FILE}`);
|
|
784
|
+
process.exit(1);
|
|
785
|
+
}
|
|
786
|
+
console.log(json ? JSON.stringify({ removed: pingId, rounds: count }, null, 2)
|
|
787
|
+
: `✓ removed ${pingId} from ${ROUNDS_FILE} (${count} open round(s) left)`);
|
|
788
|
+
process.exit(0);
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
async function cmdRounds(argv) {
|
|
792
|
+
const sub = argv[1];
|
|
793
|
+
if (sub === "add") return cmdRoundsAdd(argv);
|
|
794
|
+
if (sub === "rm" || sub === "delete" || sub === "remove") return cmdRoundsRm(argv);
|
|
795
|
+
if (sub === "--help" || sub === "-h") { console.log(ROUNDS_USAGE); process.exit(0); }
|
|
796
|
+
if (sub && !sub.startsWith("--")) {
|
|
797
|
+
console.error(`✗ unknown: qaping rounds ${sub}\n${ROUNDS_USAGE}`);
|
|
798
|
+
process.exit(2);
|
|
799
|
+
}
|
|
800
|
+
return cmdRoundsList(argv);
|
|
801
|
+
}
|
|
802
|
+
|
|
228
803
|
const HELP = `qaping — automatic QA for your game: real human playtesters, driven by your coding agent
|
|
229
804
|
|
|
230
805
|
usage:
|
|
231
806
|
qaping setup [claude-code|cursor|codex] [--force]
|
|
232
807
|
qaping remove [--client <c>] remove qaping's MCP entries, skill and rule
|
|
233
808
|
qaping publish-build <game.zip> --platform windows|macos
|
|
809
|
+
qaping builds the hosted builds you hold (oldest first)
|
|
810
|
+
qaping builds rm <slug> delete one now, freeing its slot
|
|
234
811
|
qaping publish <built-dir> host a WEB game's built output (prints the URL to file with)
|
|
235
812
|
qaping wait <ping_id> [--timeout <seconds>]
|
|
813
|
+
qaping results <ping_id> one passive fetch — exit 0 news, 2 still pending, 1 otherwise
|
|
814
|
+
qaping rounds the parked rounds this repo is owed, with live status
|
|
815
|
+
qaping rounds add <ping_id> … record a parked round so a later session collects it
|
|
816
|
+
qaping rounds rm <ping_id> forget one, once it is collected or expired
|
|
817
|
+
qaping issues <game> the game's issue board (read-only; the agent moves it)
|
|
236
818
|
qaping whoami
|
|
237
819
|
qaping version
|
|
238
820
|
|
|
@@ -245,23 +827,38 @@ function route(cmd) {
|
|
|
245
827
|
if (cmd === "remove" || cmd === "uninstall") return "remove";
|
|
246
828
|
if (cmd === "wait" || cmd === "whoami") return "vendor";
|
|
247
829
|
if (cmd === "publish-build") return "publish-build";
|
|
830
|
+
if (cmd === "builds") return "builds";
|
|
248
831
|
if (cmd === "publish") return "publish";
|
|
832
|
+
if (cmd === "rounds") return "rounds";
|
|
833
|
+
if (cmd === "results") return "results";
|
|
834
|
+
if (cmd === "issues") return "issues";
|
|
249
835
|
return "unknown";
|
|
250
836
|
}
|
|
251
837
|
|
|
838
|
+
// The async commands' rejection handler: a clean one-line message, never a raw
|
|
839
|
+
// stack — same contract as the sync catch in main() below.
|
|
840
|
+
function die(e) {
|
|
841
|
+
console.error(`✗ ${(e && e.message) || e}`);
|
|
842
|
+
process.exit(1);
|
|
843
|
+
}
|
|
844
|
+
|
|
252
845
|
function main() {
|
|
253
846
|
const argv = process.argv.slice(2);
|
|
254
847
|
try {
|
|
255
848
|
switch (route(argv[0])) {
|
|
256
849
|
case "version": return console.log(require("../package.json").version);
|
|
257
850
|
case "help": return console.log(HELP);
|
|
258
|
-
case "setup": return void cmdSetup(argv).catch(
|
|
851
|
+
case "setup": return void cmdSetup(argv).catch(die);
|
|
259
852
|
case "remove": return cmdRemove(argv);
|
|
260
853
|
// `wait` also names the wait tool the qaping mount registers — the
|
|
261
854
|
// vendored default (the stock mount's wait) is not on /api/mcp/qaping.
|
|
262
855
|
case "vendor": return process.exit(spawnVendor(argv[0] === "wait" ? [...argv, "--wait-tool", "qaping_wait"] : argv));
|
|
263
856
|
case "publish-build": return void cmdPublishBuild(argv);
|
|
857
|
+
case "builds": return void cmdBuilds(argv);
|
|
264
858
|
case "publish": return void cmdPublish(argv);
|
|
859
|
+
case "rounds": return void cmdRounds(argv).catch(die);
|
|
860
|
+
case "results": return void cmdResults(argv).catch(die);
|
|
861
|
+
case "issues": return void cmdIssues(argv).catch(die);
|
|
265
862
|
default:
|
|
266
863
|
console.error(HELP);
|
|
267
864
|
process.exit(1);
|
|
@@ -275,4 +872,8 @@ function main() {
|
|
|
275
872
|
}
|
|
276
873
|
|
|
277
874
|
if (require.main === module) main();
|
|
278
|
-
module.exports = {
|
|
875
|
+
module.exports = {
|
|
876
|
+
route, resolveKitDir, qapingWrapper, vendorFlags, isSourceCheckout, RULE_BODY, HELP,
|
|
877
|
+
ROUNDS_FILE, MAX_OPEN_ROUNDS, GAME_SLUG_RE,
|
|
878
|
+
parseRounds, upsertRound, removeRound, visibleUntil, renderRounds,
|
|
879
|
+
};
|