docx2pdf-cli
Advanced tools
Sorry, the diff of this file is not supported yet
+1
-1
@@ -8,3 +8,3 @@ # Agents | ||
| - **Success**: `{ ok: true, ... }` to **stdout** (one JSON object per file; NDJSON in batch mode), exit `0`. | ||
| - **Failure**: `{ ok: false, error: { code, message, kind?, details? } }` to **stderr**, non-zero exit. `kind` lets you branch on error class (e.g. `"NO_BACKEND"`). | ||
| - **Failure**: `{ ok: false, input, error: "<message>", exitCode }` (flat — `error` is a string), non-zero exit. Branch on `exitCode` / the process exit (`2` usage · `3` no backend · `4` conversion failed), not on the human-readable message. | ||
| - Default output is plain text. Add `--json` for the structured envelope. | ||
@@ -11,0 +11,0 @@ |
+9
-0
@@ -7,2 +7,11 @@ # Changelog | ||
| ## [0.2.3] - 2026-05-31 | ||
| ### Fixed | ||
| - **Unwritable / missing output directory no longer dumps a raw Node stack trace.** Write-path `fs.*` calls are now wrapped so an `ENOENT`/`EACCES`/`EEXIST` becomes a clean `CliError` with a documented non-zero exit code instead of a traceback + exit 1: | ||
| - `validatePaths` now rejects an output whose parent is a regular file (`Output directory is not a directory`) and converts a failed `mkdir` into `Cannot create output directory <dir>: <code>` (exit 2, `USAGE`). | ||
| - The LibreOffice backend's final `rename`/`copy` into the destination and the textutil-cups `writeFileSync` calls now raise `Cannot write output <path>: <code>` (exit 4, `CONVERT_FAIL`). | ||
| - **`--out-dir <regular-file>`** now reports a clear "not a directory" error instead of a raw `EEXIST: mkdir` trace. | ||
| - **PDF-to-stdout (`-`) no longer truncates large output** on a slow pipe: the buffer is now written synchronously to fd 1 and fully drained before exit. | ||
| ## [0.2.2] - 2026-05-15 | ||
@@ -9,0 +18,0 @@ |
@@ -38,27 +38,19 @@ # Exit codes & error envelope | ||
| "ok": false, | ||
| "error": { | ||
| "code": "NO_BACKEND", | ||
| "kind": "NO_BACKEND", | ||
| "message": "No conversion backend is available on this host.", | ||
| "exitCode": 3, | ||
| "details": { | ||
| "platform": "darwin", | ||
| "recommendation": { ... } | ||
| } | ||
| } | ||
| "input": "contract.docx", | ||
| "error": "No conversion backend is available on this host.", | ||
| "exitCode": 3 | ||
| } | ||
| ``` | ||
| `kind` is the stable class — agents should branch on this, not on the human-readable `message`. | ||
| The JSON failure object is **flat**: `error` is a human-readable string and `exitCode` is the stable class. Branch on the exit code (or the `exitCode` field), not on the message. | ||
| ## Stable error `kind`s | ||
| ## Error causes by exit code | ||
| | Kind | Class | Notes | | ||
| | Cause | Exit | Notes | | ||
| |---|---|---| | ||
| | `INVALID_INPUT` | input | Exit `2`. Missing arg, bad flag, file not found. | | ||
| | `STRICT_FIDELITY_REFUSED` | input | Exit `2`. Asked for `--strict-fidelity` but the resolved backend is `textutil-cups`. | | ||
| | `NO_BACKEND` | infra | Exit `3`. No backend can convert. `details.recommendation` names the next install step. | | ||
| | `BACKEND_TIMEOUT` | runtime | Exit `4`. Backend hung past the per-invocation timeout. | | ||
| | `BACKEND_FAILED` | runtime | Exit `4`. Backend ran but produced no PDF or exited non-zero. `details.stderr` carries the backend's own output. | | ||
| | `FONT_MISSING` | runtime | Exit `4` (only when `--check-fonts --strict` is passed). The document references a font not on the host. | | ||
| | Invalid input | `2` | Missing arg, bad flag, or file not found. | | ||
| | Strict-fidelity refused | `2` | `--strict-fidelity` set but the only resolved backend is `textutil-cups`. | | ||
| | No backend | `3` | No backend can convert; run `docx2pdf --doctor` for the next install step. | | ||
| | Backend timeout | `4` | Backend hung past the per-invocation timeout. | | ||
| | Backend failed | `4` | Backend ran but produced no PDF or exited non-zero. | | ||
@@ -65,0 +57,0 @@ ## Disabling the JSON envelope |
@@ -24,3 +24,3 @@ # JSON / NDJSON output | ||
| On failure, the error envelope (see [exit-codes.md](exit-codes.md)) goes to stderr instead. Exit code carries the class. | ||
| On failure, a flat error object is emitted instead — `{ "ok": false, "input": "...", "error": "<message>", "exitCode": N }` (`error` is a human-readable string). Branch on the exit code (see [exit-codes.md](exit-codes.md)), which carries the class. | ||
@@ -38,3 +38,3 @@ ## Batch (NDJSON) mode | ||
| {"ok": true, "backend": "libreoffice", "input": "/abs/contract-b.docx", "output": "/abs/pdfs/contract-b.pdf", "outputBytes": 18900, "durationMs": 312} | ||
| {"ok": false, "input": "/abs/broken.docx", "error": {"code": "BACKEND_FAILED", "kind": "BACKEND_FAILED", "exitCode": 4, "message": "LibreOffice exited 1", "details": {"stderr": "..."}}} | ||
| {"ok": false, "input": "/abs/broken.docx", "error": "LibreOffice exited 1", "exitCode": 4} | ||
| {"ok": true, "summary": {"total": 3, "succeeded": 2, "failed": 1, "durationMs": 920}} | ||
@@ -41,0 +41,0 @@ ``` |
@@ -24,3 +24,3 @@ # Gotenberg backend | ||
| Then point every client at the shared URL via env or a per-call `--backend-config gotenbergUrl=https://gotenberg.internal`. | ||
| Then point every client at the shared URL by setting `GOTENBERG_URL=https://gotenberg.internal` in each client's environment. | ||
@@ -27,0 +27,0 @@ ## Retries |
+6
-3
| { | ||
| "name": "docx2pdf-cli", | ||
| "version": "0.2.2", | ||
| "description": "Honest, batch-aware DOCX to PDF CLI with hybrid backends (LibreOffice, Gotenberg, ConvertAPI, Pages, Word, textutil).", | ||
| "version": "0.2.3", | ||
| "description": "Honest, batch-aware DOCX to PDF CLI with hybrid backends (LibreOffice, Gotenberg, ConvertAPI, Pages, Word, textutil). Part of the contract-ops CLI suite.", | ||
| "type": "commonjs", | ||
@@ -30,3 +30,3 @@ "bin": { | ||
| }, | ||
| "homepage": "https://github.com/DrBaher/docx2pdf-cli#readme", | ||
| "homepage": "https://cli.drbaher.com", | ||
| "author": "Baher Al Hakim", | ||
@@ -41,2 +41,5 @@ "scripts": { | ||
| "cli", | ||
| "contract-ops", | ||
| "agent-first", | ||
| "legal-tech", | ||
| "ai-agent", | ||
@@ -43,0 +46,0 @@ "automation", |
+6
-1
@@ -0,4 +1,8 @@ | ||
| <p align="center"> | ||
| <img src="assets/icon.svg" width="120" alt="docx2pdf-cli"> | ||
| </p> | ||
| # docx2pdf-cli | ||
| > Part of the three-CLI contract suite. [**nda-review-cli**](https://github.com/DrBaher/nda-review-cli) (draft, review, negotiate) → **docx2pdf-cli** (DOCX → PDF) → [**sign-cli**](https://github.com/DrBaher/sign-cli) (signing + audit). [Showcase site](https://cli.drbaher.com/). | ||
| > Part of the contract-ops CLI suite. [**draft-cli**](https://github.com/DrBaher/draft-cli) (fill placeholders) → [**nda-review-cli**](https://github.com/DrBaher/nda-review-cli) (review, redline, negotiate) → **docx2pdf-cli** (DOCX → PDF) → [**sign-cli**](https://github.com/DrBaher/sign-cli) (signing + audit). Storage layer: [**template-vault-cli**](https://github.com/DrBaher/template-vault-cli). Drift detection: [**compare-cli**](https://github.com/DrBaher/compare-cli). [Showcase site](https://cli.drbaher.com/). | ||
@@ -32,2 +36,3 @@ [](https://www.npmjs.com/package/docx2pdf-cli) | ||
| | **A contributor** | [docs/reference/](docs/reference/) (concept docs), the npm package, the CI workflows | | ||
| | **Adding a new CLI to the suite** | The [build-a-CLI playbook](https://cli.drbaher.com/build-a-cli) — the conventions every suite CLI follows | | ||
@@ -34,0 +39,0 @@ Concept deep-dives live in [docs/reference/](docs/reference/); per-backend setup in [docs/setup/](docs/setup/). |
+98
-0
@@ -219,2 +219,4 @@ #!/usr/bin/env node | ||
| function main(argv) { | ||
| if (argv[0] === "demo") return runDemo(); | ||
| const options = parseArgs(argv); | ||
@@ -302,2 +304,15 @@ | ||
| // Pipe mode: `-` as input reads a DOCX from stdin; `-` as output (or a bare | ||
| // `docx2pdf -`) streams the PDF to stdout. Single-file only. | ||
| const inputIsStdin = options.input === "-"; | ||
| const outputIsStdout = options.output === "-" || (inputIsStdin && !options.output); | ||
| if (!options.outDir && (inputIsStdin || outputIsStdout)) { | ||
| if ((options.inputs || []).length > 1) { | ||
| process.stderr.write("stdin '-' supports a single input only.\n"); | ||
| return EXIT.USAGE; | ||
| } | ||
| if (options.why) printWhy(options); | ||
| return runPipe(options, inputIsStdin, outputIsStdout); | ||
| } | ||
| if (options.why) printWhy(options); | ||
@@ -386,2 +401,85 @@ | ||
| // Zero-config first run: convert a bundled sample DOCX to PDF using whatever | ||
| // backend is installed. If none is, explain what to install (the conversion | ||
| // itself is backend-dependent, so the demo is best-effort by design). | ||
| function runDemo() { | ||
| const os = require("node:os"); | ||
| const sampleSrc = path.join(__dirname, "demo-sample.docx"); | ||
| const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "docx2pdf-demo-")); | ||
| const input = path.join(tmpDir, "sample.docx"); | ||
| fs.copyFileSync(sampleSrc, input); | ||
| process.stderr.write("docx2pdf demo — converting a bundled sample DOCX to PDF.\n"); | ||
| if (!getAvailableBackends().length) { | ||
| process.stderr.write("\nNo PDF backend is installed yet, so the demo can't render the sample.\n"); | ||
| printSetupHelp(); | ||
| try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* best-effort */ } | ||
| return EXIT.MISSING_DEP; | ||
| } | ||
| const output = path.join(tmpDir, "sample.pdf"); | ||
| try { | ||
| const result = convertDocxToPdf({ input, output, backend: "auto", overwrite: true }); | ||
| const bytes = fs.statSync(result.output).size; | ||
| process.stdout.write(`\n✓ Converted the sample DOCX to PDF (${bytes} bytes) via the '${result.backend}' backend.\n`); | ||
| process.stdout.write(` ${result.output}\n`); | ||
| process.stdout.write("\nNow try your own file:\n docx2pdf your.docx out.pdf\n docx2pdf --doctor # see all detected backends\n"); | ||
| return 0; | ||
| } catch (err) { | ||
| process.stderr.write(`\nDemo conversion failed: ${err.message}\n`); | ||
| try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* best-effort */ } | ||
| return err.exitCode || EXIT.CONVERT_FAIL; | ||
| } | ||
| } | ||
| // Single-file conversion with stdin and/or stdout. Backends need a real file | ||
| // on disk, so stdin is buffered to a temp .docx and stdout output is rendered | ||
| // to a temp .pdf then streamed out. Temp files are always cleaned up. | ||
| function runPipe(options, inputIsStdin, outputIsStdout) { | ||
| const os = require("node:os"); | ||
| const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "docx2pdf-pipe-")); | ||
| try { | ||
| let input = options.input; | ||
| if (inputIsStdin) { | ||
| const buf = fs.readFileSync(0); // fd 0 = stdin, read synchronously | ||
| if (!buf.length) { | ||
| process.stderr.write("No data on stdin (expected a .docx stream for '-').\n"); | ||
| return EXIT.USAGE; | ||
| } | ||
| input = path.join(tmpDir, "input.docx"); | ||
| fs.writeFileSync(input, buf); | ||
| } | ||
| const output = outputIsStdout ? path.join(tmpDir, "output.pdf") : path.resolve(options.output); | ||
| const result = convertDocxToPdf({ | ||
| ...options, | ||
| input, | ||
| output, | ||
| overwrite: outputIsStdout ? true : options.overwrite | ||
| }); | ||
| if (outputIsStdout) { | ||
| // Write synchronously to fd 1 so the full PDF drains before the process | ||
| // exits — process.stdout.write() can truncate a large buffer on a slow pipe. | ||
| const pdfBuf = fs.readFileSync(result.output); | ||
| let offset = 0; | ||
| while (offset < pdfBuf.length) { | ||
| offset += fs.writeSync(1, pdfBuf, offset, pdfBuf.length - offset); | ||
| } | ||
| if (!options.quiet) { | ||
| process.stderr.write(`Converted ${inputIsStdin ? "(stdin)" : path.basename(result.input)} -> stdout using ${result.backend}\n`); | ||
| } | ||
| } else { | ||
| const bytes = fs.statSync(result.output).size; | ||
| if (options.json) { | ||
| process.stdout.write(`${JSON.stringify({ ok: true, backend: result.backend, input: inputIsStdin ? "-" : result.input, output: result.output, outputBytes: bytes })}\n`); | ||
| } else if (!options.quiet) { | ||
| process.stdout.write(`Converted ${inputIsStdin ? "(stdin)" : path.basename(result.input)} -> ${result.output} using ${result.backend}\n`); | ||
| } | ||
| } | ||
| return 0; | ||
| } finally { | ||
| try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* best-effort */ } | ||
| } | ||
| } | ||
| if (require.main === module) { | ||
@@ -388,0 +486,0 @@ Promise.resolve() |
+32
-9
@@ -347,3 +347,11 @@ "use strict"; | ||
| if (fs.existsSync(output) && !overwrite) throw new CliError(`Output file already exists: ${output}. Use --overwrite to replace it.`, EXIT.USAGE); | ||
| fs.mkdirSync(path.dirname(output), { recursive: true }); | ||
| const outDir = path.dirname(output); | ||
| if (fs.existsSync(outDir) && !fs.statSync(outDir).isDirectory()) { | ||
| throw new CliError(`Output directory is not a directory: ${outDir}`, EXIT.USAGE); | ||
| } | ||
| try { | ||
| fs.mkdirSync(outDir, { recursive: true }); | ||
| } catch (err) { | ||
| throw new CliError(`Cannot create output directory ${outDir}: ${err.code || err.message}`, EXIT.USAGE); | ||
| } | ||
| } | ||
@@ -372,9 +380,13 @@ | ||
| try { | ||
| fs.renameSync(generated, output); | ||
| try { | ||
| fs.renameSync(generated, output); | ||
| } catch (err) { | ||
| if (err && err.code === "EXDEV") { | ||
| fs.copyFileSync(generated, output); | ||
| } else { | ||
| throw err; | ||
| } | ||
| } | ||
| } catch (err) { | ||
| if (err && err.code === "EXDEV") { | ||
| fs.copyFileSync(generated, output); | ||
| } else { | ||
| throw err; | ||
| } | ||
| throw new CliError(`Cannot write output ${output}: ${err.code || err.message}`, EXIT.CONVERT_FAIL); | ||
| } | ||
@@ -523,6 +535,14 @@ } finally { | ||
| if (t.status !== 0) throw new CliError(`textutil failed: ${String(t.stderr || "").trim()}`, EXIT.CONVERT_FAIL); | ||
| fs.writeFileSync(tempText, t.stdout, "utf8"); | ||
| try { | ||
| fs.writeFileSync(tempText, t.stdout, "utf8"); | ||
| } catch (err) { | ||
| throw new CliError(`Cannot write intermediate file ${tempText}: ${err.code || err.message}`, EXIT.CONVERT_FAIL); | ||
| } | ||
| const p = runner("cupsfilter", ["-m", "application/pdf", tempText], { encoding: "buffer", timeoutMs }); | ||
| if (p.status !== 0) throw new CliError(`cupsfilter failed: ${String(Buffer.isBuffer(p.stderr)?p.stderr.toString("utf8"):p.stderr||"").trim()}`, EXIT.CONVERT_FAIL); | ||
| fs.writeFileSync(output, p.stdout); | ||
| try { | ||
| fs.writeFileSync(output, p.stdout); | ||
| } catch (err) { | ||
| throw new CliError(`Cannot write output ${output}: ${err.code || err.message}`, EXIT.CONVERT_FAIL); | ||
| } | ||
| } finally { fs.rmSync(tempDir, { recursive: true, force: true }); } | ||
@@ -642,2 +662,5 @@ } | ||
| docx2pdf [options] --out-dir <dir> <input.docx>... | ||
| docx2pdf demo zero-config: convert a bundled sample | ||
| cat in.docx | docx2pdf - out.pdf read DOCX from stdin | ||
| docx2pdf in.docx - > out.pdf write PDF to stdout ('-') | ||
@@ -644,0 +667,0 @@ Options: |
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
105066
10.9%24
4.35%1267
9.89%211
2.43%