240 lines
13 KiB
TypeScript
240 lines
13 KiB
TypeScript
import { readFile } from "node:fs/promises";
|
|
|
|
import type { TaskStatus, TaskTreeCommand } from "../../internal/tasktree/contracts.ts";
|
|
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, 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);
|
|
try {
|
|
return await createTaskTreeDispatcher(runtime)(command);
|
|
} finally {
|
|
await runtime.store.close();
|
|
}
|
|
}
|
|
|
|
function help() {
|
|
return {
|
|
ok: true,
|
|
operation: "help",
|
|
mode: "local-dispatcher-default",
|
|
usage: [
|
|
"hwlab-cli tasktree health [--overapi]",
|
|
"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 --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) [--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"
|
|
};
|
|
}
|
|
|
|
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") };
|
|
if (group === "group" && action === "list") return { operation: "group.list" };
|
|
if (group === "group" && action === "overview") return { operation: "group.overview" };
|
|
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 === "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 === "workflow" && action === "start") return { operation: "workflow.start", taskId: required(parsed, "task") };
|
|
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 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"
|
|
|| 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 };
|
|
}
|
|
|
|
type Parsed = { positionals: string[]; values: Record<string, string>; flags: Set<string>; overapi: boolean; help: boolean };
|
|
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") {
|
|
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 (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 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;
|
|
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 }); }
|
|
|
|
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}`);
|
|
}
|
|
}
|