kitfly 0.1.2

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 (62) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/LICENSE +21 -0
  3. package/README.md +136 -0
  4. package/VERSION +1 -0
  5. package/package.json +63 -0
  6. package/schemas/README.md +32 -0
  7. package/schemas/site.schema.json +5 -0
  8. package/schemas/theme.schema.json +5 -0
  9. package/schemas/v0/site.schema.json +172 -0
  10. package/schemas/v0/theme.schema.json +210 -0
  11. package/scripts/build-all.ts +121 -0
  12. package/scripts/build.ts +601 -0
  13. package/scripts/bundle.ts +781 -0
  14. package/scripts/dev.ts +777 -0
  15. package/scripts/generate-checksums.sh +78 -0
  16. package/scripts/release/export-release-key.sh +28 -0
  17. package/scripts/release/release-guard-tag-version.sh +79 -0
  18. package/scripts/release/sign-release-assets.sh +123 -0
  19. package/scripts/release/upload-release-assets.sh +76 -0
  20. package/scripts/release/upload-release-provenance.sh +52 -0
  21. package/scripts/release/verify-public-key.sh +48 -0
  22. package/scripts/release/verify-signatures.sh +117 -0
  23. package/scripts/version-sync.ts +82 -0
  24. package/src/__tests__/build.test.ts +240 -0
  25. package/src/__tests__/bundle.test.ts +786 -0
  26. package/src/__tests__/cli.test.ts +706 -0
  27. package/src/__tests__/crucible.test.ts +1043 -0
  28. package/src/__tests__/engine.test.ts +157 -0
  29. package/src/__tests__/init.test.ts +450 -0
  30. package/src/__tests__/pipeline.test.ts +1087 -0
  31. package/src/__tests__/productbook.test.ts +1206 -0
  32. package/src/__tests__/runbook.test.ts +974 -0
  33. package/src/__tests__/server-registry.test.ts +1251 -0
  34. package/src/__tests__/servicebook.test.ts +1248 -0
  35. package/src/__tests__/shared.test.ts +2005 -0
  36. package/src/__tests__/styles.test.ts +14 -0
  37. package/src/__tests__/theme-schema.test.ts +47 -0
  38. package/src/__tests__/theme.test.ts +554 -0
  39. package/src/cli.ts +582 -0
  40. package/src/commands/init.ts +92 -0
  41. package/src/commands/update.ts +444 -0
  42. package/src/engine.ts +20 -0
  43. package/src/logger.ts +15 -0
  44. package/src/migrations/0000_schema_versioning.ts +67 -0
  45. package/src/migrations/0001_server_port.ts +52 -0
  46. package/src/migrations/0002_brand_logo.ts +49 -0
  47. package/src/migrations/index.ts +26 -0
  48. package/src/migrations/schema.ts +24 -0
  49. package/src/server-registry.ts +405 -0
  50. package/src/shared.ts +1239 -0
  51. package/src/site/styles.css +931 -0
  52. package/src/site/template.html +193 -0
  53. package/src/templates/crucible.ts +1163 -0
  54. package/src/templates/driver.ts +876 -0
  55. package/src/templates/handbook.ts +339 -0
  56. package/src/templates/minimal.ts +139 -0
  57. package/src/templates/pipeline.ts +966 -0
  58. package/src/templates/productbook.ts +1032 -0
  59. package/src/templates/runbook.ts +829 -0
  60. package/src/templates/schema.ts +119 -0
  61. package/src/templates/servicebook.ts +1242 -0
  62. package/src/theme.ts +245 -0
@@ -0,0 +1,405 @@
1
+ /**
2
+ * Server Registry - Track running kitfly dev servers
3
+ *
4
+ * Registry location: ~/.kitfly/servers.json
5
+ *
6
+ * Used by:
7
+ * - kitfly dev --daemon (register new server)
8
+ * - kitfly servers (list running)
9
+ * - kitfly stop (terminate server)
10
+ */
11
+
12
+ import { mkdir, readdir, readFile, unlink, writeFile } from "node:fs/promises";
13
+ import { homedir } from "node:os";
14
+ import { join } from "node:path";
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // Types
18
+ // ---------------------------------------------------------------------------
19
+
20
+ export interface ServerEntry {
21
+ pid: number;
22
+ port: number;
23
+ host: string;
24
+ contentRoot: string;
25
+ startTime: number;
26
+ kitflyVersion: string;
27
+ daemonized: boolean;
28
+ }
29
+
30
+ interface ServerRegistry {
31
+ version: 1;
32
+ servers: ServerEntry[];
33
+ }
34
+
35
+ // ---------------------------------------------------------------------------
36
+ // Paths
37
+ // ---------------------------------------------------------------------------
38
+
39
+ const KITFLY_HOME = join(homedir(), ".kitfly");
40
+ const REGISTRY_PATH = join(KITFLY_HOME, "servers.json");
41
+ const LOGS_DIR = join(KITFLY_HOME, "logs");
42
+
43
+ export function getKitflyHome(): string {
44
+ return KITFLY_HOME;
45
+ }
46
+
47
+ export function getLogsDir(): string {
48
+ return LOGS_DIR;
49
+ }
50
+
51
+ export function getLogPath(port: number): string {
52
+ return join(LOGS_DIR, `${port}.log`);
53
+ }
54
+
55
+ // ---------------------------------------------------------------------------
56
+ // Registry I/O
57
+ // ---------------------------------------------------------------------------
58
+
59
+ async function ensureKitflyHome(): Promise<void> {
60
+ await mkdir(KITFLY_HOME, { recursive: true });
61
+ await mkdir(LOGS_DIR, { recursive: true });
62
+ }
63
+
64
+ async function readRegistry(): Promise<ServerRegistry> {
65
+ try {
66
+ const content = await readFile(REGISTRY_PATH, "utf-8");
67
+ return JSON.parse(content) as ServerRegistry;
68
+ } catch {
69
+ return { version: 1, servers: [] };
70
+ }
71
+ }
72
+
73
+ async function writeRegistry(registry: ServerRegistry): Promise<void> {
74
+ await ensureKitflyHome();
75
+ await writeFile(REGISTRY_PATH, JSON.stringify(registry, null, 2));
76
+ }
77
+
78
+ // ---------------------------------------------------------------------------
79
+ // Process utilities (via sysprims)
80
+ // ---------------------------------------------------------------------------
81
+
82
+ import { listeningPorts, processList, procGet } from "@3leaps/sysprims";
83
+
84
+ /**
85
+ * Find PID listening on a port.
86
+ * When multiple processes bind the same port (e.g., nohup shell + bun child),
87
+ * prefer the bun process over shell wrappers.
88
+ */
89
+ export function findPidOnPort(port: number): number | null {
90
+ const result = listeningPorts({ local_port: port });
91
+ if (result.bindings.length === 0) return null;
92
+ if (result.bindings.length === 1) return result.bindings[0].pid ?? null;
93
+
94
+ // Multiple bindings — prefer the bun process over shell wrapper
95
+ for (const binding of result.bindings) {
96
+ if (binding.process?.name.includes("bun")) {
97
+ return binding.pid ?? null;
98
+ }
99
+ }
100
+ // Fallback: last binding (child is usually listed after parent)
101
+ return result.bindings[result.bindings.length - 1].pid ?? null;
102
+ }
103
+
104
+ /**
105
+ * Get process info
106
+ */
107
+ function getProcessInfo(pid: number): { name: string } | null {
108
+ const proc = procGet(pid);
109
+ return proc ? { name: proc.name } : null;
110
+ }
111
+
112
+ // ---------------------------------------------------------------------------
113
+ // Process validation
114
+ // ---------------------------------------------------------------------------
115
+
116
+ async function isProcessAlive(entry: ServerEntry): Promise<boolean> {
117
+ // Never signal pid <= 0 — POSIX kill(0) signals the process group,
118
+ // kill(-1) signals every process owned by the user
119
+ if (entry.pid <= 0) return false;
120
+ try {
121
+ process.kill(entry.pid, 0);
122
+ return true;
123
+ } catch {
124
+ return false;
125
+ }
126
+ }
127
+
128
+ async function isPortBoundByProcess(port: number, pid: number): Promise<boolean> {
129
+ const actualPid = await findPidOnPort(port);
130
+ if (actualPid === null) return false;
131
+ return actualPid === pid;
132
+ }
133
+
134
+ // ---------------------------------------------------------------------------
135
+ // Registry operations
136
+ // ---------------------------------------------------------------------------
137
+
138
+ /**
139
+ * Clean stale entries from registry
140
+ */
141
+ export async function cleanRegistry(): Promise<ServerEntry[]> {
142
+ const registry = await readRegistry();
143
+ const alive: ServerEntry[] = [];
144
+ const removed: ServerEntry[] = [];
145
+
146
+ for (const entry of registry.servers) {
147
+ const processAlive = await isProcessAlive(entry);
148
+ const portBound = processAlive && (await isPortBoundByProcess(entry.port, entry.pid));
149
+
150
+ if (processAlive && portBound) {
151
+ alive.push(entry);
152
+ } else {
153
+ removed.push(entry);
154
+ }
155
+ }
156
+
157
+ if (removed.length > 0) {
158
+ await writeRegistry({ version: 1, servers: alive });
159
+ }
160
+
161
+ return alive;
162
+ }
163
+
164
+ /**
165
+ * Get all registered servers (after cleaning stale entries)
166
+ */
167
+ export async function listServers(): Promise<ServerEntry[]> {
168
+ return cleanRegistry();
169
+ }
170
+
171
+ /**
172
+ * Find server by port
173
+ */
174
+ export async function findServerByPort(port: number): Promise<ServerEntry | null> {
175
+ const servers = await listServers();
176
+ return servers.find((s) => s.port === port) ?? null;
177
+ }
178
+
179
+ /**
180
+ * Find server by content root
181
+ */
182
+ export async function findServerByContentRoot(contentRoot: string): Promise<ServerEntry | null> {
183
+ const servers = await listServers();
184
+ return servers.find((s) => s.contentRoot === contentRoot) ?? null;
185
+ }
186
+
187
+ /**
188
+ * Register a new server
189
+ */
190
+ export async function registerServer(entry: ServerEntry): Promise<void> {
191
+ const servers = await listServers();
192
+ // Remove any existing entry for same port (shouldn't happen, but be safe)
193
+ const filtered = servers.filter((s) => s.port !== entry.port);
194
+ filtered.push(entry);
195
+ await writeRegistry({ version: 1, servers: filtered });
196
+ }
197
+
198
+ /**
199
+ * Unregister a server by port
200
+ */
201
+ export async function unregisterServer(port: number): Promise<boolean> {
202
+ const registry = await readRegistry();
203
+ const before = registry.servers.length;
204
+ registry.servers = registry.servers.filter((s) => s.port !== port);
205
+ if (registry.servers.length < before) {
206
+ await writeRegistry(registry);
207
+ return true;
208
+ }
209
+ return false;
210
+ }
211
+
212
+ // ---------------------------------------------------------------------------
213
+ // Port conflict detection
214
+ // ---------------------------------------------------------------------------
215
+
216
+ export interface PortConflict {
217
+ type: "kitfly" | "other";
218
+ port: number;
219
+ pid: number;
220
+ processName?: string;
221
+ contentRoot?: string;
222
+ }
223
+
224
+ /**
225
+ * Check if a port is available or identify what's using it
226
+ */
227
+ export async function checkPortConflict(
228
+ port: number,
229
+ contentRoot: string,
230
+ ): Promise<PortConflict | null> {
231
+ // First check our registry
232
+ const existingServer = await findServerByPort(port);
233
+ if (existingServer) {
234
+ // Same content root = can reuse
235
+ if (existingServer.contentRoot === contentRoot) {
236
+ return null; // No conflict - same server
237
+ }
238
+ return {
239
+ type: "kitfly",
240
+ port,
241
+ pid: existingServer.pid,
242
+ contentRoot: existingServer.contentRoot,
243
+ };
244
+ }
245
+
246
+ // Check if port is in use by another process using lsof
247
+ const pid = await findPidOnPort(port);
248
+ if (pid !== null) {
249
+ const proc = await getProcessInfo(pid);
250
+ return {
251
+ type: "other",
252
+ port,
253
+ pid,
254
+ processName: proc?.name,
255
+ };
256
+ }
257
+
258
+ return null;
259
+ }
260
+
261
+ // ---------------------------------------------------------------------------
262
+ // Server lifecycle
263
+ // ---------------------------------------------------------------------------
264
+
265
+ /**
266
+ * Stop a server by port
267
+ */
268
+ export async function stopServer(
269
+ port: number,
270
+ force = false,
271
+ ): Promise<{ success: boolean; message: string }> {
272
+ const server = await findServerByPort(port);
273
+ if (!server) {
274
+ return { success: false, message: `No server running on port ${port}` };
275
+ }
276
+
277
+ // Never signal pid <= 0 — remove the bad entry instead
278
+ if (server.pid <= 0) {
279
+ await unregisterServer(port);
280
+ return {
281
+ success: false,
282
+ message: `Removed invalid registry entry for port ${port} (pid: ${server.pid})`,
283
+ };
284
+ }
285
+
286
+ try {
287
+ // Send signal
288
+ process.kill(server.pid, force ? "SIGKILL" : "SIGTERM");
289
+
290
+ // If graceful, wait a bit then force if still running
291
+ if (!force) {
292
+ await new Promise((resolve) => setTimeout(resolve, 1000));
293
+ try {
294
+ process.kill(server.pid, 0); // Check if still alive
295
+ // Still alive - wait more
296
+ await new Promise((resolve) => setTimeout(resolve, 2000));
297
+ try {
298
+ process.kill(server.pid, 0);
299
+ // Still alive after 3s - force kill
300
+ process.kill(server.pid, "SIGKILL");
301
+ } catch {
302
+ // Dead now
303
+ }
304
+ } catch {
305
+ // Dead
306
+ }
307
+ }
308
+ } catch (err) {
309
+ // ESRCH means process doesn't exist - that's fine
310
+ if ((err as NodeJS.ErrnoException).code !== "ESRCH") {
311
+ return {
312
+ success: false,
313
+ message: `Failed to stop server (PID ${server.pid}): ${err}`,
314
+ };
315
+ }
316
+ }
317
+
318
+ await unregisterServer(port);
319
+ return { success: true, message: `Stopped server on port ${port}` };
320
+ }
321
+
322
+ /**
323
+ * Discover kitfly dev processes not tracked in the registry.
324
+ * Finds bun processes whose cmdline includes "scripts/dev.ts".
325
+ */
326
+ export async function discoverOrphans(): Promise<Array<{ pid: number; cmd: string }>> {
327
+ const snapshot = processList({ name_contains: "bun" });
328
+ const registered = await listServers();
329
+ const registeredPids = new Set(registered.map((s) => s.pid));
330
+
331
+ return snapshot.processes
332
+ .filter((p) => {
333
+ if (registeredPids.has(p.pid)) return false;
334
+ return p.cmdline.some((arg) => arg.includes("scripts/dev.ts"));
335
+ })
336
+ .map((p) => ({ pid: p.pid, cmd: p.cmdline.join(" ") }));
337
+ }
338
+
339
+ /**
340
+ * Stop all servers, then sweep for orphaned kitfly processes
341
+ */
342
+ export async function stopAllServers(
343
+ force = false,
344
+ ): Promise<{ stopped: number; failed: number; orphans: number }> {
345
+ const servers = await listServers();
346
+ let stopped = 0;
347
+ let failed = 0;
348
+
349
+ for (const server of servers) {
350
+ const result = await stopServer(server.port, force);
351
+ if (result.success) {
352
+ stopped++;
353
+ } else {
354
+ failed++;
355
+ }
356
+ }
357
+
358
+ // Sweep for orphaned kitfly processes not in the registry
359
+ let orphans = 0;
360
+ const orphanList = await discoverOrphans();
361
+ for (const orphan of orphanList) {
362
+ try {
363
+ process.kill(orphan.pid, force ? "SIGKILL" : "SIGTERM");
364
+ orphans++;
365
+ } catch {
366
+ // Already dead or permission denied
367
+ }
368
+ }
369
+
370
+ return { stopped, failed, orphans };
371
+ }
372
+
373
+ // ---------------------------------------------------------------------------
374
+ // Log cleanup
375
+ // ---------------------------------------------------------------------------
376
+
377
+ /**
378
+ * Remove log files for ports not in the server registry.
379
+ * Returns list of removed log file paths.
380
+ */
381
+ export async function cleanLogs(): Promise<string[]> {
382
+ const servers = await listServers();
383
+ const activePorts = new Set(servers.map((s) => s.port));
384
+ const removed: string[] = [];
385
+
386
+ let entries: string[];
387
+ try {
388
+ entries = await readdir(LOGS_DIR);
389
+ } catch {
390
+ return removed; // No logs dir — nothing to clean
391
+ }
392
+
393
+ for (const entry of entries) {
394
+ const match = entry.match(/^(\d+)\.log$/);
395
+ if (!match) continue;
396
+ const port = parseInt(match[1], 10);
397
+ if (!activePorts.has(port)) {
398
+ const logPath = join(LOGS_DIR, entry);
399
+ await unlink(logPath).catch(() => {});
400
+ removed.push(logPath);
401
+ }
402
+ }
403
+
404
+ return removed;
405
+ }