From b33f3cc2af347bb6772f2ec33c65303cf06fbb70 Mon Sep 17 00:00:00 2001 From: Ryan L'Italien Date: Wed, 23 Sep 2026 11:42:33 -0400 Subject: [PATCH 1/2] fix(#6): add butter projects show `projects show ` 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. --- bin/butter | 44 ++++++++++++++++++ test/butter.test.js | 111 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 155 insertions(+) diff --git a/bin/butter b/bin/butter index 4cdb13b..4449a0c 100755 --- a/bin/butter +++ b/bin/butter @@ -449,6 +449,49 @@ async function projectsList(args) { } } +async function projectsShow(args) { + const projectId = args._[2]; + if (!projectId) { + console.error(`${colors.err}✗ Usage: butter projects show ${colors.reset}`); + process.exit(1); + } + + try { + const res = await request("GET", `/api/v1/projects/${projectId}`); + if (args.json) { + console.log(JSON.stringify(res, null, 2)); + return; + } + + console.log(`\n${colors.bold}PROJECT ${res.id}${colors.reset}`); + console.log("----------------------------------------------------------------------"); + console.log(` Name: ${colors.accent}${res.name || "-"}${colors.reset}`); + console.log(` Type: ${res.project_type || "-"}`); + console.log(` Tasks: ${res.tasks_count != null ? res.tasks_count : "-"}`); + console.log(` Builds: ${res.build_runs_count != null ? res.build_runs_count : "-"}`); + console.log(` Assets: ${res.assets_count != null ? res.assets_count : "-"}`); + if (res.pending_assets_count != null) { + console.log(` Pending: ${res.pending_assets_count} asset(s) awaiting approval`); + } + + const b = res.latest_build; + if (b) { + const statusColor = + b.status === "completed" ? colors.ok : b.status === "failed" ? colors.err : colors.warn; + const parts = [b.id, `${statusColor}${b.status}${colors.reset}`]; + if (b.target_type) parts.push(b.target_type); + if (b.commit_hash) parts.push(String(b.commit_hash).slice(0, 8)); + console.log(` Latest build: ${parts.join(" ")}`); + } else { + console.log(` Latest build: ${colors.muted}none${colors.reset}`); + } + console.log(""); + } catch (err) { + console.error(`${colors.err}✗ Failed to show project: ${err.message}${colors.reset}`); + process.exit(1); + } +} + async function tasksList(args) { const projectId = args.project || loadConfig().defaultProject; if (!projectId) { @@ -925,6 +968,7 @@ async function main() { case "projects": case "project": if (subcmd === "list" || !subcmd) return projectsList(args); + if (subcmd === "show") return projectsShow(args); break; case "tasks": case "task": diff --git a/test/butter.test.js b/test/butter.test.js index f2762e2..a6d000c 100644 --- a/test/butter.test.js +++ b/test/butter.test.js @@ -579,6 +579,117 @@ test("#1938: a server with no investigation key is not reported as 'no investiga } }); +// -- #6: `projects show` was advertised in help but never dispatched --------- + +const PROJECT_DETAIL = { + id: 108, + name: "PilotLight", + project_type: "godot", + tasks_count: 12, + build_runs_count: 34, + assets_count: 56, + pending_assets_count: 3, + latest_build: { + id: BUILD_ID, + status: "failed", + target_type: null, + commit_hash: "p4-207", + duration: 0.764199, + created_at: "2026-09-18T10:55:02-04:00" + } +}; + +test("#6: `projects show` renders a project instead of failing as an unknown subcommand", async () => { + const home = mkHome(); + const { server, port, requests } = await startApiServer({ + "GET /api/v1/projects/108": { body: PROJECT_DETAIL } + }); + try { + writeCredentials(home, { host: `http://127.0.0.1:${port}` }); + const { stdout, stderr, success } = await runButter(["projects", "show", "108"], { home }); + + assert.equal(success, true, `expected success, got stderr: ${stderr}`); + assert.ok(!stderr.includes("Unknown subcommand")); + assert.ok(stdout.includes("PROJECT 108"), "the project id should be printed"); + assert.ok(stdout.includes("PilotLight"), "the project name should be printed"); + assert.ok(stdout.includes("godot"), "the project type should be printed"); + assert.ok(stdout.includes(BUILD_ID), "the latest build should be printed"); + assert.ok(stdout.includes("p4-207"), "the latest build's commit should be printed"); + + const req = await requests.pop(2000); + assert.equal(req.method, "GET"); + assert.equal(req.url, "/api/v1/projects/108"); + } finally { + await stopCaptureServer(server); + rmHome(home); + } +}); + +test("#6: `projects show` says 'none' when the project has no builds", async () => { + const home = mkHome(); + const { server, port } = await startApiServer({ + "GET /api/v1/projects/108": { body: { ...PROJECT_DETAIL, latest_build: null } } + }); + try { + writeCredentials(home, { host: `http://127.0.0.1:${port}` }); + const { stdout, success } = await runButter(["projects", "show", "108"], { home }); + assert.equal(success, true); + assert.match(stdout, /Latest build:\s+(\x1b\[[0-9;]*m)*none/); + } finally { + await stopCaptureServer(server); + rmHome(home); + } +}); + +test("#6: `projects show --json` works with the flag on either side of the id", async () => { + const home = mkHome(); + const { server, port } = await startApiServer({ + "GET /api/v1/projects/108": { body: PROJECT_DETAIL } + }); + try { + writeCredentials(home, { host: `http://127.0.0.1:${port}` }); + + const before = await runButter(["projects", "show", "--json", "108"], { home }); + assert.equal(before.success, true, `expected success, got stderr: ${before.stderr}`); + assert.deepEqual(JSON.parse(before.stdout), PROJECT_DETAIL); + + const after = await runButter(["projects", "show", "108", "--json"], { home }); + assert.equal(after.success, true, `expected success, got stderr: ${after.stderr}`); + assert.deepEqual(JSON.parse(after.stdout), PROJECT_DETAIL); + } finally { + await stopCaptureServer(server); + rmHome(home); + } +}); + +test("#6: `projects show` without an id prints usage and exits non-zero", async () => { + const home = mkHome(); + try { + const { stderr, success } = await runButter(["projects", "show"], { home }); + assert.equal(success, false); + assert.ok(stderr.includes("Usage: butter projects show")); + } finally { + rmHome(home); + } +}); + +test("#6: `projects show` for a missing project fails loudly", async () => { + const home = mkHome(); + const { server, port } = await startApiServer({ + "GET /api/v1/projects/999": { status: 404, body: { error: "Project not found" } } + }); + try { + writeCredentials(home, { host: `http://127.0.0.1:${port}` }); + const { stderr, success } = await runButter(["projects", "show", "999"], { home }); + assert.equal(success, false); + assert.ok(stderr.includes("Failed to show project")); + assert.ok(stderr.includes("Project not found"), "the server's error should be surfaced"); + } finally { + await stopCaptureServer(server); + rmHome(home); + } +}); + // buildEnv sets BUTTERSTACK_NO_BROWSER for every test; this pins that the // binary honors it. // Buffers the child's full stdout, so tests can assert on the whole block. From 9f5a48f22b2e124331291a646ea7f1d8b4ef8d53 Mon Sep 17 00:00:00 2001 From: Ryan L'Italien Date: Wed, 23 Sep 2026 11:55:22 -0400 Subject: [PATCH 2/2] docs(help): mark assets show as coming soon The assets show subcommand has no dispatcher branch yet. --- bin/butter | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bin/butter b/bin/butter index 4449a0c..e8b9f20 100755 --- a/bin/butter +++ b/bin/butter @@ -928,7 +928,8 @@ ${colors.bold}COMMANDS:${colors.reset} Inspect CI/CD builds and AI failure investigations. ${colors.bold}investigate${colors.reset} STARTS a paid AI analysis; ${colors.bold}investigation${colors.reset} reads the result back for free. - ${colors.accent}assets${colors.reset} list | show | approve | deny Review and approve textures, models, and audio + ${colors.accent}assets${colors.reset} list | approve | deny Review and approve textures, models, and audio + ${colors.bold}show${colors.reset} is coming soon ${colors.bold}GLOBAL OPTIONS:${colors.reset} --project Target project ID (e.g. --project 1)