cli.ts
294 lines · make-pdf/src/cli.ts at 3f79d81
1#!/usr/bin/env -S bun --no-env-file --config=/dev/null
2/**
3 * make-pdf CLI — argv parse, dispatch, exit.
4 *
5 * Output contract (per CEO plan DX spec):
6 * stdout: ONLY the output path on success. One line. Nothing else.
7 * stderr: progress spinner per stage, final "Done in Xs. N pages."
8 * --quiet: suppress progress. Errors still print.
9 * --verbose: per-stage timings.
10 * exit 0 success / 1 bad args / 2 render error / 3 TOC page numbers failed / 4 no browser
11 * (Aside not running AND gstack's own browser not built).
12 */
14import { COMMANDS } from "./commands";
15import { ExitCode, BrowserUnavailableError } from "./types";
16import type { GenerateOptions, PreviewOptions } from "./types";
18interface ParsedArgs {
19 command: string;
20 positional: string[];
21 flags: Record<string, string | boolean>;
22}
24/**
25 * Flags that never take a value (#2514). The parser used to treat ANY
26 * following non-flag token as the flag's value, so the skill's own
27 * documented usage — `$P generate --cover --toc essay.md essay.pdf` — worked
28 * only by luck of flag adjacency, while `$P generate --toc essay.md` ate
29 * `essay.md` as --toc's value and failed with "missing input".
30 */
31export const BOOLEAN_FLAGS = new Set([
32 "cover", "toc", "no-chapter-breaks", "confidential", "no-confidential",
33 "page-numbers", "no-page-numbers", "tagged", "no-tagged",
34 "outline", "no-outline", "quiet", "verbose", "allow-network", "strict",
35]);
37export function parseArgs(argv: string[]): ParsedArgs {
38 const args = argv.slice(2);
39 if (args.length === 0) {
40 printUsage();
41 process.exit(ExitCode.Success);
42 }
43 // setup's and gstack-doctor's launch probe (#2595): the version command needs no browser.
44 if (args[0] === "--version") return { command: "version", positional: [], flags: {} };
46 // First non-flag arg is the command.
47 let command = "";
48 const positional: string[] = [];
49 const flags: Record<string, string | boolean> = {};
51 for (let i = 0; i < args.length; i++) {
52 const a = args[i];
53 if (a.startsWith("--")) {
54 const key = a.slice(2);
55 const next = args[i + 1];
56 if (!BOOLEAN_FLAGS.has(key) && next !== undefined && !next.startsWith("--")) {
57 flags[key] = next;
58 i++;
59 } else {
60 flags[key] = true;
61 }
62 } else if (!command) {
63 command = a;
64 } else {
65 positional.push(a);
66 }
67 }
69 return { command, positional, flags };
70}
72function printUsage(): void {
73 const lines = [
74 "make-pdf — turn markdown into publication-quality PDFs",
75 "",
76 "Usage:",
77 ];
78 for (const [name, info] of COMMANDS) {
79 lines.push(` $P ${info.usage}`);
80 lines.push(` ${info.description}`);
81 }
82 lines.push("");
83 lines.push("Output format:");
84 lines.push(" --to pdf|html|docx What to produce (default: pdf).");
85 lines.push(" html = single self-contained file, no network refs.");
86 lines.push(" docx = content fidelity, diagrams as PNG.");
87 lines.push("");
88 lines.push("Page layout:");
89 lines.push(" --margins <dim> All four margins (default: 1in). in, pt, cm, mm.");
90 lines.push(" --page-size letter|a4|legal (aliases: --format — page SIZE, not output format)");
91 lines.push("");
92 lines.push("Document structure:");
93 lines.push(" --cover Add a cover page.");
94 lines.push(" --toc Generate clickable table of contents with page numbers.");
95 lines.push(" --no-chapter-breaks Don't start a new page at every H1.");
96 lines.push("");
97 lines.push("Branding:");
98 lines.push(" --watermark <text> Diagonal watermark on every page.");
99 lines.push(" --header-template <html>");
100 lines.push(" --footer-template <html> Mutex with --page-numbers.");
101 lines.push(" --no-confidential Suppress the CONFIDENTIAL footer.");
102 lines.push("");
103 lines.push("Output control:");
104 lines.push(" --page-numbers / --no-page-numbers (default: on)");
105 lines.push(" --tagged / --no-tagged (default: on, accessible PDF)");
106 lines.push(" --outline / --no-outline (default: on, PDF bookmarks)");
107 lines.push(" --quiet Suppress progress on stderr.");
108 lines.push(" --verbose Per-stage timings on stderr.");
109 lines.push("");
110 lines.push("Diagrams & images:");
111 lines.push(" ```mermaid / ```excalidraw fences render as vector diagrams.");
112 lines.push(" Add render=false to a fence info string to keep it as a code block.");
113 lines.push(" Local images are inlined; oversized rasters downscale to print resolution.");
114 lines.push(" --strict Missing/remote images fail the run (CI mode).");
115 lines.push("");
116 lines.push("Network:");
117 lines.push(" --allow-network Load external images (off by default).");
118 lines.push("");
119 lines.push("Examples:");
120 lines.push(" $P generate letter.md");
121 lines.push(" $P generate --cover --toc essay.md essay.pdf");
122 lines.push(" $P generate --watermark DRAFT memo.md draft.pdf");
123 lines.push(" $P preview letter.md");
124 lines.push("");
125 lines.push("Run `$P setup` to verify the browser (Aside, or gstack's own fallback) + pdftotext install.");
126 console.error(lines.join("\n"));
127}
129function generateOptionsFromFlags(parsed: ParsedArgs): GenerateOptions {
130 const p = parsed.positional;
131 if (p.length === 0) {
132 console.error("$P generate: missing <input.md>");
133 console.error("Usage: $P generate <input.md> [output.pdf] [options]");
134 process.exit(ExitCode.BadArgs);
135 }
136 const f = parsed.flags;
137 const booleanFlag = (key: string, def: boolean): boolean => {
138 if (f[key] === true) return true;
139 if (f[`no-${key}`] === true) return false;
140 return def;
141 };
142 const to = typeof f.to === "string" ? f.to.toLowerCase() : "pdf";
143 if (to !== "pdf" && to !== "html" && to !== "docx") {
144 console.error(`$P generate: invalid --to '${f.to}'. Expected pdf, html, or docx.`);
145 console.error("(--format is a --page-size alias, not the output format.)");
146 process.exit(ExitCode.BadArgs);
147 }
148 return {
149 input: p[0],
150 output: p[1],
151 to: to as GenerateOptions["to"],
152 margins: f.margins as string | undefined,
153 marginTop: f["margin-top"] as string | undefined,
154 marginRight: f["margin-right"] as string | undefined,
155 marginBottom: f["margin-bottom"] as string | undefined,
156 marginLeft: f["margin-left"] as string | undefined,
157 pageSize: ((f["page-size"] ?? f.format) as any),
158 cover: f.cover === true,
159 toc: f.toc === true,
160 noChapterBreaks: f["no-chapter-breaks"] === true,
161 watermark: typeof f.watermark === "string" ? f.watermark : undefined,
162 headerTemplate: typeof f["header-template"] === "string"
163 ? f["header-template"] : undefined,
164 footerTemplate: typeof f["footer-template"] === "string"
165 ? f["footer-template"] : undefined,
166 confidential: booleanFlag("confidential", true),
167 pageNumbers: booleanFlag("page-numbers", true),
168 tagged: booleanFlag("tagged", true),
169 outline: booleanFlag("outline", true),
170 quiet: f.quiet === true,
171 verbose: f.verbose === true,
172 allowNetwork: f["allow-network"] === true,
173 strict: f.strict === true,
174 title: typeof f.title === "string" ? f.title : undefined,
175 author: typeof f.author === "string" ? f.author : undefined,
176 date: typeof f.date === "string" ? f.date : undefined,
177 };
178}
180function previewOptionsFromFlags(parsed: ParsedArgs): PreviewOptions {
181 const p = parsed.positional;
182 if (p.length === 0) {
183 console.error("$P preview: missing <input.md>");
184 console.error("Usage: $P preview <input.md> [options]");
185 process.exit(ExitCode.BadArgs);
186 }
187 const f = parsed.flags;
188 const booleanFlag = (key: string, def: boolean): boolean => {
189 if (f[key] === true) return true;
190 if (f[`no-${key}`] === true) return false;
191 return def;
192 };
193 return {
194 input: p[0],
195 cover: f.cover === true,
196 toc: f.toc === true,
197 watermark: typeof f.watermark === "string" ? f.watermark : undefined,
198 noChapterBreaks: f["no-chapter-breaks"] === true,
199 confidential: booleanFlag("confidential", true),
200 allowNetwork: f["allow-network"] === true,
201 title: typeof f.title === "string" ? f.title : undefined,
202 author: typeof f.author === "string" ? f.author : undefined,
203 date: typeof f.date === "string" ? f.date : undefined,
204 quiet: f.quiet === true,
205 verbose: f.verbose === true,
206 };
207}
209async function main(): Promise<void> {
210 const parsed = parseArgs(process.argv);
212 if (!parsed.command) {
213 printUsage();
214 process.exit(ExitCode.BadArgs);
215 }
217 if (!COMMANDS.has(parsed.command)) {
218 console.error(`$P: unknown command: ${parsed.command}`);
219 console.error("");
220 printUsage();
221 process.exit(ExitCode.BadArgs);
222 }
224 try {
225 switch (parsed.command) {
226 case "version": {
227 // Read from VERSION file or fall back to a hard-coded default.
228 try {
229 const fs = await import("node:fs");
230 const path = await import("node:path");
231 const versionFile = path.resolve(
232 path.dirname(process.argv[1] || ""),
233 "../../VERSION",
234 );
235 const version = fs.readFileSync(versionFile, "utf8").trim();
236 console.log(version);
237 } catch {
238 console.log("make-pdf (version unknown)");
239 }
240 process.exit(ExitCode.Success);
241 }
243 case "setup": {
244 const { runSetup } = await import("./setup");
245 await runSetup();
246 process.exit(ExitCode.Success);
247 }
249 case "generate": {
250 const opts = generateOptionsFromFlags(parsed);
251 const { generate } = await import("./orchestrator");
252 const outputPath = await generate(opts);
253 // Contract: stdout = output path only
254 console.log(outputPath);
255 process.exit(ExitCode.Success);
256 }
258 case "preview": {
259 const opts = previewOptionsFromFlags(parsed);
260 const { preview } = await import("./orchestrator");
261 const htmlPath = await preview(opts);
262 console.log(htmlPath);
263 process.exit(ExitCode.Success);
264 }
266 default:
267 // Unreachable: COMMANDS.has guarded above
268 process.exit(ExitCode.BadArgs);
269 }
270 } catch (err: any) {
271 if (err instanceof BrowserUnavailableError) {
272 console.error(`$P: ${err.message}`);
273 process.exit(ExitCode.BrowserUnavailable);
274 }
275 if (err?.code === "ENOENT") {
276 console.error(`$P: file not found: ${err.path ?? err.message}`);
277 process.exit(ExitCode.BadArgs);
278 }
279 if (err?.name === "TocPaginationError") {
280 console.error(`$P: --toc: ${err.message}`);
281 process.exit(ExitCode.TocPagination);
282 }
283 console.error(`$P: ${err?.message ?? String(err)}`);
284 if (parsed.flags.verbose && err?.stack) {
285 console.error(err.stack);
286 }
287 process.exit(ExitCode.RenderError);
288 }
289}
291// Guarded so tests can import parseArgs/BOOLEAN_FLAGS without running the CLI.
292if (import.meta.main) {
293 main();
294}