fix: harden TaskTree CLI contracts

This commit is contained in:
root
2026-07-18 09:10:40 +02:00
parent c926dbfb91
commit 9562e63ec4
6 changed files with 271 additions and 30 deletions
+70
View File
@@ -68,6 +68,10 @@ test("TaskTree command surface covers hierarchy, lifecycle, batch, stats, and re
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);
assert.ok(result.usage.includes("hwlab-cli tasktree group list|overview"));
assert.ok(result.usage.includes("hwlab-cli tasktree group get|delete|stats --group ID"));
assert.match(result.usage.join("\n"), /task update .*--start ISO\|null.*--due ISO\|null/u);
assert.doesNotMatch(result.usage.join("\n"), /report create/u);
});
test("TaskTree rejects ambiguous title input and empty update patches before transport", async () => {
@@ -80,3 +84,69 @@ test("TaskTree rejects ambiguous title input and empty update patches before tra
(error: any) => error?.code === "update_patch_required"
);
});
test("TaskTree rejects unknown, duplicate, hidden, and extra CLI arguments", async () => {
const cases: Array<[string[], string]> = [
[["group", "list", "unexpected"], "invalid_arguments"],
[["group", "list", "--bogus", "value"], "invalid_option"],
[["group", "create", "--name", "One", "--name", "Two"], "duplicate_option"],
[["report", "create", "--task", "tt_1", "--title", "Hidden", "--body", "body"], "unsupported_command"],
[["group", "list", "--api-url", "http://tasktree.test"], "invalid_option"],
[["group", "list", "--stdin"], "invalid_option"]
];
for (const [argv, code] of cases) {
await assert.rejects(runTaskTreeCli(argv), (error: any) => error?.code === code);
}
});
test("TaskTree accepts an explicit empty description value", async () => {
const originalFetch = globalThis.fetch;
let received: unknown;
globalThis.fetch = (async (_input, init) => {
received = JSON.parse(String(init?.body));
return Response.json({ ok: true, operation: "group.create", data: {} });
}) as typeof fetch;
try {
await runTaskTreeCli(
["group", "create", "--name", "Group", "--description", "", "--overapi"],
{ HWLAB_TASKTREE_API_URL: "http://tasktree.test" }
);
assert.deepEqual(received, { operation: "group.create", name: "Group", description: "" });
} finally {
globalThis.fetch = originalFetch;
}
});
test("TaskTree returns stable transport and runtime configuration errors", async () => {
await assert.rejects(
runTaskTreeCli(["group", "list", "--overapi"]),
(error: any) => error?.code === "missing_api_url"
);
await assert.rejects(
runTaskTreeCli(["health"], {}),
(error: any) => error?.code === "missing_database_url"
);
const originalFetch = globalThis.fetch;
try {
globalThis.fetch = (async () => { throw new Error("connection refused"); }) as typeof fetch;
await assert.rejects(
runTaskTreeCli(["group", "list", "--overapi"], { HWLAB_TASKTREE_API_URL: "http://tasktree.test" }),
(error: any) => error?.code === "api_unreachable"
);
for (const response of [
new Response("not json", { status: 502 }),
Response.json({ ok: true, operation: "task.get", data: {} }),
Response.json({ ok: true, operation: "group.list", data: {} }, { status: 500 })
]) {
globalThis.fetch = (async () => response) as typeof fetch;
await assert.rejects(
runTaskTreeCli(["group", "list", "--overapi"], { HWLAB_TASKTREE_API_URL: "http://tasktree.test" }),
(error: any) => error?.code === "invalid_api_response"
);
}
} finally {
globalThis.fetch = originalFetch;
}
});
+101 -16
View File
@@ -8,6 +8,7 @@ import { taskTreeRuntime } from "../../internal/tasktree/runtime.ts";
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();
validateArguments(parsed);
const command = await commandFrom(parsed, stdin);
if (parsed.overapi) return overApi(command, env, parsed);
const runtime = taskTreeRuntime(env);
@@ -25,20 +26,24 @@ function help() {
mode: "local-dispatcher-default",
usage: [
"hwlab-cli tasktree health [--overapi]",
"hwlab-cli tasktree group list|overview|get|create|delete|stats [--group ID] [--name TEXT]",
"hwlab-cli tasktree group list|overview",
"hwlab-cli tasktree group get|delete|stats --group ID",
"hwlab-cli tasktree group create --name TEXT [--description 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 list --group ID",
"hwlab-cli tasktree task get --task ID",
"hwlab-cli tasktree task create --group ID [--parent ID] (--title TEXT|--stdin) [--description TEXT] [--start ISO] [--due ISO]",
"hwlab-cli tasktree task create-batch --group ID [--parent ID] [--description TEXT] [--start ISO] [--due ISO] TITLE...",
"hwlab-cli tasktree task update --task ID [--title TEXT|--stdin] [--description TEXT] [--status STATUS] [--start ISO|null] [--due ISO|null]",
"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 list|get --task ID|--report ID",
"hwlab-cli tasktree report write --task ID --title TEXT (--body TEXT|--body-file PATH|--stdin)",
"hwlab-cli tasktree report write --task ID --title TEXT (--body TEXT|--body-file PATH|--stdin) [--status STATUS]",
"hwlab-cli tasktree timeline --group ID [--overapi]",
"hwlab-cli tasktree workflow start --task ID"
],
transportContract: "--overapi only changes transport; commands and options are identical",
transportOptions: "--overapi [--api-url URL]",
hierarchy: "taskgroup -> task -> subtask -> subsubtask",
completionContract: "a task requires at least one execution report before completion"
};
@@ -100,17 +105,33 @@ async function commandFrom(parsed: Parsed, stdin?: string): Promise<TaskTreeComm
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 === "workflow" && action === "start") return { operation: "workflow.start", taskId: required(parsed, "task") };
throw new Error(`unsupported TaskTree command: ${group} ${action}`.trim());
throw codedError("unsupported_command", `unsupported TaskTree command: ${group} ${action}`.trim());
}
async function overApi(command: TaskTreeCommand, env: Record<string, string | undefined>, parsed: Parsed) {
const baseUrl = parsed.values["api-url"] || env.HWLAB_TASKTREE_API_URL;
if (!baseUrl) throw new Error("HWLAB_TASKTREE_API_URL or --api-url is required with --overapi");
const response = await fetch(`${baseUrl.replace(/\/$/u, "")}/v1/tasktree/commands`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(command) });
if (!baseUrl) throw codedError("missing_api_url", "HWLAB_TASKTREE_API_URL or --api-url is required with --overapi");
let response: Response;
try {
response = await fetch(`${baseUrl.replace(/\/$/u, "")}/v1/tasktree/commands`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(command)
});
} catch {
throw codedError("api_unreachable", "TaskTree API request failed");
}
const body = await response.json().catch(() => null);
if (!body || typeof body !== "object") throw new Error(`TaskTree API returned invalid JSON (${response.status})`);
if (
!body
|| typeof body !== "object"
|| typeof (body as any).ok !== "boolean"
|| (body as any).operation !== command.operation
|| response.ok !== (body as any).ok
) {
throw codedError("invalid_api_response", `TaskTree API returned an invalid response (${response.status})`);
}
return { ...(body as object), transport: "api", httpStatus: response.status };
}
@@ -119,21 +140,28 @@ function parse(argv: string[]): Parsed {
const parsed: Parsed = { positionals: [], values: {}, flags: new Set(), overapi: false, help: false };
for (let index = 0; index < argv.length; index += 1) {
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");
if (token === "--overapi") {
if (parsed.overapi) throw codedError("duplicate_option", "--overapi may only be provided once");
parsed.overapi = true;
}
else if (token === "--dry-run" || token === "--stdin") {
const flag = token.slice(2);
if (parsed.flags.has(flag)) throw codedError("duplicate_option", `${token} may only be provided once`);
parsed.flags.add(flag);
}
else if (token === "--help" || token === "-h") parsed.help = true;
else if (token.startsWith("--")) {
const key = token.slice(2);
const value = argv[index + 1];
if (!value || value.startsWith("--")) throw new Error(`${token} requires a value`);
if (parsed.values[key] !== undefined) throw codedError("duplicate_option", `${token} may only be provided once`);
if (value === undefined || value.startsWith("--")) throw codedError("missing_option_value", `${token} requires a value`);
parsed.values[key] = value;
index += 1;
} else parsed.positionals.push(token);
}
return parsed;
}
function required(parsed: Parsed, key: string) { const value = parsed.values[key]; if (!value) throw new Error(`--${key} is required`); return value; }
function required(parsed: Parsed, key: string) { const value = parsed.values[key]; if (!value) throw codedError("missing_option", `--${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;
@@ -152,3 +180,60 @@ function nullableDateOption(parsed: Parsed, key: string): string | null | undefi
}
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 }); }
type CommandSpec = {
positionals: number | { min: number; max?: number };
options?: string[];
flags?: string[];
};
const COMMAND_SPECS: Record<string, CommandSpec> = {
health: { positionals: 1 },
timeline: { positionals: 1, options: ["group"] },
"group list": { positionals: 2 },
"group overview": { positionals: 2 },
"group get": { positionals: 2, options: ["group"] },
"group create": { positionals: 2, options: ["name", "description"] },
"group delete": { positionals: 2, options: ["group"] },
"group stats": { positionals: 2, options: ["group"] },
"group import-markdown": { positionals: 2, options: ["file", "name"], flags: ["dry-run"] },
"task list": { positionals: 2, options: ["group"] },
"task get": { positionals: 2, options: ["task"] },
"task create": { positionals: 2, options: ["group", "parent", "title", "description", "start", "due"], flags: ["stdin"] },
"task create-batch": { positionals: { min: 3 }, options: ["group", "parent", "description", "start", "due"] },
"task update": { positionals: 2, options: ["task", "title", "description", "status", "start", "due"], flags: ["stdin"] },
"task delete": { positionals: 2, options: ["task"] },
"task remove": { positionals: 2, options: ["task"] },
"task start": { positionals: 2, options: ["task"] },
"task complete": { positionals: 2, options: ["task"] },
"task done": { positionals: 2, options: ["task"] },
"milestone create": { positionals: 2, options: ["group", "task", "title", "at"] },
"report list": { positionals: 2, options: ["task"] },
"report get": { positionals: 2, options: ["report"] },
"report write": { positionals: 2, options: ["task", "title", "body", "body-file", "status"], flags: ["stdin"] },
"workflow start": { positionals: 2, options: ["task"] }
};
function validateArguments(parsed: Parsed) {
const key = parsed.positionals.slice(0, 2).join(" ");
const spec = COMMAND_SPECS[key] ?? COMMAND_SPECS[parsed.positionals[0]];
if (!spec) throw codedError("unsupported_command", `unsupported TaskTree command: ${key}`);
const positionals = typeof spec.positionals === "number"
? { min: spec.positionals, max: spec.positionals }
: spec.positionals;
if (parsed.positionals.length < positionals.min || (positionals.max !== undefined && parsed.positionals.length > positionals.max)) {
throw codedError("invalid_arguments", `unexpected positional arguments for ${key}`);
}
const allowedOptions = new Set(spec.options ?? []);
if (parsed.overapi) allowedOptions.add("api-url");
for (const option of Object.keys(parsed.values)) {
if (!allowedOptions.has(option)) throw codedError("invalid_option", `--${option} is not valid for ${key}`);
}
if (parsed.values["api-url"] !== undefined && !parsed.overapi) {
throw codedError("invalid_option", "--api-url requires --overapi");
}
const allowedFlags = new Set(spec.flags ?? []);
for (const flag of parsed.flags) {
if (!allowedFlags.has(flag)) throw codedError("invalid_option", `--${flag} is not valid for ${key}`);
}
}