From 9562e63ec4de5faf3c3882b7fd1105cc608f1762 Mon Sep 17 00:00:00 2001 From: root Date: Sat, 18 Jul 2026 09:10:40 +0200 Subject: [PATCH] fix: harden TaskTree CLI contracts --- internal/tasktree/dispatcher.ts | 29 +++++-- internal/tasktree/runtime.ts | 1 + internal/tasktree/store.ts | 39 ++++++++-- internal/tasktree/tasktree.test.ts | 45 +++++++++++ tools/src/tasktree-cli.test.ts | 70 +++++++++++++++++ tools/src/tasktree-cli.ts | 117 +++++++++++++++++++++++++---- 6 files changed, 271 insertions(+), 30 deletions(-) diff --git a/internal/tasktree/dispatcher.ts b/internal/tasktree/dispatcher.ts index 41e90246..25bc7e17 100644 --- a/internal/tasktree/dispatcher.ts +++ b/internal/tasktree/dispatcher.ts @@ -43,20 +43,28 @@ export function createTaskTreeDispatcher(options: TaskTreeDispatcherOptions) { const tasks = await store.listTasks(task.groupId); data = { ...taskNode(task, tasks), reports: await store.listReports(task.id) }; } - else if (operation === "task.create") data = await store.createTask({ - ...command, - title: validTitle(command.title), - startAt: optionalDate(command.startAt, "startAt"), - dueAt: optionalDate(command.dueAt, "dueAt") - }); + else if (operation === "task.create") { + const startAt = optionalDate(command.startAt, "startAt"); + const dueAt = optionalDate(command.dueAt, "dueAt"); + validTimeRange(startAt, dueAt); + data = await store.createTask({ + ...command, + title: validTitle(command.title), + startAt, + dueAt + }); + } else if (operation === "task.create-batch") { const titles = command.titles.map(validTitle); if (titles.length < 1) throw codedError("invalid_input", "at least one title is required"); + const startAt = optionalDate(command.startAt, "startAt"); + const dueAt = optionalDate(command.dueAt, "dueAt"); + validTimeRange(startAt, dueAt); data = { tasks: await store.createTasks({ ...command, titles, - startAt: optionalDate(command.startAt, "startAt"), - dueAt: optionalDate(command.dueAt, "dueAt") + startAt, + dueAt }) }; } else if (operation === "task.update") { @@ -122,6 +130,11 @@ function optionalDate(value: string | undefined, field: string): string | undefi function nullableDate(value: string | null | undefined, field: string): string | null | undefined { return value === null || value === undefined ? value : validDate(value, field); } function validDate(value: unknown, field: string): string { const date = new Date(String(value ?? "")); if (Number.isNaN(date.valueOf())) throw codedError("invalid_input", `${field} must be an ISO date`); return date.toISOString(); } function codedError(code: string, message: string) { return Object.assign(new Error(message), { code }); } +function validTimeRange(startAt?: string, dueAt?: string) { + if (startAt !== undefined && dueAt !== undefined && new Date(startAt).valueOf() > new Date(dueAt).valueOf()) { + throw codedError("invalid_time_range", "startAt must be before or equal to dueAt"); + } +} type TaskNode = TaskItem & { children: TaskNode[] }; diff --git a/internal/tasktree/runtime.ts b/internal/tasktree/runtime.ts index 874f462d..744081f9 100644 --- a/internal/tasktree/runtime.ts +++ b/internal/tasktree/runtime.ts @@ -2,6 +2,7 @@ import { TaskTreeStore } from "./store.ts"; export function taskTreeRuntime(env: Record = process.env) { const databaseUrl = env.TASKTREE_DATABASE_URL || env.DATABASE_URL || ""; + if (!databaseUrl) throw Object.assign(new Error("TASKTREE_DATABASE_URL or DATABASE_URL is required"), { code: "missing_database_url" }); const store = new TaskTreeStore(databaseUrl); return { store, diff --git a/internal/tasktree/store.ts b/internal/tasktree/store.ts index d21150bc..7c6222c1 100644 --- a/internal/tasktree/store.ts +++ b/internal/tasktree/store.ts @@ -155,6 +155,7 @@ export class TaskTreeStore { async createTasks(input: { groupId: string; titles: string[]; description?: string; startAt?: string; dueAt?: string; parentId?: string }): Promise { await this.ensureSchema(); + ensureTimeRange(input.startAt ?? null, input.dueAt ?? null); const client = await this.pool.connect(); try { await client.query("BEGIN"); @@ -195,10 +196,13 @@ export class TaskTreeStore { const current = await this.pool.query("SELECT * FROM tasktree_tasks WHERE id=$1", [id]); if (!current.rows[0]) return null; const row = taskRow(current.rows[0]); + const startAt = patch.startAt === undefined ? row.startAt : patch.startAt; + const dueAt = patch.dueAt === undefined ? row.dueAt : patch.dueAt; + ensureTimeRange(startAt, dueAt); const result = await this.pool.query( `UPDATE tasktree_tasks SET title=$2,description=$3,status=$4,start_at=$5,due_at=$6,updated_at=now() WHERE id=$1 RETURNING *`, [id, patch.title ?? row.title, patch.description ?? row.description, patch.status ?? row.status, - patch.startAt === undefined ? row.startAt : patch.startAt, patch.dueAt === undefined ? row.dueAt : patch.dueAt] + startAt, dueAt] ); await this.pool.query("UPDATE tasktree_groups SET updated_at=now() WHERE id=$1", [row.groupId]); return taskRow(result.rows[0]); @@ -239,11 +243,29 @@ export class TaskTreeStore { async createMilestone(input: { groupId: string; title: string; occursAt: string; taskId?: string }): Promise { await this.ensureSchema(); - const result = await this.pool.query( - "INSERT INTO tasktree_milestones (id,group_id,task_id,title,occurs_at) VALUES ($1,$2,$3,$4,$5) RETURNING *", - [`tm_${randomUUID()}`, input.groupId, input.taskId ?? null, input.title, input.occursAt] - ); - return milestoneRow(result.rows[0]); + const client = await this.pool.connect(); + try { + await client.query("BEGIN"); + const group = await client.query("SELECT id FROM tasktree_groups WHERE id=$1 FOR UPDATE", [input.groupId]); + if (!group.rows[0]) throw domainError("group_not_found", "taskgroup was not found"); + if (input.taskId) { + const task = await client.query("SELECT group_id FROM tasktree_tasks WHERE id=$1", [input.taskId]); + if (!task.rows[0]) throw domainError("task_not_found", "task was not found"); + if (task.rows[0].group_id !== input.groupId) throw domainError("task_group_mismatch", "task belongs to another taskgroup"); + } + const result = await client.query( + "INSERT INTO tasktree_milestones (id,group_id,task_id,title,occurs_at) VALUES ($1,$2,$3,$4,$5) RETURNING *", + [`tm_${randomUUID()}`, input.groupId, input.taskId ?? null, input.title, input.occursAt] + ); + await client.query("UPDATE tasktree_groups SET updated_at=now() WHERE id=$1", [input.groupId]); + await client.query("COMMIT"); + return milestoneRow(result.rows[0]); + } catch (error) { + await client.query("ROLLBACK").catch(() => {}); + throw error; + } finally { + client.release(); + } } async createReport(input: { taskId: string; title: string; body: string; status?: string }): Promise { @@ -355,3 +377,8 @@ function reportRow(row: any): ExecutionReport { return { id: row.id, taskId: row function iso(value: unknown): string { return new Date(value as any).toISOString(); } function nullableIso(value: unknown): string | null { return value == null ? null : iso(value); } function domainError(code: string, message: string) { return Object.assign(new Error(message), { code }); } +function ensureTimeRange(startAt: string | null, dueAt: string | null) { + if (startAt !== null && dueAt !== null && new Date(startAt).valueOf() > new Date(dueAt).valueOf()) { + throw domainError("invalid_time_range", "startAt must be before or equal to dueAt"); + } +} diff --git a/internal/tasktree/tasktree.test.ts b/internal/tasktree/tasktree.test.ts index b4a2f2ad..7009412b 100644 --- a/internal/tasktree/tasktree.test.ts +++ b/internal/tasktree/tasktree.test.ts @@ -31,8 +31,10 @@ test("HTTP command endpoint returns the same dispatcher DTO", async () => { test("dispatcher keeps batch validation atomic and completion gated", async () => { let batchCalls = 0; + let createCalls = 0; let updateCalls = 0; const store = { + async createTask() { createCalls += 1; return null; }, async createTasks() { batchCalls += 1; return []; }, async updateTask() { updateCalls += 1; return null; }, async deleteTask() { return false; } @@ -44,6 +46,17 @@ test("dispatcher keeps batch validation atomic and completion gated", async () = assert.equal(batch.error?.code, "invalid_title"); assert.equal(batchCalls, 0); + const invalidRange = await dispatch({ + operation: "task.create", + groupId: "tg_1", + title: "Invalid range", + startAt: "2026-07-19T00:00:00.000Z", + dueAt: "2026-07-18T00:00:00.000Z" + }); + assert.equal(invalidRange.ok, false); + assert.equal(invalidRange.error?.code, "invalid_time_range"); + assert.equal(createCalls, 0); + const bypass = await dispatch({ operation: "task.update", taskId: "tt_1", status: "completed" }); assert.equal(bypass.ok, false); assert.equal(bypass.error?.code, "completion_command_required"); @@ -52,6 +65,10 @@ test("dispatcher keeps batch validation atomic and completion gated", async () = const missing = await dispatch({ operation: "task.delete", taskId: "tt_missing" }); assert.equal(missing.ok, false); assert.equal(missing.error?.code, "task_not_found"); + + const workflow = await dispatch({ operation: "workflow.start", taskId: "tt_1" }); + assert.equal(workflow.ok, false); + assert.equal(workflow.error?.code, "temporal_address_required"); }); test("native PostgreSQL store exposes overview and three task levels", async () => { @@ -59,6 +76,7 @@ test("native PostgreSQL store exposes overview and three task levels", async () assert.ok(databaseUrl, "TASKTREE_DATABASE_URL is required for the TaskTree store integration test"); const store = new TaskTreeStore(databaseUrl); const group = await store.createGroup(`TaskTree test ${Date.now()}`); + const otherGroup = await store.createGroup(`TaskTree cross-group test ${Date.now()}`); try { const task = await store.createTask({ groupId: group.id, @@ -71,6 +89,32 @@ test("native PostgreSQL store exposes overview and three task levels", async () const batch = await store.createTasks({ groupId: group.id, parentId: task.id, titles: ["Batch one", "Batch two"] }); assert.deepEqual(batch.map((item) => item.title), ["Batch one", "Batch two"]); await store.createMilestone({ groupId: group.id, taskId: task.id, title: "Review", occursAt: "2026-07-17T00:00:00.000Z" }); + await assert.rejects( + store.createMilestone({ groupId: otherGroup.id, taskId: task.id, title: "Wrong group", occursAt: "2026-07-17T00:00:00.000Z" }), + (error: any) => error?.code === "task_group_mismatch" + ); + await assert.rejects( + store.createMilestone({ groupId: "tg_missing", title: "Missing group", occursAt: "2026-07-17T00:00:00.000Z" }), + (error: any) => error?.code === "group_not_found" + ); + await assert.rejects( + store.createMilestone({ groupId: group.id, taskId: "tt_missing", title: "Missing task", occursAt: "2026-07-17T00:00:00.000Z" }), + (error: any) => error?.code === "task_not_found" + ); + await assert.rejects( + store.createTask({ + groupId: group.id, + title: "Invalid range", + startAt: "2026-07-19T00:00:00.000Z", + dueAt: "2026-07-18T00:00:00.000Z" + }), + (error: any) => error?.code === "invalid_time_range" + ); + await assert.rejects( + store.updateTask(task.id, { dueAt: "2026-07-15T00:00:00.000Z" }), + (error: any) => error?.code === "invalid_time_range" + ); + assert.equal((await store.getTask(task.id))?.dueAt, "2026-07-18T00:00:00.000Z"); await assert.rejects( store.completeTask(task.id), (error: any) => error?.code === "execution_report_required" @@ -117,6 +161,7 @@ test("native PostgreSQL store exposes overview and three task levels", async () assert.equal(overview?.startAt, "2026-07-16T00:00:00.000Z"); assert.equal(overview?.dueAt, "2026-07-18T00:00:00.000Z"); } finally { + await store.deleteGroup(otherGroup.id); await store.deleteGroup(group.id); await store.close(); } diff --git a/tools/src/tasktree-cli.test.ts b/tools/src/tasktree-cli.test.ts index 5b365123..e564a158 100644 --- a/tools/src/tasktree-cli.test.ts +++ b/tools/src/tasktree-cli.test.ts @@ -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; + } +}); diff --git a/tools/src/tasktree-cli.ts b/tools/src/tasktree-cli.ts index d8247add..39a76c75 100644 --- a/tools/src/tasktree-cli.ts +++ b/tools/src/tasktree-cli.ts @@ -8,6 +8,7 @@ import { taskTreeRuntime } from "../../internal/tasktree/runtime.ts"; export async function runTaskTreeCli(argv: string[], env: Record = 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, 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 = { + 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}`); + } +}