edfcore 0.1.15 → 0.1.17

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,23 @@
1
+ /**
2
+ * What `npx edfcore` actually does, with the process factored out.
3
+ *
4
+ * `cli.ts` is a shell that supplies real `node:fs` and `node:process`; everything decidable lives
5
+ * here, behind an injected `CliIo`. That is not ceremony — a CLI tested by spawning a subprocess
6
+ * can only be tested once the package is built, so the tests either skip in CI or test a stale
7
+ * binary. This way the exit codes and the output are ordinary unit tests.
8
+ */
9
+ /** Everything the CLI touches outside itself. */
10
+ export interface CliIo {
11
+ readFile(path: string): Promise<Uint8Array>;
12
+ out(text: string): void;
13
+ err(text: string): void;
14
+ }
15
+ export interface Args {
16
+ readonly command: string | undefined;
17
+ readonly file: string | undefined;
18
+ readonly patient: boolean;
19
+ readonly limit: number | undefined;
20
+ }
21
+ export declare function parseArgs(argv: readonly string[]): Args;
22
+ export declare function runCli(args: Args, io: CliIo): Promise<number>;
23
+ //# sourceMappingURL=cli-run.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cli-run.d.ts","sourceRoot":"","sources":["../src/cli-run.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAUH,iDAAiD;AACjD,MAAM,WAAW,KAAK;IACpB,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,CAAC,CAAC;IAC5C,GAAG,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,GAAG,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;CACzB;AAgBD,MAAM,WAAW,IAAI;IACnB,QAAQ,CAAC,OAAO,EAAE,MAAM,GAAG,SAAS,CAAC;IACrC,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,SAAS,CAAC;IAClC,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC;IAC1B,QAAQ,CAAC,KAAK,EAAE,MAAM,GAAG,SAAS,CAAC;CACpC;AAED,wBAAgB,SAAS,CAAC,IAAI,EAAE,SAAS,MAAM,EAAE,GAAG,IAAI,CAoBvD;AAQD,wBAAsB,MAAM,CAAC,IAAI,EAAE,IAAI,EAAE,EAAE,EAAE,KAAK,GAAG,OAAO,CAAC,MAAM,CAAC,CAoFnE"}
@@ -0,0 +1,126 @@
1
+ /**
2
+ * What `npx edfcore` actually does, with the process factored out.
3
+ *
4
+ * `cli.ts` is a shell that supplies real `node:fs` and `node:process`; everything decidable lives
5
+ * here, behind an injected `CliIo`. That is not ceremony — a CLI tested by spawning a subprocess
6
+ * can only be tested once the package is built, so the tests either skip in CI or test a stale
7
+ * binary. This way the exit codes and the output are ordinary unit tests.
8
+ */
9
+ import { countAnnotationsByText } from './annotations-query.js';
10
+ import { formatDiagnostics } from './diagnostics/format.js';
11
+ import { formatHeader } from './format-header.js';
12
+ import { formatValidationReport } from './format-report.js';
13
+ import { byteSource } from './io/bytes.js';
14
+ import { openEdf, readAnnotations } from './recording.js';
15
+ import { validateRecording } from './validate.js';
16
+ const USAGE = `edfcore — read EDF, EDF+, BDF and BDF+ files
17
+
18
+ npx edfcore header <file> the header, the signals, and any diagnostics
19
+ npx edfcore validate <file> a full conformance sweep, scanning every sample
20
+ npx edfcore events <file> the annotations, counted by text
21
+ npx edfcore json <file> the header as JSON, for piping into jq
22
+
23
+ Options
24
+ --patient include patient identification (header, json)
25
+ --limit <n> individual diagnostics to print (default 20)
26
+
27
+ Exit codes: 0 success, 1 the file is unreadable or failed validation, 2 bad usage.
28
+ `;
29
+ export function parseArgs(argv) {
30
+ const positional = [];
31
+ let patient = false;
32
+ let limit;
33
+ for (let i = 0; i < argv.length; i += 1) {
34
+ const arg = argv[i];
35
+ if (arg === '--patient')
36
+ patient = true;
37
+ else if (arg === '--limit') {
38
+ const value = Number(argv[i + 1]);
39
+ // A NaN limit would disable the cap silently, which is the opposite of what was asked for.
40
+ if (!Number.isSafeInteger(value) || value < 0) {
41
+ throw new RangeError(`--limit needs a whole number, received ${String(argv[i + 1])}`);
42
+ }
43
+ limit = value;
44
+ i += 1;
45
+ }
46
+ else if (arg !== undefined && !arg.startsWith('-'))
47
+ positional.push(arg);
48
+ }
49
+ return { command: positional[0], file: positional[1], patient, limit };
50
+ }
51
+ async function open(io, file) {
52
+ // Read whole rather than fileSource: a CLI invocation is one pass over one file, and holding
53
+ // it in memory removes any question of a descriptor outliving the process.
54
+ return openEdf(byteSource(await io.readFile(file)));
55
+ }
56
+ export async function runCli(args, io) {
57
+ const { command, file } = args;
58
+ if (command === undefined || command === 'help' || command === '--help') {
59
+ io.out(USAGE);
60
+ return command === undefined ? 2 : 0;
61
+ }
62
+ if (file === undefined) {
63
+ io.err(`edfcore ${command}: no file given\n\n${USAGE}`);
64
+ return 2;
65
+ }
66
+ switch (command) {
67
+ case 'header': {
68
+ const recording = await open(io, file);
69
+ io.out(`${formatHeader(recording.header, { includePatientId: args.patient })}\n`);
70
+ if (recording.header.diagnostics.length > 0) {
71
+ io.out(`\n${formatDiagnostics(recording.header.diagnostics, { maxItems: args.limit ?? 20 })}\n`);
72
+ }
73
+ return 0;
74
+ }
75
+ case 'validate': {
76
+ const recording = await open(io, file);
77
+ const report = await validateRecording(recording, { scanSamples: true });
78
+ io.out(`${formatValidationReport(report, { header: recording.header, maxItems: args.limit ?? 20 })}\n`);
79
+ // Exit 1 on failure so a CI job can gate on it without parsing the output.
80
+ return report.ok ? 0 : 1;
81
+ }
82
+ case 'events': {
83
+ const recording = await open(io, file);
84
+ const { annotations } = await readAnnotations(recording, {
85
+ start: 0,
86
+ count: recording.header.recordCount,
87
+ });
88
+ if (annotations.length === 0) {
89
+ io.out('no annotations\n');
90
+ return 0;
91
+ }
92
+ io.out(`${annotations.length} annotation(s)\n\n`);
93
+ for (const { text, count } of countAnnotationsByText(annotations)) {
94
+ io.out(`${String(count).padStart(8)} ${text}\n`);
95
+ }
96
+ return 0;
97
+ }
98
+ case 'json': {
99
+ const recording = await open(io, file);
100
+ const { header } = recording;
101
+ io.out(`${JSON.stringify({
102
+ variant: header.variant,
103
+ recordCount: header.recordCount,
104
+ recordDurationSeconds: header.recordDurationSeconds,
105
+ spanSeconds: recording.timeline.spanSeconds,
106
+ // Patient identification is opt-in here for the same reason it is in formatHeader:
107
+ // the obvious thing to do with this output is pipe it somewhere.
108
+ ...(args.patient ? { patient: header.patient.raw.trim() } : {}),
109
+ signals: header.signals.map((signal) => ({
110
+ index: signal.index,
111
+ label: signal.label,
112
+ kind: signal.kind,
113
+ samplesPerRecord: signal.samplesPerRecord,
114
+ sampleRateHz: signal.sampleRateHz,
115
+ physicalDimension: signal.physicalDimension,
116
+ })),
117
+ diagnostics: header.diagnostics.map((d) => ({ code: d.code, severity: d.severity })),
118
+ }, null, 2)}\n`);
119
+ return 0;
120
+ }
121
+ default:
122
+ io.err(`edfcore: unknown command ${JSON.stringify(command)}\n\n${USAGE}`);
123
+ return 2;
124
+ }
125
+ }
126
+ //# sourceMappingURL=cli-run.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cli-run.js","sourceRoot":"","sources":["../src/cli-run.ts"],"names":[],"mappings":"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"}
package/dist/cli.d.ts ADDED
@@ -0,0 +1,16 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * `npx edfcore` — looking at a file without writing code.
4
+ *
5
+ * A separate entry point, never re-exported from the barrel, because it imports `node:fs` and
6
+ * `node:process`. The universal entry must stay free of Node built-ins, and a test asserts it.
7
+ *
8
+ * This file is only the wiring. The decisions live in `cli-run.ts`, so they can be tested without
9
+ * spawning a process against a build that may not exist yet.
10
+ *
11
+ * `main()` runs unconditionally rather than behind an `import.meta.url === process.argv[1]` guard.
12
+ * That guard is a known trap for a bin: `npx` runs the command through a symlink, so the two paths
13
+ * differ, the condition is false, and the CLI exits 0 having done nothing.
14
+ */
15
+ export {};
16
+ //# sourceMappingURL=cli.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":";AACA;;;;;;;;;;;;GAYG"}
package/dist/cli.js ADDED
@@ -0,0 +1,51 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * `npx edfcore` — looking at a file without writing code.
4
+ *
5
+ * A separate entry point, never re-exported from the barrel, because it imports `node:fs` and
6
+ * `node:process`. The universal entry must stay free of Node built-ins, and a test asserts it.
7
+ *
8
+ * This file is only the wiring. The decisions live in `cli-run.ts`, so they can be tested without
9
+ * spawning a process against a build that may not exist yet.
10
+ *
11
+ * `main()` runs unconditionally rather than behind an `import.meta.url === process.argv[1]` guard.
12
+ * That guard is a known trap for a bin: `npx` runs the command through a symlink, so the two paths
13
+ * differ, the condition is false, and the CLI exits 0 having done nothing.
14
+ */
15
+ // biome-ignore lint/suspicious/noTsIgnore: @ts-expect-error errors when unused — see node.ts.
16
+ // @ts-ignore 'node:fs/promises' has no declarations under `types: []`; its shape is below.
17
+ import * as nodeFsPromises from 'node:fs/promises';
18
+ // biome-ignore lint/suspicious/noTsIgnore: as above.
19
+ // @ts-ignore 'node:process' has no declarations under `types: []`; its shape is below.
20
+ import * as nodeProcess from 'node:process';
21
+ import { parseArgs, runCli } from './cli-run.js';
22
+ import { isEdfError } from './errors.js';
23
+ const fs = nodeFsPromises;
24
+ const proc = nodeProcess.default ??
25
+ nodeProcess;
26
+ const io = {
27
+ readFile: (path) => fs.readFile(path),
28
+ out: (text) => {
29
+ proc.stdout.write(text);
30
+ },
31
+ err: (text) => {
32
+ proc.stderr.write(text);
33
+ },
34
+ };
35
+ async function main() {
36
+ try {
37
+ proc.exitCode = await runCli(parseArgs(proc.argv.slice(2)), io);
38
+ }
39
+ catch (error) {
40
+ // An EdfError already says what is wrong, where, and what to do next; a stack trace over the
41
+ // top of it would bury the one useful line.
42
+ const message = isEdfError(error) || error instanceof Error ? error.message : String(error);
43
+ proc.stderr.write(`edfcore: ${message}\n`);
44
+ proc.exitCode = 1;
45
+ }
46
+ }
47
+ // Not `await main()`: the package documents that no module in its graph uses top-level await,
48
+ // which is what keeps require() safe on Node >= 22.12. main() catches everything, so it cannot
49
+ // reject, and Node keeps the process alive until it settles.
50
+ void main();
51
+ //# sourceMappingURL=cli.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cli.js","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":";AACA;;;;;;;;;;;;GAYG;AAEH,8FAA8F;AAC9F,2FAA2F;AAC3F,OAAO,KAAK,cAAc,MAAM,kBAAkB,CAAC;AACnD,qDAAqD;AACrD,uFAAuF;AACvF,OAAO,KAAK,WAAW,MAAM,cAAc,CAAC;AAC5C,OAAO,EAAc,SAAS,EAAE,MAAM,EAAE,MAAM,cAAc,CAAC;AAC7D,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAYzC,MAAM,EAAE,GAAW,cAAmC,CAAC;AACvD,MAAM,IAAI,GACP,WAAoD,CAAC,OAAO;IAC5D,WAAsC,CAAC;AAE1C,MAAM,EAAE,GAAU;IAChB,QAAQ,EAAE,CAAC,IAAI,EAAE,EAAE,CAAC,EAAE,CAAC,QAAQ,CAAC,IAAI,CAAC;IACrC,GAAG,EAAE,CAAC,IAAI,EAAE,EAAE;QACZ,IAAI,CAAC,MAAM,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;IAC1B,CAAC;IACD,GAAG,EAAE,CAAC,IAAI,EAAE,EAAE;QACZ,IAAI,CAAC,MAAM,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;IAC1B,CAAC;CACF,CAAC;AAEF,KAAK,UAAU,IAAI;IACjB,IAAI,CAAC;QACH,IAAI,CAAC,QAAQ,GAAG,MAAM,MAAM,CAAC,SAAS,CAAC,IAAI,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC;IAClE,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,6FAA6F;QAC7F,4CAA4C;QAC5C,MAAM,OAAO,GAAG,UAAU,CAAC,KAAK,CAAC,IAAI,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;QAC5F,IAAI,CAAC,MAAM,CAAC,KAAK,CAAC,YAAY,OAAO,IAAI,CAAC,CAAC;QAC3C,IAAI,CAAC,QAAQ,GAAG,CAAC,CAAC;IACpB,CAAC;AACH,CAAC;AAED,8FAA8F;AAC9F,+FAA+F;AAC/F,6DAA6D;AAC7D,KAAK,IAAI,EAAE,CAAC"}
@@ -109,5 +109,5 @@ export declare const SIGNAL_FIELD_BLOCK_OFFSETS: {
109
109
  readonly reserved: 224;
110
110
  };
111
111
  /** Published package version. Kept in sync with package.json by a test. */
112
- export declare const VERSION = "0.1.15";
112
+ export declare const VERSION = "0.1.17";
113
113
  //# sourceMappingURL=constants.d.ts.map
package/dist/constants.js CHANGED
@@ -79,5 +79,5 @@ export const SIGNAL_FIELD_BLOCK_OFFSETS = {
79
79
  reserved: 224,
80
80
  };
81
81
  /** Published package version. Kept in sync with package.json by a test. */
82
- export const VERSION = '0.1.15';
82
+ export const VERSION = '0.1.17';
83
83
  //# sourceMappingURL=constants.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "edfcore",
3
- "version": "0.1.15",
3
+ "version": "0.1.17",
4
4
  "description": "Modern, typed, zero-dependency reader for EDF, EDF+, BDF and BDF+ biosignal files. Works in browsers and Node with true random access.",
5
5
  "keywords": [
6
6
  "edf",
@@ -74,5 +74,8 @@
74
74
  },
75
75
  "publishConfig": {
76
76
  "access": "public"
77
+ },
78
+ "bin": {
79
+ "edfcore": "./dist/cli.js"
77
80
  }
78
81
  }
package/src/cli-run.ts ADDED
@@ -0,0 +1,158 @@
1
+ /**
2
+ * What `npx edfcore` actually does, with the process factored out.
3
+ *
4
+ * `cli.ts` is a shell that supplies real `node:fs` and `node:process`; everything decidable lives
5
+ * here, behind an injected `CliIo`. That is not ceremony — a CLI tested by spawning a subprocess
6
+ * can only be tested once the package is built, so the tests either skip in CI or test a stale
7
+ * binary. This way the exit codes and the output are ordinary unit tests.
8
+ */
9
+
10
+ import { countAnnotationsByText } from './annotations-query.js';
11
+ import { formatDiagnostics } from './diagnostics/format.js';
12
+ import { formatHeader } from './format-header.js';
13
+ import { formatValidationReport } from './format-report.js';
14
+ import { byteSource } from './io/bytes.js';
15
+ import { openEdf, readAnnotations } from './recording.js';
16
+ import { validateRecording } from './validate.js';
17
+
18
+ /** Everything the CLI touches outside itself. */
19
+ export interface CliIo {
20
+ readFile(path: string): Promise<Uint8Array>;
21
+ out(text: string): void;
22
+ err(text: string): void;
23
+ }
24
+
25
+ const USAGE = `edfcore — read EDF, EDF+, BDF and BDF+ files
26
+
27
+ npx edfcore header <file> the header, the signals, and any diagnostics
28
+ npx edfcore validate <file> a full conformance sweep, scanning every sample
29
+ npx edfcore events <file> the annotations, counted by text
30
+ npx edfcore json <file> the header as JSON, for piping into jq
31
+
32
+ Options
33
+ --patient include patient identification (header, json)
34
+ --limit <n> individual diagnostics to print (default 20)
35
+
36
+ Exit codes: 0 success, 1 the file is unreadable or failed validation, 2 bad usage.
37
+ `;
38
+
39
+ export interface Args {
40
+ readonly command: string | undefined;
41
+ readonly file: string | undefined;
42
+ readonly patient: boolean;
43
+ readonly limit: number | undefined;
44
+ }
45
+
46
+ export function parseArgs(argv: readonly string[]): Args {
47
+ const positional: string[] = [];
48
+ let patient = false;
49
+ let limit: number | undefined;
50
+
51
+ for (let i = 0; i < argv.length; i += 1) {
52
+ const arg = argv[i];
53
+ if (arg === '--patient') patient = true;
54
+ else if (arg === '--limit') {
55
+ const value = Number(argv[i + 1]);
56
+ // A NaN limit would disable the cap silently, which is the opposite of what was asked for.
57
+ if (!Number.isSafeInteger(value) || value < 0) {
58
+ throw new RangeError(`--limit needs a whole number, received ${String(argv[i + 1])}`);
59
+ }
60
+ limit = value;
61
+ i += 1;
62
+ } else if (arg !== undefined && !arg.startsWith('-')) positional.push(arg);
63
+ }
64
+
65
+ return { command: positional[0], file: positional[1], patient, limit };
66
+ }
67
+
68
+ async function open(io: CliIo, file: string) {
69
+ // Read whole rather than fileSource: a CLI invocation is one pass over one file, and holding
70
+ // it in memory removes any question of a descriptor outliving the process.
71
+ return openEdf(byteSource(await io.readFile(file)));
72
+ }
73
+
74
+ export async function runCli(args: Args, io: CliIo): Promise<number> {
75
+ const { command, file } = args;
76
+ if (command === undefined || command === 'help' || command === '--help') {
77
+ io.out(USAGE);
78
+ return command === undefined ? 2 : 0;
79
+ }
80
+ if (file === undefined) {
81
+ io.err(`edfcore ${command}: no file given\n\n${USAGE}`);
82
+ return 2;
83
+ }
84
+
85
+ switch (command) {
86
+ case 'header': {
87
+ const recording = await open(io, file);
88
+ io.out(`${formatHeader(recording.header, { includePatientId: args.patient })}\n`);
89
+ if (recording.header.diagnostics.length > 0) {
90
+ io.out(
91
+ `\n${formatDiagnostics(recording.header.diagnostics, { maxItems: args.limit ?? 20 })}\n`,
92
+ );
93
+ }
94
+ return 0;
95
+ }
96
+
97
+ case 'validate': {
98
+ const recording = await open(io, file);
99
+ const report = await validateRecording(recording, { scanSamples: true });
100
+ io.out(
101
+ `${formatValidationReport(report, { header: recording.header, maxItems: args.limit ?? 20 })}\n`,
102
+ );
103
+ // Exit 1 on failure so a CI job can gate on it without parsing the output.
104
+ return report.ok ? 0 : 1;
105
+ }
106
+
107
+ case 'events': {
108
+ const recording = await open(io, file);
109
+ const { annotations } = await readAnnotations(recording, {
110
+ start: 0,
111
+ count: recording.header.recordCount,
112
+ });
113
+ if (annotations.length === 0) {
114
+ io.out('no annotations\n');
115
+ return 0;
116
+ }
117
+ io.out(`${annotations.length} annotation(s)\n\n`);
118
+ for (const { text, count } of countAnnotationsByText(annotations)) {
119
+ io.out(`${String(count).padStart(8)} ${text}\n`);
120
+ }
121
+ return 0;
122
+ }
123
+
124
+ case 'json': {
125
+ const recording = await open(io, file);
126
+ const { header } = recording;
127
+ io.out(
128
+ `${JSON.stringify(
129
+ {
130
+ variant: header.variant,
131
+ recordCount: header.recordCount,
132
+ recordDurationSeconds: header.recordDurationSeconds,
133
+ spanSeconds: recording.timeline.spanSeconds,
134
+ // Patient identification is opt-in here for the same reason it is in formatHeader:
135
+ // the obvious thing to do with this output is pipe it somewhere.
136
+ ...(args.patient ? { patient: header.patient.raw.trim() } : {}),
137
+ signals: header.signals.map((signal) => ({
138
+ index: signal.index,
139
+ label: signal.label,
140
+ kind: signal.kind,
141
+ samplesPerRecord: signal.samplesPerRecord,
142
+ sampleRateHz: signal.sampleRateHz,
143
+ physicalDimension: signal.physicalDimension,
144
+ })),
145
+ diagnostics: header.diagnostics.map((d) => ({ code: d.code, severity: d.severity })),
146
+ },
147
+ null,
148
+ 2,
149
+ )}\n`,
150
+ );
151
+ return 0;
152
+ }
153
+
154
+ default:
155
+ io.err(`edfcore: unknown command ${JSON.stringify(command)}\n\n${USAGE}`);
156
+ return 2;
157
+ }
158
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,65 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * `npx edfcore` — looking at a file without writing code.
4
+ *
5
+ * A separate entry point, never re-exported from the barrel, because it imports `node:fs` and
6
+ * `node:process`. The universal entry must stay free of Node built-ins, and a test asserts it.
7
+ *
8
+ * This file is only the wiring. The decisions live in `cli-run.ts`, so they can be tested without
9
+ * spawning a process against a build that may not exist yet.
10
+ *
11
+ * `main()` runs unconditionally rather than behind an `import.meta.url === process.argv[1]` guard.
12
+ * That guard is a known trap for a bin: `npx` runs the command through a symlink, so the two paths
13
+ * differ, the condition is false, and the CLI exits 0 having done nothing.
14
+ */
15
+
16
+ // biome-ignore lint/suspicious/noTsIgnore: @ts-expect-error errors when unused — see node.ts.
17
+ // @ts-ignore 'node:fs/promises' has no declarations under `types: []`; its shape is below.
18
+ import * as nodeFsPromises from 'node:fs/promises';
19
+ // biome-ignore lint/suspicious/noTsIgnore: as above.
20
+ // @ts-ignore 'node:process' has no declarations under `types: []`; its shape is below.
21
+ import * as nodeProcess from 'node:process';
22
+ import { type CliIo, parseArgs, runCli } from './cli-run.js';
23
+ import { isEdfError } from './errors.js';
24
+
25
+ interface NodeFs {
26
+ readFile(path: string): Promise<Uint8Array>;
27
+ }
28
+ interface NodeProcess {
29
+ readonly argv: readonly string[];
30
+ exitCode: number | undefined;
31
+ readonly stdout: { write(text: string): unknown };
32
+ readonly stderr: { write(text: string): unknown };
33
+ }
34
+
35
+ const fs: NodeFs = nodeFsPromises as unknown as NodeFs;
36
+ const proc: NodeProcess =
37
+ (nodeProcess as unknown as { default?: NodeProcess }).default ??
38
+ (nodeProcess as unknown as NodeProcess);
39
+
40
+ const io: CliIo = {
41
+ readFile: (path) => fs.readFile(path),
42
+ out: (text) => {
43
+ proc.stdout.write(text);
44
+ },
45
+ err: (text) => {
46
+ proc.stderr.write(text);
47
+ },
48
+ };
49
+
50
+ async function main(): Promise<void> {
51
+ try {
52
+ proc.exitCode = await runCli(parseArgs(proc.argv.slice(2)), io);
53
+ } catch (error) {
54
+ // An EdfError already says what is wrong, where, and what to do next; a stack trace over the
55
+ // top of it would bury the one useful line.
56
+ const message = isEdfError(error) || error instanceof Error ? error.message : String(error);
57
+ proc.stderr.write(`edfcore: ${message}\n`);
58
+ proc.exitCode = 1;
59
+ }
60
+ }
61
+
62
+ // Not `await main()`: the package documents that no module in its graph uses top-level await,
63
+ // which is what keeps require() safe on Node >= 22.12. main() catches everything, so it cannot
64
+ // reject, and Node keeps the process alive until it settles.
65
+ void main();
package/src/constants.ts CHANGED
@@ -93,4 +93,4 @@ export const SIGNAL_FIELD_BLOCK_OFFSETS = {
93
93
  } as const;
94
94
 
95
95
  /** Published package version. Kept in sync with package.json by a test. */
96
- export const VERSION = '0.1.15';
96
+ export const VERSION = '0.1.17';