@cliangdev/flux-plugin 0.2.0 → 0.3.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 (108) hide show
  1. package/README.md +11 -7
  2. package/agents/coder.md +150 -25
  3. package/bin/install.cjs +171 -16
  4. package/commands/breakdown.md +47 -10
  5. package/commands/dashboard.md +29 -0
  6. package/commands/flux.md +92 -12
  7. package/commands/implement.md +166 -17
  8. package/commands/linear.md +6 -5
  9. package/commands/prd.md +996 -82
  10. package/manifest.json +2 -1
  11. package/package.json +9 -11
  12. package/skills/flux-orchestrator/SKILL.md +11 -3
  13. package/skills/prd-writer/SKILL.md +761 -0
  14. package/skills/ux-ui-design/SKILL.md +346 -0
  15. package/skills/ux-ui-design/references/design-tokens.md +359 -0
  16. package/src/__tests__/version.test.ts +37 -0
  17. package/src/adapters/local/.gitkeep +0 -0
  18. package/src/dashboard/__tests__/api.test.ts +211 -0
  19. package/src/dashboard/browser.ts +35 -0
  20. package/src/dashboard/public/app.js +869 -0
  21. package/src/dashboard/public/index.html +90 -0
  22. package/src/dashboard/public/styles.css +807 -0
  23. package/src/dashboard/public/vendor/highlight.css +10 -0
  24. package/src/dashboard/public/vendor/highlight.min.js +8422 -0
  25. package/src/dashboard/public/vendor/marked.min.js +2210 -0
  26. package/src/dashboard/server.ts +296 -0
  27. package/src/dashboard/watchers.ts +83 -0
  28. package/src/server/__tests__/config.test.ts +163 -0
  29. package/src/server/adapters/__tests__/a-client-linear.test.ts +197 -0
  30. package/src/server/adapters/__tests__/adapter-factory.test.ts +230 -0
  31. package/src/server/adapters/__tests__/dependency-ops.test.ts +429 -0
  32. package/src/server/adapters/__tests__/document-ops.test.ts +306 -0
  33. package/src/server/adapters/__tests__/linear-adapter.test.ts +91 -0
  34. package/src/server/adapters/__tests__/linear-config.test.ts +425 -0
  35. package/src/server/adapters/__tests__/linear-criteria-parser.test.ts +287 -0
  36. package/src/server/adapters/__tests__/linear-description-test.ts +238 -0
  37. package/src/server/adapters/__tests__/linear-epic-crud.test.ts +496 -0
  38. package/src/server/adapters/__tests__/linear-mappers-description.test.ts +276 -0
  39. package/src/server/adapters/__tests__/linear-mappers-epic.test.ts +294 -0
  40. package/src/server/adapters/__tests__/linear-mappers-prd.test.ts +300 -0
  41. package/src/server/adapters/__tests__/linear-mappers-task.test.ts +197 -0
  42. package/src/server/adapters/__tests__/linear-prd-crud.test.ts +620 -0
  43. package/src/server/adapters/__tests__/linear-stats.test.ts +450 -0
  44. package/src/server/adapters/__tests__/linear-task-crud.test.ts +534 -0
  45. package/src/server/adapters/__tests__/linear-types.test.ts +243 -0
  46. package/src/server/adapters/__tests__/status-ops.test.ts +441 -0
  47. package/src/server/adapters/factory.ts +90 -0
  48. package/src/server/adapters/index.ts +9 -0
  49. package/src/server/adapters/linear/adapter.ts +1141 -0
  50. package/src/server/adapters/linear/client.ts +169 -0
  51. package/src/server/adapters/linear/config.ts +152 -0
  52. package/src/server/adapters/linear/helpers/criteria-parser.ts +197 -0
  53. package/src/server/adapters/linear/helpers/index.ts +7 -0
  54. package/src/server/adapters/linear/index.ts +16 -0
  55. package/src/server/adapters/linear/mappers/description.ts +136 -0
  56. package/src/server/adapters/linear/mappers/epic.ts +81 -0
  57. package/src/server/adapters/linear/mappers/index.ts +27 -0
  58. package/src/server/adapters/linear/mappers/prd.ts +178 -0
  59. package/src/server/adapters/linear/mappers/task.ts +82 -0
  60. package/src/server/adapters/linear/types.ts +264 -0
  61. package/src/server/adapters/local-adapter.ts +1009 -0
  62. package/src/server/adapters/types.ts +293 -0
  63. package/src/server/config.ts +73 -0
  64. package/src/server/db/__tests__/queries.test.ts +473 -0
  65. package/src/server/db/ids.ts +17 -0
  66. package/src/server/db/index.ts +69 -0
  67. package/src/server/db/queries.ts +142 -0
  68. package/src/server/db/refs.ts +60 -0
  69. package/src/server/db/schema.ts +97 -0
  70. package/src/server/db/sqlite.ts +10 -0
  71. package/src/server/index.ts +81 -0
  72. package/src/server/tools/__tests__/crud.test.ts +411 -0
  73. package/src/server/tools/__tests__/get-version.test.ts +27 -0
  74. package/src/server/tools/__tests__/mcp-interface.test.ts +479 -0
  75. package/src/server/tools/__tests__/query.test.ts +405 -0
  76. package/src/server/tools/__tests__/z-configure-linear.test.ts +511 -0
  77. package/src/server/tools/__tests__/z-get-linear-url.test.ts +108 -0
  78. package/src/server/tools/configure-linear.ts +373 -0
  79. package/src/server/tools/create-epic.ts +44 -0
  80. package/src/server/tools/create-prd.ts +40 -0
  81. package/src/server/tools/create-task.ts +47 -0
  82. package/src/server/tools/criteria.ts +50 -0
  83. package/src/server/tools/delete-entity.ts +76 -0
  84. package/src/server/tools/dependencies.ts +55 -0
  85. package/src/server/tools/get-entity.ts +240 -0
  86. package/src/server/tools/get-linear-url.ts +28 -0
  87. package/src/server/tools/get-stats.ts +52 -0
  88. package/src/server/tools/get-version.ts +20 -0
  89. package/src/server/tools/index.ts +158 -0
  90. package/src/server/tools/init-project.ts +108 -0
  91. package/src/server/tools/query-entities.ts +167 -0
  92. package/src/server/tools/render-status.ts +219 -0
  93. package/src/server/tools/update-entity.ts +140 -0
  94. package/src/server/tools/update-status.ts +166 -0
  95. package/src/server/utils/__tests__/mcp-response.test.ts +331 -0
  96. package/src/server/utils/logger.ts +9 -0
  97. package/src/server/utils/mcp-response.ts +254 -0
  98. package/src/server/utils/status-transitions.ts +160 -0
  99. package/src/status-line/__tests__/status-line.test.ts +215 -0
  100. package/src/status-line/index.ts +147 -0
  101. package/src/utils/__tests__/chalk-import.test.ts +32 -0
  102. package/src/utils/__tests__/display.test.ts +97 -0
  103. package/src/utils/__tests__/status-renderer.test.ts +310 -0
  104. package/src/utils/display.ts +62 -0
  105. package/src/utils/status-renderer.ts +214 -0
  106. package/src/version.ts +5 -0
  107. package/dist/server/index.js +0 -87063
  108. package/skills/prd-template/SKILL.md +0 -242
@@ -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
+ }
@@ -0,0 +1,163 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import {
3
+ existsSync,
4
+ mkdirSync,
5
+ realpathSync,
6
+ rmSync,
7
+ writeFileSync,
8
+ } from "node:fs";
9
+ import { tmpdir } from "node:os";
10
+
11
+ describe("config", () => {
12
+ const originalEnv = process.env.FLUX_PROJECT_ROOT;
13
+ const originalCwd = process.cwd();
14
+ // Use os.tmpdir() to get the real temp path (handles /tmp -> /private/tmp on macOS)
15
+ const TEST_DIR = `${realpathSync(tmpdir())}/flux-config-test-${Date.now()}`;
16
+ const NESTED_DIR = `${TEST_DIR}/subdir/nested`;
17
+
18
+ beforeEach(async () => {
19
+ // Clean up any previous test directory
20
+ if (existsSync(TEST_DIR)) {
21
+ rmSync(TEST_DIR, { recursive: true });
22
+ }
23
+
24
+ // Create test directory structure
25
+ mkdirSync(NESTED_DIR, { recursive: true });
26
+
27
+ // Clear the config cache before each test
28
+ const { config } = await import("../config.js");
29
+ config.clearCache();
30
+ });
31
+
32
+ afterEach(async () => {
33
+ // Restore original env
34
+ if (originalEnv !== undefined) {
35
+ process.env.FLUX_PROJECT_ROOT = originalEnv;
36
+ } else {
37
+ delete process.env.FLUX_PROJECT_ROOT;
38
+ }
39
+
40
+ // Restore original cwd
41
+ process.chdir(originalCwd);
42
+
43
+ // Clear the config cache
44
+ const { config } = await import("../config.js");
45
+ config.clearCache();
46
+
47
+ // Clean up test directory
48
+ if (existsSync(TEST_DIR)) {
49
+ rmSync(TEST_DIR, { recursive: true });
50
+ }
51
+ });
52
+
53
+ test("uses env var when properly set", async () => {
54
+ process.env.FLUX_PROJECT_ROOT = "/some/valid/path";
55
+
56
+ const { config } = await import("../config.js");
57
+ config.clearCache();
58
+
59
+ expect(config.projectRoot).toBe("/some/valid/path");
60
+ });
61
+
62
+ test("ignores unresolved template variable and walks up directories", async () => {
63
+ // Create a .flux folder at TEST_DIR
64
+ mkdirSync(`${TEST_DIR}/.flux`, { recursive: true });
65
+ writeFileSync(
66
+ `${TEST_DIR}/.flux/project.json`,
67
+ JSON.stringify({ name: "test" }),
68
+ );
69
+
70
+ // Simulate Claude Code passing unresolved template variable
71
+ // biome-ignore lint/suspicious/noTemplateCurlyInString: intentionally testing literal template string
72
+ process.env.FLUX_PROJECT_ROOT = "${CLAUDE_PROJECT_DIR}";
73
+
74
+ // Change to nested directory
75
+ process.chdir(NESTED_DIR);
76
+
77
+ const { config } = await import("../config.js");
78
+ config.clearCache();
79
+
80
+ // Should walk up and find TEST_DIR, not use the literal "${CLAUDE_PROJECT_DIR}"
81
+ expect(config.projectRoot).not.toContain("${");
82
+ expect(config.projectRoot).toBe(TEST_DIR);
83
+ });
84
+
85
+ test("walks up directories to find .flux folder", async () => {
86
+ // Create a .flux folder at TEST_DIR (parent of NESTED_DIR)
87
+ mkdirSync(`${TEST_DIR}/.flux`, { recursive: true });
88
+ writeFileSync(
89
+ `${TEST_DIR}/.flux/project.json`,
90
+ JSON.stringify({ name: "test" }),
91
+ );
92
+
93
+ // No env var set
94
+ delete process.env.FLUX_PROJECT_ROOT;
95
+
96
+ // Change to nested directory
97
+ process.chdir(NESTED_DIR);
98
+
99
+ const { config } = await import("../config.js");
100
+ config.clearCache();
101
+
102
+ // Should walk up and find TEST_DIR
103
+ expect(config.projectRoot).toBe(TEST_DIR);
104
+ });
105
+
106
+ test("falls back to cwd when no .flux folder found", async () => {
107
+ // No .flux folder anywhere in TEST_DIR hierarchy
108
+ // No env var set
109
+ delete process.env.FLUX_PROJECT_ROOT;
110
+
111
+ // Change to test directory (which has no .flux)
112
+ process.chdir(TEST_DIR);
113
+
114
+ const { config } = await import("../config.js");
115
+ config.clearCache();
116
+
117
+ // Should fall back to cwd
118
+ expect(config.projectRoot).toBe(TEST_DIR);
119
+ });
120
+
121
+ test("projectExists returns true when project.json exists", async () => {
122
+ mkdirSync(`${TEST_DIR}/.flux`, { recursive: true });
123
+ writeFileSync(
124
+ `${TEST_DIR}/.flux/project.json`,
125
+ JSON.stringify({ name: "test" }),
126
+ );
127
+
128
+ process.env.FLUX_PROJECT_ROOT = TEST_DIR;
129
+
130
+ const { config } = await import("../config.js");
131
+ config.clearCache();
132
+
133
+ expect(config.projectExists).toBe(true);
134
+ });
135
+
136
+ test("projectExists returns false when project.json does not exist", async () => {
137
+ // No .flux folder
138
+ process.env.FLUX_PROJECT_ROOT = TEST_DIR;
139
+
140
+ const { config } = await import("../config.js");
141
+ config.clearCache();
142
+
143
+ expect(config.projectExists).toBe(false);
144
+ });
145
+
146
+ test("caches project root for performance", async () => {
147
+ process.env.FLUX_PROJECT_ROOT = "/first/path";
148
+
149
+ const { config } = await import("../config.js");
150
+ config.clearCache();
151
+
152
+ // First call caches the value
153
+ expect(config.projectRoot).toBe("/first/path");
154
+
155
+ // Changing env var should not affect cached value
156
+ process.env.FLUX_PROJECT_ROOT = "/second/path";
157
+ expect(config.projectRoot).toBe("/first/path");
158
+
159
+ // After clearing cache, should use new env var
160
+ config.clearCache();
161
+ expect(config.projectRoot).toBe("/second/path");
162
+ });
163
+ });