@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.
- package/README.md +11 -5
- package/dist/commands/add-repo.d.ts +9 -0
- package/dist/commands/add-repo.d.ts.map +1 -1
- package/dist/commands/add-repo.js +12 -1
- package/dist/commands/add-repo.js.map +1 -1
- package/dist/commands/add-repo.test.d.ts +2 -0
- package/dist/commands/add-repo.test.d.ts.map +1 -0
- package/dist/commands/add-repo.test.js +30 -0
- package/dist/commands/add-repo.test.js.map +1 -0
- package/dist/commands/commands.test.js +6 -0
- package/dist/commands/commands.test.js.map +1 -1
- package/dist/commands/commit.d.ts +5 -1
- package/dist/commands/commit.d.ts.map +1 -1
- package/dist/commands/commit.js +4 -3
- package/dist/commands/commit.js.map +1 -1
- package/dist/commands/commit.test.d.ts +2 -0
- package/dist/commands/commit.test.d.ts.map +1 -0
- package/dist/commands/commit.test.js +54 -0
- package/dist/commands/commit.test.js.map +1 -0
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +14 -18
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/handoff.d.ts.map +1 -1
- package/dist/commands/handoff.js +9 -13
- package/dist/commands/handoff.js.map +1 -1
- package/dist/commands/tui.js +1 -1
- package/dist/commands/tui.js.map +1 -1
- package/dist/commands/ui.d.ts +1 -0
- package/dist/commands/ui.d.ts.map +1 -1
- package/dist/commands/ui.js +5 -2
- package/dist/commands/ui.js.map +1 -1
- package/dist/core/adapters/local-storage.d.ts +7 -0
- package/dist/core/adapters/local-storage.d.ts.map +1 -1
- package/dist/core/adapters/local-storage.js +13 -4
- package/dist/core/adapters/local-storage.js.map +1 -1
- package/dist/core/adapters/storage.test.js +18 -0
- package/dist/core/adapters/storage.test.js.map +1 -1
- package/dist/core/refresh.d.ts.map +1 -1
- package/dist/core/refresh.js +2 -6
- package/dist/core/refresh.js.map +1 -1
- package/dist/core/scheduler.d.ts.map +1 -1
- package/dist/core/scheduler.js +146 -32
- package/dist/core/scheduler.js.map +1 -1
- package/dist/core/scheduler.test.js +57 -5
- package/dist/core/scheduler.test.js.map +1 -1
- package/dist/core/sync.d.ts.map +1 -1
- package/dist/core/sync.js +2 -7
- package/dist/core/sync.js.map +1 -1
- package/dist/core/workspace.d.ts +6 -0
- package/dist/core/workspace.d.ts.map +1 -1
- package/dist/core/workspace.js +32 -15
- package/dist/core/workspace.js.map +1 -1
- package/dist/core/workspace.test.d.ts +2 -0
- package/dist/core/workspace.test.d.ts.map +1 -0
- package/dist/core/workspace.test.js +42 -0
- package/dist/core/workspace.test.js.map +1 -0
- package/dist/generators/map-generator.d.ts.map +1 -1
- package/dist/generators/map-generator.js +4 -3
- package/dist/generators/map-generator.js.map +1 -1
- package/dist/generators/map-generator.test.js +4 -2
- package/dist/generators/map-generator.test.js.map +1 -1
- package/dist/gui/assets/index-ElroPpSP.js +26 -0
- package/dist/gui/index.html +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/orchestration/index.d.ts +1 -1
- package/dist/orchestration/index.d.ts.map +1 -1
- package/dist/orchestration/index.js +1 -1
- package/dist/orchestration/index.js.map +1 -1
- package/dist/orchestration/runner.d.ts +16 -1
- package/dist/orchestration/runner.d.ts.map +1 -1
- package/dist/orchestration/runner.js +41 -5
- package/dist/orchestration/runner.js.map +1 -1
- package/dist/orchestration/runner.test.d.ts +2 -0
- package/dist/orchestration/runner.test.d.ts.map +1 -0
- package/dist/orchestration/runner.test.js +71 -0
- package/dist/orchestration/runner.test.js.map +1 -0
- package/dist/server.d.ts +17 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +199 -119
- package/dist/server.js.map +1 -1
- package/dist/server.test.js +92 -1
- package/dist/server.test.js.map +1 -1
- package/dist/utils/prompts.d.ts.map +1 -1
- package/dist/utils/prompts.js +8 -2
- package/dist/utils/prompts.js.map +1 -1
- package/package.json +1 -1
- 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
|
-
//
|
|
48
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
729
|
-
|
|
730
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1316
|
-
|
|
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
|
-
|
|
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);
|