feat: complete TaskTree CLI parity
This commit is contained in:
@@ -5,7 +5,9 @@ const argv = process.argv.slice(2);
|
||||
if (argv[0] === "tasktree") {
|
||||
const { runTaskTreeCli } = await import("../../src/tasktree-cli.ts");
|
||||
try {
|
||||
const result = await runTaskTreeCli(argv.slice(1));
|
||||
const tasktreeArgs = argv.slice(1);
|
||||
const stdin = tasktreeArgs.includes("--stdin") ? await Bun.stdin.text() : undefined;
|
||||
const result = await runTaskTreeCli(tasktreeArgs, process.env, stdin);
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
process.exitCode = result?.ok === false ? 1 : 0;
|
||||
} catch (error) {
|
||||
|
||||
@@ -23,3 +23,60 @@ test("--overapi preserves the command DTO and only changes transport", async ()
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("TaskTree command surface covers hierarchy, lifecycle, batch, stats, and reports", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const received: unknown[] = [];
|
||||
globalThis.fetch = (async (_input, init) => {
|
||||
const command = JSON.parse(String(init?.body));
|
||||
received.push(command);
|
||||
return Response.json({ ok: true, operation: command.operation, data: {} });
|
||||
}) as typeof fetch;
|
||||
const env = { HWLAB_TASKTREE_API_URL: "http://tasktree.test" };
|
||||
try {
|
||||
const cases: Array<[string[], unknown, string?]> = [
|
||||
[["group", "stats", "--group", "tg_1", "--overapi"], { operation: "group.stats", groupId: "tg_1" }],
|
||||
[["task", "list", "--group", "tg_1", "--overapi"], { operation: "task.list", groupId: "tg_1" }],
|
||||
[["task", "get", "--task", "tt_1", "--overapi"], { operation: "task.get", taskId: "tt_1" }],
|
||||
[["task", "create", "--group", "tg_1", "--parent", "tt_1", "--stdin", "--overapi"], {
|
||||
operation: "task.create", groupId: "tg_1", parentId: "tt_1", title: "Child"
|
||||
}, "Child\n"],
|
||||
[["task", "create-batch", "--group", "tg_1", "--parent", "tt_1", "One", "Two", "--overapi"], {
|
||||
operation: "task.create-batch", groupId: "tg_1", parentId: "tt_1", titles: ["One", "Two"]
|
||||
}],
|
||||
[["task", "update", "--task", "tt_1", "--stdin", "--overapi"], {
|
||||
operation: "task.update", taskId: "tt_1", title: "Renamed"
|
||||
}, "Renamed\n"],
|
||||
[["task", "start", "--task", "tt_1", "--overapi"], { operation: "task.start", taskId: "tt_1" }],
|
||||
[["task", "done", "--task", "tt_1", "--overapi"], { operation: "task.complete", taskId: "tt_1" }],
|
||||
[["task", "remove", "--task", "tt_1", "--overapi"], { operation: "task.delete", taskId: "tt_1" }],
|
||||
[["report", "list", "--task", "tt_1", "--overapi"], { operation: "report.list", taskId: "tt_1" }],
|
||||
[["report", "get", "--report", "tr_1", "--overapi"], { operation: "report.get", reportId: "tr_1" }],
|
||||
[["report", "write", "--task", "tt_1", "--title", "Execution", "--stdin", "--overapi"], {
|
||||
operation: "report.write", taskId: "tt_1", title: "Execution", body: "# Result\n\nPassed.\n"
|
||||
}, "# Result\n\nPassed.\n"]
|
||||
];
|
||||
for (const [argv, expected, stdin] of cases) {
|
||||
await runTaskTreeCli(argv, env, stdin);
|
||||
assert.deepEqual(received.at(-1), expected);
|
||||
}
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("TaskTree help and public operations do not expose the legacy domain term", async () => {
|
||||
const result = await runTaskTreeCli(["--help"]);
|
||||
assert.doesNotMatch(JSON.stringify(result), /mdtodo/iu);
|
||||
});
|
||||
|
||||
test("TaskTree rejects ambiguous title input and empty update patches before transport", async () => {
|
||||
await assert.rejects(
|
||||
runTaskTreeCli(["task", "create", "--group", "tg_1", "--title", "One", "--stdin", "--overapi"], { HWLAB_TASKTREE_API_URL: "http://tasktree.test" }, "Two"),
|
||||
(error: any) => error?.code === "title_source_conflict"
|
||||
);
|
||||
await assert.rejects(
|
||||
runTaskTreeCli(["task", "update", "--task", "tt_1", "--overapi"], { HWLAB_TASKTREE_API_URL: "http://tasktree.test" }),
|
||||
(error: any) => error?.code === "update_patch_required"
|
||||
);
|
||||
});
|
||||
|
||||
+80
-12
@@ -5,10 +5,10 @@ import { createTaskTreeDispatcher } from "../../internal/tasktree/dispatcher.ts"
|
||||
import { loadMdtodoImportPayload } from "../../internal/tasktree/mdtodo-import.ts";
|
||||
import { taskTreeRuntime } from "../../internal/tasktree/runtime.ts";
|
||||
|
||||
export async function runTaskTreeCli(argv: string[], env: Record<string, string | undefined> = process.env) {
|
||||
export async function runTaskTreeCli(argv: string[], env: Record<string, string | undefined> = process.env, stdin?: string) {
|
||||
const parsed = parse(argv);
|
||||
if (parsed.help || parsed.positionals.length === 0) return help();
|
||||
const command = await commandFrom(parsed);
|
||||
const command = await commandFrom(parsed, stdin);
|
||||
if (parsed.overapi) return overApi(command, env, parsed);
|
||||
const runtime = taskTreeRuntime(env);
|
||||
try {
|
||||
@@ -25,19 +25,26 @@ function help() {
|
||||
mode: "local-dispatcher-default",
|
||||
usage: [
|
||||
"hwlab-cli tasktree health [--overapi]",
|
||||
"hwlab-cli tasktree group list|overview|get|create|delete [--group ID] [--name TEXT]",
|
||||
"hwlab-cli tasktree task create|update|delete --group ID|--task ID [--parent ID] --title TEXT --start ISO --due ISO --status STATUS",
|
||||
"hwlab-cli tasktree group list|overview|get|create|delete|stats [--group ID] [--name TEXT]",
|
||||
"hwlab-cli tasktree group import-markdown --file PATH [--name TEXT] [--dry-run]",
|
||||
"hwlab-cli tasktree task list|get --group ID|--task ID",
|
||||
"hwlab-cli tasktree task create --group ID [--parent ID] (--title TEXT|--stdin)",
|
||||
"hwlab-cli tasktree task create-batch --group ID [--parent ID] TITLE...",
|
||||
"hwlab-cli tasktree task update --task ID [--title TEXT|--stdin] [--description TEXT] [--status STATUS]",
|
||||
"hwlab-cli tasktree task start|complete|done|delete|remove --task ID",
|
||||
"hwlab-cli tasktree milestone create --group ID [--task ID] --title TEXT --at ISO",
|
||||
"hwlab-cli tasktree report create --task ID --title TEXT (--body TEXT|--body-file PATH)",
|
||||
"hwlab-cli tasktree import mdtodo --file PATH [--name TEXT] [--dry-run] [--overapi]",
|
||||
"hwlab-cli tasktree report list|get --task ID|--report ID",
|
||||
"hwlab-cli tasktree report write --task ID --title TEXT (--body TEXT|--body-file PATH|--stdin)",
|
||||
"hwlab-cli tasktree timeline --group ID [--overapi]",
|
||||
"hwlab-cli tasktree workflow start --task ID"
|
||||
],
|
||||
transportContract: "--overapi only changes transport; commands and options are identical"
|
||||
transportContract: "--overapi only changes transport; commands and options are identical",
|
||||
hierarchy: "taskgroup -> task -> subtask -> subsubtask",
|
||||
completionContract: "a task requires at least one execution report before completion"
|
||||
};
|
||||
}
|
||||
|
||||
async function commandFrom(parsed: Parsed): Promise<TaskTreeCommand> {
|
||||
async function commandFrom(parsed: Parsed, stdin?: string): Promise<TaskTreeCommand> {
|
||||
const [group, action = ""] = parsed.positionals;
|
||||
if (group === "health") return { operation: "health" };
|
||||
if (group === "timeline") return { operation: "timeline.get", groupId: required(parsed, "group") };
|
||||
@@ -46,12 +53,54 @@ async function commandFrom(parsed: Parsed): Promise<TaskTreeCommand> {
|
||||
if (group === "group" && action === "get") return { operation: "group.get", groupId: required(parsed, "group") };
|
||||
if (group === "group" && action === "create") return { operation: "group.create", name: required(parsed, "name"), description: parsed.values.description };
|
||||
if (group === "group" && action === "delete") return { operation: "group.delete", groupId: required(parsed, "group") };
|
||||
if (group === "task" && action === "create") return { operation: "task.create", groupId: required(parsed, "group"), parentId: parsed.values.parent, title: required(parsed, "title"), description: parsed.values.description, startAt: parsed.values.start, dueAt: parsed.values.due };
|
||||
if (group === "task" && action === "update") return { operation: "task.update", taskId: required(parsed, "task"), title: parsed.values.title, description: parsed.values.description, status: parsed.values.status as TaskStatus | undefined, startAt: parsed.values.start, dueAt: parsed.values.due };
|
||||
if (group === "task" && action === "delete") return { operation: "task.delete", taskId: required(parsed, "task") };
|
||||
if (group === "group" && action === "stats") return { operation: "group.stats", groupId: required(parsed, "group") };
|
||||
if (group === "group" && action === "import-markdown") return {
|
||||
operation: "group.import-markdown",
|
||||
...await loadMdtodoImportPayload(required(parsed, "file"), { groupName: parsed.values.name, dryRun: parsed.flags.has("dry-run") })
|
||||
};
|
||||
if (group === "task" && action === "list") return { operation: "task.list", groupId: required(parsed, "group") };
|
||||
if (group === "task" && action === "get") return { operation: "task.get", taskId: required(parsed, "task") };
|
||||
if (group === "task" && action === "create") return {
|
||||
operation: "task.create",
|
||||
groupId: required(parsed, "group"),
|
||||
parentId: parsed.values.parent,
|
||||
title: titleInput(parsed, stdin),
|
||||
description: parsed.values.description,
|
||||
startAt: parsed.values.start,
|
||||
dueAt: parsed.values.due
|
||||
};
|
||||
if (group === "task" && action === "create-batch") return {
|
||||
operation: "task.create-batch",
|
||||
groupId: required(parsed, "group"),
|
||||
parentId: parsed.values.parent,
|
||||
titles: parsed.positionals.slice(2),
|
||||
description: parsed.values.description,
|
||||
startAt: parsed.values.start,
|
||||
dueAt: parsed.values.due
|
||||
};
|
||||
if (group === "task" && action === "update") {
|
||||
const command: TaskTreeCommand = {
|
||||
operation: "task.update",
|
||||
taskId: required(parsed, "task"),
|
||||
title: parsed.flags.has("stdin") ? titleInput(parsed, stdin) : parsed.values.title,
|
||||
description: parsed.values.description,
|
||||
status: parsed.values.status as TaskStatus | undefined,
|
||||
startAt: nullableDateOption(parsed, "start"),
|
||||
dueAt: nullableDateOption(parsed, "due")
|
||||
};
|
||||
if (command.title === undefined && command.description === undefined && command.status === undefined && command.startAt === undefined && command.dueAt === undefined) {
|
||||
throw codedError("update_patch_required", "task update requires at least one field");
|
||||
}
|
||||
return command;
|
||||
}
|
||||
if (group === "task" && (action === "delete" || action === "remove")) return { operation: "task.delete", taskId: required(parsed, "task") };
|
||||
if (group === "task" && action === "start") return { operation: "task.start", taskId: required(parsed, "task") };
|
||||
if (group === "task" && (action === "complete" || action === "done")) return { operation: "task.complete", taskId: required(parsed, "task") };
|
||||
if (group === "milestone" && action === "create") return { operation: "milestone.create", groupId: required(parsed, "group"), taskId: parsed.values.task, title: required(parsed, "title"), occursAt: required(parsed, "at") };
|
||||
if (group === "report" && action === "list") return { operation: "report.list", taskId: required(parsed, "task") };
|
||||
if (group === "report" && action === "get") return { operation: "report.get", reportId: required(parsed, "report") };
|
||||
if (group === "report" && action === "write") return { operation: "report.write", taskId: required(parsed, "task"), title: required(parsed, "title"), body: await bodyInput(parsed, stdin), status: parsed.values.status };
|
||||
if (group === "report" && action === "create") return { operation: "report.create", taskId: required(parsed, "task"), title: required(parsed, "title"), body: parsed.values.body ?? await readFile(required(parsed, "body-file"), "utf8"), status: parsed.values.status };
|
||||
if (group === "import" && action === "mdtodo") return { operation: "mdtodo.import", ...await loadMdtodoImportPayload(required(parsed, "file"), { groupName: parsed.values.name, dryRun: parsed.flags.has("dry-run") }) };
|
||||
if (group === "workflow" && action === "start") return { operation: "workflow.start", taskId: required(parsed, "task") };
|
||||
throw new Error(`unsupported TaskTree command: ${group} ${action}`.trim());
|
||||
}
|
||||
@@ -72,6 +121,7 @@ function parse(argv: string[]): Parsed {
|
||||
const token = argv[index];
|
||||
if (token === "--overapi") parsed.overapi = true;
|
||||
else if (token === "--dry-run") parsed.flags.add("dry-run");
|
||||
else if (token === "--stdin") parsed.flags.add("stdin");
|
||||
else if (token === "--help" || token === "-h") parsed.help = true;
|
||||
else if (token.startsWith("--")) {
|
||||
const key = token.slice(2);
|
||||
@@ -84,3 +134,21 @@ function parse(argv: string[]): Parsed {
|
||||
return parsed;
|
||||
}
|
||||
function required(parsed: Parsed, key: string) { const value = parsed.values[key]; if (!value) throw new Error(`--${key} is required`); return value; }
|
||||
function titleInput(parsed: Parsed, stdin?: string) {
|
||||
if (parsed.flags.has("stdin") && parsed.values.title !== undefined) throw codedError("title_source_conflict", "--title and --stdin are mutually exclusive");
|
||||
const value = parsed.flags.has("stdin") ? stripOneLineEnding(stdin ?? "") : parsed.values.title;
|
||||
if (value === undefined || value.length === 0) throw codedError("title_required", "--title or --stdin is required");
|
||||
return value;
|
||||
}
|
||||
async function bodyInput(parsed: Parsed, stdin?: string) {
|
||||
const sources = Number(parsed.values.body !== undefined) + Number(parsed.values["body-file"] !== undefined) + Number(parsed.flags.has("stdin"));
|
||||
if (sources !== 1) throw codedError("body_source_invalid", "exactly one of --body, --body-file, or --stdin is required");
|
||||
if (parsed.flags.has("stdin")) return stdin ?? "";
|
||||
return parsed.values.body ?? await readFile(required(parsed, "body-file"), "utf8");
|
||||
}
|
||||
function nullableDateOption(parsed: Parsed, key: string): string | null | undefined {
|
||||
const value = parsed.values[key];
|
||||
return value === "null" ? null : value;
|
||||
}
|
||||
function stripOneLineEnding(value: string) { return value.endsWith("\r\n") ? value.slice(0, -2) : value.endsWith("\n") ? value.slice(0, -1) : value; }
|
||||
function codedError(code: string, message: string) { return Object.assign(new Error(message), { code }); }
|
||||
|
||||
Reference in New Issue
Block a user