Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 46 additions & 1 deletion bin/butter
Original file line number Diff line number Diff line change
Expand Up @@ -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 <project_id>${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) {
Expand Down Expand Up @@ -885,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 <id> Target project ID (e.g. --project 1)
Expand Down Expand Up @@ -925,6 +969,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":
Expand Down
111 changes: 111 additions & 0 deletions test/butter.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading