beliq-cli 0.1.1 → 0.2.1
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 +12 -7
- package/dist/args.js +1 -1
- package/dist/commands/generate.js +22 -12
- package/dist/commands/validate.js +72 -18
- package/dist/format/validate.js +21 -0
- package/dist/help.js +12 -2
- package/dist/io.js +43 -1
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -17,19 +17,25 @@ Requires Node.js >= 20.15.
|
|
|
17
17
|
## Usage
|
|
18
18
|
|
|
19
19
|
```
|
|
20
|
-
beliq validate <file|->
|
|
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,20 +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:
|
|
71
|
-
for f in dist/invoices/*.xml; do
|
|
72
|
-
npx beliq-cli validate "$f" --fail-on error
|
|
73
|
-
done
|
|
78
|
+
- run: npx beliq-cli validate dist/invoices --fail-on error
|
|
74
79
|
env:
|
|
75
80
|
BELIQ_API_KEY: ${{ secrets.BELIQ_API_KEY }}
|
|
76
81
|
```
|
|
77
82
|
|
|
78
|
-
`beliq validate` takes
|
|
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`.
|
|
79
84
|
|
|
80
85
|
## Development
|
|
81
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
|
3
|
-
import { computeExitCode } from '../exit.js';
|
|
4
|
-
import {
|
|
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
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
}
|
package/dist/format/validate.js
CHANGED
|
@@ -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|->
|
|
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.
|
|
3
|
+
"version": "0.2.1",
|
|
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.
|
|
59
|
+
"@beliq/sdk": "^0.3.0"
|
|
60
60
|
},
|
|
61
61
|
"devDependencies": {
|
|
62
62
|
"@types/node": "^20.14.0",
|
|
@@ -64,6 +64,6 @@
|
|
|
64
64
|
"@typescript-eslint/parser": "^7.18.0",
|
|
65
65
|
"eslint": "^8.57.0",
|
|
66
66
|
"typescript": "^5.5.4",
|
|
67
|
-
"vitest": "^
|
|
67
|
+
"vitest": "^4.0.0"
|
|
68
68
|
}
|
|
69
69
|
}
|