@mrpatronz/nexusflow 1.3.0 → 1.3.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 (88) hide show
  1. package/README.md +11 -5
  2. package/dist/commands/add-repo.d.ts +9 -0
  3. package/dist/commands/add-repo.d.ts.map +1 -1
  4. package/dist/commands/add-repo.js +12 -1
  5. package/dist/commands/add-repo.js.map +1 -1
  6. package/dist/commands/add-repo.test.d.ts +2 -0
  7. package/dist/commands/add-repo.test.d.ts.map +1 -0
  8. package/dist/commands/add-repo.test.js +30 -0
  9. package/dist/commands/add-repo.test.js.map +1 -0
  10. package/dist/commands/commands.test.js +6 -0
  11. package/dist/commands/commands.test.js.map +1 -1
  12. package/dist/commands/commit.d.ts +5 -1
  13. package/dist/commands/commit.d.ts.map +1 -1
  14. package/dist/commands/commit.js +4 -3
  15. package/dist/commands/commit.js.map +1 -1
  16. package/dist/commands/commit.test.d.ts +2 -0
  17. package/dist/commands/commit.test.d.ts.map +1 -0
  18. package/dist/commands/commit.test.js +54 -0
  19. package/dist/commands/commit.test.js.map +1 -0
  20. package/dist/commands/doctor.d.ts.map +1 -1
  21. package/dist/commands/doctor.js +14 -18
  22. package/dist/commands/doctor.js.map +1 -1
  23. package/dist/commands/handoff.d.ts.map +1 -1
  24. package/dist/commands/handoff.js +9 -13
  25. package/dist/commands/handoff.js.map +1 -1
  26. package/dist/commands/tui.js +1 -1
  27. package/dist/commands/tui.js.map +1 -1
  28. package/dist/commands/ui.d.ts +1 -0
  29. package/dist/commands/ui.d.ts.map +1 -1
  30. package/dist/commands/ui.js +5 -2
  31. package/dist/commands/ui.js.map +1 -1
  32. package/dist/core/adapters/local-storage.d.ts +7 -0
  33. package/dist/core/adapters/local-storage.d.ts.map +1 -1
  34. package/dist/core/adapters/local-storage.js +13 -4
  35. package/dist/core/adapters/local-storage.js.map +1 -1
  36. package/dist/core/adapters/storage.test.js +18 -0
  37. package/dist/core/adapters/storage.test.js.map +1 -1
  38. package/dist/core/refresh.d.ts.map +1 -1
  39. package/dist/core/refresh.js +2 -6
  40. package/dist/core/refresh.js.map +1 -1
  41. package/dist/core/scheduler.d.ts.map +1 -1
  42. package/dist/core/scheduler.js +146 -32
  43. package/dist/core/scheduler.js.map +1 -1
  44. package/dist/core/scheduler.test.js +57 -5
  45. package/dist/core/scheduler.test.js.map +1 -1
  46. package/dist/core/sync.d.ts.map +1 -1
  47. package/dist/core/sync.js +2 -7
  48. package/dist/core/sync.js.map +1 -1
  49. package/dist/core/workspace.d.ts +6 -0
  50. package/dist/core/workspace.d.ts.map +1 -1
  51. package/dist/core/workspace.js +32 -15
  52. package/dist/core/workspace.js.map +1 -1
  53. package/dist/core/workspace.test.d.ts +2 -0
  54. package/dist/core/workspace.test.d.ts.map +1 -0
  55. package/dist/core/workspace.test.js +42 -0
  56. package/dist/core/workspace.test.js.map +1 -0
  57. package/dist/generators/map-generator.d.ts.map +1 -1
  58. package/dist/generators/map-generator.js +4 -3
  59. package/dist/generators/map-generator.js.map +1 -1
  60. package/dist/generators/map-generator.test.js +4 -2
  61. package/dist/generators/map-generator.test.js.map +1 -1
  62. package/dist/gui/assets/index-ElroPpSP.js +26 -0
  63. package/dist/gui/index.html +1 -1
  64. package/dist/index.js +1 -0
  65. package/dist/index.js.map +1 -1
  66. package/dist/orchestration/index.d.ts +1 -1
  67. package/dist/orchestration/index.d.ts.map +1 -1
  68. package/dist/orchestration/index.js +1 -1
  69. package/dist/orchestration/index.js.map +1 -1
  70. package/dist/orchestration/runner.d.ts +16 -1
  71. package/dist/orchestration/runner.d.ts.map +1 -1
  72. package/dist/orchestration/runner.js +41 -5
  73. package/dist/orchestration/runner.js.map +1 -1
  74. package/dist/orchestration/runner.test.d.ts +2 -0
  75. package/dist/orchestration/runner.test.d.ts.map +1 -0
  76. package/dist/orchestration/runner.test.js +71 -0
  77. package/dist/orchestration/runner.test.js.map +1 -0
  78. package/dist/server.d.ts +17 -1
  79. package/dist/server.d.ts.map +1 -1
  80. package/dist/server.js +199 -119
  81. package/dist/server.js.map +1 -1
  82. package/dist/server.test.js +92 -1
  83. package/dist/server.test.js.map +1 -1
  84. package/dist/utils/prompts.d.ts.map +1 -1
  85. package/dist/utils/prompts.js +8 -2
  86. package/dist/utils/prompts.js.map +1 -1
  87. package/package.json +1 -1
  88. package/dist/gui/assets/index-D8iJPvnk.js +0 -26
package/dist/server.js CHANGED
@@ -31,7 +31,7 @@ import { getWorkspaceRepos, commitAndPush, getRepoStatus } from './utils/multi-g
31
31
  import { syncWorkspace } from './core/sync.js';
32
32
  import { refreshWorkspace } from './core/refresh.js';
33
33
  import { addSchedule, loadSchedules, nextDueAt, parseInterval, removeSchedule, runJob, setScheduleEnabled, startScheduler, } from './core/scheduler.js';
34
- import { detectAllServices, detectOrchestrationTools, startServices, stopServices, loadRunningState, } from './orchestration/index.js';
34
+ import { detectAllServices, detectOrchestrationTools, startServices, stopServices, loadRunningState, getPm2List, } from './orchestration/index.js';
35
35
  import { checkForUpdates, getCurrentVersion, getToolsStatus } from './utils/update-check.js';
36
36
  import { getWorkflowTemplates, saveWorkflowTemplate, deleteWorkflowTemplate } from './utils/workflows.js';
37
37
  import { suggestWorkflow } from './utils/workflow-advisor.js';
@@ -44,8 +44,86 @@ const guiPath = path.join(__dirname, 'gui');
44
44
  export const app = new Hono();
45
45
  // Allowed editor binaries/scripts to prevent command injection
46
46
  const ALLOWED_EDITORS = new Set(['code', 'code-insiders', 'cursor', 'antigravity', 'agy', 'idea', 'charm', 'webstorm', 'subl', 'nano', 'vim', 'nvim', 'emacs']);
47
- // Enable CORS for frontend dev server
48
- app.use('/api/*', cors());
47
+ // ─── Path containment guards ──────────────────────────────────────────────
48
+ // The server exposes state-changing routes keyed by a workspace `:id` taken
49
+ // straight from the URL. Without containment checks, `..%2f..` sequences let a
50
+ // caller read/write/delete files outside the workspaces root. Every handler
51
+ // that turns an id (or repo name) into a filesystem path must go through these.
52
+ /** Thrown when a requested path escapes its permitted base directory. */
53
+ export class PathAccessError extends Error {
54
+ constructor(message = 'Invalid workspace path') {
55
+ super(message);
56
+ this.name = 'PathAccessError';
57
+ }
58
+ }
59
+ /** Resolve `target` and assert it stays within `baseDir` (or equals it). */
60
+ function assertWithin(baseDir, target) {
61
+ const base = path.resolve(baseDir);
62
+ const resolved = path.resolve(target);
63
+ // Trailing separator prevents a sibling like `feat-secret` from passing the
64
+ // prefix test for base `feat`.
65
+ if (resolved !== base && !resolved.startsWith(base + path.sep)) {
66
+ throw new PathAccessError();
67
+ }
68
+ return resolved;
69
+ }
70
+ /** Safe workspace directory for a route `:id`, contained within workspacesDir. */
71
+ export function resolveWorkspacePath(workspacesDir, id) {
72
+ return assertWithin(workspacesDir, path.join(workspacesDir, id));
73
+ }
74
+ /** Safe sub-repo path for a repo name, contained within the workspace. */
75
+ export function resolveRepoPath(workspacePath, repoName) {
76
+ return assertWithin(workspacePath, path.join(workspacePath, repoName));
77
+ }
78
+ /** Consistent error response; path-containment violations map to 400. */
79
+ function errorResponse(c, error) {
80
+ if (error instanceof PathAccessError) {
81
+ return c.json({ error: error.message }, 400);
82
+ }
83
+ const msg = error instanceof Error ? error.message : String(error);
84
+ return c.json({ error: msg }, 500);
85
+ }
86
+ /**
87
+ * Restrict the self-update download to GitHub release hosts over HTTPS so a
88
+ * cross-origin caller cannot make the server fetch and later execute an
89
+ * arbitrary binary.
90
+ */
91
+ export function isAllowedUpdateUrl(candidate) {
92
+ try {
93
+ const url = new URL(candidate);
94
+ if (url.protocol !== 'https:')
95
+ return false;
96
+ const host = url.hostname.toLowerCase();
97
+ return (host === 'github.com' ||
98
+ host.endsWith('.github.com') ||
99
+ host === 'githubusercontent.com' ||
100
+ host.endsWith('.githubusercontent.com'));
101
+ }
102
+ catch {
103
+ return false;
104
+ }
105
+ }
106
+ // Enable CORS for the local GUI only. This server can spawn processes, run
107
+ // package installs and delete worktrees, so a wildcard origin would let any
108
+ // web page the developer has open drive it cross-origin.
109
+ app.use('/api/*', cors({
110
+ origin: (origin) => {
111
+ // No Origin header → same-origin or a non-browser client (CLI/desktop).
112
+ if (!origin)
113
+ return origin;
114
+ try {
115
+ const { hostname } = new URL(origin);
116
+ const isLocal = hostname === 'localhost' ||
117
+ hostname === '127.0.0.1' ||
118
+ hostname === '::1' ||
119
+ hostname === '[::1]';
120
+ return isLocal ? origin : null;
121
+ }
122
+ catch {
123
+ return null;
124
+ }
125
+ },
126
+ }));
49
127
  // ─── API Endpoints ────────────────────────────────────────────────────────
50
128
  // 1. Get current configuration
51
129
  app.get('/api/config', async (c) => {
@@ -61,8 +139,7 @@ app.get('/api/config', async (c) => {
61
139
  return c.json({ config, exists });
62
140
  }
63
141
  catch (error) {
64
- const msg = error instanceof Error ? error.message : String(error);
65
- return c.json({ error: msg }, 500);
142
+ return errorResponse(c, error);
66
143
  }
67
144
  });
68
145
  // Get all registered storage adapters
@@ -72,8 +149,7 @@ app.get('/api/adapters', async (c) => {
72
149
  return c.json({ adapters });
73
150
  }
74
151
  catch (error) {
75
- const msg = error instanceof Error ? error.message : String(error);
76
- return c.json({ error: msg }, 500);
152
+ return errorResponse(c, error);
77
153
  }
78
154
  });
79
155
  function isSafeLocalEndpoint(urlStr) {
@@ -109,8 +185,7 @@ app.post('/api/config', async (c) => {
109
185
  return c.json({ success: true, config: newConfig });
110
186
  }
111
187
  catch (error) {
112
- const msg = error instanceof Error ? error.message : String(error);
113
- return c.json({ error: msg }, 500);
188
+ return errorResponse(c, error);
114
189
  }
115
190
  });
116
191
  // 3. Scan development directory for repositories
@@ -121,8 +196,7 @@ app.get('/api/repos', async (c) => {
121
196
  return c.json(repos);
122
197
  }
123
198
  catch (error) {
124
- const msg = error instanceof Error ? error.message : String(error);
125
- return c.json({ error: msg }, 500);
199
+ return errorResponse(c, error);
126
200
  }
127
201
  });
128
202
  // 4. List existing workspaces
@@ -133,8 +207,7 @@ app.get('/api/workspaces', async (c) => {
133
207
  return c.json(workspaces);
134
208
  }
135
209
  catch (error) {
136
- const msg = error instanceof Error ? error.message : String(error);
137
- return c.json({ error: msg }, 500);
210
+ return errorResponse(c, error);
138
211
  }
139
212
  });
140
213
  // Severity ranking for picking the worst per-repo sync outcome in a workspace.
@@ -166,6 +239,9 @@ app.get('/api/workspaces/status', async (c) => {
166
239
  try {
167
240
  const config = await loadConfig();
168
241
  const workspaces = await listWorkspaces(config.workspacesDir);
242
+ // Fetch the PM2 process list once for the whole overview instead of
243
+ // spawning `npx pm2 jlist` per workspace (slow, especially on Windows).
244
+ const pm2List = await getPm2List();
169
245
  const entries = await Promise.all(workspaces.map(async (ws) => {
170
246
  const workspacePath = ws.workspacePath || path.join(config.workspacesDir, ws.branchName);
171
247
  const status = {
@@ -189,7 +265,7 @@ app.get('/api/workspaces/status', async (c) => {
189
265
  }
190
266
  // Running services (cached running-state, PM2-verified — same source as
191
267
  // the Services tab; only workspaces that ever started services touch PM2).
192
- const runningState = await loadRunningState(workspacePath);
268
+ const runningState = await loadRunningState(workspacePath, pm2List);
193
269
  status.runningServices = runningState?.services?.length ?? 0;
194
270
  // Sync state: worst-case classification + any repo pending validation.
195
271
  const wsState = await loadWorkspaceState(workspacePath);
@@ -210,8 +286,7 @@ app.get('/api/workspaces/status', async (c) => {
210
286
  return c.json(byWorkspace);
211
287
  }
212
288
  catch (error) {
213
- const msg = error instanceof Error ? error.message : String(error);
214
- return c.json({ error: msg }, 500);
289
+ return errorResponse(c, error);
215
290
  }
216
291
  });
217
292
  // 5. Detect available AI assistants
@@ -221,8 +296,7 @@ app.get('/api/ai-detect', async (c) => {
221
296
  return c.json(assistants);
222
297
  }
223
298
  catch (error) {
224
- const msg = error instanceof Error ? error.message : String(error);
225
- return c.json({ error: msg }, 500);
299
+ return errorResponse(c, error);
226
300
  }
227
301
  });
228
302
  // 6. Detect available editors
@@ -232,8 +306,7 @@ app.get('/api/editor-detect', async (c) => {
232
306
  return c.json(editors);
233
307
  }
234
308
  catch (error) {
235
- const msg = error instanceof Error ? error.message : String(error);
236
- return c.json({ error: msg }, 500);
309
+ return errorResponse(c, error);
237
310
  }
238
311
  });
239
312
  // 6.5. Local LLM test & recommendation
@@ -294,6 +367,16 @@ app.get('/api/local-llm/recommend', async (c) => {
294
367
  }
295
368
  });
296
369
  const creationJobs = new Map();
370
+ // Keep a finished job around briefly so a late SSE reconnect can still read its
371
+ // final state, then drop it so the map doesn't grow unbounded.
372
+ const FINISHED_JOB_TTL_MS = 5 * 60 * 1000;
373
+ function scheduleJobCleanup(jobId) {
374
+ const job = creationJobs.get(jobId);
375
+ if (!job || job.cleanupTimer)
376
+ return;
377
+ job.cleanupTimer = setTimeout(() => creationJobs.delete(jobId), FINISHED_JOB_TTL_MS);
378
+ job.cleanupTimer.unref?.();
379
+ }
297
380
  function updateJobStep(jobId, stepId, status, message, extraData) {
298
381
  const job = creationJobs.get(jobId);
299
382
  if (!job)
@@ -333,10 +416,13 @@ function updateJobStep(jobId, stepId, status, message, extraData) {
333
416
  for (const listener of job.listeners) {
334
417
  listener(eventPayload);
335
418
  }
419
+ if (job.status === 'completed' || job.status === 'failed') {
420
+ scheduleJobCleanup(jobId);
421
+ }
336
422
  }
337
423
  async function runCreationJob(jobId, body, config) {
338
424
  try {
339
- const workspacePath = path.join(config.workspacesDir, body.branchName);
425
+ const workspacePath = resolveWorkspacePath(config.workspacesDir, body.branchName);
340
426
  const job = creationJobs.get(jobId);
341
427
  if (job) {
342
428
  job.workspacePath = workspacePath;
@@ -422,8 +508,7 @@ app.post('/api/workspace', async (c) => {
422
508
  return c.json({ success: true, jobId });
423
509
  }
424
510
  catch (error) {
425
- const msg = error instanceof Error ? error.message : String(error);
426
- return c.json({ error: msg }, 500);
511
+ return errorResponse(c, error);
427
512
  }
428
513
  });
429
514
  // 7.5. Create workspace SSE stream
@@ -453,23 +538,33 @@ app.get('/api/workspace/create-stream/:jobId', async (c) => {
453
538
  if (job.status === 'completed' || job.status === 'failed') {
454
539
  return;
455
540
  }
456
- const listener = async (event) => {
457
- try {
458
- await stream.writeSSE({
459
- event: event.type,
460
- data: JSON.stringify(event.data),
461
- });
462
- }
463
- catch (err) {
541
+ // Keep the connection open until the job finishes, driven by job events
542
+ // rather than polling.
543
+ await new Promise((resolve) => {
544
+ const listener = async (event) => {
545
+ try {
546
+ await stream.writeSSE({
547
+ event: event.type,
548
+ data: JSON.stringify(event.data),
549
+ });
550
+ }
551
+ catch {
552
+ job.listeners.delete(listener);
553
+ resolve();
554
+ return;
555
+ }
556
+ if (event.data?.status === 'completed' || event.data?.status === 'failed') {
557
+ job.listeners.delete(listener);
558
+ resolve();
559
+ }
560
+ };
561
+ job.listeners.add(listener);
562
+ // Guard against the job finishing between the initial state write and now.
563
+ if (job.status === 'completed' || job.status === 'failed') {
464
564
  job.listeners.delete(listener);
565
+ resolve();
465
566
  }
466
- };
467
- job.listeners.add(listener);
468
- // Keep connection alive until job finishes
469
- while (job.status === 'running') {
470
- await new Promise((resolve) => setTimeout(resolve, 1000));
471
- }
472
- job.listeners.delete(listener);
567
+ });
473
568
  });
474
569
  });
475
570
  // 7.6. Delete workspace
@@ -477,13 +572,12 @@ app.delete('/api/workspace/:id', async (c) => {
477
572
  try {
478
573
  const id = decodeURIComponent(c.req.param('id'));
479
574
  const config = await loadConfig();
480
- const workspacePath = path.join(config.workspacesDir, id);
575
+ const workspacePath = resolveWorkspacePath(config.workspacesDir, id);
481
576
  await deleteWorkspace(workspacePath);
482
577
  return c.json({ success: true });
483
578
  }
484
579
  catch (error) {
485
- const msg = error instanceof Error ? error.message : String(error);
486
- return c.json({ error: msg }, 500);
580
+ return errorResponse(c, error);
487
581
  }
488
582
  });
489
583
  // 7.7. Add repo to workspace
@@ -492,13 +586,12 @@ app.post('/api/workspace/:id/repo', async (c) => {
492
586
  const id = decodeURIComponent(c.req.param('id'));
493
587
  const { repoPath } = await c.req.json();
494
588
  const config = await loadConfig();
495
- const workspacePath = path.join(config.workspacesDir, id);
589
+ const workspacePath = resolveWorkspacePath(config.workspacesDir, id);
496
590
  await addRepoToWorkspace(workspacePath, repoPath);
497
591
  return c.json({ success: true });
498
592
  }
499
593
  catch (error) {
500
- const msg = error instanceof Error ? error.message : String(error);
501
- return c.json({ error: msg }, 500);
594
+ return errorResponse(c, error);
502
595
  }
503
596
  });
504
597
  // 7.8. Suggest workflow strategy based on feature description & selected repos
@@ -513,8 +606,7 @@ app.post('/api/workspace/suggest-workflow', async (c) => {
513
606
  });
514
607
  }
515
608
  catch (error) {
516
- const msg = error instanceof Error ? error.message : String(error);
517
- return c.json({ error: msg }, 500);
609
+ return errorResponse(c, error);
518
610
  }
519
611
  });
520
612
  // 8. Open workspace in editor
@@ -549,8 +641,7 @@ app.post('/api/open-editor', async (c) => {
549
641
  return c.json({ success: true });
550
642
  }
551
643
  catch (error) {
552
- const msg = error instanceof Error ? error.message : String(error);
553
- return c.json({ error: msg }, 500);
644
+ return errorResponse(c, error);
554
645
  }
555
646
  });
556
647
  // 9. Get status of running services in all/specific workspace
@@ -558,7 +649,7 @@ app.get('/api/workspace/:id/services', async (c) => {
558
649
  try {
559
650
  const id = c.req.param('id');
560
651
  const config = await loadConfig();
561
- const workspacePath = path.join(config.workspacesDir, id);
652
+ const workspacePath = resolveWorkspacePath(config.workspacesDir, id);
562
653
  // Detect all services
563
654
  const services = await detectAllServices(workspacePath);
564
655
  // Detect existing tools
@@ -572,8 +663,7 @@ app.get('/api/workspace/:id/services', async (c) => {
572
663
  });
573
664
  }
574
665
  catch (error) {
575
- const msg = error instanceof Error ? error.message : String(error);
576
- return c.json({ error: msg }, 500);
666
+ return errorResponse(c, error);
577
667
  }
578
668
  });
579
669
  // 10. Start services in workspace
@@ -582,14 +672,13 @@ app.post('/api/workspace/:id/services/start', async (c) => {
582
672
  const id = c.req.param('id');
583
673
  const { services } = await c.req.json();
584
674
  const config = await loadConfig();
585
- const workspacePath = path.join(config.workspacesDir, id);
675
+ const workspacePath = resolveWorkspacePath(config.workspacesDir, id);
586
676
  const logDir = path.join(workspacePath, '.nexusflow-logs');
587
677
  await startServices(services, workspacePath, logDir);
588
678
  return c.json({ success: true });
589
679
  }
590
680
  catch (error) {
591
- const msg = error instanceof Error ? error.message : String(error);
592
- return c.json({ error: msg }, 500);
681
+ return errorResponse(c, error);
593
682
  }
594
683
  });
595
684
  // 11. Stop services in workspace
@@ -597,13 +686,12 @@ app.post('/api/workspace/:id/services/stop', async (c) => {
597
686
  try {
598
687
  const id = c.req.param('id');
599
688
  const config = await loadConfig();
600
- const workspacePath = path.join(config.workspacesDir, id);
689
+ const workspacePath = resolveWorkspacePath(config.workspacesDir, id);
601
690
  await stopServices(workspacePath);
602
691
  return c.json({ success: true });
603
692
  }
604
693
  catch (error) {
605
- const msg = error instanceof Error ? error.message : String(error);
606
- return c.json({ error: msg }, 500);
694
+ return errorResponse(c, error);
607
695
  }
608
696
  });
609
697
  // 12. Get service logs
@@ -612,7 +700,7 @@ app.get('/api/workspace/:id/services/logs/:serviceName', async (c) => {
612
700
  const id = c.req.param('id');
613
701
  const serviceName = c.req.param('serviceName');
614
702
  const config = await loadConfig();
615
- const workspacePath = path.join(config.workspacesDir, id);
703
+ const workspacePath = resolveWorkspacePath(config.workspacesDir, id);
616
704
  const logFile = path.join(workspacePath, '.nexusflow-logs', `${serviceName}.log`);
617
705
  let content = '';
618
706
  try {
@@ -632,8 +720,7 @@ app.get('/api/workspace/:id/services/logs/:serviceName', async (c) => {
632
720
  return c.json({ logs: content });
633
721
  }
634
722
  catch (error) {
635
- const msg = error instanceof Error ? error.message : String(error);
636
- return c.json({ error: msg }, 500);
723
+ return errorResponse(c, error);
637
724
  }
638
725
  });
639
726
  // 13. Get git changes in workspace sub-repositories
@@ -641,7 +728,7 @@ app.get('/api/workspace/:id/changes', async (c) => {
641
728
  try {
642
729
  const id = c.req.param('id');
643
730
  const config = await loadConfig();
644
- const workspacePath = path.join(config.workspacesDir, id);
731
+ const workspacePath = resolveWorkspacePath(config.workspacesDir, id);
645
732
  // Load feature config to get repo paths
646
733
  const feature = await loadFeatureConfig(workspacePath);
647
734
  if (!feature) {
@@ -711,8 +798,7 @@ app.get('/api/workspace/:id/changes', async (c) => {
711
798
  return c.json({ changes: results });
712
799
  }
713
800
  catch (error) {
714
- const msg = error instanceof Error ? error.message : String(error);
715
- return c.json({ error: msg }, 500);
801
+ return errorResponse(c, error);
716
802
  }
717
803
  });
718
804
  // 13_diff. Get git diff for a specific file in workspace sub-repositories
@@ -725,13 +811,9 @@ app.get('/api/workspace/:id/changes/diff', async (c) => {
725
811
  return c.json({ error: 'Missing repo or file query parameter.' }, 400);
726
812
  }
727
813
  const config = await loadConfig();
728
- const workspacePath = path.join(config.workspacesDir, id);
729
- const worktreePath = path.join(workspacePath, repoName);
730
- // Security check: ensure path is within workspace directory
731
- const resolvedPath = path.resolve(worktreePath);
732
- if (!resolvedPath.startsWith(path.resolve(workspacePath))) {
733
- return c.json({ error: 'Invalid repository path.' }, 400);
734
- }
814
+ const workspacePath = resolveWorkspacePath(config.workspacesDir, id);
815
+ // Contained within the workspace; rejects `..` and sibling-prefix escapes.
816
+ const worktreePath = resolveRepoPath(workspacePath, repoName);
735
817
  let diff = '';
736
818
  // Check git status for the file to know if it's untracked
737
819
  try {
@@ -763,8 +845,7 @@ app.get('/api/workspace/:id/changes/diff', async (c) => {
763
845
  return c.json({ diff });
764
846
  }
765
847
  catch (error) {
766
- const msg = error instanceof Error ? error.message : String(error);
767
- return c.json({ error: msg }, 500);
848
+ return errorResponse(c, error);
768
849
  }
769
850
  });
770
851
  // 13a. Get workspace knowledge (nexusflow-knowledge.md)
@@ -772,7 +853,7 @@ app.get('/api/workspace/:id/knowledge', async (c) => {
772
853
  try {
773
854
  const id = c.req.param('id');
774
855
  const config = await loadConfig();
775
- const workspacePath = path.join(config.workspacesDir, id);
856
+ const workspacePath = resolveWorkspacePath(config.workspacesDir, id);
776
857
  const knowledgeFile = path.join(workspacePath, 'nexusflow-knowledge.md');
777
858
  let content = '';
778
859
  try {
@@ -784,8 +865,7 @@ app.get('/api/workspace/:id/knowledge', async (c) => {
784
865
  return c.json({ content });
785
866
  }
786
867
  catch (error) {
787
- const msg = error instanceof Error ? error.message : String(error);
788
- return c.json({ error: msg }, 500);
868
+ return errorResponse(c, error);
789
869
  }
790
870
  });
791
871
  // 13b. Update workspace knowledge (nexusflow-knowledge.md)
@@ -794,14 +874,13 @@ app.put('/api/workspace/:id/knowledge', async (c) => {
794
874
  const id = c.req.param('id');
795
875
  const { content } = await c.req.json();
796
876
  const config = await loadConfig();
797
- const workspacePath = path.join(config.workspacesDir, id);
877
+ const workspacePath = resolveWorkspacePath(config.workspacesDir, id);
798
878
  const knowledgeFile = path.join(workspacePath, 'nexusflow-knowledge.md');
799
879
  await fs.writeFile(knowledgeFile, content, 'utf-8');
800
880
  return c.json({ success: true });
801
881
  }
802
882
  catch (error) {
803
- const msg = error instanceof Error ? error.message : String(error);
804
- return c.json({ error: msg }, 500);
883
+ return errorResponse(c, error);
805
884
  }
806
885
  });
807
886
  // 13c. Get workspace plan (nexusflow-plan.md)
@@ -809,7 +888,7 @@ app.get('/api/workspace/:id/plan', async (c) => {
809
888
  try {
810
889
  const id = c.req.param('id');
811
890
  const config = await loadConfig();
812
- const workspacePath = path.join(config.workspacesDir, id);
891
+ const workspacePath = resolveWorkspacePath(config.workspacesDir, id);
813
892
  const planFile = path.join(workspacePath, 'nexusflow-plan.md');
814
893
  let content = '';
815
894
  try {
@@ -821,8 +900,7 @@ app.get('/api/workspace/:id/plan', async (c) => {
821
900
  return c.json({ content });
822
901
  }
823
902
  catch (error) {
824
- const msg = error instanceof Error ? error.message : String(error);
825
- return c.json({ error: msg }, 500);
903
+ return errorResponse(c, error);
826
904
  }
827
905
  });
828
906
  // 13d. Sync all repositories in workspace
@@ -830,7 +908,7 @@ app.post('/api/workspace/:id/sync', async (c) => {
830
908
  try {
831
909
  const id = c.req.param('id');
832
910
  const config = await loadConfig();
833
- const workspacePath = path.join(config.workspacesDir, id);
911
+ const workspacePath = resolveWorkspacePath(config.workspacesDir, id);
834
912
  const report = await syncWorkspace(workspacePath);
835
913
  const results = report.repos.map((repo) => ({
836
914
  repoName: repo.name,
@@ -847,8 +925,7 @@ app.post('/api/workspace/:id/sync', async (c) => {
847
925
  });
848
926
  }
849
927
  catch (error) {
850
- const msg = error instanceof Error ? error.message : String(error);
851
- return c.json({ error: msg }, 500);
928
+ return errorResponse(c, error);
852
929
  }
853
930
  });
854
931
  // 13e. Commit changes in all repositories in workspace
@@ -857,7 +934,7 @@ app.post('/api/workspace/:id/commit', async (c) => {
857
934
  const id = c.req.param('id');
858
935
  const { message } = await c.req.json();
859
936
  const config = await loadConfig();
860
- const workspacePath = path.join(config.workspacesDir, id);
937
+ const workspacePath = resolveWorkspacePath(config.workspacesDir, id);
861
938
  const repos = await getWorkspaceRepos(workspacePath);
862
939
  const results = [];
863
940
  for (const repo of repos) {
@@ -876,8 +953,7 @@ app.post('/api/workspace/:id/commit', async (c) => {
876
953
  return c.json({ results });
877
954
  }
878
955
  catch (error) {
879
- const msg = error instanceof Error ? error.message : String(error);
880
- return c.json({ error: msg }, 500);
956
+ return errorResponse(c, error);
881
957
  }
882
958
  });
883
959
  // 14. Resume session in workspace (copies CLI resume command and opens editor)
@@ -885,7 +961,7 @@ app.post('/api/workspace/:id/resume', async (c) => {
885
961
  try {
886
962
  const id = decodeURIComponent(c.req.param('id'));
887
963
  const config = await loadConfig();
888
- const workspacePath = path.join(config.workspacesDir, id);
964
+ const workspacePath = resolveWorkspacePath(config.workspacesDir, id);
889
965
  // Load feature config to get repo paths
890
966
  const feature = await loadFeatureConfig(workspacePath);
891
967
  if (!feature) {
@@ -958,8 +1034,7 @@ app.post('/api/workspace/:id/resume', async (c) => {
958
1034
  return c.json({ success: true, resumeCommand, workspacePath });
959
1035
  }
960
1036
  catch (error) {
961
- const msg = error instanceof Error ? error.message : String(error);
962
- return c.json({ error: msg }, 500);
1037
+ return errorResponse(c, error);
963
1038
  }
964
1039
  });
965
1040
  // 15. List past AI sessions for a workspace
@@ -967,7 +1042,7 @@ app.get('/api/workspace/:id/sessions', async (c) => {
967
1042
  try {
968
1043
  const id = decodeURIComponent(c.req.param('id'));
969
1044
  const config = await loadConfig();
970
- const workspacePath = path.join(config.workspacesDir, id);
1045
+ const workspacePath = resolveWorkspacePath(config.workspacesDir, id);
971
1046
  const feature = await loadFeatureConfig(workspacePath);
972
1047
  if (!feature) {
973
1048
  return c.json({ error: 'Workspace configuration not found.' }, 404);
@@ -976,8 +1051,7 @@ app.get('/api/workspace/:id/sessions', async (c) => {
976
1051
  return c.json({ sessions });
977
1052
  }
978
1053
  catch (error) {
979
- const msg = error instanceof Error ? error.message : String(error);
980
- return c.json({ error: msg }, 500);
1054
+ return errorResponse(c, error);
981
1055
  }
982
1056
  });
983
1057
  // 16. Fetch transcript for a specific AI session
@@ -989,8 +1063,7 @@ app.get('/api/session/:assistant/:sessionId/transcript', async (c) => {
989
1063
  return c.json({ messages });
990
1064
  }
991
1065
  catch (error) {
992
- const msg = error instanceof Error ? error.message : String(error);
993
- return c.json({ error: msg }, 500);
1066
+ return errorResponse(c, error);
994
1067
  }
995
1068
  });
996
1069
  // 17. Check for NexusFlow updates
@@ -1004,8 +1077,7 @@ app.get('/api/update-status', async (c) => {
1004
1077
  return c.json(status);
1005
1078
  }
1006
1079
  catch (error) {
1007
- const msg = error instanceof Error ? error.message : String(error);
1008
- return c.json({ error: msg }, 500);
1080
+ return errorResponse(c, error);
1009
1081
  }
1010
1082
  });
1011
1083
  // 17.5. Check tools updates status
@@ -1016,8 +1088,7 @@ app.get('/api/updates/tools', async (c) => {
1016
1088
  return c.json(status);
1017
1089
  }
1018
1090
  catch (error) {
1019
- const msg = error instanceof Error ? error.message : String(error);
1020
- return c.json({ error: msg }, 500);
1091
+ return errorResponse(c, error);
1021
1092
  }
1022
1093
  });
1023
1094
  // 17.6. Install updates for a specific tool
@@ -1045,8 +1116,7 @@ app.post('/api/updates/install', async (c) => {
1045
1116
  }
1046
1117
  }
1047
1118
  catch (error) {
1048
- const msg = error instanceof Error ? error.message : String(error);
1049
- return c.json({ error: msg }, 500);
1119
+ return errorResponse(c, error);
1050
1120
  }
1051
1121
  });
1052
1122
  let downloadedInstallerPath = null;
@@ -1057,6 +1127,9 @@ app.post('/api/updates/download', async (c) => {
1057
1127
  if (!downloadUrl) {
1058
1128
  return c.json({ error: 'Download URL is required' }, 400);
1059
1129
  }
1130
+ if (!isAllowedUpdateUrl(downloadUrl)) {
1131
+ return c.json({ error: 'Download URL is not an allowed update host' }, 400);
1132
+ }
1060
1133
  const tempDir = os.tmpdir();
1061
1134
  const fileName = 'NexusFlowSetup_Update.exe';
1062
1135
  const targetPath = path.join(tempDir, fileName);
@@ -1114,8 +1187,7 @@ app.get('/api/workflows/templates', async (c) => {
1114
1187
  return c.json({ templates });
1115
1188
  }
1116
1189
  catch (error) {
1117
- const msg = error instanceof Error ? error.message : String(error);
1118
- return c.json({ error: msg }, 500);
1190
+ return errorResponse(c, error);
1119
1191
  }
1120
1192
  });
1121
1193
  // Save or update custom teamwork template
@@ -1129,8 +1201,7 @@ app.post('/api/workflows/templates', async (c) => {
1129
1201
  return c.json({ success: true, template });
1130
1202
  }
1131
1203
  catch (error) {
1132
- const msg = error instanceof Error ? error.message : String(error);
1133
- return c.json({ error: msg }, 500);
1204
+ return errorResponse(c, error);
1134
1205
  }
1135
1206
  });
1136
1207
  // Delete custom teamwork template
@@ -1149,8 +1220,7 @@ app.delete('/api/workflows/templates/:id', async (c) => {
1149
1220
  return c.json({ success: true });
1150
1221
  }
1151
1222
  catch (error) {
1152
- const msg = error instanceof Error ? error.message : String(error);
1153
- return c.json({ error: msg }, 500);
1223
+ return errorResponse(c, error);
1154
1224
  }
1155
1225
  });
1156
1226
  // Analyze teamwork template rules via selected AI coding assistant harness
@@ -1220,8 +1290,7 @@ and suffix it with:
1220
1290
  return c.json({ analysis, suggestedImprovement });
1221
1291
  }
1222
1292
  catch (error) {
1223
- const msg = error instanceof Error ? error.message : String(error);
1224
- return c.json({ error: msg }, 500);
1293
+ return errorResponse(c, error);
1225
1294
  }
1226
1295
  });
1227
1296
  // ─── Schedules: recurring workspace jobs (sync/refresh) ─────────────────
@@ -1236,7 +1305,7 @@ app.get('/api/schedules', async (c) => {
1236
1305
  });
1237
1306
  // Create a scheduled job
1238
1307
  app.post('/api/schedules', async (c) => {
1239
- const body = await c.req.json();
1308
+ const body = await c.req.json().catch(() => ({}));
1240
1309
  const task = body.task === 'refresh' ? 'refresh' : body.task === 'sync' ? 'sync' : null;
1241
1310
  if (!task) {
1242
1311
  return c.json({ error: 'task must be "sync" or "refresh"' }, 400);
@@ -1265,7 +1334,7 @@ app.post('/api/schedules', async (c) => {
1265
1334
  });
1266
1335
  // Enable/disable a scheduled job
1267
1336
  app.post('/api/schedules/:id/enabled', async (c) => {
1268
- const body = await c.req.json();
1337
+ const body = await c.req.json().catch(() => ({}));
1269
1338
  const job = await setScheduleEnabled(c.req.param('id'), Boolean(body.enabled));
1270
1339
  if (!job)
1271
1340
  return c.json({ error: 'Schedule not found' }, 404);
@@ -1289,15 +1358,15 @@ app.delete('/api/schedules/:id', async (c) => {
1289
1358
  });
1290
1359
  // Refresh a workspace's context files on demand (cache-aware)
1291
1360
  app.post('/api/workspace/:id/refresh', async (c) => {
1292
- const config = await loadConfig();
1293
- const workspacePath = path.join(config.workspacesDir, c.req.param('id'));
1294
1361
  try {
1362
+ const config = await loadConfig();
1363
+ const workspacePath = resolveWorkspacePath(config.workspacesDir, c.req.param('id'));
1295
1364
  const body = await c.req.json().catch(() => ({}));
1296
1365
  const report = await refreshWorkspace(workspacePath, { force: Boolean(body.force) });
1297
1366
  return c.json({ report });
1298
1367
  }
1299
1368
  catch (error) {
1300
- return c.json({ error: error instanceof Error ? error.message : String(error) }, 500);
1369
+ return errorResponse(c, error);
1301
1370
  }
1302
1371
  });
1303
1372
  // Legacy pack endpoint removed.
@@ -1311,11 +1380,14 @@ app.get('/', async (c) => {
1311
1380
  return c.text('GUI dashboard built assets not found. Run "npm run build" first.', 404);
1312
1381
  }
1313
1382
  });
1314
- // Serve static assets from GUI build folder
1315
- app.use('/*', serveStatic({ root: path.relative(process.cwd(), guiPath) }));
1316
- export function startServer(port = 3000) {
1383
+ // Serve static assets from GUI build folder. Use the absolute build path
1384
+ // (serveStatic joins root + request path) so asset serving does not depend on
1385
+ // the server's cwd a cwd on another drive made the old cwd-relative path
1386
+ // resolve wrong and serve a blank GUI, e.g. under `ui --daemon`.
1387
+ app.use('/*', serveStatic({ root: guiPath }));
1388
+ export function startServer(port = 3000, opts = {}) {
1317
1389
  return new Promise((resolve, reject) => {
1318
- const server = serve({ fetch: app.fetch, port }, (info) => {
1390
+ const server = serve({ fetch: app.fetch, port, hostname: '127.0.0.1' }, (info) => {
1319
1391
  // The dashboard server doubles as the host for recurring workspace
1320
1392
  // jobs (nexusflow schedule ...); jobs are re-read from disk each tick.
1321
1393
  startScheduler({ log: (message) => console.log(`[scheduler] ${message}`) });
@@ -1323,7 +1395,15 @@ export function startServer(port = 3000) {
1323
1395
  });
1324
1396
  server.on('error', (e) => {
1325
1397
  if (e.code === 'EADDRINUSE') {
1326
- resolve(startServer(port + 1));
1398
+ // Callers that own their backend (desktop/extension) pass strictPort so
1399
+ // they can rely on the port they requested instead of chasing a silent
1400
+ // increment they'd never find.
1401
+ if (opts.strictPort) {
1402
+ reject(new Error(`Port ${port} is already in use.`));
1403
+ }
1404
+ else {
1405
+ resolve(startServer(port + 1, opts));
1406
+ }
1327
1407
  }
1328
1408
  else {
1329
1409
  reject(e);