@jasy/cli 1.0.0-alpha.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.
@@ -0,0 +1,356 @@
1
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
2
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
3
+ return new (P || (P = Promise))(function (resolve, reject) {
4
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
5
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
6
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
7
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
8
+ });
9
+ };
10
+ import { readFileSync, writeFileSync } from "node:fs";
11
+ import { basename, resolve, dirname, join } from "node:path";
12
+ import { fileURLToPath } from "node:url";
13
+ import { createScreen, createDraw, createInputManager } from "@jano-editor/ui";
14
+ import { readInvoice } from "../core/read.js";
15
+ import { describeInvoice } from "../core/detect.js";
16
+ import { checkPdfA3 } from "../core/pdfa.js";
17
+ import { validateInvoiceXml, profileFor } from "../core/validate.js";
18
+ import { exportInvoice } from "../core/export.js";
19
+ import { detectTools } from "../core/verapdf.js";
20
+ import { openFileDialog } from "./file-open.js";
21
+ // The interactive jasy terminal: `o` opens a file picker, then the loaded invoice is shown together
22
+ // with its checks - EN 16931 business rules + structural PDF/A-3. Same core as the `jasy read` command.
23
+ const BRAND = [26, 79, 138];
24
+ const INK = [230, 234, 240];
25
+ const MUTED = [123, 135, 148];
26
+ const FAINT = [80, 85, 95];
27
+ const OK = [90, 170, 110];
28
+ const ERR = [200, 90, 90];
29
+ const YELLOW = [243, 220, 41]; // JS yellow - the ▒ accent band on the crane
30
+ // the jasy origami crane (assets/jasy.txt), shown on the empty start screen; "▒" cells are the accent
31
+ const CRANE = (() => {
32
+ try {
33
+ const p = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "assets", "jasy.txt");
34
+ return readFileSync(p, "utf-8")
35
+ .replace(/\n+$/, "")
36
+ .split("\n")
37
+ .map((l) => l.replace(/\s+$/, "")); // keep leading spaces (alignment), drop trailing
38
+ }
39
+ catch (_a) {
40
+ return []; // no logo file - the start screen just shows the box
41
+ }
42
+ })();
43
+ const CRANE_W = CRANE.length ? Math.max(...CRANE.map((l) => l.length)) : 0;
44
+ const TOP = 1;
45
+ const MAX_W = 72; // cap so the box doesn't sprawl on very wide terminals
46
+ // clip a line so it never spills past the framed box
47
+ const fit = (s, max) => (s.length > max ? s.slice(0, max - 1) + "…" : s);
48
+ const money = (n) => n.toFixed(2);
49
+ export function launchTui() {
50
+ if (!process.stdout.isTTY || !process.stdin.isTTY) {
51
+ console.log("jasy - ZUGFeRD / XRechnung terminal. Run me in an interactive terminal (or try `jasy read <file>`).");
52
+ process.exit(0);
53
+ }
54
+ const screen = createScreen();
55
+ const draw = createDraw(screen);
56
+ const input = createInputManager();
57
+ let loaded = null;
58
+ let error = null;
59
+ let notice = null; // last export result, shown in the footer
60
+ let scroll = 0; // first visible body row (the body scrolls; the header stays pinned)
61
+ let pageSize = 1; // body rows per screen, set in render() - used by PgUp/PgDn
62
+ // checked once at startup: is the optional full-ISO PDF/A validator (veraPDF) ready to run?
63
+ const tools = detectTools();
64
+ const veraReady = !!(tools.verapdf && tools.java);
65
+ // The pinned top: a one-line confirmation of which file + what it is (stays put while body scrolls).
66
+ function buildHeader(w) {
67
+ if (error || !loaded)
68
+ return [];
69
+ const { read } = loaded;
70
+ return [
71
+ { text: fit("✓ " + basename(loaded.path), w - 6), fg: OK },
72
+ {
73
+ text: fit(describeInvoice(read.meta) + " · " + read.xml.length + " B XML", w - 6),
74
+ fg: MUTED,
75
+ },
76
+ ];
77
+ }
78
+ // The scrollable body: the parsed invoice + its checks (or the empty / error message).
79
+ function buildBody(w) {
80
+ if (error)
81
+ return [{ text: fit("✗ " + error, w - 6), fg: ERR }];
82
+ if (!loaded) {
83
+ const rows = [
84
+ { text: "No invoice loaded.", fg: MUTED },
85
+ { text: "", fg: MUTED },
86
+ { text: "Open a ZUGFeRD / XRechnung PDF or XML -", fg: MUTED },
87
+ { text: "jasy extracts the XML, identifies it, and checks it.", fg: MUTED },
88
+ { text: "", fg: MUTED },
89
+ ];
90
+ // full-ISO PDF/A is optional (veraPDF) - tell the user how to enable it right here
91
+ if (veraReady) {
92
+ rows.push({ text: "Full PDF/A check (veraPDF)", fg: MUTED, status: "ready", statusFg: OK });
93
+ }
94
+ else {
95
+ rows.push({ text: "Full PDF/A check (veraPDF): not set up", fg: MUTED });
96
+ rows.push({ text: "→ run jasy verapdf to enable it", fg: FAINT });
97
+ }
98
+ return rows;
99
+ }
100
+ const { read, pdfa, rules } = loaded;
101
+ const rows = [];
102
+ // the parsed invoice itself (number, parties, lines, totals)
103
+ const inv = read.invoice;
104
+ if (inv && read.totals) {
105
+ const t = read.totals;
106
+ rows.push({ text: fit(inv.number, w - 16), fg: INK, status: inv.issueDate, statusFg: MUTED });
107
+ rows.push({ text: fit(`${inv.seller.name} → ${inv.buyer.name}`, w - 6), fg: MUTED });
108
+ rows.push({ text: "", fg: MUTED });
109
+ for (const [i, l] of inv.lines.entries()) {
110
+ rows.push({
111
+ text: fit(`${l.quantity} ${l.unit} ${l.name}`, w - 14),
112
+ fg: INK,
113
+ status: money(t.lineNets[i]),
114
+ statusFg: MUTED,
115
+ });
116
+ }
117
+ rows.push({
118
+ text: `net ${money(t.taxBasisTotal)} VAT ${money(t.taxTotal)}`,
119
+ fg: MUTED,
120
+ status: `${money(t.grandTotal)} ${inv.currency}`,
121
+ statusFg: INK,
122
+ });
123
+ rows.push({ text: "", fg: MUTED });
124
+ }
125
+ // XML business rules (Schematron) - EN 16931, + XRechnung BR-DE when applicable
126
+ rows.push({
127
+ text: (rules === null || rules === void 0 ? void 0 : rules.profile.startsWith("xrechnung")) ? "XRechnung rules" : "EN 16931 rules",
128
+ fg: INK,
129
+ status: rules ? (rules.valid ? "OK" : `${rules.errors.length} errors`) : "n/a",
130
+ statusFg: rules ? (rules.valid ? OK : ERR) : FAINT,
131
+ });
132
+ if (rules && !rules.valid) {
133
+ for (const e of rules.errors.slice(0, 4)) {
134
+ rows.push({ text: fit(" ✗ " + (e.id ? `[${e.id}] ` : "") + e.text, w - 6), fg: ERR });
135
+ }
136
+ }
137
+ // structural PDF/A-3 (per check)
138
+ rows.push({ text: "", fg: MUTED });
139
+ rows.push({
140
+ text: "PDF/A-3 structure",
141
+ fg: INK,
142
+ status: pdfa ? `${pdfa.checks.filter((c) => c.ok).length}/${pdfa.checks.length}` : "raw XML",
143
+ statusFg: pdfa ? (pdfa.ok ? OK : ERR) : FAINT,
144
+ });
145
+ if (pdfa) {
146
+ for (const c of pdfa.checks) {
147
+ rows.push({
148
+ text: fit(" " + c.label, w - 14),
149
+ fg: MUTED,
150
+ status: c.ok ? "OK" : "FAILED",
151
+ statusFg: c.ok ? OK : ERR,
152
+ });
153
+ }
154
+ }
155
+ return rows;
156
+ }
157
+ // draw the crane logo centred above the box: "▒" cells in JS-yellow, the rest in brand blue
158
+ function drawCrane(cols) {
159
+ const cx = Math.max(0, Math.floor((cols - CRANE_W) / 2));
160
+ CRANE.forEach((line, row) => {
161
+ let i = 0;
162
+ while (i < line.length) {
163
+ if (line[i] === " ") {
164
+ i++;
165
+ continue;
166
+ }
167
+ const yellow = line[i] === "▒";
168
+ let j = i;
169
+ while (j < line.length && line[j] !== " " && (line[j] === "▒") === yellow)
170
+ j++;
171
+ draw.text(cx + i, TOP + row, line.slice(i, j), { fg: yellow ? YELLOW : BRAND });
172
+ i = j;
173
+ }
174
+ });
175
+ }
176
+ function render() {
177
+ const cols = screen.width;
178
+ const screenH = screen.height;
179
+ const w = Math.max(44, Math.min(cols - 2, MAX_W));
180
+ const x = Math.max(1, Math.floor((cols - w) / 2)); // centre the box
181
+ const statusEnd = x + w - 3;
182
+ const header = buildHeader(w); // pinned
183
+ const body = buildBody(w); // scrollable
184
+ const canExport = !!((loaded === null || loaded === void 0 ? void 0 : loaded.read.invoice) && loaded.read.totals);
185
+ // the crane sits above the box - only on the true start screen (nothing loaded) and only if it fits
186
+ const showCrane = !loaded && !error && CRANE.length > 0 && cols >= CRANE_W && screenH >= CRANE.length + 9;
187
+ const boxTop = TOP + (showCrane ? CRANE.length + 1 : 0);
188
+ // fit the box to the terminal height: pin the header, scroll the body in whatever is left
189
+ const headBlock = header.length ? header.length + 1 : 0; // header rows + a separator blank
190
+ const overhead = 5 + headBlock + (notice ? 1 : 0); // borders + blanks + footer around the body
191
+ const maxBodyH = Math.max(1, screenH - boxTop - overhead);
192
+ const viewH = Math.min(body.length, maxBodyH);
193
+ pageSize = viewH;
194
+ const maxScroll = Math.max(0, body.length - viewH);
195
+ scroll = Math.max(0, Math.min(scroll, maxScroll));
196
+ const visible = body.slice(scroll, scroll + viewH);
197
+ const boxH = 5 + headBlock + viewH + (notice ? 1 : 0);
198
+ const drawRow = (r, y) => {
199
+ var _a;
200
+ draw.text(x + 2, y, r.text, { fg: r.fg });
201
+ if (r.status)
202
+ draw.text(statusEnd - r.status.length, y, r.status, { fg: (_a = r.statusFg) !== null && _a !== void 0 ? _a : r.fg });
203
+ };
204
+ draw.clear();
205
+ if (showCrane)
206
+ drawCrane(cols);
207
+ draw.rect(x, boxTop, w, boxH, { border: "round" });
208
+ draw.text(x + 2, boxTop, " jasy · ZUGFeRD / XRechnung ", { fg: BRAND });
209
+ let y = boxTop + 2;
210
+ for (const r of header)
211
+ drawRow(r, y++);
212
+ if (header.length)
213
+ y++; // separator blank
214
+ const bodyTop = y;
215
+ for (const r of visible)
216
+ drawRow(r, y++);
217
+ // scrollbar on the right inner edge, only when the body overflows the viewport
218
+ if (maxScroll > 0) {
219
+ const thumbH = Math.max(1, Math.round((viewH * viewH) / body.length));
220
+ const thumbAt = Math.round((scroll * (viewH - thumbH)) / maxScroll);
221
+ for (let i = 0; i < viewH; i++) {
222
+ const on = i >= thumbAt && i < thumbAt + thumbH;
223
+ draw.text(x + w - 2, bodyTop + i, on ? "█" : "░", { fg: on ? BRAND : FAINT });
224
+ }
225
+ }
226
+ // footer: open, export shortcuts (when an invoice is parsed), quit - drawn left to right
227
+ const footerY = bodyTop + viewH + 1;
228
+ let fx = x + 2;
229
+ const key = (k, label) => {
230
+ draw.text(fx, footerY, k, { fg: BRAND });
231
+ draw.text(fx + k.length + 1, footerY, label, { fg: INK });
232
+ fx += k.length + 1 + label.length + 2;
233
+ };
234
+ key("o", loaded || error ? "open another" : "open");
235
+ if (canExport) {
236
+ key("j", "JSON");
237
+ key("t", "TXT");
238
+ key("x", "XLSX");
239
+ }
240
+ key("q", "quit");
241
+ if (notice) {
242
+ draw.text(x + 2, footerY + 1, fit(notice, w - 4), { fg: notice.startsWith("✓") ? OK : ERR });
243
+ }
244
+ draw.flush();
245
+ }
246
+ function scrollBy(delta) {
247
+ scroll += delta;
248
+ render(); // render() clamps scroll to the valid range
249
+ }
250
+ function quit() {
251
+ input.stop();
252
+ process.stdin.setRawMode(false);
253
+ screen.showCursor();
254
+ screen.leave();
255
+ process.exit(0);
256
+ }
257
+ function exportTo(format) {
258
+ const inv = loaded === null || loaded === void 0 ? void 0 : loaded.read.invoice;
259
+ if (!inv || !(loaded === null || loaded === void 0 ? void 0 : loaded.read.totals))
260
+ return;
261
+ const name = `${inv.number.replace(/[^\w.-]+/g, "_")}.${format === "txt" ? "txt" : format}`;
262
+ try {
263
+ writeFileSync(resolve(process.cwd(), name), exportInvoice(inv, loaded.read.totals, format));
264
+ notice = `✓ wrote ${name}`;
265
+ }
266
+ catch (e) {
267
+ notice = `✗ export failed: ${e.message}`;
268
+ }
269
+ render();
270
+ }
271
+ function openFlow() {
272
+ return __awaiter(this, void 0, void 0, function* () {
273
+ const chosen = yield openFileDialog({ screen, draw, input, startDir: process.cwd(), quit });
274
+ if (chosen) {
275
+ notice = null;
276
+ scroll = 0;
277
+ try {
278
+ const bytes = readFileSync(chosen);
279
+ const read = readInvoice(bytes);
280
+ const pdfa = read.isPdf ? checkPdfA3(bytes) : null;
281
+ let rules = null;
282
+ if (read.meta.syntax !== "unknown") {
283
+ try {
284
+ rules = validateInvoiceXml(read.xml, profileFor(read.meta));
285
+ }
286
+ catch (_a) {
287
+ rules = null;
288
+ }
289
+ }
290
+ loaded = { path: chosen, read, pdfa, rules };
291
+ error = null;
292
+ }
293
+ catch (e) {
294
+ error = e.message;
295
+ loaded = null;
296
+ }
297
+ }
298
+ render();
299
+ });
300
+ }
301
+ screen.enter();
302
+ screen.hideCursor();
303
+ process.stdin.setRawMode(true); // no echo - keys & mouse go to JANO, not the tty
304
+ input.start();
305
+ const main = input.pushLayer("main");
306
+ main.on("key", (key) => {
307
+ if (key.name === "q" || (key.ctrl && key.name === "c")) {
308
+ quit();
309
+ return true;
310
+ }
311
+ if (key.name === "o") {
312
+ void openFlow();
313
+ return true;
314
+ }
315
+ if ((key.name === "j" || key.name === "t" || key.name === "x") && (loaded === null || loaded === void 0 ? void 0 : loaded.read.invoice)) {
316
+ exportTo(key.name === "j" ? "json" : key.name === "t" ? "txt" : "xlsx");
317
+ return true;
318
+ }
319
+ if (key.name === "up") {
320
+ scrollBy(-1);
321
+ return true;
322
+ }
323
+ if (key.name === "down") {
324
+ scrollBy(1);
325
+ return true;
326
+ }
327
+ if (key.name === "pageup") {
328
+ scrollBy(-pageSize);
329
+ return true;
330
+ }
331
+ if (key.name === "pagedown") {
332
+ scrollBy(pageSize);
333
+ return true;
334
+ }
335
+ if (key.name === "home") {
336
+ scrollBy(-Number.MAX_SAFE_INTEGER);
337
+ return true;
338
+ }
339
+ if (key.name === "end") {
340
+ scrollBy(Number.MAX_SAFE_INTEGER);
341
+ return true;
342
+ }
343
+ });
344
+ main.on("resize", () => render());
345
+ main.on("mouse:click", () => true);
346
+ main.on("mouse:drag", () => true);
347
+ main.on("mouse:release", () => true);
348
+ main.on("mouse:scroll", (e) => {
349
+ if (e.type === "scroll-up")
350
+ scrollBy(-3);
351
+ else if (e.type === "scroll-down")
352
+ scrollBy(3);
353
+ return true;
354
+ });
355
+ render();
356
+ }
@@ -0,0 +1,10 @@
1
+ import { type Screen, type Draw, type InputManager } from "@jano-editor/ui";
2
+ interface Deps {
3
+ screen: Screen;
4
+ draw: Draw;
5
+ input: InputManager;
6
+ startDir: string;
7
+ quit: () => void;
8
+ }
9
+ export declare function openFileDialog({ screen, draw, input, startDir, quit, }: Deps): Promise<string | null>;
10
+ export {};
@@ -0,0 +1,140 @@
1
+ import { readdirSync, statSync } from "node:fs";
2
+ import { dirname, basename, join } from "node:path";
3
+ import { drawList, listMoveUp, listMoveDown, } from "@jano-editor/ui";
4
+ const BRAND = [26, 79, 138];
5
+ const INK = [230, 234, 240];
6
+ const MUTED = [123, 135, 148];
7
+ const FAINT = [80, 85, 95];
8
+ const ROWS = 10; // visible window height; the list scrolls beyond it
9
+ export function openFileDialog({ screen, draw, input, startDir, quit, }) {
10
+ return new Promise((done) => {
11
+ let path = startDir.endsWith("/") ? startDir : startDir + "/";
12
+ let items = [];
13
+ let state = { selectedIndex: 0, scrollOffset: 0 };
14
+ const layer = input.pushLayer("open");
15
+ const refresh = () => {
16
+ const dir = path.endsWith("/") ? path : dirname(path);
17
+ const prefix = path.endsWith("/") ? "" : basename(path);
18
+ // offer ".." to climb out of the folder (only while browsing, not mid-typing, and not at root)
19
+ const cur = dir.replace(/\/+$/, "") || "/";
20
+ const parent = dirname(cur);
21
+ const up = prefix === "" && parent !== cur
22
+ ? [{ label: "../", value: parent === "/" ? "/" : parent + "/" }]
23
+ : [];
24
+ try {
25
+ const entries = readdirSync(dir, { withFileTypes: true })
26
+ .filter((e) => e.name.startsWith(prefix))
27
+ .filter((e) => e.isDirectory() || /\.(pdf|xml)$/i.test(e.name))
28
+ .sort((a, b) => Number(b.isDirectory()) - Number(a.isDirectory()) || a.name.localeCompare(b.name))
29
+ .map((e) => {
30
+ const full = join(dir, e.name);
31
+ return e.isDirectory()
32
+ ? { label: e.name + "/", value: full + "/" }
33
+ : { label: e.name, value: full };
34
+ });
35
+ items = [...up, ...entries];
36
+ }
37
+ catch (_a) {
38
+ items = up;
39
+ }
40
+ state = { selectedIndex: 0, scrollOffset: 0 };
41
+ };
42
+ const render = () => {
43
+ const cols = screen.width;
44
+ const w = Math.max(44, Math.min(cols - 2, 72));
45
+ const x = Math.max(1, Math.floor((cols - w) / 2)); // centre the dialog
46
+ const y = 2;
47
+ const avail = w - 11; // keep the text + cursor strictly inside the right border
48
+ const shown = path.length > avail ? "…" + path.slice(-(avail - 1)) : path;
49
+ draw.clear();
50
+ draw.rect(x, y, w, ROWS + 6, { border: "round" });
51
+ draw.text(x + 2, y, " open invoice ", { fg: BRAND });
52
+ draw.text(x + 2, y + 1, "path:", { fg: MUTED });
53
+ draw.text(x + 8, y + 1, shown + "▌", { fg: INK });
54
+ if (items.length) {
55
+ drawList(draw, {
56
+ x: x + 2,
57
+ y: y + 3,
58
+ width: w - 4,
59
+ height: ROWS,
60
+ items,
61
+ selectedIndex: state.selectedIndex,
62
+ scrollOffset: state.scrollOffset,
63
+ });
64
+ }
65
+ else {
66
+ draw.text(x + 2, y + 3, "(no matching folders / .pdf / .xml here)", { fg: MUTED });
67
+ }
68
+ const pos = items.length ? `${state.selectedIndex + 1}/${items.length}` : "0";
69
+ draw.text(x + 2, y + ROWS + 4, `↑↓ pick (${pos}) · Tab complete · ⏎ open · Esc cancel`, {
70
+ fg: FAINT,
71
+ });
72
+ draw.flush();
73
+ };
74
+ const close = (result) => {
75
+ input.popLayer(layer);
76
+ done(result);
77
+ };
78
+ const current = () => items[state.selectedIndex];
79
+ layer.on("key", (key) => {
80
+ if (key.ctrl && key.name === "c") {
81
+ quit(); // never returns - restores the tty and exits
82
+ return true;
83
+ }
84
+ if (key.name === "escape" || (key.raw.length === 1 && key.raw[0] === 0x1b)) {
85
+ close(null);
86
+ }
87
+ else if (key.name === "up") {
88
+ state = listMoveUp(state, items);
89
+ render();
90
+ }
91
+ else if (key.name === "down") {
92
+ state = listMoveDown(state, items, ROWS);
93
+ render();
94
+ }
95
+ else if (key.name === "tab") {
96
+ const c = current();
97
+ if (c) {
98
+ path = c.value;
99
+ refresh();
100
+ render();
101
+ }
102
+ }
103
+ else if (key.name === "enter" || key.name === "return") {
104
+ const c = current();
105
+ if (c && c.value.endsWith("/")) {
106
+ path = c.value; // descend into the folder (or climb via "../")
107
+ refresh();
108
+ render();
109
+ }
110
+ else if (c) {
111
+ close(c.value); // load the file
112
+ }
113
+ else {
114
+ try {
115
+ if (statSync(path).isFile())
116
+ close(path);
117
+ }
118
+ catch (_a) {
119
+ /* nothing to open */
120
+ }
121
+ }
122
+ }
123
+ else if (key.name === "backspace") {
124
+ path = path.slice(0, -1);
125
+ refresh();
126
+ render();
127
+ }
128
+ else if (!key.ctrl && !key.alt && key.name.length === 1) {
129
+ path += key.name;
130
+ refresh();
131
+ render();
132
+ }
133
+ return true; // modal swallows everything (Ctrl-C / Esc handled above)
134
+ });
135
+ layer.on("mouse:click", () => true);
136
+ layer.on("mouse:scroll", () => true);
137
+ refresh();
138
+ render();
139
+ });
140
+ }
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "@jasy/cli",
3
+ "version": "1.0.0-alpha.1",
4
+ "description": "Interactive terminal to validate, read and export ZUGFeRD / XRechnung e-invoices.",
5
+ "keywords": [
6
+ "cli",
7
+ "e-invoice",
8
+ "tui",
9
+ "validator",
10
+ "xrechnung",
11
+ "zugferd"
12
+ ],
13
+ "license": "MIT",
14
+ "author": "Florian Heuberger",
15
+ "bin": {
16
+ "jasy": "dist/index.js"
17
+ },
18
+ "files": [
19
+ "dist",
20
+ "assets"
21
+ ],
22
+ "type": "module",
23
+ "main": "dist/index.js",
24
+ "dependencies": {
25
+ "@jano-editor/ui": "1.0.0-alpha.8",
26
+ "saxon-js": "^2.6.0",
27
+ "@jasy/zugferd": "1.0.0-alpha.1"
28
+ },
29
+ "devDependencies": {
30
+ "@types/node": "^25.9.3",
31
+ "tsx": "^4.19.2",
32
+ "xslt3": "^2.7.0"
33
+ },
34
+ "scripts": {
35
+ "build": "tsc",
36
+ "start": "node dist/index.js",
37
+ "dev": "tsx src/index.ts",
38
+ "watch": "tsx watch src/index.ts",
39
+ "typecheck": "tsc --noEmit"
40
+ }
41
+ }