kitfly 0.2.3 → 0.2.4

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.
Files changed (107) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.md +13 -11
  3. package/VERSION +1 -1
  4. package/dist/_raw/content/reference/gantt-widget.md +468 -0
  5. package/dist/_raw/content/reference/plugins.md +157 -2
  6. package/dist/content/deployment/preflight.html +5 -6
  7. package/dist/content/deployment/recipes/aws-s3.html +5 -6
  8. package/dist/content/deployment/recipes/cloudflare-pages.html +5 -6
  9. package/dist/content/deployment/recipes/cloudflare-r2.html +5 -6
  10. package/dist/content/deployment/recipes/fly-io.html +5 -6
  11. package/dist/content/deployment/recipes/github-pages.html +5 -6
  12. package/dist/content/deployment/recipes/netlify.html +5 -6
  13. package/dist/content/deployment/recipes/vercel.html +5 -6
  14. package/dist/content/deployment/secrets-and-env-vars.html +5 -6
  15. package/dist/content/deployment.html +5 -6
  16. package/dist/content/guide/approaches.html +5 -6
  17. package/dist/content/guide/branding.html +5 -6
  18. package/dist/content/guide/data-driven-content.html +5 -6
  19. package/dist/content/guide/features.html +5 -6
  20. package/dist/content/guide/getting-started.html +5 -6
  21. package/dist/content/guide/kitfly-overview.html +5 -6
  22. package/dist/content/reference/configuration.html +5 -6
  23. package/dist/content/reference/design-catalog.html +5 -6
  24. package/dist/content/reference/environment-variables.html +5 -6
  25. package/dist/content/reference/gantt-widget.html +899 -0
  26. package/dist/content/reference/glossary.html +5 -6
  27. package/dist/content/reference/key-concepts.html +5 -6
  28. package/dist/content/reference/plugins.html +245 -9
  29. package/dist/content/reference/slides-authoring-guidelines.html +5 -6
  30. package/dist/content/reference/structure.html +5 -6
  31. package/dist/content/reference.html +5 -6
  32. package/dist/content/templates/crucible.html +5 -6
  33. package/dist/content/templates/handbook.html +5 -6
  34. package/dist/content/templates/minimal.html +5 -6
  35. package/dist/content/templates/overview.html +5 -6
  36. package/dist/content/templates/pipeline.html +5 -6
  37. package/dist/content/templates/productbook.html +5 -6
  38. package/dist/content/templates/runbook.html +5 -6
  39. package/dist/content/templates/servicebook.html +5 -6
  40. package/dist/content-index.json +10 -2
  41. package/dist/docs/decisions/ADR-0001-minimalist-site-code.html +5 -6
  42. package/dist/docs/decisions/ADR-0002-ai-accessibility.html +5 -6
  43. package/dist/docs/decisions/ADR-0003-single-file-bundle.html +5 -6
  44. package/dist/docs/decisions/ADR-0004-bun-runtime.html +5 -6
  45. package/dist/docs/decisions/ADR-0005-plugin-contract-and-distribution.html +5 -6
  46. package/dist/docs/decisions/ADR-0006-data-driven-content.html +5 -6
  47. package/dist/docs/decisions/DDR-0001-viewport-locked-layout.html +5 -6
  48. package/dist/docs/decisions/DDR-0002-theme-system.html +5 -6
  49. package/dist/docs/decisions/DDR-0003-bounded-logo-slot.html +5 -6
  50. package/dist/docs/decisions/DDR-0004-slides-rendering-model.html +5 -6
  51. package/dist/docs/decisions/DDR-0005-deterministic-layout-boundary.html +5 -6
  52. package/dist/docs/userguide/cli/build.html +5 -6
  53. package/dist/docs/userguide/cli/bundle.html +5 -6
  54. package/dist/docs/userguide/cli/dev.html +5 -6
  55. package/dist/docs/userguide/cli/init.html +5 -6
  56. package/dist/docs/userguide/cli/servers.html +5 -6
  57. package/dist/docs/userguide/cli/stop.html +5 -6
  58. package/dist/docs/userguide/cli/update.html +5 -6
  59. package/dist/docs/userguide/cli/version.html +5 -6
  60. package/dist/docs/userguide/cli.html +5 -6
  61. package/dist/docs/userguide/sharing.html +5 -6
  62. package/dist/index.html +5 -6
  63. package/dist/llms.txt +3 -3
  64. package/dist/provenance.json +4 -5
  65. package/dist/reports/license-inventory.csv +199 -0
  66. package/dist/schemas/plugin-registry.schema.html +5 -6
  67. package/dist/schemas/plugin-schemas-notes.html +5 -6
  68. package/dist/schemas/plugin.schema.html +5 -6
  69. package/dist/schemas/plugins.schema.html +5 -6
  70. package/dist/schemas/v0/common.schema.html +5 -6
  71. package/dist/schemas/v0/plugin-registry.schema.html +5 -6
  72. package/dist/schemas/v0/plugin.schema.html +5 -6
  73. package/dist/schemas/v0/plugins.schema.html +5 -6
  74. package/dist/schemas/v0/site.schema.html +5 -6
  75. package/dist/schemas/v0/theme.schema.html +5 -6
  76. package/dist/schemas.html +5 -6
  77. package/package.json +1 -1
  78. package/plugins-dist/planning-visuals.css +261 -0
  79. package/plugins-dist/planning-visuals.js +669 -0
  80. package/registry/plugins.yaml +15 -1
  81. package/scripts/build-all.ts +5 -0
  82. package/scripts/build.ts +73 -11
  83. package/scripts/bundle.ts +73 -10
  84. package/scripts/dev.ts +49 -5
  85. package/scripts/embed-docs.ts +119 -0
  86. package/src/__tests__/build.test.ts +124 -0
  87. package/src/__tests__/bundle.test.ts +61 -0
  88. package/src/__tests__/docs.test.ts +117 -0
  89. package/src/__tests__/fixtures/fences/planning-visuals/invalid/bad-month-format.md +10 -0
  90. package/src/__tests__/fixtures/fences/planning-visuals/invalid/marker-format-mismatch.md +13 -0
  91. package/src/__tests__/fixtures/fences/planning-visuals/invalid/milestone-format-mismatch.md +13 -0
  92. package/src/__tests__/fixtures/fences/planning-visuals/invalid/missing-tracks.md +5 -0
  93. package/src/__tests__/fixtures/fences/planning-visuals/invalid/track-reversed.md +10 -0
  94. package/src/__tests__/fixtures/fences/planning-visuals/valid/markers-basic.md +15 -0
  95. package/src/__tests__/fixtures/fences/planning-visuals/valid/markers-no-milestones.md +13 -0
  96. package/src/__tests__/fixtures/fences/planning-visuals/valid/month-basic.md +16 -0
  97. package/src/__tests__/fixtures/fences/planning-visuals/valid/no-milestones.md +10 -0
  98. package/src/__tests__/fixtures/fences/planning-visuals/valid/week-basic.md +20 -0
  99. package/src/__tests__/planning-visuals-fence-contract.test.ts +28 -0
  100. package/src/__tests__/planning-visuals-runtime-regressions.bun.test.ts +68 -0
  101. package/src/__tests__/planning-visuals-runtime.bun.test.ts +192 -0
  102. package/src/__tests__/shared.test.ts +121 -0
  103. package/src/cli.ts +113 -18
  104. package/src/commands/docs.ts +71 -0
  105. package/src/generated/embedded-docs.ts +2384 -0
  106. package/src/server-registry.ts +50 -10
  107. package/src/shared.ts +449 -25
@@ -0,0 +1,192 @@
1
+ import { expect, test } from "bun:test";
2
+
3
+ type PlanningVisualsHooks = {
4
+ parseFence: (text: string) => { type: string; data: Record<string, unknown> } | null;
5
+ buildAxisCellLabel: (
6
+ unit: string,
7
+ info: { year: number; week?: number; month?: number; label: string },
8
+ prev: { year: number; week?: number; month?: number; label: string } | null,
9
+ index: number,
10
+ totalUnits: number,
11
+ ) => string;
12
+ weekLabelStepForUnits: (totalUnits: number) => number;
13
+ weekAxisContextLabel: (
14
+ startInfo: { year: number; week: number; label: string },
15
+ endInfo: { year: number; week: number; label: string },
16
+ ) => string;
17
+ parseListItemText: (rawText: string) => {
18
+ item: Record<string, string>;
19
+ switchListKey: string | null;
20
+ };
21
+ assignMarkerLabelLanes: (markers: Array<Record<string, any>>) => {
22
+ markers: Array<Record<string, any>>;
23
+ laneCount: number;
24
+ };
25
+ parseMarkerPosition: (value: string, unit: string) => number | null;
26
+ parseUnitOrdinal: (value: string, unit: string) => number | null;
27
+ weekLabelFromOrdinal: (ordinal: number) => { year: number; week: number; label: string };
28
+ monthLabelFromOrdinal: (ordinal: number) => { year: number; month: number; label: string };
29
+ };
30
+
31
+ async function loadHooks(): Promise<PlanningVisualsHooks> {
32
+ // @ts-expect-error JS plugin registers test hooks on globalThis in non-DOM environments.
33
+ await import("../../plugins-dist/planning-visuals.js");
34
+ const hooks = (globalThis as any).__kitflyPlanningVisualsTest as PlanningVisualsHooks | undefined;
35
+ if (!hooks) throw new Error("planning-visuals test hooks not found on globalThis");
36
+ return hooks;
37
+ }
38
+
39
+ test("planning-visuals: week roundtrip keeps same label", async () => {
40
+ const hooks = await loadHooks();
41
+ const ordinal = hooks.parseUnitOrdinal("2026-W14", "week");
42
+ expect(ordinal).not.toBeNull();
43
+ const label = hooks.weekLabelFromOrdinal(ordinal as number);
44
+ expect(label.label).toBe("W14");
45
+ expect(label.year).toBe(2026);
46
+ });
47
+
48
+ test("planning-visuals: week boundary label resolves correct year", async () => {
49
+ const hooks = await loadHooks();
50
+ const ordinal = hooks.parseUnitOrdinal("2027-W01", "week");
51
+ expect(ordinal).not.toBeNull();
52
+ const label = hooks.weekLabelFromOrdinal(ordinal as number);
53
+ expect(label.label).toBe("W01");
54
+ expect(label.year).toBe(2027);
55
+ });
56
+
57
+ test("planning-visuals: compact week axis uses sparse full week labels", async () => {
58
+ const hooks = await loadHooks();
59
+ expect(hooks.weekLabelStepForUnits(28)).toBe(4);
60
+ const first = hooks.weekLabelFromOrdinal(hooks.parseUnitOrdinal("2026-W10", "week") as number);
61
+ const interior = hooks.weekLabelFromOrdinal(
62
+ (hooks.parseUnitOrdinal("2026-W10", "week") as number) + 4,
63
+ );
64
+ const firstText = hooks.buildAxisCellLabel("week", first, null, 0, 28);
65
+ const interiorText = hooks.buildAxisCellLabel("week", interior, first, 4, 28);
66
+ expect(firstText.startsWith("W10")).toBe(true);
67
+ expect(interiorText).toBe("W14");
68
+ });
69
+
70
+ test("planning-visuals: week axis context label is explicit for audience clarity", async () => {
71
+ const hooks = await loadHooks();
72
+ const start = hooks.weekLabelFromOrdinal(hooks.parseUnitOrdinal("2026-W10", "week") as number);
73
+ const end = hooks.weekLabelFromOrdinal(hooks.parseUnitOrdinal("2026-W36", "week") as number);
74
+ const context = hooks.weekAxisContextLabel(start, end);
75
+ expect(context).toBe("ISO Weeks W10-W36 (2026)");
76
+ });
77
+
78
+ test("planning-visuals: parseFence preserves interleaved row order from repeated lists", async () => {
79
+ const hooks = await loadHooks();
80
+ const parsed = hooks.parseFence(
81
+ `:::gantt
82
+ +time-unit: week
83
+ +time-start: 2026-W14
84
+ +time-end: 2026-W20
85
+ +tracks:
86
+ + - label: Wave 1
87
+ + depth: 1
88
+ + start: 2026-W14
89
+ + end: 2026-W16
90
+ +milestones:
91
+ + - label: Go/No-Go
92
+ + date: 2026-W15
93
+ +tracks:
94
+ + - label: Wave 2
95
+ + depth: 1
96
+ + start: 2026-W17
97
+ + end: 2026-W20
98
+ +:::`.replace(/^\+/gm, ""),
99
+ );
100
+
101
+ expect(parsed?.type).toBe("gantt");
102
+ const order = (parsed?.data.__rowOrder as Array<{ kind: string; index: number }>) || [];
103
+ expect(order).toEqual([
104
+ { kind: "track", index: 0 },
105
+ { kind: "milestone", index: 0 },
106
+ { kind: "track", index: 1 },
107
+ ]);
108
+ });
109
+
110
+ test("planning-visuals: parseFence parses markers list", async () => {
111
+ const hooks = await loadHooks();
112
+ const parsed = hooks.parseFence(
113
+ `:::gantt
114
+ +time-unit: week
115
+ +time-start: 2026-W14
116
+ +time-end: 2026-W30
117
+ +markers:
118
+ + - label: Go/No-Go
119
+ + date: 2026-W20
120
+ + - label: Phase Gate
121
+ + date: 2026-W28
122
+ +tracks:
123
+ + - label: Phase 1
124
+ + depth: 1
125
+ + start: 2026-W14
126
+ + end: 2026-W24
127
+ +:::`.replace(/^\+/gm, ""),
128
+ );
129
+
130
+ expect(parsed?.type).toBe("gantt");
131
+ const markers = parsed?.data.markers as Array<Record<string, string>>;
132
+ expect(markers).toHaveLength(2);
133
+ expect(markers[0].label).toBe("Go/No-Go");
134
+ expect(markers[0].date).toBe("2026-W20");
135
+ expect(markers[1].label).toBe("Phase Gate");
136
+ expect(markers[1].date).toBe("2026-W28");
137
+ });
138
+
139
+ test("planning-visuals: parseFence keeps optional marker color", async () => {
140
+ const hooks = await loadHooks();
141
+ const parsed = hooks.parseFence(
142
+ `:::gantt
143
+ time-unit: month
144
+ time-start: 2026-03
145
+ time-end: 2026-10
146
+ markers:
147
+ - label: Go/No-Go
148
+ date: 2026-05-26
149
+ color: #38bdf8
150
+ tracks:
151
+ - label: Phase 1
152
+ depth: 1
153
+ start: 2026-03
154
+ end: 2026-06
155
+ :::`.replace(/^\+/gm, ""),
156
+ );
157
+ const markers = parsed?.data.markers as Array<Record<string, string>>;
158
+ expect(markers).toHaveLength(1);
159
+ expect(markers[0].color).toBe("#38bdf8");
160
+ });
161
+
162
+ test("planning-visuals: marker parser supports day precision in month mode", async () => {
163
+ const hooks = await loadHooks();
164
+ const monthCenter = hooks.parseMarkerPosition("2026-05", "month");
165
+ const monthDay = hooks.parseMarkerPosition("2026-05-26", "month");
166
+ expect(monthCenter).not.toBeNull();
167
+ expect(monthDay).not.toBeNull();
168
+ expect(monthDay as number).toBeGreaterThan(monthCenter as number);
169
+ expect(hooks.parseMarkerPosition("2026-02-30", "month")).toBeNull();
170
+ });
171
+
172
+ test("planning-visuals: list item parser detects in-item list key switch", async () => {
173
+ const hooks = await loadHooks();
174
+ const parsed = hooks.parseListItemText(
175
+ `label: "P66 Conf (May 26)"
176
+ date: "2026-05"
177
+ tracks:`,
178
+ );
179
+ expect(parsed.item.label).toBe("P66 Conf (May 26)");
180
+ expect(parsed.item.date).toBe("2026-05");
181
+ expect(parsed.switchListKey).toBe("tracks");
182
+ });
183
+
184
+ test("planning-visuals: marker layout assigns separate lanes for nearby labels", async () => {
185
+ const hooks = await loadHooks();
186
+ const layout = hooks.assignMarkerLabelLanes([
187
+ { label: "Conference", left: 40, color: "" },
188
+ { label: "Go-Live", left: 42, color: "" },
189
+ ]);
190
+ expect(layout.laneCount).toBeGreaterThan(1);
191
+ expect(layout.markers[0].__lane).not.toBe(layout.markers[1].__lane);
192
+ });
@@ -19,6 +19,7 @@ import {
19
19
  buildToc,
20
20
  type ContentFile,
21
21
  collectFiles,
22
+ collectPlanningVisualsContainmentWarnings,
22
23
  collectSlides,
23
24
  envBool,
24
25
  envInt,
@@ -26,6 +27,7 @@ import {
26
27
  escapeHtml,
27
28
  exists,
28
29
  filterByProfile,
30
+ filterUnknownPlanningVisualsTypeDiagnostics,
29
31
  filterUnknownSlidesVisualsTypeDiagnostics,
30
32
  formatDate,
31
33
  generateProvenance,
@@ -50,6 +52,7 @@ import {
50
52
  stripQuotes,
51
53
  toUrlPath,
52
54
  validatePath,
55
+ validatePlanningVisualsFences,
53
56
  validateSlidesVisualsFences,
54
57
  } from "../shared.ts";
55
58
 
@@ -450,6 +453,124 @@ label: Missing value
450
453
  });
451
454
  });
452
455
 
456
+ describe("planning-visuals diagnostics filtering", () => {
457
+ it("drops unknown-type diagnostics while preserving schema violations", () => {
458
+ const markdown = `:::future-planning
459
+ foo: bar
460
+ :::
461
+
462
+ :::gantt
463
+ time-unit: week
464
+ time-start: 2026-W10
465
+ time-end: 2026-W12
466
+ :::`;
467
+ const diagnostics = validatePlanningVisualsFences(markdown);
468
+ const filtered = filterUnknownPlanningVisualsTypeDiagnostics(diagnostics);
469
+ expect(
470
+ diagnostics.some((d) => d.message.startsWith("Unknown planning-visuals block type:")),
471
+ ).toBe(true);
472
+ expect(filtered.some((d) => d.message.startsWith("Unknown planning-visuals block type:"))).toBe(
473
+ false,
474
+ );
475
+ expect(filtered.some((d) => d.message.includes("Missing required key: tracks"))).toBe(true);
476
+ });
477
+ });
478
+
479
+ describe("planning-visuals containment warnings", () => {
480
+ it("returns non-fatal containment warnings when rows exceed axis", () => {
481
+ const markdown = `:::gantt
482
+ time-unit: month
483
+ time-start: 2026-04
484
+ time-end: 2026-06
485
+ tracks:
486
+ - label: Wave 1
487
+ depth: 1
488
+ start: 2026-03
489
+ end: 2026-07
490
+ milestones:
491
+ - label: Late marker
492
+ date: 2026-08
493
+ :::`;
494
+ const warnings = collectPlanningVisualsContainmentWarnings(markdown);
495
+ expect(warnings.some((w) => w.message.includes("Track range is outside axis"))).toBe(true);
496
+ expect(warnings.some((w) => w.message.includes("Milestone date is outside axis"))).toBe(true);
497
+ });
498
+
499
+ it("warns when marker date is outside axis range", () => {
500
+ const markdown = `:::gantt
501
+ time-unit: week
502
+ time-start: 2026-W14
503
+ time-end: 2026-W20
504
+ markers:
505
+ - label: Late gate
506
+ date: 2026-W22
507
+ tracks:
508
+ - label: Phase 1
509
+ depth: 1
510
+ start: 2026-W14
511
+ end: 2026-W18
512
+ :::`;
513
+ const warnings = collectPlanningVisualsContainmentWarnings(markdown);
514
+ expect(warnings.some((w) => w.message.includes("Marker date is outside axis"))).toBe(true);
515
+ });
516
+ });
517
+
518
+ describe("planning-visuals marker validation", () => {
519
+ it("accepts valid markers", () => {
520
+ const markdown = `:::gantt
521
+ time-unit: week
522
+ time-start: 2026-W14
523
+ time-end: 2026-W30
524
+ markers:
525
+ - label: Go/No-Go
526
+ date: 2026-W20
527
+ tracks:
528
+ - label: Phase 1
529
+ depth: 1
530
+ start: 2026-W14
531
+ end: 2026-W24
532
+ :::`;
533
+ const diags = validatePlanningVisualsFences(markdown);
534
+ expect(diags).toHaveLength(0);
535
+ });
536
+
537
+ it("rejects marker with mismatched date format", () => {
538
+ const markdown = `:::gantt
539
+ time-unit: month
540
+ time-start: 2026-04
541
+ time-end: 2026-10
542
+ markers:
543
+ - label: Gate
544
+ date: 2026-W20
545
+ tracks:
546
+ - label: Build
547
+ depth: 1
548
+ start: 2026-04
549
+ end: 2026-07
550
+ :::`;
551
+ const diags = validatePlanningVisualsFences(markdown);
552
+ expect(diags.some((d) => d.message.includes("Marker date must match month format"))).toBe(true);
553
+ });
554
+
555
+ it("accepts month markers with day precision", () => {
556
+ const markdown = `:::gantt
557
+ time-unit: month
558
+ time-start: 2026-04
559
+ time-end: 2026-10
560
+ markers:
561
+ - label: Conference
562
+ date: 2026-05-26
563
+ tracks:
564
+ - label: Build
565
+ depth: 1
566
+ start: 2026-04
567
+ end: 2026-07
568
+ :::`;
569
+ const diags = validatePlanningVisualsFences(markdown);
570
+ expect(diags).toHaveLength(0);
571
+ });
572
+ });
573
+
453
574
  describe("segmentSlides", () => {
454
575
  it("uses frontmatter title and class when present", () => {
455
576
  const input = `---
package/src/cli.ts CHANGED
@@ -12,6 +12,9 @@ import { dirname, join, resolve } from "node:path";
12
12
  import { fileURLToPath } from "node:url";
13
13
  import { loadSiteConfig } from "./shared.ts";
14
14
 
15
+ // Exit cleanly when piped output is closed early (e.g., `kitfly docs show x | less` then quit)
16
+ process.on("SIGPIPE", () => process.exit(0));
17
+
15
18
  // Resolve paths relative to CLI location (works in binary too)
16
19
  const __dirname = dirname(fileURLToPath(import.meta.url));
17
20
  const ROOT = join(__dirname, "..");
@@ -76,6 +79,7 @@ Usage:
76
79
  kitfly servers List running dev servers
77
80
  kitfly stop <port|all> Stop dev server(s)
78
81
  kitfly logs <port> View daemon server logs
82
+ kitfly docs [list|show] Browse embedded documentation
79
83
  kitfly version Show version (use 'version extended' for details)
80
84
  kitfly help Show this help
81
85
 
@@ -275,7 +279,7 @@ async function main() {
275
279
 
276
280
  if (daemon) {
277
281
  // Daemon mode: spawn detached process using shell redirection
278
- const { mkdir, writeFile } = await import("node:fs/promises");
282
+ const { mkdir, writeFile, open: fsOpen } = await import("node:fs/promises");
279
283
  const logsDir = join(getKitflyHome(), "logs");
280
284
  await mkdir(logsDir, { recursive: true });
281
285
 
@@ -287,19 +291,53 @@ async function main() {
287
291
 
288
292
  // Build command with shell redirection for logging
289
293
  // Pass --log-format structured so dev.ts enables structured request logging
290
- // Use nohup to prevent SIGHUP on terminal close
291
294
  const profileArg = profile ? ` --profile "${profile}"` : "";
292
- const shellCmd = `nohup bun run "${devScript}" "${folder}" --port ${port} --host "${host}"${profileArg} --no-open --log-format structured > "${logPath}" 2>&1 &`;
293
-
294
- const proc = Bun.spawn(["sh", "-c", shellCmd], {
295
- cwd: process.cwd(),
296
- stdout: "ignore",
297
- stderr: "ignore",
298
- stdin: "ignore",
299
- });
300
295
 
301
- // Wait for shell to spawn the background process
302
- await proc.exited;
296
+ // Open log file as a write handle to pass as stdout/stderr for the child
297
+ const logFd = await fsOpen(logPath, "a");
298
+
299
+ let proc: ReturnType<typeof Bun.spawn>;
300
+ if (process.platform === "win32") {
301
+ // On Windows, use Bun.spawn with detached:true and stdio redirected to log file.
302
+ // nohup and sh -c are not available; Bun's detached mode achieves the same.
303
+ const args = [
304
+ "bun",
305
+ "run",
306
+ devScript,
307
+ folder,
308
+ "--port",
309
+ String(port),
310
+ "--host",
311
+ host,
312
+ ...(profile ? ["--profile", profile] : []),
313
+ "--no-open",
314
+ "--log-format",
315
+ "structured",
316
+ ];
317
+ proc = Bun.spawn(args, {
318
+ cwd: process.cwd(),
319
+ stdout: logFd.fd,
320
+ stderr: logFd.fd,
321
+ stdin: "ignore",
322
+ detached: true,
323
+ });
324
+ proc.unref();
325
+ await logFd.close();
326
+ // Give Windows a moment to spawn the child before proc.exited resolves
327
+ await new Promise((resolve) => setTimeout(resolve, 200));
328
+ } else {
329
+ // Unix: nohup via sh -c keeps the process alive after terminal close
330
+ await logFd.close();
331
+ const shellCmd = `nohup bun run "${devScript}" "${folder}" --port ${port} --host "${host}"${profileArg} --no-open --log-format structured > "${logPath}" 2>&1 &`;
332
+ proc = Bun.spawn(["sh", "-c", shellCmd], {
333
+ cwd: process.cwd(),
334
+ stdout: "ignore",
335
+ stderr: "ignore",
336
+ stdin: "ignore",
337
+ });
338
+ // Wait for shell to spawn the background process
339
+ await proc.exited;
340
+ }
303
341
 
304
342
  // Give server a moment to start
305
343
  await new Promise((resolve) => setTimeout(resolve, 1000));
@@ -538,12 +576,47 @@ async function main() {
538
576
  const follow = flags.follow === true || flags.f === true;
539
577
 
540
578
  if (follow) {
541
- // tail -f equivalent using Bun.spawn
542
- const proc = Bun.spawn(["tail", "-f", logFile], {
543
- stdout: "inherit",
544
- stderr: "inherit",
545
- });
546
- await proc.exited;
579
+ if (process.platform !== "win32") {
580
+ // Unix: tail -f is available and efficient
581
+ const proc = Bun.spawn(["tail", "-f", logFile], {
582
+ stdout: "inherit",
583
+ stderr: "inherit",
584
+ });
585
+ await proc.exited;
586
+ } else {
587
+ // Windows: tail -f is not available; poll the file for new content
588
+ const { watch } = await import("node:fs");
589
+ const { open: fsOpen } = await import("node:fs/promises");
590
+ let fd: import("node:fs/promises").FileHandle;
591
+ try {
592
+ fd = await fsOpen(logFile, "r");
593
+ } catch {
594
+ console.error(`No log file found for port ${logPort}`);
595
+ console.error(` Expected: ${logFile}`);
596
+ process.exit(1);
597
+ }
598
+ // Print existing content first
599
+ const existing = await fd.readFile("utf-8");
600
+ if (existing.length > 0) process.stdout.write(existing);
601
+ let offset = Buffer.byteLength(existing, "utf-8");
602
+ // Watch for changes and stream new bytes
603
+ const watcher = watch(logFile, async () => {
604
+ const buf = Buffer.alloc(65536);
605
+ const { bytesRead } = await fd.read(buf, 0, buf.length, offset);
606
+ if (bytesRead > 0) {
607
+ offset += bytesRead;
608
+ process.stdout.write(buf.subarray(0, bytesRead));
609
+ }
610
+ });
611
+ // Keep running until Ctrl+C
612
+ await new Promise<void>((resolve) => {
613
+ process.on("SIGINT", () => {
614
+ watcher.close();
615
+ void fd.close();
616
+ resolve();
617
+ });
618
+ });
619
+ }
547
620
  } else {
548
621
  const { readFile } = await import("node:fs/promises");
549
622
  try {
@@ -562,6 +635,28 @@ async function main() {
562
635
  break;
563
636
  }
564
637
 
638
+ case "docs": {
639
+ const sub = positional[0];
640
+ const { docsList, docsShow } = await import("./commands/docs.ts");
641
+ if (!sub || sub === "list") {
642
+ docsList();
643
+ } else if (sub === "show") {
644
+ const slug = positional[1];
645
+ if (!slug) {
646
+ console.error("Error: Slug required.\n");
647
+ console.error("Usage: kitfly docs show <slug>");
648
+ console.error(" kitfly docs list");
649
+ process.exit(1);
650
+ }
651
+ docsShow(slug);
652
+ } else {
653
+ console.error(`Unknown docs subcommand: "${sub}"\n`);
654
+ console.error("Usage: kitfly docs [list|show <slug>]");
655
+ process.exit(1);
656
+ }
657
+ break;
658
+ }
659
+
565
660
  case "version":
566
661
  case "-v":
567
662
  case "--version": {
@@ -0,0 +1,71 @@
1
+ /**
2
+ * kitfly docs — browse embedded documentation from the CLI.
3
+ *
4
+ * Subcommands:
5
+ * kitfly docs [list] List available doc slugs with titles
6
+ * kitfly docs show <slug> Output raw markdown to stdout
7
+ */
8
+
9
+ import { EMBEDDED_DOCS } from "../generated/embedded-docs.ts";
10
+
11
+ // Build a Map from the generated tuple array for O(1) lookup
12
+ const docsMap = new Map<string, { title: string; content: string }>();
13
+ for (const [slug, title, content] of EMBEDDED_DOCS) {
14
+ docsMap.set(slug, { title, content });
15
+ }
16
+
17
+ export function docsList(): void {
18
+ if (docsMap.size === 0) {
19
+ console.log("No embedded documentation available.");
20
+ return;
21
+ }
22
+
23
+ const slugs = [...docsMap.keys()].sort();
24
+ const maxSlug = Math.max(...slugs.map((s) => s.length));
25
+
26
+ console.log(`Embedded documentation (${slugs.length} topics):\n`);
27
+
28
+ for (const slug of slugs) {
29
+ const entry = docsMap.get(slug);
30
+ if (entry) console.log(` ${slug.padEnd(maxSlug + 2)}${entry.title}`);
31
+ }
32
+
33
+ console.log(`\nUsage: kitfly docs show <slug>`);
34
+ }
35
+
36
+ export function docsShow(slug: string): void {
37
+ const entry = docsMap.get(slug);
38
+
39
+ if (!entry) {
40
+ const suggestions = findSimilar(slug, [...docsMap.keys()], 3);
41
+ console.error(`Not found: "${slug}"`);
42
+ if (suggestions.length > 0) {
43
+ console.error(`Did you mean: ${suggestions.join(", ")}?`);
44
+ }
45
+ console.error(`\nUse 'kitfly docs list' to see available topics.`);
46
+ process.exit(1);
47
+ }
48
+
49
+ console.log(entry.content);
50
+ }
51
+
52
+ /**
53
+ * Find similar slugs by prefix or substring match.
54
+ * Returns up to `max` results, prioritizing prefix matches.
55
+ */
56
+ export function findSimilar(query: string, slugs: string[], max: number): string[] {
57
+ const q = query.toLowerCase();
58
+ const prefixMatches: string[] = [];
59
+ const substringMatches: string[] = [];
60
+
61
+ for (const slug of slugs) {
62
+ const s = slug.toLowerCase();
63
+ if (s.startsWith(q) || q.startsWith(s)) {
64
+ prefixMatches.push(slug);
65
+ } else if (s.includes(q) || q.includes(s)) {
66
+ substringMatches.push(slug);
67
+ }
68
+ }
69
+
70
+ return [...prefixMatches, ...substringMatches].slice(0, max);
71
+ }