@open-nav/cli 0.1.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/LICENSE +29 -0
- package/README.md +89 -0
- package/dist/bin.d.ts +3 -0
- package/dist/bin.d.ts.map +1 -0
- package/dist/bin.js +8 -0
- package/dist/bin.js.map +1 -0
- package/dist/commands.d.ts +37 -0
- package/dist/commands.d.ts.map +1 -0
- package/dist/commands.js +711 -0
- package/dist/commands.js.map +1 -0
- package/dist/config.d.ts +93 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +192 -0
- package/dist/config.js.map +1 -0
- package/dist/errors.d.ts +26 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +25 -0
- package/dist/errors.js.map +1 -0
- package/dist/main.d.ts +23 -0
- package/dist/main.d.ts.map +1 -0
- package/dist/main.js +237 -0
- package/dist/main.js.map +1 -0
- package/dist/output.d.ts +37 -0
- package/dist/output.d.ts.map +1 -0
- package/dist/output.js +43 -0
- package/dist/output.js.map +1 -0
- package/dist/version.d.ts +7 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +8 -0
- package/dist/version.js.map +1 -0
- package/package.json +57 -0
- package/src/bin.ts +8 -0
- package/src/commands.ts +860 -0
- package/src/config.ts +246 -0
- package/src/errors.ts +27 -0
- package/src/main.ts +264 -0
- package/src/output.ts +85 -0
- package/src/version.ts +10 -0
package/src/config.ts
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { dirname, resolve } from 'node:path';
|
|
3
|
+
import type { SoftwareType } from '@open-nav/core';
|
|
4
|
+
import type { NavCredentials } from '@open-nav/client';
|
|
5
|
+
import { UsageError } from './errors.js';
|
|
6
|
+
import { VERSION } from './version.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Configuration comes from the environment, optionally seeded from a local
|
|
10
|
+
* env file. Credentials are never accepted as command line arguments: they
|
|
11
|
+
* would end up in shell history, in process listings and in any agent
|
|
12
|
+
* transcript that records the command.
|
|
13
|
+
*/
|
|
14
|
+
export const ENV_VARS = {
|
|
15
|
+
environment: 'NAV_ENVIRONMENT',
|
|
16
|
+
login: 'NAV_LOGIN',
|
|
17
|
+
password: 'NAV_PASSWORD',
|
|
18
|
+
signKey: 'NAV_SIGN_KEY',
|
|
19
|
+
exchangeKey: 'NAV_EXCHANGE_KEY',
|
|
20
|
+
taxNumber: 'NAV_TAX_NUMBER',
|
|
21
|
+
softwareId: 'NAV_SOFTWARE_ID',
|
|
22
|
+
softwareName: 'NAV_SOFTWARE_NAME',
|
|
23
|
+
softwareVersion: 'NAV_SOFTWARE_VERSION',
|
|
24
|
+
softwareDevName: 'NAV_SOFTWARE_DEV_NAME',
|
|
25
|
+
softwareDevContact: 'NAV_SOFTWARE_DEV_CONTACT',
|
|
26
|
+
softwareDevTaxNumber: 'NAV_SOFTWARE_DEV_TAX_NUMBER',
|
|
27
|
+
softwareOperation: 'NAV_SOFTWARE_OPERATION',
|
|
28
|
+
baseUrl: 'NAV_BASE_URL',
|
|
29
|
+
} as const;
|
|
30
|
+
|
|
31
|
+
/** Variables that hold secrets and must never be printed. */
|
|
32
|
+
export const SECRET_VARS = new Set<string>([
|
|
33
|
+
ENV_VARS.password,
|
|
34
|
+
ENV_VARS.signKey,
|
|
35
|
+
ENV_VARS.exchangeKey,
|
|
36
|
+
]);
|
|
37
|
+
|
|
38
|
+
const ENV_FILE_NAMES = ['.env.open-nav', '.env.local', '.env'];
|
|
39
|
+
|
|
40
|
+
/** Variables without which nothing can talk to NAV. */
|
|
41
|
+
export const REQUIRED_VARS: readonly string[] = [
|
|
42
|
+
ENV_VARS.login,
|
|
43
|
+
ENV_VARS.password,
|
|
44
|
+
ENV_VARS.signKey,
|
|
45
|
+
ENV_VARS.exchangeKey,
|
|
46
|
+
ENV_VARS.taxNumber,
|
|
47
|
+
ENV_VARS.softwareId,
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
/** Defaults applied when an optional variable is unset. */
|
|
51
|
+
export const DEFAULTS: Readonly<Record<string, string>> = {
|
|
52
|
+
[ENV_VARS.environment]: 'test',
|
|
53
|
+
[ENV_VARS.softwareName]: 'open-nav',
|
|
54
|
+
[ENV_VARS.softwareVersion]: VERSION,
|
|
55
|
+
[ENV_VARS.softwareOperation]: 'LOCAL_SOFTWARE',
|
|
56
|
+
[ENV_VARS.softwareDevName]: 'open-nav',
|
|
57
|
+
[ENV_VARS.softwareDevContact]: 'unknown',
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export interface LoadedConfig {
|
|
61
|
+
credentials: NavCredentials;
|
|
62
|
+
software: SoftwareType;
|
|
63
|
+
environment: 'test' | 'production';
|
|
64
|
+
baseUrl?: string;
|
|
65
|
+
/** Env files that were read, nearest first. */
|
|
66
|
+
envFiles: string[];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Parse a `.env` file.
|
|
71
|
+
*
|
|
72
|
+
* Deliberately minimal: `KEY=value`, `#` comments, optional `export`, and
|
|
73
|
+
* single or double quotes stripped. Anything more elaborate belongs in a real
|
|
74
|
+
* secret manager, not in a file next to the source.
|
|
75
|
+
*/
|
|
76
|
+
export function parseEnvFile(contents: string): Record<string, string> {
|
|
77
|
+
const values: Record<string, string> = {};
|
|
78
|
+
for (const rawLine of contents.split(/\r?\n/)) {
|
|
79
|
+
const line = rawLine.trim();
|
|
80
|
+
if (line === '' || line.startsWith('#')) continue;
|
|
81
|
+
const withoutExport = line.startsWith('export ') ? line.slice('export '.length) : line;
|
|
82
|
+
const separator = withoutExport.indexOf('=');
|
|
83
|
+
if (separator === -1) continue;
|
|
84
|
+
const key = withoutExport.slice(0, separator).trim();
|
|
85
|
+
let value = withoutExport.slice(separator + 1).trim();
|
|
86
|
+
if (
|
|
87
|
+
(value.startsWith('"') && value.endsWith('"') && value.length > 1) ||
|
|
88
|
+
(value.startsWith("'") && value.endsWith("'") && value.length > 1)
|
|
89
|
+
) {
|
|
90
|
+
value = value.slice(1, -1);
|
|
91
|
+
}
|
|
92
|
+
if (key !== '') values[key] = value;
|
|
93
|
+
}
|
|
94
|
+
return values;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Find env files, walking up from a starting directory.
|
|
99
|
+
*
|
|
100
|
+
* Walking up means a project-level env file is picked up from any
|
|
101
|
+
* subdirectory, which is how an agent invoked in a nested working directory
|
|
102
|
+
* still finds the credentials.
|
|
103
|
+
*/
|
|
104
|
+
export function discoverEnvFiles(from: string): string[] {
|
|
105
|
+
const found: string[] = [];
|
|
106
|
+
let directory = resolve(from);
|
|
107
|
+
for (;;) {
|
|
108
|
+
for (const name of ENV_FILE_NAMES) {
|
|
109
|
+
const candidate = resolve(directory, name);
|
|
110
|
+
if (existsSync(candidate)) found.push(candidate);
|
|
111
|
+
}
|
|
112
|
+
const parent = dirname(directory);
|
|
113
|
+
if (parent === directory) break;
|
|
114
|
+
directory = parent;
|
|
115
|
+
}
|
|
116
|
+
return found;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export interface LoadOptions {
|
|
120
|
+
/** Explicit env file, from `--env-file`. Takes precedence over discovery. */
|
|
121
|
+
envFile?: string;
|
|
122
|
+
/** Where to start looking. Defaults to the working directory. */
|
|
123
|
+
cwd?: string;
|
|
124
|
+
/** Process environment. Injectable for tests. */
|
|
125
|
+
env?: Record<string, string | undefined>;
|
|
126
|
+
/** Skip env file discovery entirely. */
|
|
127
|
+
noEnvFile?: boolean;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Read the environment, seeded from env files, without requiring credentials. */
|
|
131
|
+
export function loadEnvironment(options: LoadOptions = {}): {
|
|
132
|
+
env: Record<string, string | undefined>;
|
|
133
|
+
envFiles: string[];
|
|
134
|
+
} {
|
|
135
|
+
const base = { ...(options.env ?? process.env) };
|
|
136
|
+
const envFiles: string[] = [];
|
|
137
|
+
|
|
138
|
+
if (options.envFile) {
|
|
139
|
+
if (!existsSync(options.envFile)) {
|
|
140
|
+
throw new UsageError(`Env file not found: ${options.envFile}`);
|
|
141
|
+
}
|
|
142
|
+
envFiles.push(resolve(options.envFile));
|
|
143
|
+
} else if (!options.noEnvFile) {
|
|
144
|
+
envFiles.push(...discoverEnvFiles(options.cwd ?? process.cwd()));
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Real environment variables win over files, and a nearer file wins over a
|
|
148
|
+
// more distant one.
|
|
149
|
+
for (const file of envFiles) {
|
|
150
|
+
for (const [key, value] of Object.entries(parseEnvFile(readFileSync(file, 'utf8')))) {
|
|
151
|
+
base[key] ??= value;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return { env: base, envFiles };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export interface ConfigVariableReport {
|
|
159
|
+
variable: string;
|
|
160
|
+
set: boolean;
|
|
161
|
+
required: boolean;
|
|
162
|
+
secret: boolean;
|
|
163
|
+
/** Present for non-secret variables that are set. */
|
|
164
|
+
value?: string;
|
|
165
|
+
/** Value that applies when the variable is left unset. */
|
|
166
|
+
default?: string;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Which configuration variables are present, for the `config` command.
|
|
171
|
+
*
|
|
172
|
+
* Required and optional are reported separately: an unset optional variable
|
|
173
|
+
* with a working default is not a problem, and listing it as "missing" sends
|
|
174
|
+
* people hunting for something that is not wrong.
|
|
175
|
+
*/
|
|
176
|
+
export function describeConfig(env: Record<string, string | undefined>): ConfigVariableReport[] {
|
|
177
|
+
return Object.values(ENV_VARS).map((variable) => {
|
|
178
|
+
const value = env[variable];
|
|
179
|
+
const secret = SECRET_VARS.has(variable);
|
|
180
|
+
const set = value !== undefined && value !== '';
|
|
181
|
+
const fallback = DEFAULTS[variable];
|
|
182
|
+
return {
|
|
183
|
+
variable,
|
|
184
|
+
set,
|
|
185
|
+
required: REQUIRED_VARS.includes(variable),
|
|
186
|
+
secret,
|
|
187
|
+
...(set && !secret ? { value } : {}),
|
|
188
|
+
...(!set && fallback !== undefined ? { default: fallback } : {}),
|
|
189
|
+
};
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Assemble the full configuration, failing with a list of everything missing
|
|
195
|
+
* rather than one variable at a time.
|
|
196
|
+
*/
|
|
197
|
+
export function loadConfig(options: LoadOptions = {}): LoadedConfig {
|
|
198
|
+
const { env, envFiles } = loadEnvironment(options);
|
|
199
|
+
|
|
200
|
+
const missing = REQUIRED_VARS.filter((variable) => !env[variable]);
|
|
201
|
+
if (missing.length > 0) {
|
|
202
|
+
throw new UsageError(
|
|
203
|
+
`Missing configuration: ${missing.join(', ')}.\n` +
|
|
204
|
+
`Set them in the environment or in a .env file, then run "open-nav config" to check.\n` +
|
|
205
|
+
`See "open-nav help config" for the full list.`,
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const environmentRaw = env[ENV_VARS.environment] ?? 'test';
|
|
210
|
+
if (environmentRaw !== 'test' && environmentRaw !== 'production') {
|
|
211
|
+
throw new UsageError(
|
|
212
|
+
`${ENV_VARS.environment} must be "test" or "production", got ${JSON.stringify(environmentRaw)}`,
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const operationRaw = env[ENV_VARS.softwareOperation] ?? 'LOCAL_SOFTWARE';
|
|
217
|
+
if (operationRaw !== 'LOCAL_SOFTWARE' && operationRaw !== 'ONLINE_SERVICE') {
|
|
218
|
+
throw new UsageError(
|
|
219
|
+
`${ENV_VARS.softwareOperation} must be "LOCAL_SOFTWARE" or "ONLINE_SERVICE", got ${JSON.stringify(operationRaw)}`,
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return {
|
|
224
|
+
credentials: {
|
|
225
|
+
login: env[ENV_VARS.login]!,
|
|
226
|
+
password: env[ENV_VARS.password]!,
|
|
227
|
+
signKey: env[ENV_VARS.signKey]!,
|
|
228
|
+
exchangeKey: env[ENV_VARS.exchangeKey]!,
|
|
229
|
+
taxNumber: env[ENV_VARS.taxNumber]!,
|
|
230
|
+
},
|
|
231
|
+
software: {
|
|
232
|
+
softwareId: env[ENV_VARS.softwareId]!,
|
|
233
|
+
softwareName: env[ENV_VARS.softwareName] ?? 'open-nav',
|
|
234
|
+
softwareOperation: operationRaw,
|
|
235
|
+
softwareMainVersion: env[ENV_VARS.softwareVersion] ?? VERSION,
|
|
236
|
+
softwareDevName: env[ENV_VARS.softwareDevName] ?? 'open-nav',
|
|
237
|
+
softwareDevContact: env[ENV_VARS.softwareDevContact] ?? 'unknown',
|
|
238
|
+
...(env[ENV_VARS.softwareDevTaxNumber]
|
|
239
|
+
? { softwareDevTaxNumber: env[ENV_VARS.softwareDevTaxNumber] }
|
|
240
|
+
: {}),
|
|
241
|
+
},
|
|
242
|
+
environment: environmentRaw,
|
|
243
|
+
...(env[ENV_VARS.baseUrl] ? { baseUrl: env[ENV_VARS.baseUrl] } : {}),
|
|
244
|
+
envFiles,
|
|
245
|
+
};
|
|
246
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Exit codes, so a caller — a shell script or an agent — can branch on the
|
|
3
|
+
* outcome without parsing the output.
|
|
4
|
+
*/
|
|
5
|
+
export const EXIT = {
|
|
6
|
+
ok: 0,
|
|
7
|
+
/** Something went wrong that the tool did not anticipate. */
|
|
8
|
+
failure: 1,
|
|
9
|
+
/** The command line or configuration was wrong. */
|
|
10
|
+
usage: 2,
|
|
11
|
+
/** A document failed local validation and was not sent. */
|
|
12
|
+
invalid: 3,
|
|
13
|
+
/** NAV rejected the request or the invoices in it. */
|
|
14
|
+
rejected: 4,
|
|
15
|
+
/**
|
|
16
|
+
* The work could not be completed because something it depends on was
|
|
17
|
+
* unavailable: the network, a NAV verdict that has not arrived, or a
|
|
18
|
+
* browser to convert a document with.
|
|
19
|
+
*/
|
|
20
|
+
unavailable: 5,
|
|
21
|
+
} as const;
|
|
22
|
+
|
|
23
|
+
export type ExitCode = (typeof EXIT)[keyof typeof EXIT];
|
|
24
|
+
|
|
25
|
+
export class UsageError extends Error {
|
|
26
|
+
readonly exitCode = EXIT.usage;
|
|
27
|
+
}
|
package/src/main.ts
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import { parseArgs } from 'node:util';
|
|
2
|
+
import { NavApiError, NavTransportError, NavValidationError } from '@open-nav/core';
|
|
3
|
+
import { PdfConversionError, ThemeError } from '@open-nav/invoicing';
|
|
4
|
+
import { COMMANDS, describeCommands, findCommand, type CommandContext } from './commands.js';
|
|
5
|
+
import { ENV_VARS } from './config.js';
|
|
6
|
+
import { EXIT, UsageError, type ExitCode } from './errors.js';
|
|
7
|
+
import { consoleWriter, resolveFormat, writeError, type Format, type Writer } from './output.js';
|
|
8
|
+
import { VERSION } from './version.js';
|
|
9
|
+
|
|
10
|
+
export interface RunOptions {
|
|
11
|
+
argv: string[];
|
|
12
|
+
writer?: Writer;
|
|
13
|
+
/** Whether stdout is a terminal, which decides the default output format. */
|
|
14
|
+
isTty?: boolean;
|
|
15
|
+
env?: Record<string, string | undefined>;
|
|
16
|
+
cwd?: string;
|
|
17
|
+
readFile?: (path: string) => string;
|
|
18
|
+
writeFile?: (path: string, contents: string) => void;
|
|
19
|
+
writeBinaryFile?: (path: string, contents: Buffer) => void;
|
|
20
|
+
readBinaryFile?: (path: string) => Buffer;
|
|
21
|
+
fileExists?: (path: string) => boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const GLOBAL_OPTIONS = {
|
|
25
|
+
json: { type: 'boolean' },
|
|
26
|
+
pretty: { type: 'boolean' },
|
|
27
|
+
'env-file': { type: 'string' },
|
|
28
|
+
'no-env-file': { type: 'boolean' },
|
|
29
|
+
help: { type: 'boolean', short: 'h' },
|
|
30
|
+
version: { type: 'boolean' },
|
|
31
|
+
describe: { type: 'boolean' },
|
|
32
|
+
} as const;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Run the CLI and return an exit code.
|
|
36
|
+
*
|
|
37
|
+
* Everything is injectable so the whole surface can be tested without
|
|
38
|
+
* spawning a process or touching the filesystem.
|
|
39
|
+
*/
|
|
40
|
+
export async function run(options: RunOptions): Promise<ExitCode> {
|
|
41
|
+
const writer = options.writer ?? consoleWriter;
|
|
42
|
+
|
|
43
|
+
let parsed;
|
|
44
|
+
try {
|
|
45
|
+
parsed = parseArgs({
|
|
46
|
+
args: options.argv,
|
|
47
|
+
options: {
|
|
48
|
+
...GLOBAL_OPTIONS,
|
|
49
|
+
// Command options are accepted loosely here and validated by the
|
|
50
|
+
// command, so a new option never needs registering in two places.
|
|
51
|
+
operation: { type: 'string' },
|
|
52
|
+
language: { type: 'string' },
|
|
53
|
+
direction: { type: 'string' },
|
|
54
|
+
supplier: { type: 'string' },
|
|
55
|
+
from: { type: 'string' },
|
|
56
|
+
to: { type: 'string' },
|
|
57
|
+
page: { type: 'string' },
|
|
58
|
+
wait: { type: 'boolean' },
|
|
59
|
+
compress: { type: 'boolean' },
|
|
60
|
+
xml: { type: 'boolean' },
|
|
61
|
+
'skip-validation': { type: 'boolean' },
|
|
62
|
+
out: { type: 'string' },
|
|
63
|
+
note: { type: 'string' },
|
|
64
|
+
pdf: { type: 'string' },
|
|
65
|
+
theme: { type: 'string' },
|
|
66
|
+
logo: { type: 'string' },
|
|
67
|
+
browser: { type: 'string' },
|
|
68
|
+
'no-sandbox': { type: 'boolean' },
|
|
69
|
+
engine: { type: 'string' },
|
|
70
|
+
delay: { type: 'string' },
|
|
71
|
+
refresh: { type: 'boolean' },
|
|
72
|
+
'number-from': { type: 'string' },
|
|
73
|
+
'number-to': { type: 'string' },
|
|
74
|
+
'warnings-as-errors': { type: 'boolean' },
|
|
75
|
+
},
|
|
76
|
+
allowPositionals: true,
|
|
77
|
+
strict: true,
|
|
78
|
+
});
|
|
79
|
+
} catch (cause) {
|
|
80
|
+
writer.err((cause as Error).message);
|
|
81
|
+
writer.err('Run "open-nav --help" for usage.');
|
|
82
|
+
return EXIT.usage;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const flags = parsed.values as Record<string, string | boolean | undefined>;
|
|
86
|
+
const explicitFormat: Format | undefined =
|
|
87
|
+
flags['json'] === true ? 'json' : flags['pretty'] === true ? 'text' : undefined;
|
|
88
|
+
const format = resolveFormat(explicitFormat, options.isTty ?? false);
|
|
89
|
+
|
|
90
|
+
if (flags['describe'] === true) {
|
|
91
|
+
writer.out(JSON.stringify(describeCommands(), null, 2));
|
|
92
|
+
return EXIT.ok;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (flags['version'] === true) {
|
|
96
|
+
writer.out(VERSION);
|
|
97
|
+
return EXIT.ok;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const [commandName, ...positionals] = parsed.positionals;
|
|
101
|
+
|
|
102
|
+
if (flags['help'] === true || commandName === undefined || commandName === 'help') {
|
|
103
|
+
const topic = commandName === 'help' ? positionals[0] : undefined;
|
|
104
|
+
writeHelp(writer, topic);
|
|
105
|
+
return commandName === undefined && flags['help'] !== true ? EXIT.usage : EXIT.ok;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const command = findCommand(commandName);
|
|
109
|
+
if (!command) {
|
|
110
|
+
writer.err(`Unknown command: ${commandName}`);
|
|
111
|
+
writer.err(`Available: ${COMMANDS.map((entry) => entry.name).join(', ')}`);
|
|
112
|
+
return EXIT.usage;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const context: CommandContext = {
|
|
116
|
+
format,
|
|
117
|
+
writer,
|
|
118
|
+
load: {
|
|
119
|
+
...(flags['env-file'] ? { envFile: String(flags['env-file']) } : {}),
|
|
120
|
+
...(flags['no-env-file'] === true ? { noEnvFile: true } : {}),
|
|
121
|
+
...(options.env ? { env: options.env } : {}),
|
|
122
|
+
...(options.cwd ? { cwd: options.cwd } : {}),
|
|
123
|
+
},
|
|
124
|
+
...(options.readFile ? { readFile: options.readFile } : {}),
|
|
125
|
+
...(options.writeFile ? { writeFile: options.writeFile } : {}),
|
|
126
|
+
...(options.writeBinaryFile ? { writeBinaryFile: options.writeBinaryFile } : {}),
|
|
127
|
+
...(options.readBinaryFile ? { readBinaryFile: options.readBinaryFile } : {}),
|
|
128
|
+
...(options.fileExists ? { fileExists: options.fileExists } : {}),
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
try {
|
|
132
|
+
return await command.run(positionals, flags, context);
|
|
133
|
+
} catch (error) {
|
|
134
|
+
return reportError(writer, format, command.name, error);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function reportError(writer: Writer, format: Format, command: string, error: unknown): ExitCode {
|
|
139
|
+
if (error instanceof UsageError) {
|
|
140
|
+
writeError(writer, format, command, { message: error.message, code: 'USAGE' });
|
|
141
|
+
return EXIT.usage;
|
|
142
|
+
}
|
|
143
|
+
if (error instanceof NavApiError) {
|
|
144
|
+
writeError(writer, format, command, {
|
|
145
|
+
message: error.message,
|
|
146
|
+
...(error.errorCode ? { code: error.errorCode } : {}),
|
|
147
|
+
details: {
|
|
148
|
+
status: error.status,
|
|
149
|
+
funcCode: error.funcCode,
|
|
150
|
+
validationMessages: error.validationMessages,
|
|
151
|
+
},
|
|
152
|
+
});
|
|
153
|
+
return EXIT.rejected;
|
|
154
|
+
}
|
|
155
|
+
if (error instanceof NavValidationError) {
|
|
156
|
+
writeError(writer, format, command, {
|
|
157
|
+
message: error.message,
|
|
158
|
+
code: 'INVALID',
|
|
159
|
+
details: error.issues,
|
|
160
|
+
});
|
|
161
|
+
return EXIT.invalid;
|
|
162
|
+
}
|
|
163
|
+
if (error instanceof NavTransportError) {
|
|
164
|
+
writeError(writer, format, command, { message: error.message, code: 'UNAVAILABLE' });
|
|
165
|
+
return EXIT.unavailable;
|
|
166
|
+
}
|
|
167
|
+
if (error instanceof ThemeError) {
|
|
168
|
+
// A malformed theme is a configuration mistake, like a bad flag.
|
|
169
|
+
writeError(writer, format, command, { message: error.message, code: 'THEME' });
|
|
170
|
+
return EXIT.usage;
|
|
171
|
+
}
|
|
172
|
+
if (error instanceof PdfConversionError) {
|
|
173
|
+
// Nothing wrong with the document; the machine lacks a browser.
|
|
174
|
+
writeError(writer, format, command, { message: error.message, code: 'PDF_CONVERSION' });
|
|
175
|
+
return EXIT.unavailable;
|
|
176
|
+
}
|
|
177
|
+
writeError(writer, format, command, { message: (error as Error).message ?? String(error) });
|
|
178
|
+
return EXIT.failure;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function writeHelp(writer: Writer, topic: string | undefined): void {
|
|
182
|
+
if (topic === 'config') {
|
|
183
|
+
writer.out('Configuration is read from the environment, or from a .env file');
|
|
184
|
+
writer.out('discovered by walking up from the working directory.');
|
|
185
|
+
writer.out('');
|
|
186
|
+
writer.out('Required:');
|
|
187
|
+
for (const variable of [
|
|
188
|
+
ENV_VARS.login,
|
|
189
|
+
ENV_VARS.password,
|
|
190
|
+
ENV_VARS.signKey,
|
|
191
|
+
ENV_VARS.exchangeKey,
|
|
192
|
+
ENV_VARS.taxNumber,
|
|
193
|
+
ENV_VARS.softwareId,
|
|
194
|
+
]) {
|
|
195
|
+
writer.out(` ${variable}`);
|
|
196
|
+
}
|
|
197
|
+
writer.out('');
|
|
198
|
+
writer.out('Optional:');
|
|
199
|
+
for (const variable of [
|
|
200
|
+
ENV_VARS.environment,
|
|
201
|
+
ENV_VARS.softwareName,
|
|
202
|
+
ENV_VARS.softwareVersion,
|
|
203
|
+
ENV_VARS.softwareOperation,
|
|
204
|
+
ENV_VARS.softwareDevName,
|
|
205
|
+
ENV_VARS.softwareDevContact,
|
|
206
|
+
ENV_VARS.softwareDevTaxNumber,
|
|
207
|
+
ENV_VARS.baseUrl,
|
|
208
|
+
]) {
|
|
209
|
+
writer.out(` ${variable}`);
|
|
210
|
+
}
|
|
211
|
+
writer.out('');
|
|
212
|
+
writer.out(`${ENV_VARS.environment} defaults to "test". Set it to "production" deliberately.`);
|
|
213
|
+
writer.out(`${ENV_VARS.taxNumber} is the 8 digit core tax number, not the 11 digit form.`);
|
|
214
|
+
writer.out('');
|
|
215
|
+
writer.out('Credentials are never taken as command line arguments: they would');
|
|
216
|
+
writer.out('be recorded in shell history, process listings and agent transcripts.');
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (topic !== undefined) {
|
|
221
|
+
const command = findCommand(topic);
|
|
222
|
+
if (!command) {
|
|
223
|
+
writer.err(`Unknown command: ${topic}`);
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
writer.out(command.summary);
|
|
227
|
+
writer.out('');
|
|
228
|
+
writer.out(` ${command.usage}`);
|
|
229
|
+
if (command.options && command.options.length > 0) {
|
|
230
|
+
writer.out('');
|
|
231
|
+
const width = Math.max(...command.options.map((option) => option.flag.length));
|
|
232
|
+
for (const option of command.options) {
|
|
233
|
+
writer.out(` ${option.flag.padEnd(width)} ${option.description}`);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
if (command.needsCredentials) {
|
|
237
|
+
writer.out('');
|
|
238
|
+
writer.out('Needs credentials. Run "open-nav config" to check them.');
|
|
239
|
+
}
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
writer.out('open-nav — command line access to the NAV Online Számla invoice service');
|
|
244
|
+
writer.out('');
|
|
245
|
+
writer.out(' open-nav <command> [options]');
|
|
246
|
+
writer.out('');
|
|
247
|
+
const width = Math.max(...COMMANDS.map((command) => command.name.length));
|
|
248
|
+
for (const command of COMMANDS) {
|
|
249
|
+
writer.out(` ${command.name.padEnd(width)} ${command.summary}`);
|
|
250
|
+
}
|
|
251
|
+
writer.out('');
|
|
252
|
+
writer.out('Options:');
|
|
253
|
+
writer.out(' --json Output JSON (the default when not a terminal)');
|
|
254
|
+
writer.out(' --pretty Output human readable text');
|
|
255
|
+
writer.out(' --env-file <path> Read configuration from this file');
|
|
256
|
+
writer.out(' --no-env-file Ignore .env files entirely');
|
|
257
|
+
writer.out(' --describe Print the command surface as JSON, for tooling');
|
|
258
|
+
writer.out(' --version Print the version');
|
|
259
|
+
writer.out('');
|
|
260
|
+
writer.out('Exit codes: 0 ok, 2 usage, 3 invalid document, 4 rejected by NAV, 5 unavailable.');
|
|
261
|
+
writer.out('');
|
|
262
|
+
writer.out('Help on a command: open-nav help <command>');
|
|
263
|
+
writer.out('Help on configuration: open-nav help config');
|
|
264
|
+
}
|
package/src/output.ts
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import type { InvoiceValidationIssue } from '@open-nav/core';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Output format.
|
|
5
|
+
*
|
|
6
|
+
* JSON is the default whenever output is not a terminal, so a program or an
|
|
7
|
+
* agent calling this tool always gets something parseable without having to
|
|
8
|
+
* remember a flag. A human at a terminal gets readable text.
|
|
9
|
+
*/
|
|
10
|
+
export type Format = 'json' | 'text';
|
|
11
|
+
|
|
12
|
+
export function resolveFormat(explicit: Format | undefined, isTty: boolean): Format {
|
|
13
|
+
return explicit ?? (isTty ? 'text' : 'json');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface Writer {
|
|
17
|
+
out: (line: string) => void;
|
|
18
|
+
err: (line: string) => void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const consoleWriter: Writer = {
|
|
22
|
+
out: (line) => process.stdout.write(`${line}\n`),
|
|
23
|
+
err: (line) => process.stderr.write(`${line}\n`),
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/** Envelope every JSON result shares, so callers can branch on `ok` alone. */
|
|
27
|
+
export interface ResultEnvelope {
|
|
28
|
+
ok: boolean;
|
|
29
|
+
command: string;
|
|
30
|
+
data?: unknown;
|
|
31
|
+
error?: { message: string; code?: string; details?: unknown };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function writeResult(
|
|
35
|
+
writer: Writer,
|
|
36
|
+
format: Format,
|
|
37
|
+
command: string,
|
|
38
|
+
data: unknown,
|
|
39
|
+
renderText: (data: never) => string[],
|
|
40
|
+
): void {
|
|
41
|
+
if (format === 'json') {
|
|
42
|
+
const envelope: ResultEnvelope = { ok: true, command, data };
|
|
43
|
+
writer.out(JSON.stringify(envelope, null, 2));
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
for (const line of renderText(data as never)) writer.out(line);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function writeError(
|
|
50
|
+
writer: Writer,
|
|
51
|
+
format: Format,
|
|
52
|
+
command: string,
|
|
53
|
+
error: { message: string; code?: string; details?: unknown },
|
|
54
|
+
): void {
|
|
55
|
+
if (format === 'json') {
|
|
56
|
+
const envelope: ResultEnvelope = { ok: false, command, error };
|
|
57
|
+
writer.out(JSON.stringify(envelope, null, 2));
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
writer.err(error.code ? `${error.code}: ${error.message}` : error.message);
|
|
61
|
+
if (error.details !== undefined) {
|
|
62
|
+
writer.err(
|
|
63
|
+
typeof error.details === 'string' ? error.details : JSON.stringify(error.details, null, 2),
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Render validation issues as aligned text. */
|
|
69
|
+
export function renderIssues(issues: InvoiceValidationIssue[]): string[] {
|
|
70
|
+
return issues.map((issue) => {
|
|
71
|
+
const marker = issue.severity === 'error' ? 'error' : 'warn ';
|
|
72
|
+
const nav = issue.navMessage ? `\n NAV: ${issue.navMessage}` : '';
|
|
73
|
+
return ` ${marker} ${issue.code}\n ${issue.path}\n ${issue.message}${nav}`;
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** A compact `key: value` block. */
|
|
78
|
+
export function renderFields(
|
|
79
|
+
fields: Array<[string, string | number | boolean | undefined]>,
|
|
80
|
+
): string[] {
|
|
81
|
+
const width = Math.max(...fields.map(([label]) => label.length));
|
|
82
|
+
return fields
|
|
83
|
+
.filter(([, value]) => value !== undefined)
|
|
84
|
+
.map(([label, value]) => ` ${label.padEnd(width)} ${String(value)}`);
|
|
85
|
+
}
|
package/src/version.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { createRequire } from 'node:module';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The package's own version, read from its manifest rather than duplicated
|
|
5
|
+
* here. A hardcoded copy is a release-day footgun: it drifts silently, and
|
|
6
|
+
* the first thing a bug report quotes is `open-nav --version`.
|
|
7
|
+
*/
|
|
8
|
+
export const VERSION: string = (
|
|
9
|
+
createRequire(import.meta.url)('../package.json') as { version: string }
|
|
10
|
+
).version;
|