@dotit/core 1.0.0 → 1.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/cli.js ADDED
@@ -0,0 +1,877 @@
1
+ #!/usr/bin/env node
2
+
3
+ const {
4
+ parseIntentText,
5
+ renderHTML,
6
+ renderPrint,
7
+ mergeData,
8
+ convertMarkdownToIntentText,
9
+ convertHtmlToIntentText,
10
+ queryBlocks,
11
+ formatQueryResult,
12
+ validateDocument,
13
+ formatValidationResult,
14
+ PREDEFINED_SCHEMAS,
15
+ sealDocument,
16
+ verifyDocument,
17
+ computeDocumentHash,
18
+ listBuiltinThemes,
19
+ getBuiltinTheme,
20
+ buildShallowIndex,
21
+ buildIndexEntry,
22
+ checkStaleness,
23
+ updateIndex,
24
+ composeIndexes,
25
+ queryComposed,
26
+ formatTable,
27
+ formatJSON,
28
+ formatCSV,
29
+ serializeContext,
30
+ findHistoryBoundaryInSource,
31
+ } = require("./dist");
32
+ const readline = require("readline");
33
+ const fs = require("fs");
34
+ const path = require("path");
35
+
36
+ const CORE_VERSION = require("./package.json").version;
37
+
38
+ function main() {
39
+ const args = process.argv.slice(2);
40
+
41
+ if (args.length === 0 || args.includes("--help") || args.includes("-h")) {
42
+ console.log(`
43
+ šŸš€ dotit CLI (IntentText) v${CORE_VERSION}
44
+
45
+ Usage:
46
+ dotit <file.it> Parse and show JSON
47
+ dotit <file.it> --html Generate HTML output
48
+ dotit <file.it> --output Save HTML to file
49
+ dotit <file.md> --to-it Convert Markdown to .it
50
+ dotit <file.html> --to-it Convert HTML to .it
51
+ dotit <file> --to-it --output Save converted .it next to source
52
+ dotit <file.it> --query "..." Query blocks
53
+ dotit <file.it> --validate <schema> Validate against schema
54
+
55
+ Template / Document Generation:
56
+ dotit <file.it> --data data.json Merge and show JSON
57
+ dotit <file.it> --data data.json --html Merge and render HTML
58
+ dotit <file.it> --data data.json --print Merge and render print HTML
59
+ dotit <file.it> --data data.json --pdf Merge and save as PDF (requires puppeteer)
60
+
61
+ Themes (v2.10):
62
+ dotit <file.it> --html --theme corporate Render with theme
63
+ dotit <file.it> --print --theme minimal Print with theme
64
+ dotit theme list List built-in themes
65
+ dotit theme info <name> Show theme metadata
66
+
67
+ Query (v2.10):
68
+ dotit query <dir> --type task Query a directory
69
+ dotit query "docs/*.it" --type sign Query a glob pattern
70
+ dotit query <dir> --type task --format table Table output (default)
71
+ dotit query <dir> --type task --format json JSON output
72
+ dotit query <dir> --type task --format csv CSV output
73
+
74
+ Runtime Telemetry:
75
+
76
+ Index (v2.10):
77
+ dotit index <dir> Build shallow index
78
+ dotit index <dir> --recursive Build indexes in all subfolders
79
+
80
+ Natural Language Query (v2.10):
81
+ dotit ask <dir> "question" Ask about documents
82
+ dotit ask <dir> "question" --format json Ask with JSON output
83
+
84
+ Document Trust (v2.8):
85
+ dotit seal <file.it> --signer "Name" --role "Role" Seal document
86
+ dotit verify <file.it> Verify integrity
87
+ dotit history <file.it> Show history
88
+ dotit history <file.it> --json History as JSON
89
+ dotit history <file.it> --by "Ahmed" Filter by author
90
+ dotit history <file.it> --section "Scope" Filter by section
91
+
92
+ Amendment (v2.11):
93
+ dotit amend <file.it> --section "Payment" --was "30 days" --now "15 days" --ref "Amendment #1"
94
+ dotit amend <file.it> --section "Scope" --now "Includes Phase 2" --ref "Amendment #2" --by "Ahmed"
95
+
96
+ Query examples:
97
+ dotit todo.it --query "type=task owner=Ahmed"
98
+ dotit project.it --query "type=task due<2026-03-01 sort:due:asc limit:10"
99
+
100
+ Validation:
101
+ dotit project.it --validate project
102
+ dotit article.it --validate article
103
+
104
+ Available schemas: ${Object.keys(PREDEFINED_SCHEMAS).join(", ")}
105
+ Built-in themes: ${listBuiltinThemes().join(", ")}
106
+ `);
107
+ return;
108
+ }
109
+
110
+ const inputFile = args[0];
111
+
112
+ // v2.10: Theme commands
113
+ if (inputFile === "theme") {
114
+ const subCmd = args[1];
115
+ if (subCmd === "list") {
116
+ const themes = listBuiltinThemes();
117
+ console.log("Built-in themes:");
118
+ for (const name of themes) {
119
+ const t = getBuiltinTheme(name);
120
+ console.log(` ${name.padEnd(12)} ${t?.description || ""}`);
121
+ }
122
+ return;
123
+ }
124
+ if (subCmd === "info") {
125
+ const name = args[2];
126
+ if (!name) {
127
+ console.error("āŒ Missing theme name");
128
+ process.exit(1);
129
+ }
130
+ const t = getBuiltinTheme(name);
131
+ if (!t) {
132
+ console.error(`āŒ Theme not found: ${name}`);
133
+ process.exit(1);
134
+ }
135
+ console.log(`Theme: ${t.name} v${t.version}`);
136
+ console.log(`Description: ${t.description || ""}`);
137
+ console.log(`Author: ${t.author || ""}`);
138
+ console.log(
139
+ `Fonts: body=${t.fonts.body}, heading=${t.fonts.heading}, mono=${t.fonts.mono}`,
140
+ );
141
+ console.log(`Size: ${t.fonts.size}, Leading: ${t.fonts.leading}`);
142
+ console.log(
143
+ `Colors: text=${t.colors.text}, accent=${t.colors.accent}, bg=${t.colors.background}`,
144
+ );
145
+ return;
146
+ }
147
+ console.error(
148
+ "āŒ Unknown theme command. Use: theme list | theme info <name>",
149
+ );
150
+ process.exit(1);
151
+ }
152
+
153
+ // v2.10: Folder / glob query command
154
+ if (inputFile === "query") {
155
+ const target = args[1];
156
+ if (!target) {
157
+ console.error("āŒ Missing directory or glob argument");
158
+ process.exit(1);
159
+ }
160
+ const typeFilter =
161
+ args.indexOf("--type") >= 0 ? args[args.indexOf("--type") + 1] : null;
162
+ const byFilter =
163
+ args.indexOf("--by") >= 0 ? args[args.indexOf("--by") + 1] : null;
164
+ const statusFilter =
165
+ args.indexOf("--status") >= 0 ? args[args.indexOf("--status") + 1] : null;
166
+ const sectionFilter =
167
+ args.indexOf("--section") >= 0
168
+ ? args[args.indexOf("--section") + 1]
169
+ : null;
170
+ const contentFilter =
171
+ args.indexOf("--content") >= 0
172
+ ? args[args.indexOf("--content") + 1]
173
+ : null;
174
+ const formatIdx = args.indexOf("--format");
175
+ const fmt = formatIdx >= 0 ? args[formatIdx + 1] : "table";
176
+
177
+ const itFiles = resolveItFiles(target);
178
+ if (itFiles.length === 0) {
179
+ console.log("No .it files found.");
180
+ return;
181
+ }
182
+
183
+ const composed = [];
184
+ const resolvedTarget = path.resolve(target);
185
+ const isDir =
186
+ fs.existsSync(resolvedTarget) &&
187
+ fs.statSync(resolvedTarget).isDirectory();
188
+
189
+ if (isDir) {
190
+ // Index-backed: each folder owns a self-healing .it-index. Refresh only the
191
+ // changed files, then compose the (sub)folder indexes and query.
192
+ const folders = [...new Set(itFiles.map((f) => path.dirname(f)))];
193
+ const indexes = folders.map((f) => loadOrRefreshFolderIndex(f).index);
194
+ composed.push(...composeIndexes(indexes, "."));
195
+ } else {
196
+ // Single file or glob — parse directly (no index to persist for a one-off).
197
+ for (const filePath of itFiles) {
198
+ const source = fs.readFileSync(filePath, "utf-8");
199
+ const doc = parseIntentText(source);
200
+ const relPath = path.relative(process.cwd(), filePath);
201
+ const entry = buildIndexEntry(doc, source, new Date().toISOString());
202
+ for (const block of entry.blocks) {
203
+ composed.push({ file: relPath, block });
204
+ }
205
+ }
206
+ }
207
+
208
+ // Apply filters
209
+ const filtered = queryComposed(composed, {
210
+ type: typeFilter || undefined,
211
+ content: contentFilter || undefined,
212
+ by: byFilter || undefined,
213
+ status: statusFilter || undefined,
214
+ section: sectionFilter || undefined,
215
+ });
216
+
217
+ if (fmt === "json") console.log(formatJSON(filtered));
218
+ else if (fmt === "csv") console.log(formatCSV(filtered));
219
+ else console.log(formatTable(filtered));
220
+ return;
221
+ }
222
+
223
+ // v2.10: Index command
224
+ if (inputFile === "index") {
225
+ const target = args[1];
226
+ if (!target) {
227
+ console.error("āŒ Missing directory argument");
228
+ process.exit(1);
229
+ }
230
+ const recursive = args.includes("--recursive");
231
+ const resolvedTarget = path.resolve(target);
232
+
233
+ if (
234
+ !fs.existsSync(resolvedTarget) ||
235
+ !fs.statSync(resolvedTarget).isDirectory()
236
+ ) {
237
+ console.error(`āŒ Not a directory: ${target}`);
238
+ process.exit(1);
239
+ }
240
+
241
+ if (recursive) {
242
+ buildIndexRecursive(resolvedTarget);
243
+ } else {
244
+ buildIndexForFolder(resolvedTarget);
245
+ }
246
+ return;
247
+ }
248
+
249
+ // v2.10: Ask command (natural language query)
250
+ if (inputFile === "ask") {
251
+ const target = args[1];
252
+ const question = args[2];
253
+ if (!target || !question) {
254
+ console.error('āŒ Usage: dotit ask <dir> "question"');
255
+ process.exit(1);
256
+ }
257
+
258
+ const itFiles = resolveItFiles(target);
259
+ if (itFiles.length === 0) {
260
+ console.log("No .it files found.");
261
+ return;
262
+ }
263
+
264
+ const composed = [];
265
+ for (const filePath of itFiles) {
266
+ const source = fs.readFileSync(filePath, "utf-8");
267
+ const doc = parseIntentText(source);
268
+ const relPath = path.relative(process.cwd(), filePath);
269
+ const entry = buildIndexEntry(doc, source, new Date().toISOString());
270
+ for (const block of entry.blocks) {
271
+ composed.push({ file: relPath, block });
272
+ }
273
+ }
274
+
275
+ // Dynamic import for ask module (async)
276
+ const { askDocuments } = require("./dist");
277
+ const formatIdx = args.indexOf("--format");
278
+ const fmt = formatIdx >= 0 ? args[formatIdx + 1] : "text";
279
+ askDocuments(composed, question, {
280
+ format: fmt === "json" ? "json" : "text",
281
+ })
282
+ .then((answer) => console.log(answer))
283
+ .catch((err) => {
284
+ console.error(`āŒ ${err.message}`);
285
+ process.exit(1);
286
+ });
287
+ return;
288
+ }
289
+
290
+ // v2.8: Trust commands (seal, verify, history)
291
+ if (
292
+ inputFile === "seal" ||
293
+ inputFile === "verify" ||
294
+ inputFile === "history"
295
+ ) {
296
+ const trustCommand = inputFile;
297
+ const targetFile = args[1];
298
+
299
+ if (!targetFile) {
300
+ console.error(`āŒ Missing file argument for ${trustCommand} command`);
301
+ process.exit(1);
302
+ }
303
+
304
+ if (!fs.existsSync(targetFile)) {
305
+ console.error(`āŒ File not found: ${targetFile}`);
306
+ process.exit(1);
307
+ }
308
+
309
+ const source = fs.readFileSync(targetFile, "utf-8");
310
+
311
+ if (trustCommand === "seal") {
312
+ const signerIdx = args.indexOf("--signer");
313
+ const signer = signerIdx >= 0 ? args[signerIdx + 1] : null;
314
+ const roleIdx = args.indexOf("--role");
315
+ const role = roleIdx >= 0 ? args[roleIdx + 1] : undefined;
316
+ const skipSign = args.includes("--no-sign");
317
+
318
+ if (!signer && !skipSign) {
319
+ console.error(
320
+ "āŒ --signer is required for seal command (or use --no-sign)",
321
+ );
322
+ process.exit(1);
323
+ }
324
+
325
+ const result = sealDocument(source, {
326
+ signer: signer || "",
327
+ role,
328
+ skipSign,
329
+ });
330
+
331
+ if (result.success) {
332
+ fs.writeFileSync(targetFile, result.source);
333
+ console.log("āœ… Document sealed");
334
+ if (signer)
335
+ console.log(` Signer: ${signer}${role ? ` (${role})` : ""}`);
336
+ console.log(` Hash: ${result.hash}`);
337
+ console.log(` Frozen: ${result.at}`);
338
+ } else {
339
+ console.error(`āŒ Seal failed: ${result.error}`);
340
+ process.exit(1);
341
+ }
342
+ return;
343
+ }
344
+
345
+ if (trustCommand === "verify") {
346
+ const result = verifyDocument(source);
347
+
348
+ if (!result.frozen) {
349
+ console.log("āš ļø Document is not sealed. No freeze: block found.");
350
+ return;
351
+ }
352
+
353
+ if (result.intact) {
354
+ console.log("āœ… Document intact");
355
+ console.log(` Sealed: ${result.frozenAt}`);
356
+ if (result.signers && result.signers.length > 0) {
357
+ console.log(
358
+ " Signers: " +
359
+ result.signers
360
+ .map(
361
+ (s) =>
362
+ `${s.signer}${s.role ? ` (${s.role})` : ""} ${s.valid ? "āœ…" : "āŒ"}`,
363
+ )
364
+ .join("\n "),
365
+ );
366
+ }
367
+ console.log(` Hash: ${result.hash} āœ… matches`);
368
+ // v2.11: Report amendment count
369
+ const doc = parseIntentText(source);
370
+ const allBlocks = doc.blocks.flatMap(function walk(b) {
371
+ return [b, ...(b.children || []).flatMap(walk)];
372
+ });
373
+ const amendments = allBlocks.filter((b) => b.type === "amendment");
374
+ if (amendments.length > 0) {
375
+ console.log(` Amendments: ${amendments.length}`);
376
+ }
377
+ } else {
378
+ console.log("āŒ Document has been modified since sealing");
379
+ console.log(` Sealed: ${result.frozenAt}`);
380
+ console.log(` Expected: ${result.expectedHash}`);
381
+ console.log(` Current: ${result.hash}`);
382
+ if (result.signers && result.signers.length > 0) {
383
+ console.log(
384
+ " Signers: " +
385
+ result.signers
386
+ .map(
387
+ (s) =>
388
+ `${s.signer}${s.role ? ` (${s.role})` : ""} ${s.valid ? "āœ…" : "āŒ signature invalid"}`,
389
+ )
390
+ .join("\n "),
391
+ );
392
+ }
393
+ process.exit(1);
394
+ }
395
+ return;
396
+ }
397
+
398
+ if (trustCommand === "history") {
399
+ const doc = parseIntentText(source, { includeHistorySection: true });
400
+ const jsonMode = args.includes("--json");
401
+ const byFilter =
402
+ args.indexOf("--by") >= 0 ? args[args.indexOf("--by") + 1] : null;
403
+ const sectionFilter =
404
+ args.indexOf("--section") >= 0
405
+ ? args[args.indexOf("--section") + 1]
406
+ : null;
407
+ const blockFilter =
408
+ args.indexOf("--block") >= 0 ? args[args.indexOf("--block") + 1] : null;
409
+
410
+ if (!doc.history || doc.history.revisions.length === 0) {
411
+ console.log("No history found. Document may not be tracked.");
412
+ return;
413
+ }
414
+
415
+ let revisions = doc.history.revisions;
416
+ if (byFilter) revisions = revisions.filter((r) => r.by === byFilter);
417
+ if (sectionFilter)
418
+ revisions = revisions.filter((r) => r.section === sectionFilter);
419
+ if (blockFilter)
420
+ revisions = revisions.filter((r) => r.id === blockFilter);
421
+
422
+ if (jsonMode) {
423
+ console.log(
424
+ JSON.stringify(
425
+ { revisions, registry: doc.history.registry },
426
+ null,
427
+ 2,
428
+ ),
429
+ );
430
+ } else {
431
+ for (const r of revisions) {
432
+ const date = r.at ? r.at.slice(0, 10) : "";
433
+ const detail =
434
+ r.change === "modified"
435
+ ? `"${(r.was || "").slice(0, 30)}" → "${(r.now || "").slice(0, 30)}"`
436
+ : r.change === "added"
437
+ ? (r.now || "").slice(0, 50)
438
+ : r.change === "removed"
439
+ ? (r.was || "").slice(0, 50)
440
+ : `${r.wasSection || ""} → ${r.nowSection || ""}`;
441
+ console.log(
442
+ ` ${r.version.padEnd(5)} ${date} ${(r.by || "").padEnd(10)} [${r.change.padEnd(8)}] ${(r.block || "").padEnd(10)} ${r.section ? r.section + " › " : ""}${detail}`,
443
+ );
444
+ }
445
+ }
446
+ return;
447
+ }
448
+ }
449
+
450
+ // v2.11: Amend command
451
+ if (inputFile === "amend") {
452
+ const targetFile = args[1];
453
+ if (!targetFile) {
454
+ console.error("āŒ Missing file argument for amend command");
455
+ process.exit(1);
456
+ }
457
+ if (!fs.existsSync(targetFile)) {
458
+ console.error(`āŒ File not found: ${targetFile}`);
459
+ process.exit(1);
460
+ }
461
+
462
+ const sectionIdx = args.indexOf("--section");
463
+ const section = sectionIdx >= 0 ? args[sectionIdx + 1] : null;
464
+ const wasIdx = args.indexOf("--was");
465
+ const was = wasIdx >= 0 ? args[wasIdx + 1] : null;
466
+ const nowIdx = args.indexOf("--now");
467
+ const now = nowIdx >= 0 ? args[nowIdx + 1] : null;
468
+ const refIdx = args.indexOf("--ref");
469
+ const ref = refIdx >= 0 ? args[refIdx + 1] : null;
470
+ const byIdx = args.indexOf("--by");
471
+ const by = byIdx >= 0 ? args[byIdx + 1] : null;
472
+ const description = args[2] && !args[2].startsWith("--") ? args[2] : null;
473
+
474
+ if (!now) {
475
+ console.error("āŒ --now is required for amend command");
476
+ process.exit(1);
477
+ }
478
+ if (!ref) {
479
+ console.error("āŒ --ref is required for amend command");
480
+ process.exit(1);
481
+ }
482
+
483
+ const source = fs.readFileSync(targetFile, "utf-8");
484
+ const doc = parseIntentText(source);
485
+
486
+ // Check freeze exists
487
+ if (!doc.metadata?.freeze) {
488
+ console.error(
489
+ "āŒ Cannot amend: document is not frozen. Seal the document first.",
490
+ );
491
+ process.exit(1);
492
+ }
493
+
494
+ // Build the amendment line
495
+ const at = new Date().toISOString().split("T")[0];
496
+ let amendLine = `amendment: ${description || "Amendment"}${section ? ` | section: ${section}` : ""}${was ? ` | was: ${was}` : ""} | now: ${now} | ref: ${ref}${by ? ` | by: ${by}` : ""} | at: ${at}`;
497
+
498
+ // Find insertion point: after the last freeze:/sign:/amendment: line, before history
499
+ const historyPos = findHistoryBoundaryInSource(source);
500
+ const contentEnd = historyPos === -1 ? source.length : historyPos;
501
+ const contentPart = source.slice(0, contentEnd);
502
+ const lines = contentPart.split("\n");
503
+
504
+ // Find the last freeze/sign/amendment line
505
+ let insertAfterLine = -1;
506
+ for (let i = lines.length - 1; i >= 0; i--) {
507
+ const trimmed = lines[i].trim();
508
+ if (
509
+ trimmed.startsWith("freeze:") ||
510
+ trimmed.startsWith("sign:") ||
511
+ trimmed.startsWith("amendment:")
512
+ ) {
513
+ insertAfterLine = i;
514
+ break;
515
+ }
516
+ }
517
+
518
+ if (insertAfterLine === -1) {
519
+ console.error("āŒ Cannot find freeze: block in document source");
520
+ process.exit(1);
521
+ }
522
+
523
+ // Build the updated source
524
+ const beforeLines = lines.slice(0, insertAfterLine + 1);
525
+ const afterLines = lines.slice(insertAfterLine + 1);
526
+ const afterContent = historyPos === -1 ? "" : source.slice(historyPos);
527
+
528
+ const updatedContent =
529
+ beforeLines.join("\n") + "\n" + amendLine + "\n" + afterLines.join("\n");
530
+ const updatedSource = afterContent
531
+ ? updatedContent + afterContent
532
+ : updatedContent;
533
+
534
+ // Show preview and confirm
535
+ console.log("\nšŸ“ Amendment to add:");
536
+ console.log(` ${amendLine}`);
537
+ console.log(`\n File: ${targetFile}`);
538
+ console.log(` Insert after line ${insertAfterLine + 1}`);
539
+
540
+ const rl = readline.createInterface({
541
+ input: process.stdin,
542
+ output: process.stdout,
543
+ });
544
+ rl.question("\nApply amendment? (y/N) ", (answer) => {
545
+ rl.close();
546
+ if (answer.toLowerCase() === "y" || answer.toLowerCase() === "yes") {
547
+ fs.writeFileSync(targetFile, updatedSource);
548
+ console.log("āœ… Amendment added successfully");
549
+ } else {
550
+ console.log("āŒ Amendment cancelled");
551
+ }
552
+ });
553
+ return;
554
+ }
555
+
556
+ const outputHtml = args.includes("--html");
557
+ const saveFile = args.includes("--output");
558
+ const toIt = args.includes("--to-it");
559
+ const printMode = args.includes("--print");
560
+ const pdfMode = args.includes("--pdf");
561
+ const themeIdx = args.indexOf("--theme");
562
+ const themeName = themeIdx >= 0 ? args[themeIdx + 1] : null;
563
+ const renderOpts = themeName ? { theme: themeName } : undefined;
564
+ const queryIndex = args.indexOf("--query");
565
+ const queryString = queryIndex >= 0 ? args[queryIndex + 1] : null;
566
+ const validateIndex = args.indexOf("--validate");
567
+ const schemaName = validateIndex >= 0 ? args[validateIndex + 1] : null;
568
+ const dataIndex = args.indexOf("--data");
569
+ const dataFile = dataIndex >= 0 ? args[dataIndex + 1] : null;
570
+
571
+ try {
572
+ if (!fs.existsSync(inputFile)) {
573
+ console.error(`āŒ File not found: ${inputFile}`);
574
+ process.exit(1);
575
+ }
576
+
577
+ const content = fs.readFileSync(inputFile, "utf-8");
578
+
579
+ // Convert mode: Markdown or HTML → .it
580
+ if (toIt) {
581
+ let converted;
582
+ if (/\.html?$/i.test(inputFile)) {
583
+ converted = convertHtmlToIntentText(content);
584
+ } else {
585
+ converted = convertMarkdownToIntentText(content);
586
+ }
587
+ if (saveFile) {
588
+ const outputFile = inputFile.replace(/\.(md|markdown|html?)$/i, ".it");
589
+ fs.writeFileSync(outputFile, converted);
590
+ console.log(`āœ… IntentText saved to: ${outputFile}`);
591
+ } else {
592
+ console.log(converted);
593
+ }
594
+ return;
595
+ }
596
+
597
+ // Parse the document, optionally merging template data
598
+ let document;
599
+ if (dataFile) {
600
+ if (!fs.existsSync(dataFile)) {
601
+ console.error(`āŒ Data file not found: ${dataFile}`);
602
+ process.exit(1);
603
+ }
604
+ const dataContent = JSON.parse(fs.readFileSync(dataFile, "utf-8"));
605
+ const parsed = parseIntentText(content);
606
+ document = mergeData(parsed, dataContent);
607
+ } else {
608
+ document = parseIntentText(content);
609
+ }
610
+
611
+ // Query mode
612
+ if (queryString) {
613
+ const result = queryBlocks(document, queryString);
614
+ console.log(formatQueryResult(result, "table"));
615
+ return;
616
+ }
617
+
618
+ // Validation mode
619
+ if (schemaName) {
620
+ const result = validateDocument(document, schemaName);
621
+ console.log(formatValidationResult(result));
622
+ process.exit(result.valid ? 0 : 1);
623
+ }
624
+
625
+ // PDF mode
626
+ if (pdfMode) {
627
+ let puppeteer;
628
+ try {
629
+ puppeteer = require("puppeteer");
630
+ } catch {
631
+ console.error(
632
+ `PDF output requires puppeteer. Run: npm install puppeteer\nThen retry: dotit ${inputFile} --data ${dataFile || "data.json"} --pdf`,
633
+ );
634
+ process.exit(1);
635
+ }
636
+ const printHtml = renderPrint(document, renderOpts);
637
+ (async () => {
638
+ const browser = await puppeteer.launch({ headless: true });
639
+ const page = await browser.newPage();
640
+ await page.setContent(printHtml, { waitUntil: "networkidle0" });
641
+ const pdfPath = inputFile.replace(/\.it$/i, ".pdf");
642
+ await page.pdf({ path: pdfPath, format: "A4", printBackground: true });
643
+ await browser.close();
644
+ console.log(`āœ… PDF saved to: ${pdfPath}`);
645
+ })();
646
+ return;
647
+ }
648
+
649
+ // Print mode
650
+ if (printMode) {
651
+ const printHtml = renderPrint(document, renderOpts);
652
+ if (saveFile) {
653
+ const outputFile = inputFile.replace(/\.it$/i, "-print.html");
654
+ fs.writeFileSync(outputFile, printHtml);
655
+ console.log(`āœ… Print HTML saved to: ${outputFile}`);
656
+ } else {
657
+ console.log(printHtml);
658
+ }
659
+ return;
660
+ }
661
+
662
+ // HTML output
663
+ if (outputHtml || saveFile) {
664
+ const html = renderHTML(document, renderOpts);
665
+ if (saveFile) {
666
+ const outputFile = inputFile.replace(/\.it$/i, ".html");
667
+ fs.writeFileSync(outputFile, html);
668
+ console.log(`āœ… HTML saved to: ${outputFile}`);
669
+ } else {
670
+ console.log(html);
671
+ }
672
+ } else {
673
+ // Default: JSON output
674
+ console.log(JSON.stringify(document, null, 2));
675
+ }
676
+ } catch (error) {
677
+ console.error(`āŒ Error: ${error.message}`);
678
+ process.exit(1);
679
+ }
680
+ }
681
+
682
+ main();
683
+
684
+ // ── v2.10 Helper Functions ──────────────────────────────
685
+
686
+ /**
687
+ * Resolve .it files from a path that could be a file, directory, or glob.
688
+ */
689
+ function resolveItFiles(target) {
690
+ const resolved = path.resolve(target);
691
+
692
+ // If it's a directory, glob all .it files recursively
693
+ if (fs.existsSync(resolved) && fs.statSync(resolved).isDirectory()) {
694
+ return walkDir(resolved).filter((f) => f.endsWith(".it"));
695
+ }
696
+
697
+ // If it's a single .it file
698
+ if (fs.existsSync(resolved) && resolved.endsWith(".it")) {
699
+ return [resolved];
700
+ }
701
+
702
+ // Treat as a glob pattern — basic implementation
703
+ const dir = path.dirname(resolved);
704
+ const pattern = path.basename(target);
705
+ if (fs.existsSync(dir) && fs.statSync(dir).isDirectory()) {
706
+ const files = fs.readdirSync(dir).filter((f) => {
707
+ if (!f.endsWith(".it")) return false;
708
+ if (pattern.includes("*")) {
709
+ const regex = new RegExp(
710
+ "^" + pattern.replace(/\*/g, ".*").replace(/\?/g, ".") + "$",
711
+ );
712
+ return regex.test(f);
713
+ }
714
+ return f === pattern;
715
+ });
716
+ return files.map((f) => path.join(dir, f));
717
+ }
718
+
719
+ // Recursive glob: **/*.it
720
+ if (target.includes("**")) {
721
+ const base = target.split("**")[0] || ".";
722
+ const baseResolved = path.resolve(base);
723
+ if (
724
+ fs.existsSync(baseResolved) &&
725
+ fs.statSync(baseResolved).isDirectory()
726
+ ) {
727
+ return walkDir(baseResolved).filter((f) => f.endsWith(".it"));
728
+ }
729
+ }
730
+
731
+ return [];
732
+ }
733
+
734
+ function walkDir(dir) {
735
+ const results = [];
736
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
737
+ for (const entry of entries) {
738
+ const full = path.join(dir, entry.name);
739
+ if (
740
+ entry.isDirectory() &&
741
+ !entry.name.startsWith(".") &&
742
+ entry.name !== "node_modules"
743
+ ) {
744
+ results.push(...walkDir(full));
745
+ } else if (entry.isFile()) {
746
+ results.push(full);
747
+ }
748
+ }
749
+ return results;
750
+ }
751
+
752
+ /** Read every .it file directly in `folder` (non-recursive) → parsed file data. */
753
+ function readFolderItFiles(folder) {
754
+ const entries = fs.readdirSync(folder, { withFileTypes: true });
755
+ const filesData = {};
756
+ for (const entry of entries) {
757
+ if (!entry.isFile() || !entry.name.endsWith(".it")) continue;
758
+ const filePath = path.join(folder, entry.name);
759
+ const source = fs.readFileSync(filePath, "utf-8");
760
+ filesData[entry.name] = {
761
+ source,
762
+ doc: parseIntentText(source),
763
+ modifiedAt: fs.statSync(filePath).mtime.toISOString(),
764
+ };
765
+ }
766
+ return filesData;
767
+ }
768
+
769
+ /**
770
+ * Lazy self-healing index for one folder. Loads the existing .it-index, refreshes
771
+ * only the changed/added/removed entries (incremental), and writes it back. Builds
772
+ * from scratch if absent. The .it-index is a cache — never a source of truth.
773
+ * Returns { index, stats: { added, stale, removed, unchanged, full } }.
774
+ */
775
+ function loadOrRefreshFolderIndex(folder, { write = true } = {}) {
776
+ const indexPath = path.join(folder, ".it-index");
777
+ const relFolder = path.relative(process.cwd(), folder) || ".";
778
+ const filesData = readFolderItFiles(folder);
779
+
780
+ let existing = null;
781
+ if (fs.existsSync(indexPath)) {
782
+ try {
783
+ const parsed = JSON.parse(fs.readFileSync(indexPath, "utf-8"));
784
+ if (parsed && parsed.scope === "shallow") existing = parsed;
785
+ } catch {
786
+ existing = null; // corrupt cache — rebuild
787
+ }
788
+ }
789
+
790
+ let index;
791
+ let stats;
792
+ if (!existing) {
793
+ index = buildShallowIndex(relFolder, filesData, CORE_VERSION);
794
+ stats = {
795
+ added: Object.keys(filesData).length,
796
+ stale: 0,
797
+ removed: 0,
798
+ unchanged: 0,
799
+ full: true,
800
+ };
801
+ } else {
802
+ const forStaleness = Object.fromEntries(
803
+ Object.entries(filesData).map(([n, d]) => [
804
+ n,
805
+ { source: d.source, modifiedAt: d.modifiedAt },
806
+ ]),
807
+ );
808
+ const { stale, added, removed, unchanged } = checkStaleness(
809
+ existing,
810
+ forStaleness,
811
+ );
812
+ if (stale.length || added.length || removed.length) {
813
+ const updates = {};
814
+ for (const n of [...stale, ...added]) updates[n] = filesData[n];
815
+ index = updateIndex(existing, updates, removed);
816
+ } else {
817
+ index = existing;
818
+ }
819
+ stats = {
820
+ added: added.length,
821
+ stale: stale.length,
822
+ removed: removed.length,
823
+ unchanged: unchanged.length,
824
+ full: false,
825
+ };
826
+ }
827
+
828
+ const changed = stats.full || stats.added || stats.stale || stats.removed;
829
+ if (write && changed) {
830
+ fs.writeFileSync(indexPath, JSON.stringify(index, null, 2));
831
+ }
832
+ return { index, stats, changed: Boolean(changed) };
833
+ }
834
+
835
+ function buildIndexForFolder(folder) {
836
+ const { index, stats } = loadOrRefreshFolderIndex(folder);
837
+ const fileCount = Object.keys(index.files).length;
838
+ if (fileCount === 0) {
839
+ console.log(`No .it files in ${folder}`);
840
+ return;
841
+ }
842
+ const indexPath = path.join(folder, ".it-index");
843
+ if (stats.full) {
844
+ console.log(`āœ… Index built: ${indexPath} (${fileCount} files)`);
845
+ } else if (stats.added || stats.stale || stats.removed) {
846
+ console.log(
847
+ `āœ… Index refreshed: ${indexPath} (+${stats.added} ~${stats.stale} -${stats.removed}, ${stats.unchanged} unchanged)`,
848
+ );
849
+ } else {
850
+ console.log(`āœ“ Index up to date: ${indexPath} (${fileCount} files)`);
851
+ }
852
+ }
853
+
854
+ function buildIndexRecursive(rootDir) {
855
+ let count = 0;
856
+ function walk(dir) {
857
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
858
+ const hasItFiles = entries.some(
859
+ (e) => e.isFile() && e.name.endsWith(".it"),
860
+ );
861
+ if (hasItFiles) {
862
+ buildIndexForFolder(dir);
863
+ count++;
864
+ }
865
+ for (const entry of entries) {
866
+ if (
867
+ entry.isDirectory() &&
868
+ !entry.name.startsWith(".") &&
869
+ entry.name !== "node_modules"
870
+ ) {
871
+ walk(path.join(dir, entry.name));
872
+ }
873
+ }
874
+ }
875
+ walk(rootDir);
876
+ console.log(`\nāœ… Built ${count} indexes recursively under ${rootDir}`);
877
+ }