Files
Panama/user/agents/skills/ticket/scripts/ticket-page.ts
T

437 lines
23 KiB
TypeScript

// ticket-page: the only writer of a ticket's page data. See ../SITE.md for when the
// ticket skill calls each command. Run `ticket-page help` for the command list.
import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, join, relative, resolve } from "node:path";
import { spawnSync } from "node:child_process";
import { createHash } from "node:crypto";
import { htmlToText, importIssue, type JiraIssue, scanResources } from "./jira-import";
import {
type Criterion, type Epic, type Finding, type Mock, now, type Plan, progress, readData, type Step,
storyStatus, type Ticket, writeData,
} from "./ticket-data";
const SITE_TEMPLATES = resolve(import.meta.dir, "../templates/site");
class UsageError extends Error {}
function fail(msg: string): never { throw new UsageError(msg); }
/** --flag value pairs and bare --switches after the positional arguments. */
function parse(argv: string[]) {
const pos: string[] = [];
const opt = new Map<string, string[]>();
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (!a.startsWith("--")) { pos.push(a); continue; }
const name = a.slice(2);
const next = argv[i + 1];
const value = next === undefined || next.startsWith("--") ? "true" : (i++, next);
opt.set(name, [...(opt.get(name) ?? []), value]);
}
return { pos, one: (k: string) => opt.get(k)?.at(-1), all: (k: string) => opt.get(k) ?? [], has: (k: string) => opt.has(k) };
}
const list = (s?: string) => (s ? s.split(",").map(x => x.trim()).filter(Boolean) : []);
// ── Where things live ────────────────────────────────────────────────────────
export function docsRoot(): string {
if (process.env.TICKET_DOCS) return resolve(process.env.TICKET_DOCS);
const git = spawnSync("git", ["rev-parse", "--show-toplevel"], { encoding: "utf8" });
if (git.status !== 0) fail("not inside a git repository, and TICKET_DOCS is not set");
return join(git.stdout.trim(), ".claude/docs/epics");
}
function ticketDir(key: string): string {
const root = docsRoot();
const hit = existsSync(root) && readdirSync(root).map(d => join(root, d, key)).find(d => existsSync(join(d, "ticket.data.js")));
return hit || fail(`no ticket page for ${key} under ${root}. Run: ticket-page init ${key} --issue <issue.raw.json>`);
}
const ticketFile = (dir: string) => join(dir, "ticket.data.js");
const epicFile = (epicKey: string) => join(docsRoot(), epicKey, "epic.data.js");
/** Copies the shared renderer into _site/ when the skill's copy changed, and writes the
* page shell. Run on every write so an old ticket picks up the current renderer. */
function refreshSite(pageDir: string, kind: "ticket" | "epic", title: string) {
const site = join(docsRoot(), "_site");
mkdirSync(site, { recursive: true });
const version = createHash("sha1");
for (const f of ["site.css", "site.js"]) {
const from = join(SITE_TEMPLATES, f), to = join(site, f);
const body = readFileSync(from, "utf8");
version.update(body);
if (!existsSync(to) || readFileSync(to, "utf8") !== body) copyFileSync(from, to);
}
// The shell names the renderer by content hash, so a browser that cached an older
// renderer loads the new one the next time the page opens.
const shell = readFileSync(join(SITE_TEMPLATES, `${kind}.html`), "utf8")
.replaceAll("{{SITE}}", relative(pageDir, site) || ".")
.replaceAll("{{VERSION}}", version.digest("hex").slice(0, 10))
.replaceAll("{{TITLE}}", title.replace(/[<&]/g, c => (c === "<" ? "&lt;" : "&amp;")));
const page = join(pageDir, "index.html");
if (!existsSync(page) || readFileSync(page, "utf8") !== shell) writeFileSync(page, shell);
}
// ── Ticket reads and writes ──────────────────────────────────────────────────
function load(key: string) {
const dir = ticketDir(key);
return { dir, t: readData(ticketFile(dir), "ticket") };
}
function save(dir: string, t: Ticket) {
writeData(ticketFile(dir), "ticket", t);
refreshSite(dir, "ticket", `${t.key}: ${t.title}`);
if (t.epic) syncStory(t);
}
function log(t: Ticket, kind: string, text: string, commit?: string) {
t.events.push({ at: now(), kind, text, ...(commit ? { commit } : {}) });
}
/** Keeps this ticket's row on its epic index current. Creates the epic page if the
* ticket is the first one worked under it. A story marked done stays done. */
function syncStory(t: Ticket) {
const epic = t.epic!;
const file = epicFile(epic.key);
const e: Epic = existsSync(file) ? readData(file, "epic")
: { schema: 1, key: epic.key, title: epic.title, jira: t.jira.replace(t.key, epic.key), why: [], stories: [], closed: [], updatedAt: now() };
let s = e.stories.find(x => x.key === t.key);
if (!s) { s = { key: t.key, title: t.title, after: [], status: "todo" }; e.stories.push(s); }
s.title = t.title;
s.page = true;
s.progress = progress(t);
if (s.status !== "done") s.status = storyStatus(t);
writeData(file, "epic", e);
refreshSite(dirname(file), "epic", `${e.key}: ${e.title}`);
}
// ── Commands ─────────────────────────────────────────────────────────────────
type Args = ReturnType<typeof parse>;
const commands: Record<string, { usage: string; run: (a: Args) => void }> = {};
const command = (name: string, usage: string, run: (a: Args) => void) => { commands[name] = { usage, run }; };
command("init", "init <KEY> --issue <issue.raw.json> [--reset] import the ticket, keeping any plan unless --reset", a => {
const [key] = a.pos;
const issuePath = a.one("issue") ?? fail("init needs --issue <issue.raw.json>");
const issue: JiraIssue = JSON.parse(readFileSync(issuePath, "utf8"));
if (issue.key !== key) fail(`${issuePath} holds ${issue.key}, not ${key}`);
const imported = importIssue(issue, process.env.JIRA_BASE_URL ?? "https://ksense-tech.atlassian.net");
const { unplaced, ...meta } = imported;
const dir = join(docsRoot(), meta.epic?.key ?? "tickets", key);
mkdirSync(join(dir, "resources"), { recursive: true });
const prior = existsSync(ticketFile(dir)) ? readData(ticketFile(dir), "ticket") : null;
const keep = prior && !a.has("reset") ? prior : null;
const t: Ticket = {
schema: 1, ...meta,
branch: keep?.branch, classified: keep?.classified,
// Notes the agent wrote survive a re-import. Jira's own sections are replaced.
source: { ...meta.source, sections: [...meta.source.sections, ...(keep?.source.sections.filter(s => s.origin === "note") ?? [])] },
attachments: scanResources(dir),
plan: keep?.plan ?? null, proof: keep?.proof ?? [], review: keep?.review ?? { findings: [], preMr: null },
jiraFields: keep?.jiraFields ?? [], mr: keep?.mr ?? null, events: keep?.events ?? [], updatedAt: now(),
};
log(t, "fetch", prior ? (keep ? "Re-imported the ticket from Jira, plan kept" : "Re-imported the ticket from Jira, plan cleared") : "Imported the ticket from Jira");
save(dir, t);
console.log(dir);
if (unplaced.length) console.log(`\nPopulated fields not on the page. Add any that matter with \`ticket-page note\`:\n ${unplaced.join("\n ")}`);
});
command("scan", "scan <KEY> re-read resources/ after downloads, transcripts or frames change", a => {
const { dir, t } = load(a.pos[0]);
t.attachments = scanResources(dir);
const withTranscript = t.attachments.filter(x => x.transcript?.length).length;
log(t, "fetch", `Scanned ${t.attachments.length} attachments, ${withTranscript} with a transcript`);
save(dir, t);
});
command("meta", "meta <KEY> [--branch B] [--classified bounded|architectural]", a => {
const { dir, t } = load(a.pos[0]);
if (a.has("branch")) t.branch = a.one("branch");
const c = a.one("classified");
if (c) t.classified = c === "bounded" || c === "architectural" ? c : fail("--classified is bounded or architectural");
save(dir, t);
});
command("note", "note <KEY> <title> --file <notes.html> add or replace an agent-written section on the Ticket tab", a => {
const [key, title] = a.pos;
if (!title) fail("note needs a title");
const { dir, t } = load(key);
const html = readFileSync(a.one("file") ?? fail("note needs --file"), "utf8");
const id = "note-" + title.toLowerCase().replace(/[^a-z0-9]+/g, "-");
t.source.sections = [...t.source.sections.filter(s => s.id !== id), { id, title, html, origin: "note" }];
save(dir, t);
});
command("plan", "plan <KEY> --file <plan.json> [--amend] write the plan. A rewrite resets progress and proof; --amend keeps both for the steps and criteria that remain", a => {
const { dir, t } = load(a.pos[0]);
type StepIn = Omit<Step, "status" | "commits" | "doneAt"> & Partial<Pick<Step, "kind">>;
type CritIn = Omit<Criterion, "status" | "doneAt" | "evidence">;
const input = JSON.parse(readFileSync(a.one("file") ?? fail("plan needs --file"), "utf8")) as
Partial<Omit<Plan, "steps" | "criteria">> & { steps: StepIn[]; criteria: CritIn[] };
const prior = t.plan;
const amend = a.has("amend");
if (amend && !prior) fail("--amend needs an existing plan");
const was = <T,>(list: T[] | undefined, match: (x: T) => boolean) => (amend ? list?.find(match) : undefined);
const plan: Plan = {
writtenAt: now(),
...(amend && prior?.approvedAt ? { approvedAt: prior.approvedAt } : {}),
criteria: (input.criteria ?? fail("plan.json needs criteria")).map(c => {
const old = was(prior?.criteria, x => x.id === c.id);
return { ...c, steps: c.steps ?? [], status: old?.status ?? "todo", ...(old?.doneAt ? { doneAt: old.doneAt } : {}), ...(old?.evidence ? { evidence: old.evidence } : {}) };
}),
questions: input.questions ?? [],
approach: input.approach ?? "",
decisions: input.decisions ?? [],
steps: (input.steps ?? fail("plan.json needs steps")).map(s => {
const old = was(prior?.steps, x => x.n === s.n && x.text === s.text);
return { ...s, kind: s.kind ?? "code", status: old?.status ?? "todo", commits: old?.commits ?? [], ...(old?.doneAt ? { doneAt: old.doneAt } : {}) };
}),
risks: input.risks ?? [],
tests: input.tests ?? [],
mocks: (input.mocks ?? prior?.mocks ?? []).map(m => ({ ...m, criteria: m.criteria ?? [] })),
};
t.plan = plan;
const ids = new Set(plan.criteria.map(c => c.id));
t.proof = amend ? t.proof.map(p => ({ ...p, proves: p.proves.filter(c => ids.has(c)) })).filter(p => p.proves.length) : [];
log(t, "plan", amend ? `Amended the plan: ${plan.criteria.length} criteria, ${plan.steps.length} steps`
: `Wrote the plan: ${plan.criteria.length} criteria, ${plan.steps.length} steps${t.classified ? `, classified ${t.classified}` : ""}`);
save(dir, t);
});
command("plan-json", "plan-json <KEY> print the current plan as plan.json, to edit and reload with plan --amend", a => {
const pl = load(a.pos[0]).t.plan ?? fail("there is no plan yet");
console.log(JSON.stringify({
criteria: pl.criteria.map(({ id, text, steps }) => ({ id, text, steps })),
questions: pl.questions, approach: pl.approach, decisions: pl.decisions,
steps: pl.steps.map(({ n, text, kind, tdd }) => ({ n, text, kind, ...(tdd ? { tdd } : {}) })),
risks: pl.risks, tests: pl.tests,
}, null, 2));
});
command("mock", "mock <KEY> <id> --title T --criteria AC1,AC2 --file <artboard.html> [--note N] add or replace a mock artboard", a => {
const [key, id] = a.pos;
const { dir, t } = load(key);
if (!t.plan) fail("write the plan before its mocks");
const m: Mock = { id: id ?? fail("mock needs an id"), title: a.one("title") ?? fail("mock needs --title"), note: a.one("note") ?? "", html: readFileSync(a.one("file") ?? fail("mock needs --file"), "utf8"),
criteria: list(a.one("criteria")).length ? list(a.one("criteria")) : fail("mock needs --criteria, the criteria it illustrates") };
t.plan!.mocks = [...t.plan!.mocks.filter(x => x.id !== id), m];
save(dir, t);
});
command("approve", "approve <KEY> mark the plan approved; Phase 2 starts", a => {
const { dir, t } = load(a.pos[0]);
if (!t.plan) fail("there is no plan to approve");
t.plan!.approvedAt = now();
log(t, "approve", "Plan approved");
save(dir, t);
});
command("step", "step <KEY> <n> running|done|todo [--commit SHA]... done needs at least one commit, or --no-commit for a step with none", a => {
const [key, n, status] = a.pos;
const { dir, t } = load(key);
const step = t.plan?.steps.find(s => s.n === Number(n)) ?? fail(`no step ${n}`);
if (status !== "running" && status !== "done" && status !== "todo") fail("status is running, done or todo");
const commits = a.all("commit");
if (status === "done" && !commits.length && !step.commits.length && !a.has("no-commit")) fail(`step ${n} is done only once its commit exists: pass --commit <sha>`);
if (status === "running") t.plan!.steps.forEach(s => { if (s.status === "running") s.status = "todo"; });
step.status = status;
step.commits = [...new Set([...step.commits, ...commits])];
step.doneAt = status === "done" ? now() : undefined;
if (status === "running") log(t, "start", `Started step ${n}: ${step.text}`);
if (status === "done") log(t, "commit", `Finished step ${n}`, commits.at(-1));
save(dir, t);
});
command("criterion", "criterion <KEY> <id> done|todo [--evidence TEXT]", a => {
const [key, id, status] = a.pos;
const { dir, t } = load(key);
const c = t.plan?.criteria.find(x => x.id === id) ?? fail(`no criterion ${id}`);
if (status !== "done" && status !== "todo") fail("status is done or todo");
c.status = status;
c.doneAt = status === "done" ? now() : undefined;
if (a.has("evidence")) c.evidence = a.one("evidence");
if (status === "done") log(t, "criterion", `Met ${id}${c.evidence ? `: ${c.evidence}` : ""}`);
save(dir, t);
});
command("proof", "proof <KEY> (--file proof/<name> | --test NAME) --proves AC1,AC2 --caption TEXT [--mock ID]", a => {
const { dir, t } = load(a.pos[0]);
const file = a.one("file"), test = a.one("test");
if (!file === !test) fail("proof takes exactly one of --file or --test");
const proves = list(a.one("proves"));
if (!proves.length) fail("proof needs --proves");
const caption = a.one("caption") ?? fail("proof needs --caption");
if (file && !existsSync(join(dir, file))) fail(`${file} does not exist in ${dir}`);
const isText = !!file && /\.(txt|log|md|json|csv|diff)$/i.test(file);
t.proof = t.proof.filter(p => (file ? p.file !== file : p.test !== test));
t.proof.push({
kind: test ? "test" : isText ? "text" : "image", ...(file ? { file } : { test }),
...(isText ? { text: readFileSync(join(dir, file!), "utf8") } : {}),
proves, caption, ...(a.one("mock") ? { mock: a.one("mock") } : {}), at: now(),
});
log(t, "proof", `Captured proof for ${proves.join(", ")}: ${caption}`);
save(dir, t);
});
command("unproof", "unproof <KEY> <file-or-test> drop a proof entry, for files cut from proof/", a => {
const [key, what] = a.pos;
const { dir, t } = load(key);
t.proof = t.proof.filter(p => p.file !== what && p.test !== what);
save(dir, t);
});
command("finding", "finding <KEY> add TEXT --severity CONFIRMED|PLAUSIBLE [--where file:line] | finding <KEY> <id> fixed --commit SHA | rejected --reason TEXT", a => {
const [key, idOrAdd, arg] = a.pos;
const { dir, t } = load(key);
if (idOrAdd === "add") {
const severity = a.one("severity");
if (severity !== "CONFIRMED" && severity !== "PLAUSIBLE") fail("--severity is CONFIRMED or PLAUSIBLE");
const f: Finding = { id: `F${t.review.findings.length + 1}`, severity, text: arg ?? fail("finding add needs its text"), status: "open", at: now(), ...(a.one("where") ? { where: a.one("where") } : {}) };
t.review.findings.push(f);
log(t, "review", `Review finding ${f.id}: ${f.text}`);
console.log(f.id);
} else {
const f = t.review.findings.find(x => x.id === idOrAdd) ?? fail(`no finding ${idOrAdd}`);
if (arg === "fixed") { f.status = "fixed"; f.commit = a.one("commit") ?? fail("fixed needs --commit"); log(t, "fix", `Fixed ${f.id}: ${f.text}`, f.commit); }
else if (arg === "rejected") { f.status = "rejected"; f.reason = a.one("reason") ?? fail("rejected needs --reason"); log(t, "review", `Rejected ${f.id}: ${f.reason}`); }
else fail("finding <id> takes fixed or rejected");
f.at = now();
}
save(dir, t);
});
command("verdict", "verdict <KEY> <verdict> [--recommendation R] [--head SHA] record a pre-mr-review run", a => {
const [key, ...words] = a.pos;
const { dir, t } = load(key);
const verdict = words.join(" ") || fail("verdict needs the verdict text");
t.review.preMr = { verdict, recommendation: a.one("recommendation"), head: a.one("head"), runs: (t.review.preMr?.runs ?? 0) + 1, at: now() };
log(t, "verdict", `pre-mr-review: ${verdict}`);
save(dir, t);
});
command("jira", "jira <KEY> <field name> record a Jira field as filled", a => {
const [key, ...name] = a.pos;
const { dir, t } = load(key);
const field = name.join(" ") || fail("jira needs the field name");
t.jiraFields = [...t.jiraFields.filter(f => f.name !== field), { name: field, at: now() }];
log(t, "jira", `Filled ${field} in Jira`);
save(dir, t);
});
command("mr", "mr <KEY> record that mr.md is written", a => {
const { dir, t } = load(a.pos[0]);
t.mr = { at: now() };
log(t, "mr", "Wrote mr.md");
save(dir, t);
});
command("event", "event <KEY> TEXT [--kind K] [--commit SHA] add a line to the live log", a => {
const [key, ...text] = a.pos;
const { dir, t } = load(key);
log(t, a.one("kind") ?? "note", text.join(" ") || fail("event needs text"), a.one("commit"));
save(dir, t);
});
command("show", "show <KEY> print where the ticket stands", a => {
const { dir, t } = load(a.pos[0]);
const p = progress(t)!;
console.log(`${t.key} ${storyStatus(t)} criteria ${p.criteria.join("/")} steps ${p.steps.join("/")} proof ${t.proof.length}\n${join(dir, "index.html")}`);
for (const s of t.plan?.steps ?? []) console.log(` ${s.status === "done" ? "x" : s.status === "running" ? ">" : " "} ${s.n}. ${s.text}${s.commits.length ? ` ${s.commits.join(" ")}` : ""}`);
});
command("path", "path <KEY> print the page path", a => console.log(join(ticketDir(a.pos[0]), "index.html")));
command("open", "open <KEY> open the page in the browser", a => {
spawnSync("xdg-open", [join(ticketDir(a.pos[0]), "index.html")], { stdio: "ignore" });
});
command("md", "md <KEY> print the ticket and plan as markdown, for an agent's reading order", a => {
const { t } = load(a.pos[0]);
const out: string[] = [`# ${t.key}: ${t.title}`, "", `${t.type}, ${t.status}. ${t.jira}`, ""];
for (const [k, v] of t.source.fields) out.push(`- ${k}: ${v}`);
for (const s of t.source.sections) out.push("", `## ${s.title}${s.origin === "note" ? " (notes)" : ""}`, "", htmlToText(s.html));
const pl = t.plan;
if (pl) {
out.push("", "# Plan", "", "## Acceptance criteria");
for (const c of pl.criteria) out.push(`- [${c.status === "done" ? "x" : " "}] ${c.id}: ${c.text} (steps ${c.steps.join(", ")})`);
out.push("", "## Open questions", ...(pl.questions.length ? pl.questions.map(q => `- ${htmlToText(q)}`) : ["None"]));
out.push("", "## Approach", "", htmlToText(pl.approach));
if (pl.decisions.length) out.push("", "## Decisions", ...pl.decisions.map(([q, c, w]) => `- ${q}: ${c}. ${w}`));
out.push("", "## Steps", ...pl.steps.map(s => `- [${s.status === "done" ? "x" : " "}] ${s.n}. ${s.text}${s.kind !== "code" ? ` (${s.kind})` : ""}${s.commits.length ? ` ${s.commits.join(" ")}` : ""}`));
out.push("", "## Risks", ...pl.risks.map(r => `- ${r.risk}: ${r.handling}${r.fromTicket ? " (from the ticket)" : ""}`));
out.push("", "## Test plan", ...pl.tests.map(x => `- ${x.kind}: ${x.text}${x.fromTicket ? " (from the ticket)" : ""}`));
}
if (t.proof.length) out.push("", "# Proof", ...t.proof.map(p => `- ${p.file ?? p.test}: ${p.caption} (proves ${p.proves.join(", ")})`));
console.log(out.join("\n"));
});
command("check", "check <KEY> validate the data file", a => {
const { t } = load(a.pos[0]);
console.log(`${t.key} is valid`);
});
// Epic commands work on the epic's own data file.
function withEpic(key: string, fn: (e: Epic) => void) {
const file = epicFile(key);
const e = existsSync(file) ? readData(file, "epic") : fail(`no epic page for ${key}. Run: ticket-page epic ${key} init --title T`);
fn(e);
writeData(file, "epic", e);
refreshSite(dirname(file), "epic", `${e.key}: ${e.title}`);
}
command("epic", "epic <EPIC> init --title T [--jira URL] | story <KEY> [--title T --hours H --risk R --after K1,K2 --status S] | order <KEY>... | why --file <reasons.txt> | closed <KEY> --title T", a => {
const [key, sub, ...rest] = a.pos;
if (sub === "init") {
const file = epicFile(key);
const e: Epic = existsSync(file) ? readData(file, "epic") : { schema: 1, key, title: "", jira: "", why: [], stories: [], closed: [], updatedAt: now() };
e.title = a.one("title") ?? (e.title || fail("epic init needs --title"));
e.jira = a.one("jira") ?? (e.jira || `${process.env.JIRA_BASE_URL ?? "https://ksense-tech.atlassian.net"}/browse/${key}`);
writeData(file, "epic", e);
refreshSite(dirname(file), "epic", `${e.key}: ${e.title}`);
return console.log(join(dirname(file), "index.html"));
}
withEpic(key, e => {
if (sub === "story") {
const [storyKey] = rest;
let s = e.stories.find(x => x.key === storyKey);
if (!s) { s = { key: storyKey ?? fail("story needs a key"), title: "", after: [], status: "todo" }; e.stories.push(s); }
if (a.has("title")) s.title = a.one("title")!;
if (a.has("hours")) s.hours = Number(a.one("hours"));
if (a.has("risk")) s.risk = a.one("risk");
if (a.has("after")) s.after = list(a.one("after"));
const st = a.one("status");
if (st) s.status = (["todo", "planned", "building", "verifying", "ready", "done"] as const).find(x => x === st) ?? fail("unknown status");
if (!s.title) fail(`${s.key} needs --title`);
} else if (sub === "order") {
const missing = e.stories.filter(s => !rest.includes(s.key)).map(s => s.key);
if (missing.length || rest.length !== e.stories.length) fail(`order must list every story exactly once. Missing: ${missing.join(", ") || "none"}`);
e.stories = rest.map(k => e.stories.find(s => s.key === k) ?? fail(`${k} is not in the epic`));
} else if (sub === "why") {
e.why = readFileSync(a.one("file") ?? fail("why needs --file"), "utf8").split("\n").map(x => x.trim()).filter(Boolean);
} else if (sub === "closed") {
const [k] = rest;
e.closed = [...e.closed.filter(c => c.key !== k), { key: k ?? fail("closed needs a key"), title: a.one("title") ?? fail("closed needs --title") }];
e.stories = e.stories.filter(s => s.key !== k);
} else fail("epic takes init, story, order, why or closed");
});
});
function help() {
console.log("ticket-page <command> ...\n");
for (const c of Object.values(commands)) console.log(" " + c.usage);
}
if (import.meta.main) {
const [name, ...rest] = process.argv.slice(2);
if (!name || name === "help" || !commands[name]) { help(); process.exit(name && name !== "help" ? 2 : 0); }
try {
const args = parse(rest);
if (name !== "epic" && !/^[A-Z][A-Z0-9]+-\d+$/.test(args.pos[0] ?? "")) fail(`${name} needs a ticket key like KACP-12345 first`);
commands[name].run(args);
} catch (err) {
console.error(`ticket-page ${name}: ${err instanceof Error ? err.message : err}`);
process.exit(err instanceof UsageError ? 2 : 1);
}
}