@cliangdev/flux-plugin 0.2.0-dev.2b9c207 → 0.2.0-dev.359209a

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,296 @@
1
+ /**
2
+ * Flux Dashboard Server
3
+ *
4
+ * HTTP + WebSocket server for the Flux Dashboard.
5
+ * Serves static files and API endpoints for viewing PRD/Epic/Task data.
6
+ */
7
+
8
+ import { getAdapter } from "../server/adapters/index.js";
9
+ import type { Epic, Prd, Task } from "../server/adapters/types.js";
10
+ import { initDb } from "../server/db/index.js";
11
+ import { openBrowser } from "./browser.js";
12
+ import { startWatchers, stopWatchers } from "./watchers.js";
13
+
14
+ const DEFAULT_PORT = 3333;
15
+ const MAX_PORT_ATTEMPTS = 10;
16
+
17
+ interface DashboardServer {
18
+ server: ReturnType<typeof Bun.serve>;
19
+ port: number;
20
+ stop: () => void;
21
+ }
22
+
23
+ function getPort(): number {
24
+ const envPort = process.env.FLUX_DASHBOARD_PORT;
25
+ if (envPort) {
26
+ const parsed = Number.parseInt(envPort, 10);
27
+ if (!Number.isNaN(parsed) && parsed > 0 && parsed < 65536) {
28
+ return parsed;
29
+ }
30
+ }
31
+ return DEFAULT_PORT;
32
+ }
33
+
34
+ async function findAvailablePort(startPort: number): Promise<number> {
35
+ for (let port = startPort; port < startPort + MAX_PORT_ATTEMPTS; port++) {
36
+ try {
37
+ const testServer = Bun.serve({
38
+ port,
39
+ fetch: () => new Response("test"),
40
+ });
41
+ testServer.stop();
42
+ return port;
43
+ } catch {}
44
+ }
45
+ throw new Error(
46
+ `No available port found between ${startPort} and ${startPort + MAX_PORT_ATTEMPTS - 1}`,
47
+ );
48
+ }
49
+
50
+ function getContentType(path: string): string {
51
+ if (path.endsWith(".html")) return "text/html";
52
+ if (path.endsWith(".css")) return "text/css";
53
+ if (path.endsWith(".js")) return "application/javascript";
54
+ if (path.endsWith(".json")) return "application/json";
55
+ if (path.endsWith(".svg")) return "image/svg+xml";
56
+ return "application/octet-stream";
57
+ }
58
+
59
+ export async function startDashboard(): Promise<DashboardServer> {
60
+ const preferredPort = getPort();
61
+ const port = await findAvailablePort(preferredPort);
62
+
63
+ const publicDir = new URL("./public/", import.meta.url).pathname;
64
+
65
+ const server = Bun.serve({
66
+ port,
67
+
68
+ async fetch(req, server) {
69
+ const url = new URL(req.url);
70
+ const path = url.pathname;
71
+
72
+ // WebSocket upgrade
73
+ if (path === "/ws") {
74
+ const upgraded = server.upgrade(req);
75
+ return upgraded
76
+ ? undefined
77
+ : new Response("WebSocket upgrade failed", { status: 400 });
78
+ }
79
+
80
+ // API endpoints
81
+ if (path.startsWith("/api/")) {
82
+ return handleApiRequest(path, url);
83
+ }
84
+
85
+ // Static files
86
+ const filePath = path === "/" ? "/index.html" : path;
87
+ const fullPath = `${publicDir}${filePath}`;
88
+
89
+ try {
90
+ const file = Bun.file(fullPath);
91
+ if (await file.exists()) {
92
+ return new Response(file, {
93
+ headers: { "Content-Type": getContentType(fullPath) },
94
+ });
95
+ }
96
+ } catch {}
97
+
98
+ return new Response("Not found", { status: 404 });
99
+ },
100
+
101
+ websocket: {
102
+ open(ws) {
103
+ ws.subscribe("updates");
104
+ },
105
+ message(_ws, _message) {
106
+ // Client messages not needed for read-only dashboard
107
+ },
108
+ close(ws) {
109
+ ws.unsubscribe("updates");
110
+ },
111
+ },
112
+ });
113
+
114
+ // Start file watchers
115
+ startWatchers(server);
116
+
117
+ // Graceful shutdown
118
+ const cleanup = () => {
119
+ console.log("\nShutting down dashboard...");
120
+ stopWatchers();
121
+ server.stop();
122
+ process.exit(0);
123
+ };
124
+
125
+ process.on("SIGINT", cleanup);
126
+ process.on("SIGTERM", cleanup);
127
+
128
+ return {
129
+ server,
130
+ port,
131
+ stop: () => {
132
+ stopWatchers();
133
+ server.stop();
134
+ },
135
+ };
136
+ }
137
+
138
+ interface TreePrd extends Prd {
139
+ epics: TreeEpic[];
140
+ }
141
+
142
+ interface TreeEpic extends Epic {
143
+ tasks: Task[];
144
+ }
145
+
146
+ async function handleApiRequest(path: string, _url: URL): Promise<Response> {
147
+ try {
148
+ const adapter = getAdapter();
149
+
150
+ // GET /api/tree
151
+ if (path === "/api/tree") {
152
+ const prds = await adapter.listPrds({}, { limit: 100, offset: 0 });
153
+ const tree: TreePrd[] = await Promise.all(
154
+ prds.items.map(async (prd) => {
155
+ const epics = await adapter.listEpics(
156
+ { prdRef: prd.ref },
157
+ { limit: 100, offset: 0 },
158
+ );
159
+ const epicsWithTasks: TreeEpic[] = await Promise.all(
160
+ epics.items.map(async (epic) => {
161
+ const tasks = await adapter.listTasks(
162
+ { epicRef: epic.ref },
163
+ { limit: 100, offset: 0 },
164
+ );
165
+ return { ...epic, tasks: tasks.items };
166
+ }),
167
+ );
168
+ return { ...prd, epics: epicsWithTasks };
169
+ }),
170
+ );
171
+ return Response.json(tree);
172
+ }
173
+
174
+ // GET /api/prd/:ref
175
+ if (path.startsWith("/api/prd/")) {
176
+ const ref = path.slice("/api/prd/".length);
177
+ const prd = await adapter.getPrd(ref);
178
+ if (!prd) {
179
+ return Response.json({ error: "PRD not found" }, { status: 404 });
180
+ }
181
+
182
+ let markdown = prd.description || "";
183
+ if (prd.folderPath) {
184
+ try {
185
+ const prdPath = `${prd.folderPath}/prd.md`;
186
+ const file = Bun.file(prdPath);
187
+ if (await file.exists()) {
188
+ markdown = await file.text();
189
+ }
190
+ } catch {}
191
+ }
192
+
193
+ return Response.json({ ...prd, markdown });
194
+ }
195
+
196
+ // GET /api/epic/:ref
197
+ if (path.startsWith("/api/epic/")) {
198
+ const ref = path.slice("/api/epic/".length);
199
+ const epic = await adapter.getEpic(ref);
200
+ if (!epic) {
201
+ return Response.json({ error: "Epic not found" }, { status: 404 });
202
+ }
203
+ const criteria = await adapter.getCriteria(ref);
204
+ return Response.json({ ...epic, criteria });
205
+ }
206
+
207
+ // GET /api/task/:ref
208
+ if (path.startsWith("/api/task/")) {
209
+ const ref = path.slice("/api/task/".length);
210
+ const task = await adapter.getTask(ref);
211
+ if (!task) {
212
+ return Response.json({ error: "Task not found" }, { status: 404 });
213
+ }
214
+ const criteria = await adapter.getCriteria(ref);
215
+ const deps = await adapter.getDependencies(ref);
216
+ return Response.json({ ...task, criteria, dependencies: deps });
217
+ }
218
+
219
+ // GET /api/tags
220
+ if (path === "/api/tags") {
221
+ const prds = await adapter.listPrds({}, { limit: 1000, offset: 0 });
222
+ const tagCounts = new Map<string, number>();
223
+ let total = 0;
224
+
225
+ for (const prd of prds.items) {
226
+ total++;
227
+ if (prd.tag) {
228
+ tagCounts.set(prd.tag, (tagCounts.get(prd.tag) || 0) + 1);
229
+ }
230
+ }
231
+
232
+ const tags = [
233
+ { tag: "All", count: total },
234
+ ...Array.from(tagCounts.entries())
235
+ .map(([tag, count]) => ({ tag, count }))
236
+ .sort((a, b) => a.tag.localeCompare(b.tag)),
237
+ ];
238
+
239
+ return Response.json(tags);
240
+ }
241
+
242
+ // GET /api/dependencies
243
+ if (path === "/api/dependencies") {
244
+ const edges: Array<{ from: string; to: string; type: string }> = [];
245
+
246
+ const prds = await adapter.listPrds({}, { limit: 1000, offset: 0 });
247
+ for (const prd of prds.items) {
248
+ const deps = await adapter.getDependencies(prd.ref);
249
+ for (const depRef of deps) {
250
+ edges.push({ from: prd.ref, to: depRef, type: "prd" });
251
+ }
252
+ }
253
+
254
+ const epics = await adapter.listEpics({}, { limit: 1000, offset: 0 });
255
+ for (const epic of epics.items) {
256
+ const deps = await adapter.getDependencies(epic.ref);
257
+ for (const depRef of deps) {
258
+ edges.push({ from: epic.ref, to: depRef, type: "epic" });
259
+ }
260
+ }
261
+
262
+ const tasks = await adapter.listTasks({}, { limit: 1000, offset: 0 });
263
+ for (const task of tasks.items) {
264
+ const deps = await adapter.getDependencies(task.ref);
265
+ for (const depRef of deps) {
266
+ edges.push({ from: task.ref, to: depRef, type: "task" });
267
+ }
268
+ }
269
+
270
+ return Response.json({ edges });
271
+ }
272
+
273
+ return Response.json({ error: "Unknown endpoint" }, { status: 404 });
274
+ } catch (error) {
275
+ console.error("API error:", error);
276
+ return Response.json(
277
+ { error: error instanceof Error ? error.message : "Internal error" },
278
+ { status: 500 },
279
+ );
280
+ }
281
+ }
282
+
283
+ // Main entry point
284
+ if (import.meta.main) {
285
+ try {
286
+ // Initialize local database (no-op if using external adapter like Linear)
287
+ initDb();
288
+ const { port } = await startDashboard();
289
+ const url = `http://localhost:${port}`;
290
+ console.log(`\n Flux Dashboard running at ${url}\n`);
291
+ await openBrowser(url);
292
+ } catch (error) {
293
+ console.error("Failed to start dashboard:", error);
294
+ process.exit(1);
295
+ }
296
+ }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * File Watchers for Real-time Updates
3
+ *
4
+ * Watches .flux/flux.db and .flux/prds/ for changes and broadcasts
5
+ * updates to connected WebSocket clients.
6
+ */
7
+
8
+ import { type FSWatcher, watch } from "node:fs";
9
+ import { resolve } from "node:path";
10
+
11
+ let dbWatcher: FSWatcher | null = null;
12
+ let prdsWatcher: FSWatcher | null = null;
13
+ let debounceTimer: Timer | null = null;
14
+
15
+ const DEBOUNCE_MS = 500;
16
+
17
+ export function startWatchers(server: ReturnType<typeof Bun.serve>): void {
18
+ const projectRoot = process.env.FLUX_PROJECT_ROOT || process.cwd();
19
+ const fluxDir = resolve(projectRoot, ".flux");
20
+ const dbPath = resolve(fluxDir, "flux.db");
21
+ const prdsDir = resolve(fluxDir, "prds");
22
+
23
+ const broadcast = (type: "db" | "file", path?: string) => {
24
+ if (debounceTimer) {
25
+ clearTimeout(debounceTimer);
26
+ }
27
+
28
+ debounceTimer = setTimeout(() => {
29
+ server.publish(
30
+ "updates",
31
+ JSON.stringify({
32
+ type: "update",
33
+ source: type,
34
+ path,
35
+ timestamp: Date.now(),
36
+ }),
37
+ );
38
+ }, DEBOUNCE_MS);
39
+ };
40
+
41
+ // Watch database file
42
+ try {
43
+ dbWatcher = watch(dbPath, (eventType) => {
44
+ if (eventType === "change") {
45
+ broadcast("db");
46
+ }
47
+ });
48
+ } catch (error) {
49
+ console.warn("Could not watch database file:", error);
50
+ }
51
+
52
+ // Watch PRDs directory recursively
53
+ try {
54
+ prdsWatcher = watch(
55
+ prdsDir,
56
+ { recursive: true },
57
+ (_eventType, filename) => {
58
+ if (filename?.endsWith(".md")) {
59
+ broadcast("file", filename);
60
+ }
61
+ },
62
+ );
63
+ } catch (error) {
64
+ console.warn("Could not watch PRDs directory:", error);
65
+ }
66
+ }
67
+
68
+ export function stopWatchers(): void {
69
+ if (debounceTimer) {
70
+ clearTimeout(debounceTimer);
71
+ debounceTimer = null;
72
+ }
73
+
74
+ if (dbWatcher) {
75
+ dbWatcher.close();
76
+ dbWatcher = null;
77
+ }
78
+
79
+ if (prdsWatcher) {
80
+ prdsWatcher.close();
81
+ prdsWatcher = null;
82
+ }
83
+ }
@@ -12,7 +12,7 @@ import {
12
12
  rmSync,
13
13
  writeFileSync,
14
14
  } from "node:fs";
15
- import { join } from "node:path";
15
+ import { isAbsolute, join } from "node:path";
16
16
  import { config } from "../config.js";
17
17
  import {
18
18
  count,
@@ -108,6 +108,12 @@ interface ProjectRow {
108
108
  // =============================================================================
109
109
 
110
110
  function toPrd(row: PrdRow): Prd {
111
+ // Normalize folderPath to absolute path for consistency
112
+ let folderPath = row.folder_path ?? undefined;
113
+ if (folderPath && !isAbsolute(folderPath)) {
114
+ folderPath = join(config.projectRoot, folderPath);
115
+ }
116
+
111
117
  return {
112
118
  id: row.id,
113
119
  projectId: row.project_id,
@@ -116,7 +122,7 @@ function toPrd(row: PrdRow): Prd {
116
122
  description: row.description ?? undefined,
117
123
  status: row.status as Prd["status"],
118
124
  tag: row.tag ?? undefined,
119
- folderPath: row.folder_path ?? undefined,
125
+ folderPath,
120
126
  createdAt: row.created_at,
121
127
  updatedAt: row.updated_at,
122
128
  };
@@ -3,7 +3,7 @@ import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
 
5
5
  // Set up test environment BEFORE any imports
6
- const TEST_DIR = `/tmp/flux-test-db-${Date.now()}`;
6
+ const TEST_DIR = `/tmp/flux-test-db-${Date.now()}-${Math.random().toString(36).slice(2)}`;
7
7
  const FLUX_DIR = join(TEST_DIR, ".flux");
8
8
  process.env.FLUX_PROJECT_ROOT = TEST_DIR;
9
9
 
@@ -28,6 +28,7 @@ describe("Database Queries", () => {
28
28
  let projectId: string;
29
29
 
30
30
  beforeEach(() => {
31
+ closeDb();
31
32
  config.clearCache();
32
33
  process.env.FLUX_PROJECT_ROOT = TEST_DIR;
33
34
 
@@ -3,7 +3,7 @@ import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
 
5
5
  // Set up test environment BEFORE any imports
6
- const TEST_DIR = `/tmp/flux-test-${Date.now()}`;
6
+ const TEST_DIR = `/tmp/flux-test-${Date.now()}-${Math.random().toString(36).slice(2)}`;
7
7
  const FLUX_DIR = join(TEST_DIR, ".flux");
8
8
  process.env.FLUX_PROJECT_ROOT = TEST_DIR;
9
9
 
@@ -22,6 +22,7 @@ import { updateStatusTool } from "../update-status.js";
22
22
 
23
23
  describe("CRUD MCP Tools", () => {
24
24
  beforeEach(() => {
25
+ closeDb();
25
26
  config.clearCache();
26
27
  clearAdapterCache();
27
28
  process.env.FLUX_PROJECT_ROOT = TEST_DIR;
@@ -3,7 +3,7 @@ import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
 
5
5
  // Set up test environment BEFORE any imports
6
- const TEST_DIR = `/tmp/flux-test-mcp-${Date.now()}`;
6
+ const TEST_DIR = `/tmp/flux-test-mcp-${Date.now()}-${Math.random().toString(36).slice(2)}`;
7
7
  const FLUX_DIR = join(TEST_DIR, ".flux");
8
8
  process.env.FLUX_PROJECT_ROOT = TEST_DIR;
9
9
 
@@ -19,6 +19,7 @@ import {
19
19
 
20
20
  describe("MCP Interface", () => {
21
21
  beforeEach(() => {
22
+ closeDb();
22
23
  config.clearCache();
23
24
  process.env.FLUX_PROJECT_ROOT = TEST_DIR;
24
25
 
@@ -3,7 +3,7 @@ import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
 
5
5
  // Set up test environment BEFORE any imports
6
- const TEST_DIR = `/tmp/flux-test-query-${Date.now()}`;
6
+ const TEST_DIR = `/tmp/flux-test-query-${Date.now()}-${Math.random().toString(36).slice(2)}`;
7
7
  const FLUX_DIR = join(TEST_DIR, ".flux");
8
8
  process.env.FLUX_PROJECT_ROOT = TEST_DIR;
9
9
 
@@ -22,6 +22,7 @@ import { queryEntitiesTool } from "../query-entities.js";
22
22
 
23
23
  describe("Query MCP Tools", () => {
24
24
  beforeEach(() => {
25
+ closeDb();
25
26
  config.clearCache();
26
27
  clearAdapterCache();
27
28
  process.env.FLUX_PROJECT_ROOT = TEST_DIR;
@@ -341,9 +342,10 @@ describe("Query MCP Tools", () => {
341
342
  });
342
343
 
343
344
  describe("init_project", () => {
344
- const INIT_TEST_DIR = `/tmp/flux-init-test-${Date.now()}`;
345
+ const INIT_TEST_DIR = `/tmp/flux-init-test-${Date.now()}-${Math.random().toString(36).slice(2)}`;
345
346
 
346
347
  beforeEach(() => {
348
+ closeDb();
347
349
  config.clearCache();
348
350
  clearAdapterCache();
349
351
  process.env.FLUX_PROJECT_ROOT = INIT_TEST_DIR;
@@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
2
2
  import { existsSync, mkdirSync, readFileSync, rmSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
 
5
- const TEST_DIR = `/tmp/flux-test-${Date.now()}`;
5
+ const TEST_DIR = `/tmp/flux-test-${Date.now()}-${Math.random().toString(36).slice(2)}`;
6
6
  const FLUX_DIR = join(TEST_DIR, ".flux");
7
7
  process.env.FLUX_PROJECT_ROOT = TEST_DIR;
8
8
 
@@ -3,7 +3,7 @@ import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
 
5
5
  // Set up test environment BEFORE any imports
6
- const TEST_DIR = `/tmp/flux-test-${Date.now()}`;
6
+ const TEST_DIR = `/tmp/flux-test-${Date.now()}-${Math.random().toString(36).slice(2)}`;
7
7
  const FLUX_DIR = join(TEST_DIR, ".flux");
8
8
  process.env.FLUX_PROJECT_ROOT = TEST_DIR;
9
9
 
@@ -31,7 +31,7 @@ function setupTestProject(testDir: string, projectName = "test-project") {
31
31
  }
32
32
 
33
33
  describe("status-line script", () => {
34
- const TEST_DIR = `/tmp/flux-status-line-test-${Date.now()}`;
34
+ const TEST_DIR = `/tmp/flux-status-line-test-${Date.now()}-${Math.random().toString(36).slice(2)}`;
35
35
 
36
36
  beforeEach(() => {
37
37
  if (existsSync(TEST_DIR)) {