whygraph 0.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.
Files changed (92) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +205 -0
  3. package/dist/cli/commands/config.d.ts +14 -0
  4. package/dist/cli/commands/config.js +123 -0
  5. package/dist/cli/commands/down.d.ts +9 -0
  6. package/dist/cli/commands/down.js +46 -0
  7. package/dist/cli/commands/init.d.ts +17 -0
  8. package/dist/cli/commands/init.js +144 -0
  9. package/dist/cli/commands/issues.d.ts +10 -0
  10. package/dist/cli/commands/issues.js +376 -0
  11. package/dist/cli/commands/mcp.d.ts +2 -0
  12. package/dist/cli/commands/mcp.js +9 -0
  13. package/dist/cli/commands/restart.d.ts +11 -0
  14. package/dist/cli/commands/restart.js +43 -0
  15. package/dist/cli/commands/serve.d.ts +14 -0
  16. package/dist/cli/commands/serve.js +132 -0
  17. package/dist/cli/commands/server-utils.d.ts +6 -0
  18. package/dist/cli/commands/server-utils.js +94 -0
  19. package/dist/cli/commands/status.d.ts +11 -0
  20. package/dist/cli/commands/status.js +97 -0
  21. package/dist/cli/commands/up.d.ts +13 -0
  22. package/dist/cli/commands/up.js +62 -0
  23. package/dist/cli/commands/validate.d.ts +14 -0
  24. package/dist/cli/commands/validate.js +88 -0
  25. package/dist/cli/commands/viz.d.ts +7 -0
  26. package/dist/cli/commands/viz.js +97 -0
  27. package/dist/cli/index.d.ts +2 -0
  28. package/dist/cli/index.js +33 -0
  29. package/dist/entity/id.d.ts +8 -0
  30. package/dist/entity/id.js +48 -0
  31. package/dist/entity/issues.d.ts +12 -0
  32. package/dist/entity/issues.js +68 -0
  33. package/dist/entity/parser.d.ts +6 -0
  34. package/dist/entity/parser.js +166 -0
  35. package/dist/entity/types.d.ts +54 -0
  36. package/dist/entity/types.js +21 -0
  37. package/dist/entity/validate.d.ts +12 -0
  38. package/dist/entity/validate.js +136 -0
  39. package/dist/entity/writer.d.ts +16 -0
  40. package/dist/entity/writer.js +142 -0
  41. package/dist/frontend/assets/index-ByZzPwVe.css +1 -0
  42. package/dist/frontend/assets/index-F9dxfzD_.js +170 -0
  43. package/dist/frontend/index.html +14 -0
  44. package/dist/graph/cascade.d.ts +10 -0
  45. package/dist/graph/cascade.js +49 -0
  46. package/dist/graph/decisions.d.ts +11 -0
  47. package/dist/graph/decisions.js +27 -0
  48. package/dist/graph/gaps.d.ts +10 -0
  49. package/dist/graph/gaps.js +58 -0
  50. package/dist/graph/nodes.d.ts +20 -0
  51. package/dist/graph/nodes.js +33 -0
  52. package/dist/graph/projection.d.ts +6 -0
  53. package/dist/graph/projection.js +44 -0
  54. package/dist/graph/query.d.ts +15 -0
  55. package/dist/graph/query.js +82 -0
  56. package/dist/graph/search.d.ts +2 -0
  57. package/dist/graph/search.js +23 -0
  58. package/dist/graph/supersede.d.ts +7 -0
  59. package/dist/graph/supersede.js +48 -0
  60. package/dist/graph/temporal.d.ts +13 -0
  61. package/dist/graph/temporal.js +28 -0
  62. package/dist/mcp/index.d.ts +2 -0
  63. package/dist/mcp/index.js +10 -0
  64. package/dist/mcp/server.d.ts +3 -0
  65. package/dist/mcp/server.js +340 -0
  66. package/dist/onboarding/interview.d.ts +22 -0
  67. package/dist/onboarding/interview.js +92 -0
  68. package/dist/onboarding/scan.d.ts +17 -0
  69. package/dist/onboarding/scan.js +106 -0
  70. package/dist/platform/rules.d.ts +8 -0
  71. package/dist/platform/rules.js +229 -0
  72. package/dist/server/core.d.ts +26 -0
  73. package/dist/server/core.js +111 -0
  74. package/dist/server/derived.d.ts +8 -0
  75. package/dist/server/derived.js +13 -0
  76. package/dist/server/etag.d.ts +9 -0
  77. package/dist/server/etag.js +25 -0
  78. package/dist/server/http.d.ts +13 -0
  79. package/dist/server/http.js +131 -0
  80. package/dist/server/pubsub.d.ts +12 -0
  81. package/dist/server/pubsub.js +19 -0
  82. package/dist/server/schema.d.ts +2 -0
  83. package/dist/server/schema.js +362 -0
  84. package/dist/server/stale-refs.d.ts +7 -0
  85. package/dist/server/stale-refs.js +23 -0
  86. package/dist/server/watcher.d.ts +21 -0
  87. package/dist/server/watcher.js +98 -0
  88. package/dist/server/worktree-watcher.d.ts +20 -0
  89. package/dist/server/worktree-watcher.js +79 -0
  90. package/dist/server/worktree.d.ts +22 -0
  91. package/dist/server/worktree.js +84 -0
  92. package/package.json +73 -0
@@ -0,0 +1,376 @@
1
+ import { join } from "node:path";
2
+ import { readFileSync, readdirSync, unlinkSync, writeFileSync } from "node:fs";
3
+ import prompts from "prompts";
4
+ import { listIssues, deleteIssue } from "../../entity/issues.js";
5
+ import { parseEntity } from "../../entity/parser.js";
6
+ import { writeEntity } from "../../entity/writer.js";
7
+ import { DECISION_TAGS } from "../../entity/types.js";
8
+ import { isDecisionNode, isStructuralNode } from "../../entity/types.js";
9
+ import { findWhygraphDir, getConfiguredPort, isServerRunning } from "./server-utils.js";
10
+ class UserCancelled extends Error {
11
+ constructor() { super("cancelled"); }
12
+ }
13
+ const onCancel = () => { throw new UserCancelled(); };
14
+ const CLI_RESOLVABLE_FIELDS = new Set(["tags", "parent", "date", "affects", "supersedes"]);
15
+ function isCliResolvable(issue) {
16
+ return issue.errors.length > 0 && issue.errors.every((e) => CLI_RESOLVABLE_FIELDS.has(e.field));
17
+ }
18
+ function loadEntitiesFromDisk(graphDir) {
19
+ const entities = new Map();
20
+ let files;
21
+ try {
22
+ files = readdirSync(graphDir).filter((f) => f.endsWith(".md"));
23
+ /* v8 ignore start */
24
+ }
25
+ catch {
26
+ return entities;
27
+ }
28
+ /* v8 ignore stop */
29
+ for (const file of files) {
30
+ try {
31
+ const content = readFileSync(join(graphDir, file), "utf-8");
32
+ const entity = parseEntity(content);
33
+ /* v8 ignore next 1 */
34
+ if (entity)
35
+ entities.set(entity.id, entity);
36
+ /* v8 ignore start */
37
+ }
38
+ catch { /* skip */ }
39
+ /* v8 ignore stop */
40
+ }
41
+ return entities;
42
+ }
43
+ function generateAgentPrompt(issue, graphDir) {
44
+ const files = readdirSync(graphDir).filter((f) => f.startsWith(issue.entityId));
45
+ const filePath = files.length > 0
46
+ ? `.whygraph/graph/${files[0]}`
47
+ : `.whygraph/graph/${issue.entityId}.md`;
48
+ const missingFields = issue.errors.map((e) => e.field).join(", ");
49
+ return (`Review ${issue.entityId} in ${filePath}. ` +
50
+ `It has validation issues: missing ${missingFields}. ` +
51
+ `Read the existing decision content and the surrounding codebase, ` +
52
+ `then fill in the missing sections.`);
53
+ }
54
+ export function runIssues(targetDir) {
55
+ const projectDir = findWhygraphDir(targetDir);
56
+ if (!projectDir) {
57
+ throw new Error(`.whygraph/ not found. Run "whygraph init" first.`);
58
+ }
59
+ const whygraphDir = join(projectDir, ".whygraph");
60
+ const issues = listIssues(whygraphDir);
61
+ let agentNeeded = 0;
62
+ let cliResolvable = 0;
63
+ for (const issue of issues) {
64
+ if (isCliResolvable(issue)) {
65
+ cliResolvable++;
66
+ }
67
+ else {
68
+ agentNeeded++;
69
+ }
70
+ }
71
+ return { agentNeeded, cliResolvable, total: issues.length, issues };
72
+ }
73
+ async function resolveParentInteractively(entity, entities, graphDir) {
74
+ /* v8 ignore start */
75
+ const features = Array.from(entities.values())
76
+ .filter((e) => isStructuralNode(e) && e.label === "Feature")
77
+ .sort((a, b) => a.name.localeCompare(b.name));
78
+ /* v8 ignore stop */
79
+ if (features.length === 0) {
80
+ process.stdout.write(" No features found in the graph. Cannot assign parent.\n");
81
+ return false;
82
+ }
83
+ // Show context so the developer knows what this node is
84
+ process.stdout.write(`\n ${entity.label}: ${entity.name} (${entity.id})\n`);
85
+ if (entity.description) {
86
+ process.stdout.write(` ${entity.description}\n`);
87
+ }
88
+ if (entity.refs && entity.refs.length > 0) {
89
+ process.stdout.write(` Files: ${entity.refs.map((r) => r.file).join(", ")}\n`);
90
+ }
91
+ process.stdout.write("\n");
92
+ const featureResponse = await prompts({
93
+ type: "select",
94
+ name: "featureId",
95
+ message: `Which feature does "${entity.name}" belong to?`,
96
+ choices: features.map((f) => ({
97
+ title: `${f.name} (${f.id})`,
98
+ value: f.id,
99
+ })),
100
+ }, { onCancel });
101
+ /* v8 ignore next 1 */
102
+ if (!featureResponse.featureId)
103
+ return false;
104
+ const components = Array.from(entities.values())
105
+ .filter((e) => isStructuralNode(e) && e.label === "Component" && e.parent === featureResponse.featureId)
106
+ .sort((a, b) => a.name.localeCompare(b.name));
107
+ if (components.length > 0 && entity.label === "Component") {
108
+ const drillDown = await prompts({
109
+ type: "select",
110
+ name: "parentId",
111
+ message: "Attach to the feature, or nest under a component?",
112
+ choices: [
113
+ { title: `Attach to ${entities.get(featureResponse.featureId).name}`, value: featureResponse.featureId },
114
+ ...components.map((c) => ({
115
+ title: `Under ${c.name} (${c.id})`,
116
+ value: c.id,
117
+ })),
118
+ ],
119
+ }, { onCancel });
120
+ /* v8 ignore next 1 */
121
+ if (!drillDown.parentId)
122
+ return false;
123
+ entity.parent = drillDown.parentId;
124
+ }
125
+ else {
126
+ entity.parent = featureResponse.featureId;
127
+ }
128
+ entity.updated_at = new Date().toISOString();
129
+ writeEntity(graphDir, entity);
130
+ process.stdout.write(` Updated ${entity.id}: parent set to ${entity.parent}\n\n`);
131
+ return true;
132
+ }
133
+ function printDecisionContext(entity) {
134
+ process.stdout.write(`\n Decision: ${entity.title} (${entity.id})\n`);
135
+ /* v8 ignore next 3 */
136
+ if (entity.context) {
137
+ const preview = entity.context.length > 120 ? entity.context.slice(0, 120) + "..." : entity.context;
138
+ process.stdout.write(` Context: ${preview}\n`);
139
+ }
140
+ if (entity.affects.length > 0) {
141
+ process.stdout.write(` Affects: ${entity.affects.join(", ")}\n`);
142
+ }
143
+ process.stdout.write("\n");
144
+ }
145
+ async function resolveDateInteractively(entity, graphDir) {
146
+ printDecisionContext(entity);
147
+ const response = await prompts({
148
+ type: "text",
149
+ name: "date",
150
+ message: `Invalid date "${entity.date}". Enter corrected date (YYYY-MM-DD):`,
151
+ /* v8 ignore start */
152
+ validate: (v) => /^\d{4}-\d{2}-\d{2}$/.test(v) || "Must be YYYY-MM-DD",
153
+ /* v8 ignore stop */
154
+ }, { onCancel });
155
+ /* v8 ignore start */
156
+ if (!response.date)
157
+ return false;
158
+ entity.date = response.date;
159
+ entity.updated_at = new Date().toISOString();
160
+ writeEntity(graphDir, entity);
161
+ process.stdout.write(` Updated ${entity.id}: date set to ${entity.date}\n\n`);
162
+ return true;
163
+ /* v8 ignore stop */
164
+ }
165
+ async function resolveTagInteractively(entity, badTag, graphDir) {
166
+ printDecisionContext(entity);
167
+ const response = await prompts({
168
+ type: "select",
169
+ name: "tag",
170
+ message: `Invalid tag "${badTag}". Pick a replacement:`,
171
+ choices: DECISION_TAGS.map((t) => ({ title: t, value: t })),
172
+ }, { onCancel });
173
+ /* v8 ignore start */
174
+ if (!response.tag)
175
+ return false;
176
+ entity.tags = entity.tags.map((t) => (t === badTag ? response.tag : t));
177
+ entity.updated_at = new Date().toISOString();
178
+ writeEntity(graphDir, entity);
179
+ process.stdout.write(` Updated ${entity.id}: tag "${badTag}" replaced with "${response.tag}"\n\n`);
180
+ return true;
181
+ /* v8 ignore stop */
182
+ }
183
+ async function resolveAffectsInteractively(entity, invalidRefs, entities, graphDir) {
184
+ printDecisionContext(entity);
185
+ const validChoices = Array.from(entities.values()).filter((e) => !isDecisionNode(e))
186
+ .sort((a, b) => a.name.localeCompare(b.name))
187
+ .map((e) => ({ title: `${e.name} (${e.id})`, value: e.id }));
188
+ let anyResolved = false;
189
+ for (const ref of invalidRefs) {
190
+ const isLastRef = entity.affects.length === 1;
191
+ const choices = isLastRef
192
+ ? [{ title: "Remove ref and delete node", value: "delete" }, { title: "Replace it", value: "replace" }]
193
+ : [{ title: "Remove it", value: "remove" }, { title: "Replace it", value: "replace" }];
194
+ const response = await prompts({
195
+ type: "select",
196
+ name: "action",
197
+ message: `Invalid affects ref "${ref}". What do you want to do?`,
198
+ choices,
199
+ }, { onCancel });
200
+ if (response.action === "delete") {
201
+ const files = readdirSync(graphDir).filter((f) => f.startsWith(entity.id));
202
+ /* v8 ignore next 1 */
203
+ if (files.length > 0)
204
+ unlinkSync(join(graphDir, files[0]));
205
+ const whygraphDir = join(graphDir, "..");
206
+ deleteIssue(whygraphDir, entity.id);
207
+ process.stdout.write(` Deleted ${entity.id}\n\n`);
208
+ return true;
209
+ /* v8 ignore start */
210
+ }
211
+ else if (response.action === "remove") {
212
+ entity.affects = entity.affects.filter((a) => a !== ref);
213
+ anyResolved = true;
214
+ }
215
+ else if (response.action === "replace") {
216
+ const replacement = await prompts({
217
+ type: "select",
218
+ name: "id",
219
+ message: `Replace "${ref}" with:`,
220
+ choices: validChoices,
221
+ }, { onCancel });
222
+ if (replacement.id) {
223
+ entity.affects = entity.affects.map((a) => (a === ref ? replacement.id : a));
224
+ anyResolved = true;
225
+ }
226
+ }
227
+ /* v8 ignore stop */
228
+ }
229
+ /* v8 ignore next 4 */
230
+ if (anyResolved) {
231
+ entity.updated_at = new Date().toISOString();
232
+ writeEntity(graphDir, entity);
233
+ process.stdout.write(` Updated ${entity.id}: affects refs resolved\n\n`);
234
+ }
235
+ return anyResolved;
236
+ }
237
+ async function resolveIssueInteractively(issue, entities, graphDir) {
238
+ const entity = entities.get(issue.entityId);
239
+ if (!entity) {
240
+ const whygraphDir = join(graphDir, "..");
241
+ deleteIssue(whygraphDir, issue.entityId);
242
+ process.stdout.write(` Entity ${issue.entityId} no longer exists — issue cleared.\n\n`);
243
+ return true;
244
+ }
245
+ let resolved = false;
246
+ for (const err of issue.errors) {
247
+ if (err.field === "parent" && isStructuralNode(entity)) {
248
+ if (await resolveParentInteractively(entity, entities, graphDir))
249
+ resolved = true;
250
+ }
251
+ else if (err.field === "date" && isDecisionNode(entity)) {
252
+ /* v8 ignore next 1 */
253
+ if (await resolveDateInteractively(entity, graphDir))
254
+ resolved = true;
255
+ }
256
+ else if (err.field === "tags" && isDecisionNode(entity)) {
257
+ const match = err.message.match(/invalid tag "([^"]+)"/);
258
+ /* v8 ignore next 3 */
259
+ if (match) {
260
+ if (await resolveTagInteractively(entity, match[1], graphDir))
261
+ resolved = true;
262
+ }
263
+ /* v8 ignore start */
264
+ }
265
+ else if (err.field === "affects" && isDecisionNode(entity)) {
266
+ const invalidRefs = issue.errors
267
+ .filter((e) => e.field === "affects")
268
+ .map((e) => e.message.match(/affects ref "([^"]+)"/)?.[1])
269
+ .filter((r) => r !== undefined);
270
+ if (invalidRefs.length > 0) {
271
+ if (await resolveAffectsInteractively(entity, invalidRefs, entities, graphDir))
272
+ resolved = true;
273
+ }
274
+ break; // all affects errors handled in one pass
275
+ }
276
+ /* v8 ignore stop */
277
+ }
278
+ return resolved;
279
+ }
280
+ export function registerIssuesCommand(program) {
281
+ program
282
+ .command("issues")
283
+ .description("List and resolve entity validation issues")
284
+ .option("--json", "Output results as JSON")
285
+ .action(async (opts) => {
286
+ try {
287
+ const result = runIssues(process.cwd());
288
+ if (opts.json) {
289
+ process.stdout.write(JSON.stringify(result, null, 2) + "\n");
290
+ return;
291
+ }
292
+ if (result.total === 0) {
293
+ process.stdout.write("No issues found.\n");
294
+ return;
295
+ }
296
+ const parts = [];
297
+ if (result.agentNeeded > 0)
298
+ parts.push(`${result.agentNeeded} need an agent`);
299
+ if (result.cliResolvable > 0)
300
+ parts.push(`${result.cliResolvable} resolvable now`);
301
+ process.stdout.write(`${result.total} issue(s): ${parts.join(", ")}\n\n`);
302
+ const projectDir = findWhygraphDir(process.cwd());
303
+ const graphDir = join(projectDir, ".whygraph", "graph");
304
+ // Agent-needed issues: inline if < 3, MD file if >= 3
305
+ const agentIssues = result.issues.filter((i) => !isCliResolvable(i));
306
+ if (agentIssues.length > 0) {
307
+ if (agentIssues.length < 3) {
308
+ for (const issue of agentIssues) {
309
+ process.stdout.write(`[AGENT] ${generateAgentPrompt(issue, graphDir)}\n\n`);
310
+ }
311
+ }
312
+ else {
313
+ // Group by missing fields to avoid repeating instructions
314
+ const byFields = new Map();
315
+ for (const issue of agentIssues) {
316
+ const files = readdirSync(graphDir).filter((f) => f.startsWith(issue.entityId));
317
+ /* v8 ignore next 1 */
318
+ const filePath = files.length > 0 ? `.whygraph/graph/${files[0]}` : `.whygraph/graph/${issue.entityId}.md`;
319
+ const key = issue.errors.map((e) => e.field).sort().join(", ");
320
+ if (!byFields.has(key))
321
+ byFields.set(key, []);
322
+ byFields.get(key).push(filePath);
323
+ }
324
+ const mdLines = [
325
+ `For each decision file below, read its existing content and write meaningful content for the missing sections.`,
326
+ `Base your additions on the context, decision, and tradeoffs already written.`,
327
+ `Do not write placeholder text like "None considered." or "N/A."`,
328
+ `The file watcher will automatically clear each issue once the missing sections are present.`,
329
+ "",
330
+ ];
331
+ for (const [fields, files] of byFields) {
332
+ mdLines.push(`Missing: ${fields}`);
333
+ for (const f of files) {
334
+ mdLines.push(`- ${f}`);
335
+ }
336
+ mdLines.push("");
337
+ }
338
+ const mdPath = join(projectDir, ".whygraph", "AGENT_ISSUES.md");
339
+ writeFileSync(mdPath, mdLines.join("\n"), "utf-8");
340
+ process.stdout.write(`${agentIssues.length} agent issues written to .whygraph/AGENT_ISSUES.md\n` +
341
+ `Prompt: "Read .whygraph/AGENT_ISSUES.md and resolve each issue."\n\n`);
342
+ }
343
+ }
344
+ // Walk through CLI-resolvable issues interactively
345
+ const cliIssues = result.issues.filter((i) => isCliResolvable(i));
346
+ if (cliIssues.length > 0) {
347
+ const port = getConfiguredPort(projectDir);
348
+ if (!isServerRunning(port)) {
349
+ process.stdout.write(`${cliIssues.length} resolvable issue(s) found.\n` +
350
+ `Run 'whygraph up' first so resolved issues are properly reconciled.\n`);
351
+ return;
352
+ }
353
+ process.stdout.write("--- Resolvable issues ---\n\n");
354
+ const entities = loadEntitiesFromDisk(graphDir);
355
+ for (const issue of cliIssues) {
356
+ await resolveIssueInteractively(issue, entities, graphDir);
357
+ }
358
+ }
359
+ }
360
+ catch (err) {
361
+ if (err instanceof UserCancelled) {
362
+ process.stdout.write("\nCancelled.\n");
363
+ return;
364
+ }
365
+ /* v8 ignore next 1 */
366
+ const message = err instanceof Error ? err.message : String(err);
367
+ if (opts.json) {
368
+ process.stdout.write(JSON.stringify({ error: message }, null, 2) + "\n");
369
+ }
370
+ else {
371
+ process.stderr.write(`Error: ${message}\n`);
372
+ }
373
+ process.exitCode = 1;
374
+ }
375
+ });
376
+ }
@@ -0,0 +1,2 @@
1
+ import type { Command } from "commander";
2
+ export declare function registerMcpCommand(program: Command): void;
@@ -0,0 +1,9 @@
1
+ import { startMcpServer } from "../../mcp/server.js";
2
+ export function registerMcpCommand(program) {
3
+ program
4
+ .command("mcp")
5
+ .description("Start the MCP stdio server")
6
+ .action(/* v8 ignore next 3 */ async () => {
7
+ await startMcpServer();
8
+ });
9
+ }
@@ -0,0 +1,11 @@
1
+ import type { Command } from "commander";
2
+ export declare function runRestart(targetDir: string, options?: {
3
+ port?: number;
4
+ json?: boolean;
5
+ }): Promise<{
6
+ stopped: boolean;
7
+ url: string;
8
+ port: number;
9
+ pid: number;
10
+ }>;
11
+ export declare function registerRestartCommand(program: Command): void;
@@ -0,0 +1,43 @@
1
+ import { runDown } from "./down.js";
2
+ import { runUp } from "./up.js";
3
+ export async function runRestart(targetDir, options = {}) {
4
+ const downResult = await runDown(targetDir, options);
5
+ const upResult = await runUp(targetDir, options);
6
+ return {
7
+ stopped: downResult.stopped,
8
+ ...upResult,
9
+ };
10
+ }
11
+ export function registerRestartCommand(program) {
12
+ program
13
+ .command("restart")
14
+ .description("Stop and restart the whygraph server")
15
+ .option("--port <number>", "Port number")
16
+ .option("--json", "Output results as JSON")
17
+ .action(async (opts) => {
18
+ try {
19
+ const port = opts.port ? parseInt(opts.port, 10) : undefined;
20
+ const result = await runRestart(process.cwd(), { port, json: opts.json });
21
+ if (opts.json) {
22
+ process.stdout.write(JSON.stringify(result) + "\n");
23
+ }
24
+ else {
25
+ if (result.stopped) {
26
+ process.stdout.write(`Stopped previous server\n`);
27
+ }
28
+ process.stdout.write(`whygraph server running at ${result.url} (pid ${result.pid})\n`);
29
+ }
30
+ }
31
+ catch (err) {
32
+ /* v8 ignore next 1 */
33
+ const message = err instanceof Error ? err.message : String(err);
34
+ if (opts.json) {
35
+ process.stdout.write(JSON.stringify({ error: message }) + "\n");
36
+ }
37
+ else {
38
+ process.stderr.write(`Error: ${message}\n`);
39
+ }
40
+ process.exitCode = 1;
41
+ }
42
+ });
43
+ }
@@ -0,0 +1,14 @@
1
+ import type { Command } from "commander";
2
+ export interface ServeOptions {
3
+ port?: number;
4
+ json?: boolean;
5
+ }
6
+ export interface ServeResult {
7
+ url: string;
8
+ entities: number;
9
+ port: number;
10
+ stop: () => Promise<void>;
11
+ }
12
+ export declare function findWhygraphDir(startDir: string): string | null;
13
+ export declare function runServe(targetDir: string, options?: ServeOptions): Promise<ServeResult>;
14
+ export declare function registerServeCommand(program: Command): void;
@@ -0,0 +1,132 @@
1
+ import * as fs from "node:fs/promises";
2
+ import { existsSync } from "node:fs";
3
+ import { join, resolve } from "node:path";
4
+ import { ServerCore } from "../../server/core.js";
5
+ import { FileWatcher } from "../../server/watcher.js";
6
+ import { WorktreeWatcher } from "../../server/worktree-watcher.js";
7
+ import { createHttpServer } from "../../server/http.js";
8
+ import { parseEntity } from "../../entity/parser.js";
9
+ // ============================================================
10
+ // Find .whygraph directory
11
+ // ============================================================
12
+ export function findWhygraphDir(startDir) {
13
+ let current = resolve(startDir);
14
+ const root = resolve("/");
15
+ while (current !== root) {
16
+ const candidate = join(current, ".whygraph");
17
+ if (existsSync(candidate)) {
18
+ return current;
19
+ }
20
+ const parent = resolve(current, "..");
21
+ /* v8 ignore next 2 */
22
+ if (parent === current)
23
+ break;
24
+ current = parent;
25
+ }
26
+ // Check root too
27
+ /* v8 ignore next 3 */
28
+ if (existsSync(join(root, ".whygraph"))) {
29
+ return root;
30
+ }
31
+ return null;
32
+ }
33
+ // ============================================================
34
+ // Core Logic
35
+ // ============================================================
36
+ export async function runServe(targetDir, options = {}) {
37
+ const projectDir = findWhygraphDir(targetDir);
38
+ if (!projectDir) {
39
+ throw new Error(`.whygraph/ not found. Run "whygraph init" first.`);
40
+ }
41
+ const whygraphDir = join(projectDir, ".whygraph");
42
+ /* v8 ignore next 1 */
43
+ const port = options.port ?? 4777;
44
+ // Create and load ServerCore
45
+ const core = new ServerCore(whygraphDir);
46
+ await core.load();
47
+ // Start file watcher on main graph dir
48
+ const graphDir = join(whygraphDir, "graph");
49
+ const mainWatcher = new FileWatcher(graphDir);
50
+ mainWatcher.start((events) => {
51
+ for (const event of events) {
52
+ if (event.type === "deleted" && event.entityId) {
53
+ core.removeEntity(event.entityId);
54
+ }
55
+ else {
56
+ /* v8 ignore next 1 */
57
+ if (event.entityId) {
58
+ fs.readFile(event.filePath, "utf-8")
59
+ .then((content) => {
60
+ const entity = parseEntity(content);
61
+ /* v8 ignore next 1 */
62
+ if (entity) {
63
+ core.addOrUpdateEntity(entity.id, entity);
64
+ }
65
+ })
66
+ /* v8 ignore start */
67
+ .catch(() => { });
68
+ /* v8 ignore stop */
69
+ }
70
+ }
71
+ }
72
+ });
73
+ // Start worktree watcher
74
+ const worktreeWatcher = new WorktreeWatcher(core, projectDir);
75
+ await worktreeWatcher.startWatching();
76
+ // Start HTTP server
77
+ const httpServer = createHttpServer(core, port);
78
+ await httpServer.start();
79
+ const url = `http://localhost:${port}`;
80
+ // Set up graceful shutdown
81
+ const shutdown = async () => {
82
+ await httpServer.stop();
83
+ await worktreeWatcher.stop();
84
+ await mainWatcher.stop();
85
+ };
86
+ /* v8 ignore next 3 */
87
+ process.on("SIGINT", () => { shutdown().then(() => process.exit(0)); });
88
+ /* v8 ignore next 3 */
89
+ process.on("SIGTERM", () => { shutdown().then(() => process.exit(0)); });
90
+ return {
91
+ url,
92
+ entities: core.getAllEntities().length,
93
+ port,
94
+ stop: shutdown,
95
+ };
96
+ }
97
+ // ============================================================
98
+ // CLI Wiring
99
+ // ============================================================
100
+ export function registerServeCommand(program) {
101
+ program
102
+ .command("serve")
103
+ .description("Start the whygraph server")
104
+ .option("--port <number>", "Port number", "4777")
105
+ .option("--json", "Output results as JSON")
106
+ .action(async (opts) => {
107
+ try {
108
+ /* v8 ignore next 1 */
109
+ const port = opts.port ? parseInt(opts.port, 10) : undefined;
110
+ const result = await runServe(process.cwd(), { port, json: opts.json });
111
+ if (opts.json) {
112
+ process.stdout.write(JSON.stringify(result, null, 2) + "\n");
113
+ }
114
+ else {
115
+ process.stdout.write(`whygraph server running at ${result.url}\n` +
116
+ ` Entities loaded: ${result.entities}\n` +
117
+ ` Press Ctrl+C to stop\n`);
118
+ }
119
+ }
120
+ catch (err) {
121
+ /* v8 ignore next 1 */
122
+ const message = err instanceof Error ? err.message : String(err);
123
+ if (opts.json) {
124
+ process.stdout.write(JSON.stringify({ error: message }, null, 2) + "\n");
125
+ }
126
+ else {
127
+ process.stderr.write(`Error: ${message}\n`);
128
+ }
129
+ process.exitCode = 1;
130
+ }
131
+ });
132
+ }
@@ -0,0 +1,6 @@
1
+ export declare function findWhygraphDir(startDir: string): string | null;
2
+ export declare function getConfiguredPort(projectDir: string): number;
3
+ export declare function killProcessOnPort(port: number): boolean;
4
+ export declare function waitForPortFree(port: number, timeoutMs?: number): Promise<void>;
5
+ export declare function waitForServerReady(port: number, timeoutMs?: number): Promise<void>;
6
+ export declare function isServerRunning(port: number): boolean;