beliq-cli 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -17,19 +17,25 @@ Requires Node.js >= 20.15.
17
17
  ## Usage
18
18
 
19
19
  ```
20
- beliq validate <file|-> [--format auto|cii|ubl] [--fail-on error|warning] [--content-type <mime>] [--json]
20
+ beliq validate <file|dir|-> [<file|dir> ...] [--format auto|cii|ubl] [--fail-on error|warning] [--json]
21
21
  beliq generate <invoice.json|-> --standard xrechnung|zugferd|facturx|peppol-bis [--pdf] [--facturx-profile <p>] [--no-verify] [--output <file>] [--json]
22
22
  beliq parse <file|-> [--format auto|cii|ubl] [--json]
23
23
  beliq convert <file|-> --target-format cii|ubl|zugferd|facturx|xrechnung|peppol-bis [--source-format <f>] [--target-profile <p>] [--output <file>] [--json]
24
24
  beliq me [--json]
25
25
  ```
26
26
 
27
- A file argument of `-` reads from stdin. `--json` prints the raw API result as the only thing on stdout, so it pipes cleanly.
27
+ `validate` accepts an XML document or a ZUGFeRD/Factur-X PDF. A file argument of `-` reads from stdin. Pass several files, a shell glob, or a directory (its `.xml`/`.pdf` files, recursively) to validate a batch: you get a per-file verdict, a summary, and one exit code for the whole run. `--json` prints the raw API result (a report object in batch mode) as the only thing on stdout, so it pipes cleanly.
28
28
 
29
29
  ```bash
30
30
  # Validate a file, human-readable
31
31
  beliq validate invoice.xml
32
32
 
33
+ # Validate a hybrid ZUGFeRD/Factur-X PDF (the embedded XML is checked)
34
+ beliq validate invoice.pdf
35
+
36
+ # Validate a whole folder of invoices (its .xml and .pdf files, recursively)
37
+ beliq validate ./invoices
38
+
33
39
  # Validate from a pipe, machine-readable, fail the shell on any warning too
34
40
  cat invoice.xml | beliq validate - --json --fail-on warning
35
41
 
@@ -62,17 +68,19 @@ The exit code is the contract that makes it useful in scripts and CI:
62
68
  | 3 | beliq API error (bad key, quota, engine, a rejected document) |
63
69
  | 4 | I/O error (unreadable input, or an output path that already exists) |
64
70
 
71
+ In batch mode (many files or a directory) the code covers the whole run: `0` if every file passes, `1` if a document fails `--fail-on`, `3` if any file could not be checked (unreadable, or the API errored on it).
72
+
65
73
  ## In CI
66
74
 
67
75
  Validate every invoice a build produces and fail on a non-compliant one:
68
76
 
69
77
  ```yaml
70
- - run: npx beliq-cli validate "dist/invoices/*.xml" --fail-on error
78
+ - run: npx beliq-cli validate dist/invoices --fail-on error
71
79
  env:
72
80
  BELIQ_API_KEY: ${{ secrets.BELIQ_API_KEY }}
73
81
  ```
74
82
 
75
- For a first-class GitHub Action with a per-file step summary, see `beliq-eu/beliq-validate-action`.
83
+ `beliq validate` takes a directory, so this validates every `.xml`/`.pdf` invoice under `dist/invoices` in one run and fails the step if any is non-compliant. For a per-file step summary and job outputs on GitHub, use the Action `beliq-eu/beliq-validate-action`.
76
84
 
77
85
  ## Development
78
86
 
package/dist/args.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { UsageError } from './errors.js';
2
2
  /** Flags that take no value. */
3
- const BOOLEAN_FLAGS = new Set(['json', 'pdf', 'no-verify', 'france-ctc']);
3
+ const BOOLEAN_FLAGS = new Set(['json', 'pdf', 'no-verify', 'france-ctc', 'seal']);
4
4
  /** Flags that take a value (`--flag value` or `--flag=value`). */
5
5
  const VALUE_FLAGS = new Set([
6
6
  'format',
@@ -23,26 +23,36 @@ export async function runGenerate(args, deps, io) {
23
23
  throw new UsageError(`${what} is not valid JSON; generate takes an EN 16931 invoice as JSON`);
24
24
  }
25
25
  const output = flagBool(args, 'pdf') ? 'pdf' : 'xml';
26
+ const seal = flagBool(args, 'seal');
26
27
  const result = await deps.client.generate({
27
28
  standard,
28
29
  invoice,
29
30
  output,
30
31
  facturxProfile: flagStr(args, 'facturx-profile'),
31
32
  verify: !flagBool(args, 'no-verify'),
33
+ seal,
32
34
  });
33
35
  const checked = result.meta.schematronVersion
34
36
  ? `, checked against Schematron ${result.meta.schematronVersion}`
35
37
  : '';
36
- return emitDocument(io, args, {
37
- kind: output,
38
- bytes: result.bytes,
39
- meta: {
40
- output,
41
- contentType: result.contentType,
42
- schematronVersion: result.meta.schematronVersion,
43
- pdfKind: result.meta.pdfKind,
44
- outputEnvelope: result.meta.outputEnvelope,
45
- },
46
- summary: `Generated a ${standard} ${output} document${checked}.`,
47
- });
38
+ const sandbox = result.meta.livemode === false ? ' (sandbox)' : '';
39
+ const meta = {
40
+ output,
41
+ contentType: result.contentType,
42
+ schematronVersion: result.meta.schematronVersion,
43
+ pdfKind: result.meta.pdfKind,
44
+ outputEnvelope: result.meta.outputEnvelope,
45
+ livemode: result.meta.livemode,
46
+ };
47
+ let summary = `Generated a ${standard} ${output} document${checked}${sandbox}.`;
48
+ if (seal) {
49
+ meta.sha256 = result.sha256;
50
+ meta.rulesetSha256 = result.meta.rulesetSha256;
51
+ meta.validationResult = result.validationResult;
52
+ const verdict = result.validationResult
53
+ ? ` Validation ${result.validationResult.valid ? 'passed' : 'failed'}.`
54
+ : '';
55
+ summary += ` sha256 ${result.sha256}.${verdict}`;
56
+ }
57
+ return emitDocument(io, args, { kind: output, bytes: result.bytes, meta, summary });
48
58
  }
@@ -1,25 +1,79 @@
1
- import { LIVE_VALIDATE_FORMATS } from '@beliq/sdk';
2
- import { flagBool, flagStr, oneOf, requirePositional } from '../args.js';
3
- import { computeExitCode } from '../exit.js';
4
- import { renderValidationHuman } from '../format/validate.js';
1
+ import { LIVE_VALIDATE_FORMATS, BeliqApiError } from '@beliq/sdk';
2
+ import { flagBool, flagStr, oneOf } from '../args.js';
3
+ import { EXIT, computeExitCode } from '../exit.js';
4
+ import { UsageError } from '../errors.js';
5
+ import { renderValidationHuman, renderBatchHuman } from '../format/validate.js';
5
6
  const FAIL_ON = ['error', 'warning'];
7
+ // API statuses that mean the whole batch is doomed (bad key, forbidden, or
8
+ // rate-limited), not one bad document. Fail fast rather than retry per file.
9
+ const FATAL_API_STATUSES = new Set([401, 403, 429]);
6
10
  /**
7
- * Validate one document against beliq's authority-pinned rules. Prints the
8
- * verdict (human table or, with --json, the raw ValidationResult) and returns
9
- * the CI exit code: 0 if it passes the --fail-on threshold, 1 if it fails.
11
+ * Validate one or many documents against beliq's authority-pinned rules.
12
+ *
13
+ * One input (a file or `-`) keeps the classic per-document output. Multiple
14
+ * files, or a directory (its `.xml`/`.pdf` files, recursively), switch to a
15
+ * batch: a per-file verdict, an aggregate summary, and a batch exit code (0 all
16
+ * pass, 1 some document fails --fail-on, 3 some file could not be checked).
10
17
  */
11
18
  export async function runValidate(args, deps, io) {
12
- const file = requirePositional(args, 'beliq validate <file|->');
13
19
  const format = oneOf(flagStr(args, 'format'), LIVE_VALIDATE_FORMATS, 'format');
14
20
  const failOn = (oneOf(flagStr(args, 'fail-on'), FAIL_ON, 'fail-on') ?? 'error');
15
- const bytes = await io.readInput(file);
16
- const result = await deps.client.validate(bytes, {
17
- format: format,
18
- contentType: flagStr(args, 'content-type'),
19
- });
20
- if (flagBool(args, 'json'))
21
- io.stdout(`${JSON.stringify(result, null, 2)}\n`);
22
- else
23
- io.stdout(`${renderValidationHuman(result)}\n`);
24
- return computeExitCode(result, failOn);
21
+ const contentType = flagStr(args, 'content-type');
22
+ const json = flagBool(args, 'json');
23
+ if (args.positionals.length === 0) {
24
+ throw new UsageError('missing file argument. Usage: beliq validate <file|dir|-> [<file|dir> ...]');
25
+ }
26
+ const files = await io.expandInputs(args.positionals);
27
+ if (files.length === 0) {
28
+ throw new UsageError(`no .xml or .pdf files found in: ${args.positionals.join(', ')}`);
29
+ }
30
+ if (files.length > 1 && files.includes('-')) {
31
+ throw new UsageError('cannot mix "-" (stdin) with other inputs');
32
+ }
33
+ if (files.length === 1) {
34
+ const result = await deps.client.validate(await io.readInput(files[0]), { format, contentType });
35
+ io.stdout(json ? `${JSON.stringify(result, null, 2)}\n` : `${renderValidationHuman(result)}\n`);
36
+ return computeExitCode(result, failOn);
37
+ }
38
+ return runBatch(files, { format, contentType, failOn, json }, deps, io);
39
+ }
40
+ async function runBatch(files, opts, deps, io) {
41
+ const rows = [];
42
+ for (const file of files) {
43
+ try {
44
+ const bytes = await io.readInput(file);
45
+ const result = await deps.client.validate(bytes, { format: opts.format, contentType: opts.contentType });
46
+ rows.push({ file, result, fails: computeExitCode(result, opts.failOn) !== EXIT.OK });
47
+ }
48
+ catch (err) {
49
+ if (err instanceof BeliqApiError && FATAL_API_STATUSES.has(err.status))
50
+ throw err;
51
+ rows.push({ file, error: errorMessage(err) });
52
+ }
53
+ }
54
+ io.stdout(opts.json ? `${JSON.stringify(batchReport(rows), null, 2)}\n` : `${renderBatchHuman(rows)}\n`);
55
+ if (rows.some((r) => 'error' in r))
56
+ return EXIT.API;
57
+ if (rows.some((r) => 'result' in r && r.fails))
58
+ return EXIT.INVALID;
59
+ return EXIT.OK;
60
+ }
61
+ function errorMessage(err) {
62
+ if (err instanceof BeliqApiError) {
63
+ const code = err.code ? ` (${err.code})` : '';
64
+ return `API error ${err.status}${code}: ${err.message}`;
65
+ }
66
+ return err.message;
67
+ }
68
+ function batchReport(rows) {
69
+ const results = rows.map((r) => 'error' in r
70
+ ? { file: r.file, status: 'error', message: r.error }
71
+ : { file: r.file, status: r.fails ? 'fail' : 'pass', ...r.result });
72
+ return {
73
+ total: rows.length,
74
+ passed: rows.filter((r) => 'result' in r && !r.fails).length,
75
+ failed: rows.filter((r) => 'result' in r && r.fails).length,
76
+ errored: rows.filter((r) => 'error' in r).length,
77
+ results,
78
+ };
25
79
  }
@@ -31,3 +31,24 @@ export function renderValidationHuman(result) {
31
31
  const rows = issues.map((i) => [i.severity, i.ruleId, i.location || '-', i.message]);
32
32
  return `${head}\n${counts}\n\n${renderTable(['SEVERITY', 'RULE', 'LOCATION', 'MESSAGE'], rows)}`;
33
33
  }
34
+ /**
35
+ * A batch verdict: a per-file table (PASS / FAIL / ERROR with error and warning
36
+ * counts) and a one-line summary. The exit code, not this text, is the CI
37
+ * contract; this is the human view.
38
+ */
39
+ export function renderBatchHuman(rows) {
40
+ const cells = rows.map((r) => 'error' in r
41
+ ? ['ERROR', '-', '-', `${r.file} (${r.error})`]
42
+ : [
43
+ r.fails ? 'FAIL' : 'PASS',
44
+ String((r.result.errors ?? []).length),
45
+ String((r.result.warnings ?? []).length),
46
+ r.file,
47
+ ]);
48
+ const table = renderTable(['STATUS', 'ERR', 'WARN', 'FILE'], cells);
49
+ const passed = rows.filter((r) => 'result' in r && !r.fails).length;
50
+ const failed = rows.filter((r) => 'result' in r && r.fails).length;
51
+ const errored = rows.filter((r) => 'error' in r).length;
52
+ const summary = `${plural(rows.length, 'file')}: ${passed} passed, ${failed} failed, ${errored} errored`;
53
+ return `${table}\n\n${summary}`;
54
+ }
package/dist/help.js CHANGED
@@ -7,12 +7,19 @@ export function version() {
7
7
  export const HELP = `beliq: validate, generate, parse, and convert EU e-invoices
8
8
 
9
9
  Usage:
10
- beliq validate <file|-> [--format auto|cii|ubl] [--fail-on error|warning] [--content-type <mime>] [--json]
11
- beliq generate <invoice.json|-> --standard xrechnung|zugferd|facturx|peppol-bis [--pdf] [--facturx-profile <p>] [--no-verify] [--output <file>] [--json]
10
+ beliq validate <file|dir|-> [<file|dir> ...] [--format auto|cii|ubl] [--fail-on error|warning] [--json]
11
+ beliq generate <invoice.json|-> --standard xrechnung|zugferd|facturx|peppol-bis [--pdf] [--facturx-profile <p>] [--no-verify] [--seal] [--output <file>] [--json]
12
12
  beliq parse <file|-> [--format auto|cii|ubl] [--json]
13
13
  beliq convert <file|-> --target-format cii|ubl|zugferd|facturx|xrechnung|peppol-bis [--source-format <f>] [--target-profile <p>] [--output <file>] [--json]
14
14
  beliq me [--json]
15
15
 
16
+ validate accepts an XML document or a ZUGFeRD/Factur-X PDF. Pass several files,
17
+ a shell glob, or a directory (its .xml/.pdf files, recursively) to validate a
18
+ batch: a per-file verdict, a summary, and one exit code for the whole run.
19
+
20
+ generate --seal also returns the document sha256 and validation verdict, so you
21
+ can prove which rules the document passed; hashing the output reproduces it.
22
+
16
23
  Global options:
17
24
  --api-key <key> beliq API key (default: BELIQ_API_KEY)
18
25
  --base-url <url> API base URL (default: BELIQ_BASE_URL, else https://api.beliq.eu)
@@ -28,6 +35,9 @@ Exit codes:
28
35
  3 beliq API error
29
36
  4 I/O error
30
37
 
38
+ In batch mode the code covers the whole run: 0 if every file passes, 1 if a
39
+ document fails --fail-on, 3 if any file could not be checked.
40
+
31
41
  A file argument of "-" reads from stdin. Set BELIQ_API_KEY (or pass --api-key)
32
42
  with a key from the beliq dashboard; the free tier is enough to evaluate.
33
43
  `;
package/dist/io.js CHANGED
@@ -1,4 +1,5 @@
1
- import { readFile, writeFile } from 'node:fs/promises';
1
+ import { readFile, writeFile, stat, readdir } from 'node:fs/promises';
2
+ import { join } from 'node:path';
2
3
  import { IoError } from './errors.js';
3
4
  async function readStdin() {
4
5
  const chunks = [];
@@ -34,5 +35,46 @@ export function nodeIO() {
34
35
  throw new IoError(`could not write ${path}: ${err.message}`);
35
36
  }
36
37
  },
38
+ async expandInputs(paths) {
39
+ const out = [];
40
+ const seen = new Set();
41
+ const add = (p) => {
42
+ if (!seen.has(p)) {
43
+ seen.add(p);
44
+ out.push(p);
45
+ }
46
+ };
47
+ for (const p of paths) {
48
+ if (p === '-') {
49
+ add('-');
50
+ continue;
51
+ }
52
+ let info;
53
+ try {
54
+ info = await stat(p);
55
+ }
56
+ catch (err) {
57
+ throw new IoError(`could not read ${p}: ${err.message}`);
58
+ }
59
+ if (!info.isDirectory()) {
60
+ add(p);
61
+ continue;
62
+ }
63
+ let entries;
64
+ try {
65
+ entries = await readdir(p, { recursive: true, withFileTypes: true });
66
+ }
67
+ catch (err) {
68
+ throw new IoError(`could not read directory ${p}: ${err.message}`);
69
+ }
70
+ const found = entries
71
+ .filter((e) => e.isFile() && /\.(xml|pdf)$/i.test(e.name))
72
+ .map((e) => join(e.parentPath, e.name))
73
+ .sort();
74
+ for (const f of found)
75
+ add(f);
76
+ }
77
+ return out;
78
+ },
37
79
  };
38
80
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "beliq-cli",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "beliq CLI: validate, generate, parse, and convert EU e-invoices (XRechnung, ZUGFeRD, Factur-X, Peppol BIS) against authority-pinned, drift-checked rules, from the terminal or CI.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -56,7 +56,7 @@
56
56
  "prepublishOnly": "npm run build"
57
57
  },
58
58
  "dependencies": {
59
- "@beliq/sdk": "^0.1.1"
59
+ "@beliq/sdk": "^0.2.0"
60
60
  },
61
61
  "devDependencies": {
62
62
  "@types/node": "^20.14.0",