Skip to content

Commit b33f3cc

Browse files
committed
fix(#6): add butter projects show <id>
`projects show <id>` was listed in the help text but had no dispatcher branch, so it fell through to the unknown-subcommand error. Add projectsShow, mirroring buildsShow: GET /api/v1/projects/:id, plain and --json output (the flag works on either side of the id), a usage error when the id is missing, and the server's error surfaced on failure. The plain output prints the project's counts and the latest build on one line, or "none". Tests cover plain output, no builds, --json in both positions, a missing id, and a 404.
1 parent e215f6e commit b33f3cc

2 files changed

Lines changed: 155 additions & 0 deletions

File tree

‎bin/butter‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -449,6 +449,49 @@ async function projectsList(args) {
449449
}
450450
}
451451

452+
async function projectsShow(args) {
453+
const projectId = args._[2];
454+
if (!projectId) {
455+
console.error(`${colors.err}✗ Usage: butter projects show <project_id>${colors.reset}`);
456+
process.exit(1);
457+
}
458+
459+
try {
460+
const res = await request("GET", `/api/v1/projects/${projectId}`);
461+
if (args.json) {
462+
console.log(JSON.stringify(res, null, 2));
463+
return;
464+
}
465+
466+
console.log(`\n${colors.bold}PROJECT ${res.id}${colors.reset}`);
467+
console.log("----------------------------------------------------------------------");
468+
console.log(` Name: ${colors.accent}${res.name || "-"}${colors.reset}`);
469+
console.log(` Type: ${res.project_type || "-"}`);
470+
console.log(` Tasks: ${res.tasks_count != null ? res.tasks_count : "-"}`);
471+
console.log(` Builds: ${res.build_runs_count != null ? res.build_runs_count : "-"}`);
472+
console.log(` Assets: ${res.assets_count != null ? res.assets_count : "-"}`);
473+
if (res.pending_assets_count != null) {
474+
console.log(` Pending: ${res.pending_assets_count} asset(s) awaiting approval`);
475+
}
476+
477+
const b = res.latest_build;
478+
if (b) {
479+
const statusColor =
480+
b.status === "completed" ? colors.ok : b.status === "failed" ? colors.err : colors.warn;
481+
const parts = [b.id, `${statusColor}${b.status}${colors.reset}`];
482+
if (b.target_type) parts.push(b.target_type);
483+
if (b.commit_hash) parts.push(String(b.commit_hash).slice(0, 8));
484+
console.log(` Latest build: ${parts.join(" ")}`);
485+
} else {
486+
console.log(` Latest build: ${colors.muted}none${colors.reset}`);
487+
}
488+
console.log("");
489+
} catch (err) {
490+
console.error(`${colors.err}✗ Failed to show project: ${err.message}${colors.reset}`);
491+
process.exit(1);
492+
}
493+
}
494+
452495
async function tasksList(args) {
453496
const projectId = args.project || loadConfig().defaultProject;
454497
if (!projectId) {
@@ -925,6 +968,7 @@ async function main() {
925968
case "projects":
926969
case "project":
927970
if (subcmd === "list" || !subcmd) return projectsList(args);
971+
if (subcmd === "show") return projectsShow(args);
928972
break;
929973
case "tasks":
930974
case "task":

‎test/butter.test.js‎

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -579,6 +579,117 @@ test("#1938: a server with no investigation key is not reported as 'no investiga
579579
}
580580
});
581581

582+
// -- #6: `projects show` was advertised in help but never dispatched ---------
583+
584+
const PROJECT_DETAIL = {
585+
id: 108,
586+
name: "PilotLight",
587+
project_type: "godot",
588+
tasks_count: 12,
589+
build_runs_count: 34,
590+
assets_count: 56,
591+
pending_assets_count: 3,
592+
latest_build: {
593+
id: BUILD_ID,
594+
status: "failed",
595+
target_type: null,
596+
commit_hash: "p4-207",
597+
duration: 0.764199,
598+
created_at: "2026-09-18T10:55:02-04:00"
599+
}
600+
};
601+
602+
test("#6: `projects show` renders a project instead of failing as an unknown subcommand", async () => {
603+
const home = mkHome();
604+
const { server, port, requests } = await startApiServer({
605+
"GET /api/v1/projects/108": { body: PROJECT_DETAIL }
606+
});
607+
try {
608+
writeCredentials(home, { host: `http://127.0.0.1:${port}` });
609+
const { stdout, stderr, success } = await runButter(["projects", "show", "108"], { home });
610+
611+
assert.equal(success, true, `expected success, got stderr: ${stderr}`);
612+
assert.ok(!stderr.includes("Unknown subcommand"));
613+
assert.ok(stdout.includes("PROJECT 108"), "the project id should be printed");
614+
assert.ok(stdout.includes("PilotLight"), "the project name should be printed");
615+
assert.ok(stdout.includes("godot"), "the project type should be printed");
616+
assert.ok(stdout.includes(BUILD_ID), "the latest build should be printed");
617+
assert.ok(stdout.includes("p4-207"), "the latest build's commit should be printed");
618+
619+
const req = await requests.pop(2000);
620+
assert.equal(req.method, "GET");
621+
assert.equal(req.url, "/api/v1/projects/108");
622+
} finally {
623+
await stopCaptureServer(server);
624+
rmHome(home);
625+
}
626+
});
627+
628+
test("#6: `projects show` says 'none' when the project has no builds", async () => {
629+
const home = mkHome();
630+
const { server, port } = await startApiServer({
631+
"GET /api/v1/projects/108": { body: { ...PROJECT_DETAIL, latest_build: null } }
632+
});
633+
try {
634+
writeCredentials(home, { host: `http://127.0.0.1:${port}` });
635+
const { stdout, success } = await runButter(["projects", "show", "108"], { home });
636+
assert.equal(success, true);
637+
assert.match(stdout, /Latest build:\s+(\x1b\[[0-9;]*m)*none/);
638+
} finally {
639+
await stopCaptureServer(server);
640+
rmHome(home);
641+
}
642+
});
643+
644+
test("#6: `projects show --json` works with the flag on either side of the id", async () => {
645+
const home = mkHome();
646+
const { server, port } = await startApiServer({
647+
"GET /api/v1/projects/108": { body: PROJECT_DETAIL }
648+
});
649+
try {
650+
writeCredentials(home, { host: `http://127.0.0.1:${port}` });
651+
652+
const before = await runButter(["projects", "show", "--json", "108"], { home });
653+
assert.equal(before.success, true, `expected success, got stderr: ${before.stderr}`);
654+
assert.deepEqual(JSON.parse(before.stdout), PROJECT_DETAIL);
655+
656+
const after = await runButter(["projects", "show", "108", "--json"], { home });
657+
assert.equal(after.success, true, `expected success, got stderr: ${after.stderr}`);
658+
assert.deepEqual(JSON.parse(after.stdout), PROJECT_DETAIL);
659+
} finally {
660+
await stopCaptureServer(server);
661+
rmHome(home);
662+
}
663+
});
664+
665+
test("#6: `projects show` without an id prints usage and exits non-zero", async () => {
666+
const home = mkHome();
667+
try {
668+
const { stderr, success } = await runButter(["projects", "show"], { home });
669+
assert.equal(success, false);
670+
assert.ok(stderr.includes("Usage: butter projects show"));
671+
} finally {
672+
rmHome(home);
673+
}
674+
});
675+
676+
test("#6: `projects show` for a missing project fails loudly", async () => {
677+
const home = mkHome();
678+
const { server, port } = await startApiServer({
679+
"GET /api/v1/projects/999": { status: 404, body: { error: "Project not found" } }
680+
});
681+
try {
682+
writeCredentials(home, { host: `http://127.0.0.1:${port}` });
683+
const { stderr, success } = await runButter(["projects", "show", "999"], { home });
684+
assert.equal(success, false);
685+
assert.ok(stderr.includes("Failed to show project"));
686+
assert.ok(stderr.includes("Project not found"), "the server's error should be surfaced");
687+
} finally {
688+
await stopCaptureServer(server);
689+
rmHome(home);
690+
}
691+
});
692+
582693
// buildEnv sets BUTTERSTACK_NO_BROWSER for every test; this pins that the
583694
// binary honors it.
584695
// Buffers the child's full stdout, so tests can assert on the whole block.

0 commit comments

Comments
 (0)