Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,25 @@ dispatch layer over it.

**Targets:** Node.js ≥ 20, Bun, Deno (via `node dist/cli.cjs`).

## Working Modes & Token Economy

Default to the cheapest mode that fits the request. Do not over-explore.

- **Plan mode** — for vague, multi-file, or risky requests. Produce a short numbered plan
(files to touch + approach), then stop for confirmation. No edits yet. Keep it to a handful
of bullets; do not dump file contents.
- **Implement mode** — for clear, scoped requests. Edit directly, then validate. Skip the plan.

Token discipline (this file loads on every request — keep edits to it minimal):

- Read in **wide ranges**, not many small reads. Batch independent searches/reads in parallel.
- Stop searching once you can act. Don't re-search for facts already in context or in
`/memories/repo/`.
- Don't restate file contents back to the user; summarize in 1–3 sentences.
- Reuse the per-area instruction files (`.github/instructions/*`) instead of re-deriving
conventions; they hold the deltas, this file holds the globals.
- After code changes, run the smallest sufficient check (targeted test) before the full suite.

## Architecture

```
Expand All @@ -31,6 +50,7 @@ src/
│ ├── error.ts # CliError class, die() helper
│ ├── config.ts # `.pdfnativerc.json` discovery + flag-default merge
│ ├── colors.ts # NO_COLOR/TTY-aware ANSI helper
│ ├── projection.ts # Agent output projection (compact JSON, --summary, --fields)
│ ├── keys.ts # PEM/DER loaders for RSA + EC private keys + X.509 certs
│ ├── layout.ts # `--layout` flag parsing & `PdfLayoutOptions` assembly
│ ├── asn1-walk.ts # ASN.1/DER walker with absolute byte offsets (50 MiB cap)
Expand Down
67 changes: 25 additions & 42 deletions .github/instructions/cli-design.instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,54 +2,37 @@
description: "Use when working on the CLI entry point, arg parser, or overall dispatch logic. Covers entry point contract, help formatting, and exit code conventions."
applyTo: "src/index.ts"
---
# CLI Design Standards
# CLI Design

## Entry Point Rules
> Entry-point and arg-parser contracts live in `.github/copilot-instructions.md`. This file only
> adds deltas — do not restate the global rules.

- `main()` is the sole entry point — called at module bottom with `main().catch(...)`.
- Command dispatch uses a `switch` on `args.positionals[0]`.
- `--help` / `-h` is checked before command dispatch — print help and `process.exit(0)`.
- `--version` / `-V` prints `package.json` `version` and exits 0.
- Unknown commands print `"Unknown command: <name>. Run pdfnative --help for usage."` to stderr, then exit 1.
## Exit codes

## Exit Codes
| Code | Meaning |
|------|---------|
| 0 | Success |
| 1 | Runtime error (invalid input, I/O failure) |
| 2 | Usage error (missing/invalid required argument) |

| Code | Meaning |
|------|-------------------------------------------|
| 0 | Success |
| 1 | Runtime error (invalid input, I/O error) |
| 2 | Usage error (missing required argument) |
## Dispatch

## Help Text Format
- `switch` on `args.positionals[0]` over the commands: `render`, `sign`, `inspect`,
`verify`, `batch`, `completion`, `schema`.
- `--help`/`-h` and `--version`/`-v` handled before dispatch.
- Unknown command → stderr message + exit 1.

```
pdfnative-cli — Official CLI for pdfnative
## Agent globals

Usage:
pdfnative <command> [options]
- The global-flag block sets `PDFNATIVE_JSON=1` on `--json` and `PDFNATIVE_DRY_RUN=1` on
`--dry-run` so all commands and `utils/agent.ts` can read them via env.
- Track the active command in a module-level `activeCommand` (set in `main()`); on a thrown
error, when `isJsonMode()` is true, `emitJsonError(activeCommand, e)` writes the failure
envelope to stderr and the process exits with the `CliError.exitCode` (default 1).
- Numeric exit codes (0/1/2) are unchanged in every mode — `--json` only adds the envelope.

Commands:
render Render a JSON document definition to PDF
sign Apply a digital signature to an existing PDF
inspect Analyse a PDF and output metadata / conformance info
## Help text

Options:
--help, -h Show this help message
--version, -V Show version

Run `pdfnative <command> --help` for per-command options.
```

## Arg Parser Rules (`src/utils/args.ts`)

- Handle: `--flag value`, `--flag=value`, `-f value`, `--flag` (boolean).
- `--` stops flag parsing; remaining tokens become positionals.
- Return type: `ParsedArgs = { flags: Record<string, string | boolean>; positionals: string[] }`.
- Helper: `getFlag(flags, ...names)` returns the first matching flag value or `undefined`.
- Never throw on unknown flags — collect them silently.

## Naming

- Command functions: `render`, `sign`, `inspect` (verb, no prefix).
- Flag names: kebab-case (`--input`, `--output`, `--key`, `--cert`).
- Env vars: `PDFNATIVE_SIGN_KEY`, `PDFNATIVE_SIGN_CERT` (screaming snake).
- One global `USAGE` block listing all commands (incl. `schema`) and the `--json` / `--dry-run`
global options, plus one `*_USAGE` block per command. Point agents at `AGENTS.md`.
- Keep `*_USAGE` flag lists in sync with each command's actual flags.
111 changes: 57 additions & 54 deletions .github/instructions/commands.instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,70 +2,73 @@
description: "Use when implementing or modifying render, sign, or inspect commands. Covers flag conventions, stdin/stdout, streaming, secret handling, and error contracts."
applyTo: "src/commands/**"
---
# Command Implementation Standards
# Command Implementation

## Shared Conventions
> Shared conventions and security constraints are in `.github/copilot-instructions.md`
> (Command Conventions + Security Constraints). This file only adds per-command deltas.

- Signature: `export async function <name>(args: ParsedArgs): Promise<void>`
- `--input` = file path; omit → read from stdin
- `--output` = file path; omit → write to stdout (binary: `process.stdout.write(buffer)`)
- Validation/usage errors → `throw new CliError('message', 2)`
- Runtime errors → `throw new CliError('message', 1)`
- Never catch and swallow errors — let `main()` handle exit
## Shared

## `render` Command
- Signature: `export async function <name>(args: ParsedArgs): Promise<void>`.
- `--input` omitted → stdin; `--output` omitted → stdout (binary via `process.stdout.write`).
- Usage error → `CliError(msg, 2)`; runtime error → `CliError(msg, 1)`. Never swallow errors.
- Validate every path arg against `..` traversal before read/write.

```
pdfnative render [--input <file.json>] [--output <out.pdf>] [--stream] [--conformance 1b|2b|3b]
```
## Agent contract (cross-cutting)

- Reads JSON from `--input` or stdin.
- Parses as `DocumentParams` (full pdfnative API surface).
- Input size cap: **50 MB** before `JSON.parse` — throw `CliError` if exceeded.
- `--stream` flag: use `streamDocumentPdf` (AsyncGenerator) instead of `buildDocumentPDFBytes`.
- When streaming to a file, pipe chunks via `fs.createWriteStream`.
- When streaming to stdout, call `process.stdout.write(chunk)` per chunk.
- `--conformance`: inject `pdfaConformance` into the parsed params.
- **Error codes:** pass a stable `ErrorCode` as the 3rd `CliError` arg
(`E_USAGE`/`E_INPUT`/`E_PARSE`/`E_IO`/`E_SIGN`/`E_VERIFY_FAILED`/`E_CHECK_FAILED`/
`E_UNSUPPORTED`/`E_RUNTIME`). Omitting it derives `E_USAGE` from exit 2, else `E_RUNTIME`.
- **`--json`:** never write the envelope yourself in the dispatcher path — `index.ts` emits
the failure envelope. Use `emitStatus({...})` (from `utils/agent.ts`) for success status on
`render`/`sign`/`batch`; it is a no-op outside `--json`. stdout stays artifact-only.
- **`--dry-run`:** read `hasFlag(args.flags, 'dry-run') || isDryRun()`; validate fully, then
short-circuit before producing/writing output.
- In `--json` mode, do NOT pre-print a detail to stderr that the envelope already carries
(e.g. `inspect --check` detail rides in the `CliError` message instead).
- **Output projection (`inspect`/`verify`/`batch`):** route the JSON-on-stdout branch through
`utils/projection.ts`. Order: `out = --summary ? toSummary(full) : full`, then
`if (--fields) out = selectFields(out, parseFieldList(raw))`, then
`serializeJson(out, hasFlag('pretty') || !isJsonMode())`. Compact is the default under
`--json`; `--pretty` opts back in; non-`--json` stays pretty. Keep `--summary` shapes minimal
and in lock-step with the `*-summary` `schema` subjects. Strip `summary`/`fields`/`pretty`
from any flags forwarded to a sub-command (see `batch`'s `BATCH_ONLY_FLAGS`).

## `sign` Command
## `render`

```
pdfnative sign --input <file.pdf> [--output <out.pdf>] [--key <key.pem>] [--cert <cert.pem>]
```
- JSON → `DocumentParams` (or `PdfParams` when `--variant table`). 50 MB cap before `JSON.parse`.
- Streaming flags are mutually exclusive: `--stream`, `--stream-page-by-page`, `--stream-true`
(1.3.0 `buildDocumentPDFStreamTrue` / `buildPDFStreamTrue`). `--stream` and `--stream-true`
reject TOC blocks and `{pages}`.
- `--font` allow-list: `latin`, `emoji`, `color-emoji`, and 22 script codes
(`ar hy bn ru hi am ka el he ja km ko my pl zh si ta te th bo tr vi`). Name doubles as `--lang`.
- `--max-blocks <n>` → positive integer → `layout.maxBlocks` (invalid → `CliError` exit 2).

- Secret loading priority (highest first):
1. `PDFNATIVE_SIGN_KEY` env var (PEM string of private key)
2. `--key <path>` flag (file path to PEM)
- Same logic for cert: `PDFNATIVE_SIGN_CERT` → `--cert`.
- **Never log key material** — not on error, not on debug. Truncate or omit from messages.
- If neither env var nor flag is provided for key or cert → `CliError` exit code 2.
- Path traversal: validate `--input`, `--output`, `--key`, `--cert` against `../` sequences.
- Call `signPdfBytes(pdfBytes, { privateKeyPem, certificatePem })` from core-bridge.
## `sign`

## `inspect` Command
- Secret priority: env (`PDFNATIVE_SIGN_KEY` / `PDFNATIVE_SIGN_CERT`) over `--key` / `--cert`.
- **Never log key material.** Replace any `signPdfBytes` error with the fixed string
`'Failed to sign PDF.'`. Missing key or cert → `CliError` exit 2.
- `--timestamp` is reserved and must error clearly (sign-side LTV upstream-blocked).

```
pdfnative inspect [--input <file.pdf>] [--format json|text]
```
## `inspect`

- Default output format: `json`.
- `--format text`: human-readable table (key: value lines).
- Output shape (JSON):
```json
{
"version": "1.7",
"pageCount": 3,
"encrypted": false,
"pdfaConformance": "2b",
"signatures": 1,
"metadata": { "title": "...", "author": "...", "creationDate": "..." }
}
```
- No raw binary blobs in output — sanitize all fields.
- Uses `PdfReader` from core-bridge.
- Default `--format json`; `--text` is human-readable. No raw binary blobs in output.
- `--pdfua` adds a `validatePdfUA` report `{ valid, errors, warnings }`.
- `--check` allow-list: `pdfa`, `signed`, `encrypted`, `pdfua` (sets exit code 0/1).

## Security Checklist
## `verify` / `batch` / `completion`

- All file paths: validate no `..` segments before read/write.
- JSON input: size-check buffer before parsing (50 MB cap).
- Signing keys: zero from memory isn't guaranteed in JS, but never persist or log.
- `verify`: offline by default; online revocation only through `utils/fetch-guard.ts`.
Redact CMS parse errors — never leak byte offsets / parser state. Strict fail →
`CliError('', 1, ErrorCode.VERIFY_FAILED)`.
- `batch`: parallel directory render, reuse render logic, per-file summary. Global `--json`
forces the JSON summary. `--dry-run` skips `mkdir` and forwards to each `render`.
- `completion`: emit static bash/zsh/fish scripts only (keep `schema` + `--json`/`--dry-run`
in the flag/command tables).

## `schema`

- `pdfnative schema [render|inspect|verify|batch|list]` — print a hand-authored, versioned
JSON Schema (Draft 2020-12). `$id` embeds the CLI version. Pure data, zero deps; the CLI
only PRODUCES schemas (no bundled validator). Unknown subject → `CliError(..., 2, USAGE)`.
108 changes: 20 additions & 88 deletions .github/instructions/testing.instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,100 +2,32 @@
description: "Use when writing tests, adding test coverage, or debugging test failures in pdfnative-cli. Covers vitest patterns, CLI testing conventions, and coverage targets."
applyTo: "tests/**"
---
# Testing Standards
# Testing

## Framework

- **vitest** native ESM, fast watch mode, built-in coverage.
- Run: `npm run test` (single run), `npm run test:watch` (watch), `npm run test:coverage`.
- Config: `vitest.config.ts`.
- **vitest** (native ESM). Run: `npm test`, `npm run test:watch`, `npm run test:coverage`.
- Tests mirror `src/`: `tests/commands/*.test.ts`, `tests/utils/*.test.ts`,
`tests/integration/*.test.ts`.

## Test Organization
## Command test pattern

```
tests/
├── utils/
│ └── args.test.ts # arg parser edge cases
└── commands/
├── render.test.ts # render command (JSON → PDF bytes)
├── sign.test.ts # sign command (PDF + key → signed PDF)
└── inspect.test.ts # inspect command (PDF → metadata)
```
1. Capture stdout via `vi.spyOn(process.stdout, 'write')`.
2. Use `os.tmpdir()` temp files; clean up in `afterEach`.
3. Test error paths with `await expect(fn(...)).rejects.toBeInstanceOf(CliError)` and assert
`.exitCode`.
4. Drive commands through `parseArgs([...])`, e.g. `await render(parseArgs(['--input', tmpIn]))`.
5. Assert PDF output starts with `%PDF` and contains `%%EOF`.

## Command Testing Patterns
## Conventions

Because commands write to `process.stdout` or files, tests should:
- `describe('functionName')` → `it('should ...')`; one concept per assertion; `it.each` for
parameterized flag forms.
- Append new cases before the final `});` of the relevant `describe`.
- `--variant table` tests need COMPLETE `PdfParams` (incl. `infoItems`, `balanceText`,
`countText`) — `assembleTableParts` throws on missing `infoItems`.

1. **Capture stdout**: redirect `process.stdout.write` via `vi.spyOn`.
2. **Use temp files**: write fixtures to OS temp dir (`os.tmpdir()`), clean up in `afterEach`.
3. **Test error paths**: verify `CliError` is thrown with correct `.exitCode`.
## Coverage targets

```typescript
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render } from '../../src/commands/render.js';
import { CliError } from '../../src/utils/error.js';
import { parseArgs } from '../../src/utils/args.js';
import * as os from 'node:os';
import * as path from 'node:path';
import * as fs from 'node:fs/promises';

describe('render', () => {
it('should produce a valid PDF for minimal DocumentParams', async () => {
const tmpOut = path.join(os.tmpdir(), `test-${Date.now()}.pdf`);
const input = JSON.stringify({ title: 'Test' });
const tmpIn = path.join(os.tmpdir(), `in-${Date.now()}.json`);
await fs.writeFile(tmpIn, input, 'utf8');

await render(parseArgs([`--input`, tmpIn, `--output`, tmpOut]));

const bytes = await fs.readFile(tmpOut);
expect(bytes.slice(0, 4).toString()).toBe('%PDF');
expect(bytes.toString().includes('%%EOF')).toBe(true);

await fs.unlink(tmpIn);
await fs.unlink(tmpOut);
});

it('should throw CliError(2) when JSON is invalid', async () => {
const tmpIn = path.join(os.tmpdir(), `bad-${Date.now()}.json`);
await fs.writeFile(tmpIn, '{bad json}', 'utf8');

await expect(render(parseArgs([`--input`, tmpIn]))).rejects.toBeInstanceOf(CliError);

await fs.unlink(tmpIn);
});
});
```

## Args Testing Patterns

```typescript
import { describe, it, expect } from 'vitest';
import { parseArgs } from '../../src/utils/args.js';

describe('parseArgs', () => {
it('handles --flag value', () => {
const result = parseArgs(['--input', 'file.pdf']);
expect(result.flags['input']).toBe('file.pdf');
});

it('handles --flag=value', () => {
const result = parseArgs(['--input=file.pdf']);
expect(result.flags['input']).toBe('file.pdf');
});
});
```

## Coverage Targets

- Statements: ≥ 90%
- Branches: ≥ 80%
- Functions: ≥ 85%
- Lines: ≥ 90%
- `src/index.ts` is excluded from coverage (entry point, tested via smoke test).

## Vitest Naming Convention

- `describe('functionName')` → `it('should ...')`
- One assertion per concept.
- Use `it.each` for parameterized cases (e.g., multiple flag forms).
- Statements ≥ 90% · Branches ≥ 80% · Functions ≥ 85% · Lines ≥ 90%.
- `src/index.ts` excluded (entry point, covered by smoke test).
24 changes: 24 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@ jobs:
publish:
runs-on: ubuntu-latest
timeout-minutes: 20
# `id-token: write` mints the OIDC token for Trusted Publishing + provenance.
# `contents: write` lets the workflow attach the generated SBOM to the release.
permissions:
contents: write
id-token: write
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

Expand Down Expand Up @@ -53,5 +58,24 @@ jobs:
- name: Binary smoke test
run: node dist/cli.cjs --help

- name: Generate SBOM (CycloneDX)
# Software Bill of Materials for supply-chain transparency. Uses the
# CycloneDX generator via npx (build-time only — adds ZERO runtime
# dependencies to the published package).
run: npx --yes @cyclonedx/cyclonedx-npm@^1 --output-format JSON --output-file sbom.cdx.json

- name: Upload SBOM artifact
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: sbom-cyclonedx
path: sbom.cdx.json
retention-days: 90

- name: Attach SBOM to release
if: github.event_name == 'release'
env:
GH_TOKEN: ${{ github.token }}
run: gh release upload "${{ github.event.release.tag_name }}" sbom.cdx.json --clobber

- name: Publish
run: npm publish --provenance --access public
Loading