@cosyte/cli 0.0.5 → 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.
@@ -0,0 +1,381 @@
1
+ import { Readable } from 'node:stream';
2
+
3
+ /**
4
+ * The `cosyte` CLI **exit-code contract**: a designed, documented surface that CI pipelines and
5
+ * shell scripts branch on. `0` is the conventional success status; the codes from `65` up are the
6
+ * values the Unix `sysexits.h` header assigns to the constants named beside them; `1` and `2` are
7
+ * this CLI's own, because that header defines no constant for either.
8
+ *
9
+ * The load-bearing rule: **the CLI never prints a reassuring line and exits `0` on input it could not
10
+ * handle.** An undetectable format, an unreadable file, a parser that throws, or a consumer that
11
+ * closed the output stream before the output arrived each map to a distinct, stable non-zero code,
12
+ * never a silent success.
13
+ *
14
+ * | Code | Name | Meaning |
15
+ * |------|------------|----------------------------------------------------------------------|
16
+ * | `0` | `OK` | success: the operation completed; `validate` found the input **valid** |
17
+ * | `1` | `INVALID` | operation-level failure: `validate` found the input **invalid**, or `redact` could not de-identify every locus (this CLI's own value; a real, expected CI signal: the tool worked, the operation did not complete) |
18
+ * | `2` | `USAGE` | usage error: unknown command, bad flag, missing argument (this CLI's own value) |
19
+ * | `65` | `DATAERR` | data error: input could not be parsed / format not detected (EX_DATAERR) |
20
+ * | `66` | `NOINPUT` | no input: the file does not exist or is unreadable (EX_NOINPUT) |
21
+ * | `69` | `UNAVAILABLE` | a required capability is not wired for this input: e.g. `redact` on a format `@cosyte/deid` has no adapter for (EX_UNAVAILABLE) |
22
+ * | `70` | `SOFTWARE` | internal error: an unexpected exception, i.e. a bug (EX_SOFTWARE) |
23
+ * | `74` | `IOERR` | output error: the output stream closed before the CLI had finished writing to it, the shape a downstream consumer that exits early produces (EX_IOERR) |
24
+ *
25
+ * The load-bearing `validate` rule: a **parseable-but-invalid** message is exit `1`, never exit `0`.
26
+ * The CLI must never print a reassuring line and exit green on a bad message. Exit `65` is reserved
27
+ * for input that could not be **parsed** at all (a distinct signal from "parsed, but does not
28
+ * conform").
29
+ *
30
+ * @packageDocumentation
31
+ */
32
+ /**
33
+ * The stable exit-code map. Adding a code is a documented, tested change to the CLI's contract;
34
+ * renaming or repurposing one is a breaking change.
35
+ *
36
+ * @example
37
+ * ```ts
38
+ * import { EXIT } from "@cosyte/cli";
39
+ *
40
+ * process.exitCode = EXIT.OK; // => 0
41
+ * ```
42
+ */
43
+ declare const EXIT: {
44
+ /** Success: the operation completed; `validate` found the input **valid**. */
45
+ readonly OK: 0;
46
+ /** Operation-level failure: `validate` found the input **invalid** (parseable but non-conformant),
47
+ * or `redact` could not de-identify every locus: a real, expected CI signal that the tool worked
48
+ * and the operation did not complete. Never emitted for unparseable input (that is `DATAERR`). */
49
+ readonly INVALID: 1;
50
+ /** Usage error: unknown command, bad flag, missing argument. A value specific to this CLI, not a
51
+ * `sysexits.h` constant. */
52
+ readonly USAGE: 2;
53
+ /** Data error: input could not be parsed or its format could not be detected (`EX_DATAERR`). */
54
+ readonly DATAERR: 65;
55
+ /** No input: the named file does not exist or is unreadable (`EX_NOINPUT`). */
56
+ readonly NOINPUT: 66;
57
+ /** Unavailable: a required capability is not wired for this input (e.g. `redact` on a format
58
+ * `@cosyte/deid` has no adapter for, or with that optional library absent), a distinct non-zero
59
+ * signal that is never a fake success (`EX_UNAVAILABLE`). */
60
+ readonly UNAVAILABLE: 69;
61
+ /** Internal error: an unexpected exception (a bug), distinct from a handled bad input (`EX_SOFTWARE`). */
62
+ readonly SOFTWARE: 70;
63
+ /** Output error: the stream the CLI was writing to closed before the output had been delivered, so
64
+ * the consumer holds nothing or holds only part of the answer. The shape a downstream consumer
65
+ * that exits early produces (a pager quit, a `head` that has seen enough). A handled condition the
66
+ * consumer owns, never a defect in the CLI, so it is deliberately distinct from `SOFTWARE`
67
+ * (`EX_IOERR`). */
68
+ readonly IOERR: 74;
69
+ };
70
+ /**
71
+ * A value from {@link EXIT}: the exit code a CLI invocation resolves to.
72
+ */
73
+ type ExitCode = (typeof EXIT)[keyof typeof EXIT];
74
+
75
+ /**
76
+ * The result of running a CLI invocation, as data: decoupled from `process` so the whole command
77
+ * tree is testable. The thin `bin` entry writes {@link RunResult.stdout} to `process.stdout`,
78
+ * {@link RunResult.stderr} to `process.stderr`, and exits with {@link RunResult.exit}.
79
+ *
80
+ * The two channels carry the CLI's PHI posture: **stdout is the data channel** (the
81
+ * user's explicit request: the parsed model), while **stderr is value-free**: codes and positions
82
+ * only, never an input value.
83
+ *
84
+ * @packageDocumentation
85
+ */
86
+
87
+ /**
88
+ * A fully-resolved CLI invocation.
89
+ *
90
+ * @example
91
+ * ```ts
92
+ * import { EXIT, type RunResult } from "@cosyte/cli";
93
+ *
94
+ * const r: RunResult = { stdout: "{}\n", stderr: "", exit: EXIT.OK };
95
+ * r.exit; // => 0
96
+ * ```
97
+ */
98
+ interface RunResult {
99
+ /** The data channel: the parsed model or requested output. May contain the user's data by design. */
100
+ readonly stdout: string;
101
+ /** The diagnostic channel is **value-free**: codes, positions, file paths, format names only. */
102
+ readonly stderr: string;
103
+ /** The exit code the invocation resolves to. */
104
+ readonly exit: ExitCode;
105
+ }
106
+
107
+ /**
108
+ * The CLI's **value-free diagnostic channel**. Every error the CLI raises on its own behalf carries a
109
+ * stable `CLI_*` code, a fixed exit code, and a **PHI-safe** message built only from positional /
110
+ * structural context: a segment or field index, a byte offset, a file path, a format name, a warning
111
+ * code. **A diagnostic never echoes an input value** (a name, a DOB, an MRN, a result). This is the
112
+ * parsers' "warning = code + position, never a value" rule applied at the CLI edge.
113
+ *
114
+ * @packageDocumentation
115
+ */
116
+
117
+ /**
118
+ * Stable **CLI diagnostic code** registry: errors the CLI owns (routing, I/O, argument handling),
119
+ * distinct from the wrapped library's own warning/issue codes which are passed through unchanged.
120
+ * Each code is its own value (`key === value`) so the set survives `Object.values(...)`; renaming or
121
+ * removing one is a **breaking change** because scripts branch on the stderr text.
122
+ *
123
+ * @example
124
+ * ```ts
125
+ * import { CLI_CODES } from "@cosyte/cli";
126
+ *
127
+ * if (diagnostic.code === CLI_CODES.CLI_FORMAT_UNDETECTED) {
128
+ * // ask the user for --format
129
+ * }
130
+ * ```
131
+ */
132
+ declare const CLI_CODES: {
133
+ /** A required argument was missing or a flag was invalid. Exit `2`. */
134
+ readonly CLI_USAGE: "CLI_USAGE";
135
+ /** The named file does not exist or could not be read. Exit `66`. */
136
+ readonly CLI_NO_INPUT: "CLI_NO_INPUT";
137
+ /** The input was empty, no bytes to detect or parse. Exit `65`. */
138
+ readonly CLI_EMPTY_INPUT: "CLI_EMPTY_INPUT";
139
+ /** No format signature matched; the CLI will not guess. Names the candidates, never the bytes. Exit `65`. */
140
+ readonly CLI_FORMAT_UNDETECTED: "CLI_FORMAT_UNDETECTED";
141
+ /** More than one format signature matched; the CLI will not guess. Names the candidates, never the bytes. Exit `65`. */
142
+ readonly CLI_FORMAT_AMBIGUOUS: "CLI_FORMAT_AMBIGUOUS";
143
+ /** A recognised format whose parser does not support the requested operation (e.g. `parse` on a
144
+ * format wired only for `inspect`). Names the format + operation, never the bytes. Exit `65`. */
145
+ readonly CLI_FORMAT_UNSUPPORTED: "CLI_FORMAT_UNSUPPORTED";
146
+ /** The optional parser package for a recognised format is not installed. The six breadth parsers
147
+ * (dicom/x12/ccda/ncpdp/astm/mllp) are `optionalDependencies`: normally installed, but if
148
+ * one is absent the CLI degrades to this value-free signal rather than crashing. Exit `69`. */
149
+ readonly CLI_PARSER_UNAVAILABLE: "CLI_PARSER_UNAVAILABLE";
150
+ /** The wrapped parser rejected the input. Positional context only, never the offending bytes. Exit `65`. */
151
+ readonly CLI_PARSE_FAILED: "CLI_PARSE_FAILED";
152
+ /** The input is larger than the documented size limit the CLI reads in one invocation. Raised
153
+ * against the running byte count while the input is read, so the refusal always arrives before an
154
+ * allocation ceiling could. Names the limit, never the input. Exit `65`, never `70`. */
155
+ readonly CLI_INPUT_TOO_LARGE: "CLI_INPUT_TOO_LARGE";
156
+ /** The output stream could not be written: a consumer that closes the pipe ends the invocation
157
+ * here rather than as an unhandled platform error, whether it went away part way through a record
158
+ * stream or before a single-result write was delivered. Names no input, and never the platform's
159
+ * own error text. Exit `74`. */
160
+ readonly CLI_OUTPUT_WRITE_FAILED: "CLI_OUTPUT_WRITE_FAILED";
161
+ /** The BYO ConceptMap supplied to `map-codes` is not valid JSON or not a loadable FHIR ConceptMap.
162
+ * Names the stable terminology-loader code, never the map's bytes. Exit `65`. */
163
+ readonly CLI_MAP_INVALID: "CLI_MAP_INVALID";
164
+ /** A command whose ground-layer library does not cover the resolved format (e.g. `redact` on a
165
+ * format `@cosyte/deid` ships no adapter for). Never a fake success: a distinct, value-free
166
+ * "unavailable" signal. Exit `69`. */
167
+ readonly CLI_NOT_IMPLEMENTED: "CLI_NOT_IMPLEMENTED";
168
+ /** The delegated de-identifier reported that it could not handle every element of the input (a
169
+ * fail-closed `blocked` locus, or its own fatal). The run is **not** a de-identified copy, so no
170
+ * output is emitted: an operation-level failure, never a bug. Names loci and codes, never a
171
+ * value. Exit `1`. */
172
+ readonly CLI_DEID_INCOMPLETE: "CLI_DEID_INCOMPLETE";
173
+ /** An unexpected internal error (a bug). Exit `70`. */
174
+ readonly CLI_INTERNAL: "CLI_INTERNAL";
175
+ };
176
+ /**
177
+ * A value from {@link CLI_CODES}: the stable code carried by a {@link CliError}.
178
+ */
179
+ type CliCode = (typeof CLI_CODES)[keyof typeof CLI_CODES];
180
+ /**
181
+ * A typed, PHI-safe CLI error. Its {@link CliError.message | message} is **value-free by
182
+ * construction**: callers must build it from codes, indices, offsets, format names, and file paths,
183
+ * never from an input value. It carries the {@link ExitCode} the invocation resolves to.
184
+ *
185
+ * @example
186
+ * ```ts
187
+ * import { CliError, CLI_CODES, EXIT } from "@cosyte/cli";
188
+ *
189
+ * throw new CliError(CLI_CODES.CLI_NO_INPUT, EXIT.NOINPUT, "cannot read the named file");
190
+ * ```
191
+ */
192
+ declare class CliError extends Error {
193
+ /** The stable diagnostic code. */
194
+ readonly code: CliCode;
195
+ /** The exit code this error resolves the invocation to. */
196
+ readonly exit: ExitCode;
197
+ /**
198
+ * @param code - A stable {@link CliCode}.
199
+ * @param exit - The {@link ExitCode} to resolve to.
200
+ * @param message - A **value-free** explanation (positional/structural context only, never PHI).
201
+ */
202
+ constructor(code: CliCode, exit: ExitCode, message: string);
203
+ }
204
+ /**
205
+ * Render a {@link CliError} as a single value-free `stderr` line: `cosyte: <CODE>: <message>`. The
206
+ * message is already value-free by the {@link CliError} contract; this only prefixes the tool name
207
+ * and the code so scripts and humans can branch on a stable token.
208
+ *
209
+ * @param err - The CLI error to render.
210
+ * @returns The stderr line (no trailing newline).
211
+ * @example
212
+ * ```ts
213
+ * import { CliError, CLI_CODES, EXIT, formatDiagnostic } from "@cosyte/cli";
214
+ *
215
+ * formatDiagnostic(new CliError(CLI_CODES.CLI_USAGE, EXIT.USAGE, "missing <file> argument"));
216
+ * // => "cosyte: CLI_USAGE: missing <file> argument"
217
+ * ```
218
+ */
219
+ declare function formatDiagnostic(err: CliError): string;
220
+ /**
221
+ * Resolve a {@link CliError} into a value-free {@link RunResult}: empty `stdout` (nothing reaches the
222
+ * data channel on an error), the rendered diagnostic on `stderr`, and the error's exit code. The
223
+ * single place the CLI turns an owned error into a result, so every command and the dispatcher render
224
+ * errors identically.
225
+ *
226
+ * @param err - The CLI error to resolve.
227
+ * @returns The value-free {@link RunResult}.
228
+ * @example
229
+ * ```ts
230
+ * import { CliError, CLI_CODES, EXIT, errorResult } from "@cosyte/cli";
231
+ *
232
+ * errorResult(new CliError(CLI_CODES.CLI_USAGE, EXIT.USAGE, "missing <file>")).exit; // => 2
233
+ * ```
234
+ */
235
+ declare function errorResult(err: CliError): RunResult;
236
+ /**
237
+ * Coerce an unknown thrown value into a {@link CliError}. A {@link CliError} passes through; anything
238
+ * else becomes a `CLI_INTERNAL` / {@link EXIT.SOFTWARE} error whose message is a **fixed, value-free
239
+ * string**: the original message is deliberately discarded so a parser exception that embedded input
240
+ * bytes can never reach stderr.
241
+ *
242
+ * @param err - The caught value.
243
+ * @returns A {@link CliError} safe to render to stderr.
244
+ * @example
245
+ * ```ts
246
+ * import { toCliError } from "@cosyte/cli";
247
+ *
248
+ * toCliError(new Error("boom")).code; // => "CLI_INTERNAL"
249
+ * ```
250
+ */
251
+ declare function toCliError(err: unknown): CliError;
252
+
253
+ /**
254
+ * Input I/O for the CLI: read the bytes to operate on from a **file argument** or from **stdin**
255
+ * (`-`), with a value-free failure mode. The CLI never writes a temp file and never logs to a file;
256
+ * this module only *reads*.
257
+ *
258
+ * Reading comes in two shapes. The **whole-input** readers ({@link readFileBytes},
259
+ * {@link readStreamBytes}) hand a command one buffer, which is what a single-message command wants.
260
+ * The **chunk** readers ({@link fileChunks}, {@link streamChunks}) hand it an async stream of chunks,
261
+ * which is what a multi-record command wants: it can emit a record's output before the rest of the
262
+ * input has arrived, and the CLI's own size limit can be enforced against the **running total**
263
+ * rather than after an oversized input has already been assembled.
264
+ *
265
+ * The reader functions are injected into the command layer as {@link RunDeps} so the whole dispatch
266
+ * path is testable without touching `process`: the real `process.stdin` wiring lives in the thin
267
+ * `bin` entry, and so does the one function in {@link RunDeps} that writes rather than reads.
268
+ *
269
+ * @packageDocumentation
270
+ */
271
+
272
+ /**
273
+ * The injectable I/O side-effects the dispatcher needs. Kept tiny and pure-ish so tests drive the
274
+ * CLI end to end with in-memory fakes and no real filesystem, stdin or stdout.
275
+ *
276
+ * The two whole-input readers are required; the chunk readers and the output sink are **optional**,
277
+ * and a caller that omits them still gets identical output. They are the difference between reading
278
+ * a stream as it arrives and being handed one buffer: with them, a multi-record `parse` emits each
279
+ * record as it is produced and refuses an over-limit input while it is still arriving; without them,
280
+ * the same code path runs over a single chunk and returns its output on the result's `stdout` as
281
+ * before.
282
+ */
283
+ interface RunDeps {
284
+ /** Read a file's bytes, or raise a `CLI_NO_INPUT` {@link CliError} if it cannot be read. */
285
+ readonly readFile: (path: string) => Promise<Uint8Array>;
286
+ /** Read all of stdin's bytes. */
287
+ readonly readStdin: () => Promise<Uint8Array>;
288
+ /** Optional: open a file as a stream of byte chunks (see {@link fileChunks}). */
289
+ readonly openFile?: (path: string) => AsyncIterable<Uint8Array>;
290
+ /** Optional: open stdin as a stream of byte chunks (see {@link streamChunks}). */
291
+ readonly openStdin?: () => AsyncIterable<Uint8Array>;
292
+ /**
293
+ * Optional: write one piece of the data channel **as it is produced**. When present, a multi-record
294
+ * `parse` writes each record's NDJSON line through here instead of accumulating them, and the
295
+ * returned result's `stdout` is empty because the output has already been delivered. May throw (a
296
+ * consumer that closed the pipe); the caller turns that into a value-free
297
+ * `CLI_OUTPUT_WRITE_FAILED`.
298
+ */
299
+ readonly writeStdout?: (chunk: string) => void;
300
+ }
301
+ /**
302
+ * Read a file into bytes, mapping any read failure to a **value-free** `CLI_NO_INPUT` /
303
+ * {@link EXIT.NOINPUT} error. The path is structural context (the user supplied it), so it may appear
304
+ * in the message; the file *contents* never do.
305
+ *
306
+ * A file whose size already exceeds `limit` is refused **before it is read**, with the value-free
307
+ * over-limit data error, so an oversized file is never allocated in the first place. The size is read
308
+ * from the **open descriptor** rather than from the path, so what is measured and what is read are the
309
+ * same file even if the path is replaced in between.
310
+ *
311
+ * @param path - The file path to read.
312
+ * @param limit - The maximum number of bytes to accept. Defaults to {@link MAX_INPUT_BYTES}.
313
+ * @returns The file bytes.
314
+ * @throws {CliError} `CLI_NO_INPUT` (exit `66`) when the file is missing, a directory, or unreadable;
315
+ * `CLI_INPUT_TOO_LARGE` (exit `65`) when it is larger than `limit`.
316
+ * @example
317
+ * ```ts throws
318
+ * import { readFileBytes } from "@cosyte/cli";
319
+ *
320
+ * await readFileBytes("/no/such/file"); // throws CliError CLI_NO_INPUT
321
+ * ```
322
+ */
323
+ declare function readFileBytes(path: string, limit?: number): Promise<Uint8Array>;
324
+ /**
325
+ * Drain a readable stream (e.g. `process.stdin`) into a single byte buffer, refusing an over-limit
326
+ * stream against the **running total**: the read is abandoned the moment the accumulated size passes
327
+ * `limit`, so the bytes past it are never allocated and no platform allocation ceiling is ever
328
+ * reached.
329
+ *
330
+ * @param stream - The readable stream to drain.
331
+ * @param limit - The maximum number of bytes to accept. Defaults to {@link MAX_INPUT_BYTES}.
332
+ * @returns The concatenated bytes.
333
+ * @throws {CliError} `CLI_INPUT_TOO_LARGE` (exit `65`) as soon as the running total passes `limit`.
334
+ * @example
335
+ * ```ts
336
+ * import { Readable } from "node:stream";
337
+ * import { readStreamBytes } from "@cosyte/cli";
338
+ *
339
+ * const bytes = await readStreamBytes(Readable.from([Buffer.from("MSH|")]));
340
+ * bytes.length; // => 4
341
+ * ```
342
+ */
343
+ declare function readStreamBytes(stream: Readable, limit?: number): Promise<Uint8Array>;
344
+ /**
345
+ * Open a readable stream (e.g. `process.stdin`) as a stream of byte **chunks**, in arrival order.
346
+ * Unlike {@link readStreamBytes} this never concatenates: the consumer decides what to keep, which is
347
+ * what lets a multi-record command emit a record before the rest of the input has been read.
348
+ *
349
+ * @param stream - The readable stream to iterate.
350
+ * @returns An async iterable of the stream's chunks, string chunks encoded as UTF-8.
351
+ * @example
352
+ * ```ts
353
+ * import { Readable } from "node:stream";
354
+ * import { streamChunks } from "@cosyte/cli";
355
+ *
356
+ * const seen: number[] = [];
357
+ * for await (const c of streamChunks(Readable.from([Buffer.from("MS"), Buffer.from("H|")]))) {
358
+ * seen.push(c.length);
359
+ * }
360
+ * seen.length; // => 2
361
+ * ```
362
+ */
363
+ declare function streamChunks(stream: Readable): AsyncGenerator<Uint8Array>;
364
+ /**
365
+ * Open a file as a stream of byte **chunks**, with the same value-free failure mode as
366
+ * {@link readFileBytes}: a missing, unreadable or non-file path is a `CLI_NO_INPUT` error, never a
367
+ * raw filesystem exception.
368
+ *
369
+ * @param path - The file path to read.
370
+ * @returns An async iterable of the file's chunks.
371
+ * @throws {CliError} `CLI_NO_INPUT` (exit `66`) when the file cannot be opened or read.
372
+ * @example
373
+ * ```ts throws
374
+ * import { fileChunks } from "@cosyte/cli";
375
+ *
376
+ * for await (const chunk of fileChunks("/no/such/file")) chunk.length; // throws CLI_NO_INPUT
377
+ * ```
378
+ */
379
+ declare function fileChunks(path: string): AsyncGenerator<Uint8Array>;
380
+
381
+ export { type CliCode as C, EXIT as E, type RunDeps as R, CliError as a, type RunResult as b, CLI_CODES as c, type ExitCode as d, errorResult as e, fileChunks as f, formatDiagnostic as g, readStreamBytes as h, readFileBytes as r, streamChunks as s, toCliError as t };