From be8e89baed5b3c0574c49f013a3486d27585ae22 Mon Sep 17 00:00:00 2001 From: Danijel Martinek Date: Wed, 13 May 2026 07:46:51 +0200 Subject: [PATCH] =?UTF-8?q?feat(scripts):=20pnpm=20work=20CLI=20=E2=80=94?= =?UTF-8?q?=20rebuild-state,=20status,=20next?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/work/cli.mjs | 87 +++++++++++++++++++++++++++++++++++++++ scripts/work/cli.test.mjs | 42 +++++++++++++++++++ 2 files changed, 129 insertions(+) create mode 100644 scripts/work/cli.mjs create mode 100644 scripts/work/cli.test.mjs diff --git a/scripts/work/cli.mjs b/scripts/work/cli.mjs new file mode 100644 index 0000000..6440349 --- /dev/null +++ b/scripts/work/cli.mjs @@ -0,0 +1,87 @@ +#!/usr/bin/env node +/** + * pnpm work — CLI for the local work-system. + * + * Subcommands: + * rebuild-state Rewrites docs/work/_state.json from the current markdown + * status Prints a tree of all epics + their stories + * next Prints the first non-done story in the first non-done epic + */ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { buildState } from "./state-builder.mjs"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = path.resolve(__dirname, "..", ".."); +const WORK_ROOT = path.join(REPO_ROOT, "docs", "work"); +const STATE_FILE = path.join(WORK_ROOT, "_state.json"); + +function rebuildState() { + const state = buildState(WORK_ROOT); + fs.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2) + "\n"); + console.log(`Rebuilt ${path.relative(REPO_ROOT, STATE_FILE)} with ${Object.keys(state.epics).length} epic(s).`); +} + +function printStatus() { + const state = buildState(WORK_ROOT); + const epicIds = Object.keys(state.epics).sort(); + if (epicIds.length === 0) { + console.log("No epics found under docs/work/."); + return; + } + for (const epicId of epicIds) { + const epic = state.epics[epicId]; + const storyIds = Object.keys(epic.stories).sort(); + const storyTotals = storyIds.reduce( + (acc, sid) => ({ + total: acc.total + epic.stories[sid].ac_total, + done: acc.done + epic.stories[sid].ac_completed, + }), + { total: 0, done: 0 }, + ); + const epicMark = mark(epic.status); + console.log(`${epicMark} ${epicId} — ${epic.title} (${storyTotals.done}/${storyTotals.total} tasks done)`); + for (const sid of storyIds) { + const s = epic.stories[sid]; + console.log(` ${mark(s.status)} ${sid} (${s.ac_completed}/${s.ac_total}) — ${s.title}`); + } + } +} + +function printNext() { + const state = buildState(WORK_ROOT); + const epicIds = Object.keys(state.epics).sort(); + for (const epicId of epicIds) { + const epic = state.epics[epicId]; + if (epic.status === "done") continue; + const storyIds = Object.keys(epic.stories).sort(); + for (const sid of storyIds) { + const s = epic.stories[sid]; + if (s.status !== "done") { + console.log(`${epicId} / ${sid} — ${s.title}`); + console.log(` status: ${s.status}, tasks: ${s.ac_completed}/${s.ac_total}`); + return; + } + } + } + console.log("All epics + stories are done. ✓"); +} + +function mark(status) { + if (status === "done") return "✓"; + if (status === "in-progress") return "→"; + if (status === "blocked") return "✗"; + return "○"; +} + +function usage() { + console.log("Usage: pnpm work "); + process.exit(2); +} + +const cmd = process.argv[2]; +if (cmd === "rebuild-state") rebuildState(); +else if (cmd === "status") printStatus(); +else if (cmd === "next") printNext(); +else usage(); diff --git a/scripts/work/cli.test.mjs b/scripts/work/cli.test.mjs new file mode 100644 index 0000000..901289f --- /dev/null +++ b/scripts/work/cli.test.mjs @@ -0,0 +1,42 @@ +import { describe, it, expect } from "vitest"; +import { execSync } from "node:child_process"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const CLI = path.join(__dirname, "cli.mjs"); + +function run(args) { + try { + return execSync(`node "${CLI}" ${args}`, { encoding: "utf8" }); + } catch (e) { + return e.stdout + e.stderr; + } +} + +describe("pnpm work cli", () => { + it("prints usage when no subcommand is given", () => { + const out = run(""); + expect(out).toContain("Usage:"); + expect(out).toContain("rebuild-state"); + expect(out).toContain("status"); + expect(out).toContain("next"); + }); + + it("rebuild-state writes _state.json", () => { + const out = run("rebuild-state"); + expect(out).toContain("Rebuilt"); + expect(out).toContain("epic"); + }); + + it("status prints a tree", () => { + const out = run("status"); + // We expect at least one epic-line marker character + expect(out).toMatch(/[✓→○]/); + }); + + it("next prints the next non-done story OR confirms all done", () => { + const out = run("next"); + expect(out.length).toBeGreaterThan(0); + }); +});