@programinglive/dev-workflow-mcp-server 1.1.7 → 1.1.11

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.
package/web/server.js ADDED
@@ -0,0 +1,57 @@
1
+ import { createServer } from "http";
2
+ import { readFile } from "fs/promises";
3
+ import { fileURLToPath } from "url";
4
+ import { dirname, join } from "path";
5
+ import getPort from "get-port";
6
+ import { getHistoryForUser, getSummaryForUser, getHistorySummary } from "../db/index.js";
7
+
8
+ const __filename = fileURLToPath(import.meta.url);
9
+ const __dirname = dirname(__filename);
10
+
11
+ async function start() {
12
+ const PORT = process.env.DEV_WORKFLOW_WEB_PORT || await getPort({ port: 3111 });
13
+
14
+ const server = createServer(async (req, res) => {
15
+ try {
16
+ const url = new URL(req.url, `http://${req.headers.host}`);
17
+ const userId = url.searchParams.get("user") || "default";
18
+
19
+ if (req.method === "GET" && url.pathname === "/") {
20
+ const html = await readFile(join(__dirname, "index.html"), "utf-8");
21
+ res.writeHead(200, { "Content-Type": "text/html" });
22
+ res.end(html);
23
+ } else if (req.method === "GET" && url.pathname === "/api/summary") {
24
+ const summary = getSummaryForUser(userId);
25
+ res.writeHead(200, { "Content-Type": "application/json" });
26
+ res.end(JSON.stringify({ summary }));
27
+ } else if (req.method === "GET" && url.pathname === "/api/history") {
28
+ const page = Number(url.searchParams.get("page") || "1");
29
+ const pageSize = Number(url.searchParams.get("pageSize") || "20");
30
+ const startDate = url.searchParams.get("startDate") || undefined;
31
+ const endDate = url.searchParams.get("endDate") || undefined;
32
+ const history = getHistoryForUser(userId, { page, pageSize, startDate, endDate });
33
+ res.writeHead(200, { "Content-Type": "application/json" });
34
+ res.end(JSON.stringify(history));
35
+ } else if (req.method === "GET" && url.pathname === "/api/history-summary") {
36
+ const startDate = url.searchParams.get("startDate") || undefined;
37
+ const endDate = url.searchParams.get("endDate") || undefined;
38
+ const frequency = url.searchParams.get("frequency") || "daily";
39
+ const summary = getHistorySummary(userId, { startDate, endDate, frequency });
40
+ res.writeHead(200, { "Content-Type": "application/json" });
41
+ res.end(JSON.stringify({ summary }));
42
+ } else {
43
+ res.writeHead(404, { "Content-Type": "text/plain" });
44
+ res.end("Not found");
45
+ }
46
+ } catch (err) {
47
+ res.writeHead(500, { "Content-Type": "text/plain" });
48
+ res.end(err.message);
49
+ }
50
+ });
51
+
52
+ server.listen(PORT, () => {
53
+ console.log(`🌐 Dev Workflow Dashboard running at http://localhost:${PORT}`);
54
+ });
55
+ }
56
+
57
+ start();
package/workflow-state.js CHANGED
@@ -1,10 +1,182 @@
1
1
  import fs from "fs/promises";
2
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync } from "fs";
2
3
  import path from "path";
3
4
  import { fileURLToPath } from "url";
4
5
 
5
6
  const __filename = fileURLToPath(import.meta.url);
6
7
  const __dirname = path.dirname(__filename);
7
- const STATE_FILENAME = ".workflow-state.json";
8
+ const STATE_DIR = ".state";
9
+ const STATE_FILENAME = "workflow-state.json";
10
+ const LEGACY_STATE_FILENAME = ".workflow-state.json";
11
+ const PROJECT_SUMMARY_FILENAME = "project-summary.json";
12
+ const USER_ID_FILENAME = "user-id";
13
+
14
+ let cachedUserId = null;
15
+ let cachedUserStateDir = null;
16
+
17
+ function generateUserId() {
18
+ const timestamp = Date.now().toString(16);
19
+ const randomSuffix = Math.random().toString(16).slice(2, 10);
20
+ return `user-${timestamp}-${randomSuffix}`;
21
+ }
22
+
23
+ function resolveUserId(stateDir) {
24
+ const envUserId = (process.env.DEV_WORKFLOW_USER_ID || "").trim();
25
+ if (envUserId) {
26
+ cachedUserId = envUserId;
27
+ cachedUserStateDir = stateDir;
28
+ return envUserId;
29
+ }
30
+
31
+ if (cachedUserId && cachedUserStateDir === stateDir) {
32
+ return cachedUserId;
33
+ }
34
+
35
+ const userIdFile = path.join(stateDir, USER_ID_FILENAME);
36
+
37
+ try {
38
+ if (existsSync(userIdFile)) {
39
+ const stored = readFileSync(userIdFile, "utf-8").trim();
40
+ if (stored) {
41
+ cachedUserId = stored;
42
+ cachedUserStateDir = stateDir;
43
+ return stored;
44
+ }
45
+ }
46
+ } catch {
47
+ // Ignore read errors; we'll generate a new ID below.
48
+ }
49
+
50
+ const usersDir = path.join(stateDir, "users");
51
+
52
+ try {
53
+ const entries = readdirSync(usersDir, { withFileTypes: true });
54
+ const candidateDirs = entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name);
55
+ if (candidateDirs.length === 1) {
56
+ const legacyId = candidateDirs[0];
57
+ try {
58
+ mkdirSync(stateDir, { recursive: true });
59
+ writeFileSync(userIdFile, legacyId, "utf-8");
60
+ } catch {
61
+ // Persisting the legacy ID is best-effort.
62
+ }
63
+ cachedUserId = legacyId;
64
+ cachedUserStateDir = stateDir;
65
+ return legacyId;
66
+ }
67
+ } catch (error) {
68
+ if (error?.code !== "ENOENT") {
69
+ // Unexpected error accessing users directory – fall through to generate new ID.
70
+ }
71
+ }
72
+
73
+ const legacyDefaultPath = path.join(usersDir, "default", STATE_FILENAME);
74
+ if (existsSync(legacyDefaultPath)) {
75
+ try {
76
+ mkdirSync(stateDir, { recursive: true });
77
+ writeFileSync(userIdFile, "default", "utf-8");
78
+ } catch {
79
+ // Best-effort persistence; continue with in-memory fallback.
80
+ }
81
+ cachedUserId = "default";
82
+ cachedUserStateDir = stateDir;
83
+ return "default";
84
+ }
85
+
86
+ const generatedId = generateUserId();
87
+ try {
88
+ mkdirSync(stateDir, { recursive: true });
89
+ writeFileSync(userIdFile, generatedId, "utf-8");
90
+ } catch {
91
+ // Ignore persistence errors; continue with in-memory ID.
92
+ }
93
+
94
+ cachedUserId = generatedId;
95
+ cachedUserStateDir = stateDir;
96
+ return generatedId;
97
+ }
98
+
99
+ function hasProjectMarkers(candidate) {
100
+ if (!candidate) {
101
+ return false;
102
+ }
103
+
104
+ const resolved = path.resolve(candidate);
105
+ return (
106
+ existsSync(path.join(resolved, "package.json")) || existsSync(path.join(resolved, ".git"))
107
+ );
108
+ }
109
+
110
+ function selectProjectRoot(candidates) {
111
+ for (const candidate of candidates) {
112
+ if (!candidate) {
113
+ continue;
114
+ }
115
+
116
+ const resolved = path.resolve(candidate);
117
+ if (isRootPath(resolved) && !hasProjectMarkers(resolved)) {
118
+ continue;
119
+ }
120
+
121
+ return resolved;
122
+ }
123
+
124
+ return null;
125
+ }
126
+
127
+ function getUserScopedDir(baseDir) {
128
+ const userId = resolveUserId(baseDir);
129
+ return path.join(baseDir, "users", userId);
130
+ }
131
+
132
+ function getCurrentUserId() {
133
+ const envUserId = (process.env.DEV_WORKFLOW_USER_ID || "").trim();
134
+ if (envUserId) {
135
+ return envUserId;
136
+ }
137
+
138
+ if (cachedUserId) {
139
+ return cachedUserId;
140
+ }
141
+
142
+ if (cachedUserStateDir) {
143
+ return resolveUserId(cachedUserStateDir);
144
+ }
145
+
146
+ return "default";
147
+ }
148
+
149
+ async function migrateLegacyStateFile(targetPath) {
150
+ const stateDir = path.dirname(targetPath);
151
+ if (path.basename(stateDir) !== STATE_DIR) {
152
+ return;
153
+ }
154
+
155
+ const projectRoot = path.dirname(stateDir);
156
+ const legacyPath = path.join(projectRoot, LEGACY_STATE_FILENAME);
157
+
158
+ if (legacyPath === targetPath) {
159
+ return;
160
+ }
161
+
162
+ try {
163
+ await fs.access(legacyPath);
164
+ } catch {
165
+ return;
166
+ }
167
+
168
+ await fs.mkdir(stateDir, { recursive: true });
169
+
170
+ try {
171
+ await fs.access(targetPath);
172
+ await fs.unlink(legacyPath);
173
+ return;
174
+ } catch {
175
+ // New state file doesn't exist yet; fall through to migrate.
176
+ }
177
+
178
+ await fs.rename(legacyPath, targetPath);
179
+ }
8
180
 
9
181
  function isRootPath(candidate) {
10
182
  if (!candidate) {
@@ -15,30 +187,197 @@ function isRootPath(candidate) {
15
187
  return path.parse(resolved).root === resolved;
16
188
  }
17
189
 
18
- function resolveDefaultStateFile() {
19
- if (process.env.DEV_WORKFLOW_STATE_FILE) {
20
- return path.resolve(process.env.DEV_WORKFLOW_STATE_FILE);
190
+ function findProjectRoot(startDir) {
191
+ if (!startDir) {
192
+ return null;
193
+ }
194
+
195
+ let current = path.resolve(startDir);
196
+ const { root } = path.parse(current);
197
+
198
+ while (true) {
199
+ const hasPackageJson = existsSync(path.join(current, "package.json"));
200
+ const hasGitDir = existsSync(path.join(current, ".git"));
201
+ if (hasPackageJson || hasGitDir) {
202
+ return current;
203
+ }
204
+
205
+ if (current === root) {
206
+ return null;
207
+ }
208
+
209
+ const parent = path.dirname(current);
210
+ if (parent === current) {
211
+ return null;
212
+ }
213
+
214
+ current = parent;
215
+ }
216
+ }
217
+
218
+ function getCompatibilityPaths(primaryPath) {
219
+ const resolvedPrimary = path.resolve(primaryPath);
220
+ const candidates = new Set();
221
+ const moduleDir = __dirname;
222
+
223
+ const directCandidate = path.resolve(moduleDir, STATE_DIR, STATE_FILENAME);
224
+ if (directCandidate !== resolvedPrimary) {
225
+ candidates.add(directCandidate);
226
+ }
227
+
228
+ const possibleDistDirs = [
229
+ path.join(moduleDir, "dist"),
230
+ path.join(path.dirname(moduleDir), "dist"),
231
+ ];
232
+
233
+ for (const distDir of possibleDistDirs) {
234
+ const candidate = path.resolve(distDir, STATE_DIR, STATE_FILENAME);
235
+ if (candidate !== resolvedPrimary) {
236
+ candidates.add(candidate);
237
+ }
238
+ }
239
+
240
+ return Array.from(candidates);
241
+ }
242
+
243
+ async function pathExists(filePath) {
244
+ try {
245
+ await fs.access(filePath);
246
+ return true;
247
+ } catch {
248
+ return false;
21
249
  }
250
+ }
22
251
 
23
- const cwd = process.cwd();
24
- if (!cwd.includes(`node_modules${path.sep}`) && !isRootPath(cwd)) {
25
- return path.join(cwd, STATE_FILENAME);
252
+ async function ensureCompatibilityLinks(primaryPath) {
253
+ const resolvedPrimary = path.resolve(primaryPath);
254
+
255
+ if (!(await pathExists(resolvedPrimary))) {
256
+ return;
26
257
  }
27
258
 
28
- if (process.env.INIT_CWD && !isRootPath(process.env.INIT_CWD)) {
29
- return path.join(process.env.INIT_CWD, STATE_FILENAME);
259
+ const compatibilityPaths = getCompatibilityPaths(resolvedPrimary);
260
+
261
+ for (const compatPath of compatibilityPaths) {
262
+ try {
263
+ await fs.mkdir(path.dirname(compatPath), { recursive: true });
264
+ } catch {
265
+ // Ignore directory creation errors; we'll surface them on actual writes.
266
+ }
267
+
268
+ try {
269
+ const compatStat = await fs.lstat(compatPath);
270
+ if (compatStat.isSymbolicLink()) {
271
+ const target = await fs.readlink(compatPath);
272
+ if (path.resolve(path.dirname(compatPath), target) === resolvedPrimary) {
273
+ continue;
274
+ }
275
+ await fs.unlink(compatPath);
276
+ } else if (compatStat.isFile()) {
277
+ const [primaryStat, existingStat] = await Promise.all([
278
+ fs.stat(resolvedPrimary),
279
+ fs.stat(compatPath),
280
+ ]);
281
+ if (primaryStat.dev === existingStat.dev && primaryStat.ino === existingStat.ino) {
282
+ continue;
283
+ }
284
+ await fs.unlink(compatPath);
285
+ } else {
286
+ await fs.unlink(compatPath);
287
+ }
288
+ } catch (error) {
289
+ if (error.code !== "ENOENT") {
290
+ // Unexpected error accessing legacy path; skip mirroring to avoid breaking primary file.
291
+ continue;
292
+ }
293
+ }
294
+
295
+ try {
296
+ await fs.link(resolvedPrimary, compatPath);
297
+ continue;
298
+ } catch (error) {
299
+ if (!["EEXIST", "EXDEV", "EPERM"].includes(error.code)) {
300
+ continue;
301
+ }
302
+ if (error.code === "EEXIST") {
303
+ continue;
304
+ }
305
+ }
306
+
307
+ try {
308
+ await fs.symlink(resolvedPrimary, compatPath);
309
+ continue;
310
+ } catch (error) {
311
+ if (error.code === "EEXIST") {
312
+ continue;
313
+ }
314
+ if (!["EPERM", "ENOTSUP", "EEXIST"].includes(error.code)) {
315
+ // If symlink fails for another reason, skip to avoid interfering with primary file.
316
+ continue;
317
+ }
318
+ }
319
+
320
+ try {
321
+ await fs.copyFile(resolvedPrimary, compatPath);
322
+ } catch {
323
+ // Final fallback failed; ignore to avoid disrupting primary save.
324
+ }
30
325
  }
326
+ }
31
327
 
32
- const [beforeNodeModules] = cwd.split(`node_modules${path.sep}`);
33
- if (beforeNodeModules && !isRootPath(beforeNodeModules)) {
34
- return path.join(beforeNodeModules, STATE_FILENAME);
328
+ export function resolveStateFile() {
329
+ const customPath = process.env.DEV_WORKFLOW_STATE_FILE;
330
+ if (customPath) {
331
+ return path.resolve(customPath);
35
332
  }
36
333
 
37
- return path.join(__dirname, STATE_FILENAME);
334
+ const initCwd = process.env.INIT_CWD || process.cwd();
335
+ const resolvedInitCwd = path.resolve(initCwd);
336
+ const moduleRoot = path.resolve(__dirname, "..");
337
+ const cwdResolved = path.resolve(process.cwd());
338
+
339
+ const candidateRoots = [];
340
+
341
+ const initProjectRoot = findProjectRoot(resolvedInitCwd);
342
+ if (initProjectRoot) {
343
+ candidateRoots.push(initProjectRoot);
344
+ }
345
+
346
+ const moduleProjectRoot = findProjectRoot(moduleRoot);
347
+ if (moduleProjectRoot) {
348
+ candidateRoots.push(moduleProjectRoot);
349
+ } else if (!isRootPath(moduleRoot)) {
350
+ candidateRoots.push(moduleRoot);
351
+ }
352
+
353
+ const cwdProjectRoot = findProjectRoot(cwdResolved);
354
+ if (cwdProjectRoot) {
355
+ candidateRoots.push(cwdProjectRoot);
356
+ } else if (!isRootPath(cwdResolved)) {
357
+ candidateRoots.push(cwdResolved);
358
+ }
359
+
360
+ if (!isRootPath(resolvedInitCwd)) {
361
+ candidateRoots.push(resolvedInitCwd);
362
+ }
363
+
364
+ if (!isRootPath(__dirname)) {
365
+ candidateRoots.push(__dirname);
366
+ }
367
+
368
+ let projectRoot = selectProjectRoot(candidateRoots);
369
+
370
+ if (!projectRoot) {
371
+ projectRoot = path.resolve(__dirname);
372
+ }
373
+
374
+ const stateDir = path.join(projectRoot, STATE_DIR);
375
+ const userDir = getUserScopedDir(stateDir);
376
+ return path.join(userDir, STATE_FILENAME);
38
377
  }
39
378
 
40
379
  export class WorkflowState {
41
- constructor(stateFilePath = resolveDefaultStateFile()) {
380
+ constructor(stateFilePath = resolveStateFile()) {
42
381
  this.stateFile = stateFilePath;
43
382
  this.state = {
44
383
  currentPhase: "idle",
@@ -58,10 +397,12 @@ export class WorkflowState {
58
397
  lastCommitMessage: "",
59
398
  lastPushBranch: "",
60
399
  history: [],
400
+ taskType: "",
61
401
  };
62
402
  }
63
403
 
64
404
  async load() {
405
+ await migrateLegacyStateFile(this.stateFile);
65
406
  try {
66
407
  const data = await fs.readFile(this.stateFile, "utf-8");
67
408
  const parsed = JSON.parse(data);
@@ -69,6 +410,8 @@ export class WorkflowState {
69
410
  ...this.state,
70
411
  ...parsed,
71
412
  };
413
+ // Ensure compatibility mirrors exist whenever the primary file is present
414
+ await ensureCompatibilityLinks(this.stateFile);
72
415
  if (typeof this.state.testsCreated !== "boolean") {
73
416
  this.state.testsCreated = false;
74
417
  }
@@ -100,12 +443,93 @@ export class WorkflowState {
100
443
  this.state.lastPushBranch = "";
101
444
  }
102
445
  } catch (error) {
103
- // File doesn't exist yet, use default state
446
+ if (error?.code !== "ENOENT") {
447
+ throw error;
448
+ }
449
+ // Missing file is fine; bootstrap fresh artifacts for downstream consumers.
450
+ await this.ensurePrimaryFile();
104
451
  }
105
452
  }
106
453
 
454
+ async ensurePrimaryFile() {
455
+ let created = false;
456
+ try {
457
+ await fs.access(this.stateFile);
458
+ } catch {
459
+ await fs.mkdir(path.dirname(this.stateFile), { recursive: true });
460
+ await fs.writeFile(this.stateFile, JSON.stringify(this.state, null, 2));
461
+ created = true;
462
+ }
463
+
464
+ // Always ensure a fresh project summary so dependent tooling can read it immediately.
465
+ await this.updateProjectSummary();
466
+ await ensureCompatibilityLinks(this.stateFile);
467
+
468
+ return created;
469
+ }
470
+
107
471
  async save() {
108
472
  await fs.writeFile(this.stateFile, JSON.stringify(this.state, null, 2));
473
+ await ensureCompatibilityLinks(this.stateFile);
474
+ await this.updateProjectSummary();
475
+ await this.syncToDatabase();
476
+ }
477
+
478
+ async updateProjectSummary() {
479
+ const summaryPath = path.join(path.dirname(this.stateFile), PROJECT_SUMMARY_FILENAME);
480
+ const summary = this.generateProjectSummaryData();
481
+ await fs.mkdir(path.dirname(summaryPath), { recursive: true });
482
+ await fs.writeFile(summaryPath, JSON.stringify(summary, null, 2));
483
+ }
484
+
485
+ async syncToDatabase() {
486
+ try {
487
+ const { insertHistoryEntry, updateSummaryForUser } = await import("./db/index.js");
488
+ const userId = getCurrentUserId();
489
+ for (const entry of this.state.history || []) {
490
+ insertHistoryEntry(userId, entry);
491
+ }
492
+ updateSummaryForUser(userId);
493
+ } catch {
494
+ // SQLite not available; skip sync
495
+ }
496
+ }
497
+
498
+ generateProjectSummaryData() {
499
+ const history = this.state.history || [];
500
+ if (!history || history.length === 0) {
501
+ return {
502
+ totalTasks: 0,
503
+ taskTypes: {},
504
+ lastActive: null,
505
+ recentTasks: [],
506
+ updatedAt: new Date().toISOString(),
507
+ };
508
+ }
509
+
510
+ const taskTypes = {};
511
+ const recentTasks = history.slice(-20);
512
+
513
+ for (const entry of recentTasks) {
514
+ const type = entry.taskType || "other";
515
+ taskTypes[type] = (taskTypes[type] || 0) + 1;
516
+ }
517
+
518
+ const totalTasks = history.length;
519
+ const lastTask = history[history.length - 1];
520
+ const lastActive = lastTask ? lastTask.timestamp : null;
521
+
522
+ return {
523
+ totalTasks,
524
+ taskTypes,
525
+ lastActive,
526
+ recentTasks: history.slice(-5).reverse().map((e) => ({
527
+ description: e.taskDescription,
528
+ type: e.taskType,
529
+ timestamp: e.timestamp,
530
+ })),
531
+ updatedAt: new Date().toISOString(),
532
+ };
109
533
  }
110
534
 
111
535
  reset() {