feat(cli/boulder): implement boulder() entry point and register subcommand
This commit is contained in:
@@ -0,0 +1,215 @@
|
||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
import { tmpdir } from "node:os"
|
||||
import { afterEach, describe, expect, it } from "bun:test"
|
||||
|
||||
import { boulder } from "./boulder"
|
||||
|
||||
function createTempDirectory(): string {
|
||||
return mkdtempSync(join(tmpdir(), "omo-boulder-cli-"))
|
||||
}
|
||||
|
||||
function seedPlanAndState(directory: string): void {
|
||||
const planDirectory = join(directory, ".sisyphus", "plans")
|
||||
mkdirSync(planDirectory, { recursive: true })
|
||||
|
||||
const planAPath = join(planDirectory, "alpha.md")
|
||||
const planBPath = join(planDirectory, "beta.md")
|
||||
|
||||
writeFileSync(
|
||||
planAPath,
|
||||
[
|
||||
"## TODOs",
|
||||
"- [x] 1. Alpha task done",
|
||||
"- [ ] 2. Alpha task running",
|
||||
].join("\n"),
|
||||
"utf-8",
|
||||
)
|
||||
writeFileSync(
|
||||
planBPath,
|
||||
[
|
||||
"## TODOs",
|
||||
"- [x] 1. Beta task done",
|
||||
"- [x] 2. Beta task done too",
|
||||
].join("\n"),
|
||||
"utf-8",
|
||||
)
|
||||
|
||||
const boulderDirectory = join(directory, ".sisyphus")
|
||||
mkdirSync(boulderDirectory, { recursive: true })
|
||||
|
||||
writeFileSync(
|
||||
join(boulderDirectory, "boulder.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
schema_version: 2,
|
||||
active_work_id: "work-alpha",
|
||||
active_plan: planAPath,
|
||||
started_at: "2026-05-10T00:00:00.000Z",
|
||||
ended_at: "2026-05-10T00:30:00.000Z",
|
||||
elapsed_ms: 1_800_000,
|
||||
status: "active",
|
||||
updated_at: "2026-05-10T00:30:00.000Z",
|
||||
session_ids: ["ses-1", "ses-2"],
|
||||
plan_name: "alpha",
|
||||
task_sessions: {
|
||||
"todo:2": {
|
||||
task_key: "todo:2",
|
||||
task_label: "2",
|
||||
task_title: "Alpha task running",
|
||||
session_id: "ses-2",
|
||||
elapsed_ms: 60000,
|
||||
status: "running",
|
||||
updated_at: "2026-05-10T00:30:00.000Z",
|
||||
},
|
||||
},
|
||||
works: {
|
||||
"work-alpha": {
|
||||
work_id: "work-alpha",
|
||||
active_plan: planAPath,
|
||||
plan_name: "alpha",
|
||||
status: "active",
|
||||
started_at: "2026-05-10T00:00:00.000Z",
|
||||
elapsed_ms: 1_800_000,
|
||||
updated_at: "2026-05-10T00:30:00.000Z",
|
||||
session_ids: ["ses-1", "ses-2"],
|
||||
task_sessions: {
|
||||
"todo:2": {
|
||||
task_key: "todo:2",
|
||||
task_label: "2",
|
||||
task_title: "Alpha task running",
|
||||
session_id: "ses-2",
|
||||
elapsed_ms: 60000,
|
||||
status: "running",
|
||||
updated_at: "2026-05-10T00:30:00.000Z",
|
||||
},
|
||||
},
|
||||
},
|
||||
"work-beta": {
|
||||
work_id: "work-beta",
|
||||
active_plan: planBPath,
|
||||
plan_name: "beta",
|
||||
status: "completed",
|
||||
started_at: "2026-05-10T01:00:00.000Z",
|
||||
ended_at: "2026-05-10T01:10:00.000Z",
|
||||
elapsed_ms: 600000,
|
||||
updated_at: "2026-05-10T01:10:00.000Z",
|
||||
session_ids: ["ses-3"],
|
||||
task_sessions: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
)
|
||||
}
|
||||
|
||||
describe("boulder command", () => {
|
||||
const createdDirectories: string[] = []
|
||||
const outputRestores: Array<() => void> = []
|
||||
|
||||
afterEach(() => {
|
||||
for (const directory of createdDirectories) {
|
||||
rmSync(directory, { recursive: true, force: true })
|
||||
}
|
||||
createdDirectories.length = 0
|
||||
for (const restoreOutput of outputRestores) {
|
||||
restoreOutput()
|
||||
}
|
||||
outputRestores.length = 0
|
||||
})
|
||||
|
||||
function captureOutput(target: "stdout" | "stderr", sink: { value: string }): void {
|
||||
const originalWrite = process[target].write
|
||||
process[target].write = ((chunk: string | Uint8Array) => {
|
||||
sink.value += typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf-8")
|
||||
return true
|
||||
}) as typeof process.stdout.write
|
||||
|
||||
outputRestores.push(() => {
|
||||
process[target].write = originalWrite
|
||||
})
|
||||
}
|
||||
|
||||
it("prints multi-work text mode with plan names and percentages", async () => {
|
||||
const directory = createTempDirectory()
|
||||
createdDirectories.push(directory)
|
||||
seedPlanAndState(directory)
|
||||
|
||||
const stdout = { value: "" }
|
||||
const stderr = { value: "" }
|
||||
captureOutput("stdout", stdout)
|
||||
captureOutput("stderr", stderr)
|
||||
|
||||
const exitCode = await boulder({ directory })
|
||||
|
||||
expect(exitCode).toBe(0)
|
||||
expect(stderr.value).toBe("")
|
||||
expect(stdout.value).toContain("plan: alpha")
|
||||
expect(stdout.value).toContain("plan: beta")
|
||||
expect(stdout.value).toContain("progress: 50% (1/2)")
|
||||
expect(stdout.value).toContain("progress: 100% (2/2)")
|
||||
expect(stdout.value).toContain("elapsed:")
|
||||
})
|
||||
|
||||
it("prints json mode with expected fields", async () => {
|
||||
const directory = createTempDirectory()
|
||||
createdDirectories.push(directory)
|
||||
seedPlanAndState(directory)
|
||||
|
||||
const stdout = { value: "" }
|
||||
captureOutput("stdout", stdout)
|
||||
|
||||
const exitCode = await boulder({ directory, json: true })
|
||||
expect(exitCode).toBe(0)
|
||||
|
||||
const parsed = JSON.parse(stdout.value)
|
||||
expect(parsed.works).toHaveLength(2)
|
||||
expect(parsed.works[0]).toHaveProperty("work_id")
|
||||
expect(parsed.works[0]).toHaveProperty("percentage")
|
||||
expect(parsed.works[0]).toHaveProperty("remaining_tasks")
|
||||
})
|
||||
|
||||
it("returns 1 when boulder state does not exist", async () => {
|
||||
const directory = createTempDirectory()
|
||||
createdDirectories.push(directory)
|
||||
|
||||
const stderr = { value: "" }
|
||||
captureOutput("stderr", stderr)
|
||||
|
||||
const exitCode = await boulder({ directory })
|
||||
expect(exitCode).toBe(1)
|
||||
expect(stderr.value).toContain("No boulder state found")
|
||||
})
|
||||
|
||||
it("returns 1 when workId filter matches none", async () => {
|
||||
const directory = createTempDirectory()
|
||||
createdDirectories.push(directory)
|
||||
seedPlanAndState(directory)
|
||||
|
||||
const stderr = { value: "" }
|
||||
captureOutput("stderr", stderr)
|
||||
|
||||
const exitCode = await boulder({ directory, workId: "missing" })
|
||||
expect(exitCode).toBe(1)
|
||||
expect(stderr.value).toContain("No boulder state found")
|
||||
})
|
||||
|
||||
it("returns one work when workId filter matches", async () => {
|
||||
const directory = createTempDirectory()
|
||||
createdDirectories.push(directory)
|
||||
seedPlanAndState(directory)
|
||||
|
||||
const stdout = { value: "" }
|
||||
captureOutput("stdout", stdout)
|
||||
|
||||
const exitCode = await boulder({ directory, workId: "work-beta", json: true })
|
||||
expect(exitCode).toBe(0)
|
||||
|
||||
const parsed = JSON.parse(stdout.value)
|
||||
expect(parsed.works).toHaveLength(1)
|
||||
expect(parsed.works[0].work_id).toBe("work-beta")
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user