beliq-cli 0.2.2 → 0.3.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,16 +17,18 @@ Requires Node.js >= 20.15.
17
17
  ## Usage
18
18
 
19
19
  ```
20
- beliq validate <file|dir|-> [<file|dir> ...] [--format auto|cii|ubl] [--fail-on error|warning] [--json]
21
- beliq generate <invoice.json|-> --standard xrechnung|zugferd|facturx|peppol-bis [--pdf] [--facturx-profile <p>] [--no-verify] [--output <file>] [--json]
22
- beliq parse <file|-> [--format auto|cii|ubl] [--json]
23
- beliq convert <file|-> --target-format cii|ubl|zugferd|facturx|xrechnung|peppol-bis [--source-format <f>] [--target-profile <p>] [--output <file>] [--json]
20
+ beliq validate <file|dir|-> [<file|dir> ...] [--format auto|cii|ubl] [--fail-on error|warning] [--france-ctc] [--content-type <type>] [--json]
21
+ beliq generate <invoice.json|-> --standard xrechnung|zugferd|facturx|peppol-bis [--pdf] [--facturx-profile basicwl|en16931|extended|extended-ctc-fr] [--no-verify] [--seal] [--output <file>] [--json]
22
+ beliq parse <file|-> [--format auto|cii|ubl] [--content-type <type>] [--json]
23
+ beliq convert <file|-> --target-format cii|ubl|zugferd|facturx|xrechnung|peppol-bis [--source-format auto|cii|ubl|zugferd|facturx|xrechnung|peppol-bis] [--target-profile basicwl|en16931|extended|extended-ctc-fr] [--content-type <type>] [--output <file>] [--json]
24
24
  beliq me [--json]
25
25
  ```
26
26
 
27
+ `--facturx-profile` applies to `zugferd` and `facturx`; `extended-ctc-fr` is Factur-X only. `generate --seal` also returns the document's sha256 and its validation verdict, so you can prove which rules the document passed. `--content-type` sets the input's media type for `validate`, `parse` and `convert`; without it, input starting with `%PDF-` is sent as `application/pdf` and anything else as `application/xml`.
28
+
27
29
  `generate --pdf` returns a hybrid PDF/A-3 with the XML embedded for `zugferd` and `facturx`. `xrechnung` and `peppol-bis` have no hybrid form, so `--pdf` returns a visualization with no XML inside it, and their legal document stays the XML.
28
30
 
29
- `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.
31
+ `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. A directory is a batch even when it holds one file, so its `--json` shape does not depend on how many invoices it contains. `--france-ctc` also applies the French CTC rules (BR-FR-CTC) to each document. `--json` prints the raw API result (a report object in batch mode) as the only thing on stdout, so it pipes cleanly.
30
32
 
31
33
  ```bash
32
34
  # Validate a file, human-readable
@@ -41,8 +43,10 @@ beliq validate ./invoices
41
43
  # Validate from a pipe, machine-readable, fail the shell on any warning too
42
44
  cat invoice.xml | beliq validate - --json --fail-on warning
43
45
 
44
- # Generate an XRechnung from a JSON invoice, write the XML to stdout
45
- beliq generate examples/invoice.json --standard xrechnung > invoice.xml
46
+ # Generate an XRechnung from the example invoice, write the XML to stdout.
47
+ # The example ships in the npm package; in a clone of this repository it is
48
+ # examples/invoice.json.
49
+ beliq generate "$(npm root -g)/beliq-cli/examples/invoice.json" --standard xrechnung > invoice.xml
46
50
 
47
51
  # Convert a CII document to a Peppol BIS UBL document
48
52
  beliq convert invoice.xml --target-format peppol-bis --output peppol.xml
@@ -67,11 +71,13 @@ The exit code is the contract that makes it useful in scripts and CI:
67
71
  | 0 | success, or a valid document |
68
72
  | 1 | document invalid (`validate`, per `--fail-on`) |
69
73
  | 2 | usage error (bad flag, missing argument, missing API key, PDF without `--output`) |
70
- | 3 | beliq API error (bad key, quota, engine, a rejected document) |
74
+ | 3 | beliq API error (bad key, quota, engine, a rejected document), the API could not be reached (DNS, a refused connection, a timeout), or an unexpected error. Never a verdict on the document. |
71
75
  | 4 | I/O error (unreadable input, or an output path that already exists) |
72
76
 
73
77
  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).
74
78
 
79
+ On an API error the message goes to stderr, followed by the failed rules when the API names them (`generate` refuses a document that fails its own validation with a 422 that lists them). With `--json`, stdout also gets the error as `{ "error": { "status", "code", "message", "details" } }`.
80
+
75
81
  ## In CI
76
82
 
77
83
  Validate every invoice a build produces and fail on a non-compliant one:
package/dist/args.js CHANGED
@@ -1,8 +1,8 @@
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', 'seal']);
3
+ export 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
- const VALUE_FLAGS = new Set([
5
+ export const VALUE_FLAGS = new Set([
6
6
  'format',
7
7
  'target-format',
8
8
  'source-format',
package/dist/cli.js CHANGED
@@ -4,6 +4,7 @@ import { resolveConfig } from './config.js';
4
4
  import { EXIT } from './exit.js';
5
5
  import { IoError, UsageError } from './errors.js';
6
6
  import { HELP, version } from './help.js';
7
+ import { renderValidationHuman } from './format/validate.js';
7
8
  import { runValidate } from './commands/validate.js';
8
9
  import { runGenerate } from './commands/generate.js';
9
10
  import { runParse } from './commands/parse.js';
@@ -63,11 +64,36 @@ export async function main(argv, io, env = process.env) {
63
64
  return EXIT.IO;
64
65
  }
65
66
  if (err instanceof BeliqApiError) {
66
- const code = err.code ? ` (${err.code})` : '';
67
- io.stderr(`beliq: API error ${err.status}${code}: ${err.message}`);
67
+ reportApiError(err, args, io);
68
68
  return EXIT.API;
69
69
  }
70
- io.stderr(`beliq: unexpected error: ${err.message}`);
71
- return 1;
70
+ // Mostly a transport failure (DNS, a refused connection, TLS), which the SDK
71
+ // rethrows as fetch's own TypeError. None of these is a verdict on the
72
+ // document, and exit 1 is the "document invalid" code a CI gate reads.
73
+ io.stderr(`beliq: unexpected error: ${describeError(err)}`);
74
+ return EXIT.API;
72
75
  }
73
76
  }
77
+ /**
78
+ * An API error on stderr, followed by the rules a rejected document failed when
79
+ * the API names them (a 422 from generate carries its validation result in
80
+ * `details`). With --json the error object goes to stdout as well, so a script
81
+ * reading stdout gets a payload on failure too.
82
+ */
83
+ function reportApiError(err, args, io) {
84
+ const code = err.code ? ` (${err.code})` : '';
85
+ io.stderr(`beliq: API error ${err.status}${code}: ${err.message}`);
86
+ const result = err.details?.validationResult;
87
+ if (result && typeof result === 'object')
88
+ io.stderr(renderValidationHuman(result));
89
+ if (args.flags.json === true) {
90
+ const error = { status: err.status, code: err.code, message: err.message, details: err.details };
91
+ io.stdout(`${JSON.stringify({ error }, null, 2)}\n`);
92
+ }
93
+ }
94
+ /** The message plus its cause: fetch reports only "fetch failed" and keeps the reason in `cause`. */
95
+ function describeError(err) {
96
+ const message = err instanceof Error ? err.message : String(err);
97
+ const cause = err instanceof Error && err.cause instanceof Error ? err.cause.message : undefined;
98
+ return cause ? `${message} (${cause})` : message;
99
+ }
@@ -1,4 +1,4 @@
1
- import { LIVE_CONVERT_SOURCE_FORMATS, LIVE_CONVERT_TARGET_FORMATS, } from '@beliq/sdk';
1
+ import { LIVE_CONVERT_SOURCE_FORMATS, LIVE_CONVERT_TARGET_FORMATS, LIVE_PROFILES, } from '@beliq/sdk';
2
2
  import { flagStr, oneOf, requirePositional } from '../args.js';
3
3
  import { UsageError } from '../errors.js';
4
4
  import { emitDocument } from './emit.js';
@@ -17,11 +17,15 @@ export async function runConvert(args, deps, io) {
17
17
  throw new UsageError(`--target-format is required (one of: ${LIVE_CONVERT_TARGET_FORMATS.join(', ')})`);
18
18
  }
19
19
  const sourceFormat = oneOf(flagStr(args, 'source-format'), LIVE_CONVERT_SOURCE_FORMATS, 'source-format');
20
+ // The flat list, not the per-standard one generate checks: the engine's
21
+ // convert route resolves a profile the same way for both hybrid targets, and
22
+ // the API ignores it for the others.
23
+ const targetProfile = oneOf(flagStr(args, 'target-profile'), LIVE_PROFILES, 'target-profile');
20
24
  const bytes = await io.readInput(file);
21
25
  const result = await deps.client.convert(bytes, {
22
26
  targetFormat: targetFormat,
23
27
  sourceFormat: sourceFormat,
24
- targetProfile: flagStr(args, 'target-profile'),
28
+ targetProfile: targetProfile,
25
29
  contentType: flagStr(args, 'content-type'),
26
30
  });
27
31
  const kind = PDF_TARGETS.has(targetFormat) ? 'pdf' : 'xml';
@@ -10,15 +10,19 @@ const FATAL_API_STATUSES = new Set([401, 403, 429]);
10
10
  /**
11
11
  * Validate one or many documents against beliq's authority-pinned rules.
12
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).
13
+ * One file (or `-`) named on its own keeps the classic per-document output.
14
+ * Multiple inputs, or a directory (its `.xml`/`.pdf` files, recursively), switch
15
+ * to a batch: a per-file verdict, an aggregate summary, and a batch exit code (0
16
+ * all pass, 1 some document fails --fail-on, 3 some file could not be checked).
17
+ * A directory stays a batch even when it holds one invoice, so the --json shape
18
+ * follows what was asked for rather than how many files were found.
17
19
  */
18
20
  export async function runValidate(args, deps, io) {
19
21
  const format = oneOf(flagStr(args, 'format'), LIVE_VALIDATE_FORMATS, 'format');
20
22
  const failOn = (oneOf(flagStr(args, 'fail-on'), FAIL_ON, 'fail-on') ?? 'error');
21
23
  const contentType = flagStr(args, 'content-type');
24
+ // Sent only when set: the API's own default decides otherwise.
25
+ const franceCtc = flagBool(args, 'france-ctc') ? true : undefined;
22
26
  const json = flagBool(args, 'json');
23
27
  if (args.positionals.length === 0) {
24
28
  throw new UsageError('missing file argument. Usage: beliq validate <file|dir|-> [<file|dir> ...]');
@@ -30,19 +34,26 @@ export async function runValidate(args, deps, io) {
30
34
  if (files.length > 1 && files.includes('-')) {
31
35
  throw new UsageError('cannot mix "-" (stdin) with other inputs');
32
36
  }
33
- if (files.length === 1) {
34
- const result = await deps.client.validate(await io.readInput(files[0]), { format, contentType });
37
+ // expandInputs returns a named file as given and a directory as the files
38
+ // inside it, so a single input that comes back unchanged was a file.
39
+ const single = args.positionals.length === 1 && files.length === 1 && files[0] === args.positionals[0];
40
+ if (single) {
41
+ const result = await deps.client.validate(await io.readInput(files[0]), { format, contentType, franceCtc });
35
42
  io.stdout(json ? `${JSON.stringify(result, null, 2)}\n` : `${renderValidationHuman(result)}\n`);
36
43
  return computeExitCode(result, failOn);
37
44
  }
38
- return runBatch(files, { format, contentType, failOn, json }, deps, io);
45
+ return runBatch(files, { format, contentType, franceCtc, failOn, json }, deps, io);
39
46
  }
40
47
  async function runBatch(files, opts, deps, io) {
41
48
  const rows = [];
42
49
  for (const file of files) {
43
50
  try {
44
51
  const bytes = await io.readInput(file);
45
- const result = await deps.client.validate(bytes, { format: opts.format, contentType: opts.contentType });
52
+ const result = await deps.client.validate(bytes, {
53
+ format: opts.format,
54
+ contentType: opts.contentType,
55
+ franceCtc: opts.franceCtc,
56
+ });
46
57
  rows.push({ file, result, fails: computeExitCode(result, opts.failOn) !== EXIT.OK });
47
58
  }
48
59
  catch (err) {
package/dist/config.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { DEFAULT_BASE_URL } from '@beliq/sdk';
2
- import { flagStr } from './args.js';
2
+ import { flagStr, oneOf } from './args.js';
3
3
  import { UsageError } from './errors.js';
4
+ const AUTH_MODES = ['header', 'bearer'];
4
5
  /**
5
6
  * Resolve the typed config with precedence flag > env > default. Throws a
6
7
  * UsageError if no API key is available so the CLI reports a clean usage error
@@ -13,7 +14,9 @@ export function resolveConfig(args, env = process.env) {
13
14
  throw new UsageError('no API key. Set BELIQ_API_KEY or pass --api-key. Create a key in the beliq dashboard under API Keys.');
14
15
  }
15
16
  const baseUrl = (flagStr(args, 'base-url') ?? env.BELIQ_BASE_URL ?? DEFAULT_BASE_URL).replace(/\/+$/, '');
17
+ // An unknown mode is refused rather than read as `header`: a typo in
18
+ // `bearer` would otherwise send the key a way the caller did not ask for.
16
19
  const authRaw = (flagStr(args, 'auth') ?? env.BELIQ_AUTH ?? '').trim().toLowerCase();
17
- const auth = authRaw === 'bearer' ? 'bearer' : 'header';
20
+ const auth = oneOf(authRaw || undefined, AUTH_MODES, 'auth (or BELIQ_AUTH)', 'header');
18
21
  return { apiKey, baseUrl, auth };
19
22
  }
package/dist/exit.js CHANGED
@@ -5,7 +5,11 @@ export const EXIT = {
5
5
  INVALID: 1,
6
6
  /** A usage problem: bad flag, missing argument, missing API key, PDF without --output. */
7
7
  USAGE: 2,
8
- /** The beliq API returned an error (bad key, quota, engine, a rejected document). */
8
+ /**
9
+ * The beliq API returned an error (bad key, quota, engine, a rejected
10
+ * document), could not be reached, or something unexpected failed. Never a
11
+ * verdict on the document.
12
+ */
9
13
  API: 3,
10
14
  /** A local I/O error: an unreadable input or an output path that already exists. */
11
15
  IO: 4,
package/dist/help.js CHANGED
@@ -7,15 +7,20 @@ 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|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
- beliq parse <file|-> [--format auto|cii|ubl] [--json]
13
- beliq convert <file|-> --target-format cii|ubl|zugferd|facturx|xrechnung|peppol-bis [--source-format <f>] [--target-profile <p>] [--output <file>] [--json]
10
+ beliq validate <file|dir|-> [<file|dir> ...] [--format auto|cii|ubl] [--fail-on error|warning] [--france-ctc] [--content-type <type>] [--json]
11
+ beliq generate <invoice.json|-> --standard xrechnung|zugferd|facturx|peppol-bis [--pdf] [--facturx-profile basicwl|en16931|extended|extended-ctc-fr] [--no-verify] [--seal] [--output <file>] [--json]
12
+ beliq parse <file|-> [--format auto|cii|ubl] [--content-type <type>] [--json]
13
+ beliq convert <file|-> --target-format cii|ubl|zugferd|facturx|xrechnung|peppol-bis [--source-format auto|cii|ubl|zugferd|facturx|xrechnung|peppol-bis] [--target-profile basicwl|en16931|extended|extended-ctc-fr] [--content-type <type>] [--output <file>] [--json]
14
14
  beliq me [--json]
15
15
 
16
16
  validate accepts an XML document or a ZUGFeRD/Factur-X PDF. Pass several files,
17
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.
18
+ batch: a per-file verdict, a summary, and one exit code for the whole run. A
19
+ directory is a batch even when it holds one file. validate --france-ctc also
20
+ applies the French CTC rules (BR-FR-CTC).
21
+
22
+ generate --facturx-profile applies to zugferd and facturx; extended-ctc-fr is
23
+ Factur-X only.
19
24
 
20
25
  generate --seal also returns the document sha256 and validation verdict, so you
21
26
  can prove which rules the document passed; hashing the output reproduces it.
@@ -29,6 +34,9 @@ Global options:
29
34
  --base-url <url> API base URL (default: BELIQ_BASE_URL, else https://api.beliq.eu)
30
35
  --auth <mode> header (default, X-API-Key) or bearer
31
36
  --json machine-readable JSON on stdout
37
+ --content-type <type>
38
+ the input's media type (default: application/pdf when the
39
+ bytes start with %PDF-, else application/xml)
32
40
  -h, --help show this help
33
41
  -v, --version show the version
34
42
 
@@ -36,7 +44,7 @@ Exit codes:
36
44
  0 success, or a valid document
37
45
  1 document invalid (validate, per --fail-on)
38
46
  2 usage error
39
- 3 beliq API error
47
+ 3 beliq API error, the API could not be reached, or an unexpected error
40
48
  4 I/O error
41
49
 
42
50
  In batch mode the code covers the whole run: 0 if every file passes, 1 if a
package/dist/index.js CHANGED
@@ -1,9 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
  import { main } from './cli.js';
3
+ import { EXIT } from './exit.js';
3
4
  import { nodeIO } from './io.js';
4
5
  main(process.argv.slice(2), nodeIO()).then((code) => {
5
6
  process.exitCode = code;
6
7
  }, (err) => {
7
8
  process.stderr.write(`beliq: fatal: ${err.message}\n`);
8
- process.exitCode = 1;
9
+ process.exitCode = EXIT.API;
9
10
  });
@@ -1,8 +1,8 @@
1
1
  <?xml version="1.0" encoding="UTF-8"?>
2
2
  <!-- A minimal CII (UN/CEFACT Cross Industry Invoice) carrying the XRechnung
3
3
  guideline id but missing the Buyer reference (BT-10), which XRechnung
4
- requires (rule BR-DE-15). Used as a known-invalid example for the gated
5
- live test and for the skill walkthrough. -->
4
+ requires (rule BR-DE-15). Used as the known-invalid example in the gated
5
+ live test. -->
6
6
  <rsm:CrossIndustryInvoice
7
7
  xmlns:rsm="urn:un:unece:uncefact:data:standard:CrossIndustryInvoice:100"
8
8
  xmlns:ram="urn:un:unece:uncefact:data:standard:ReusableAggregateBusinessInformationEntity:100"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "beliq-cli",
3
- "version": "0.2.2",
3
+ "version": "0.3.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",
@@ -61,8 +61,8 @@
61
61
  },
62
62
  "devDependencies": {
63
63
  "@types/node": "^20.14.0",
64
- "@typescript-eslint/eslint-plugin": "^7.18.0",
65
- "@typescript-eslint/parser": "^7.18.0",
64
+ "@typescript-eslint/eslint-plugin": "^8.0.0",
65
+ "@typescript-eslint/parser": "^8.0.0",
66
66
  "eslint": "^8.57.0",
67
67
  "typescript": "^5.5.4",
68
68
  "vitest": "^4.0.0"