mstro-app 0.4.16 → 0.4.20
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 +148 -75
- package/dist/server/cli/headless/claude-invoker-process.d.ts +1 -1
- package/dist/server/cli/headless/claude-invoker-process.d.ts.map +1 -1
- package/dist/server/cli/headless/claude-invoker-process.js +4 -10
- package/dist/server/cli/headless/claude-invoker-process.js.map +1 -1
- package/dist/server/cli/headless/claude-invoker.js +1 -1
- package/dist/server/cli/headless/claude-invoker.js.map +1 -1
- package/dist/server/cli/headless/mcp-config.d.ts +7 -2
- package/dist/server/cli/headless/mcp-config.d.ts.map +1 -1
- package/dist/server/cli/headless/mcp-config.js +28 -4
- package/dist/server/cli/headless/mcp-config.js.map +1 -1
- package/dist/server/cli/headless/runner.d.ts.map +1 -1
- package/dist/server/cli/headless/runner.js +0 -1
- package/dist/server/cli/headless/runner.js.map +1 -1
- package/dist/server/cli/headless/types.d.ts +1 -4
- package/dist/server/cli/headless/types.d.ts.map +1 -1
- package/dist/server/cli/improvisation-retry.d.ts +1 -1
- package/dist/server/cli/improvisation-retry.d.ts.map +1 -1
- package/dist/server/cli/improvisation-retry.js +1 -2
- package/dist/server/cli/improvisation-retry.js.map +1 -1
- package/dist/server/cli/improvisation-session-manager.d.ts +0 -1
- package/dist/server/cli/improvisation-session-manager.d.ts.map +1 -1
- package/dist/server/cli/improvisation-session-manager.js +44 -9
- package/dist/server/cli/improvisation-session-manager.js.map +1 -1
- package/dist/server/index.js +17 -2
- package/dist/server/index.js.map +1 -1
- package/dist/server/mcp/bouncer-haiku.d.ts.map +1 -1
- package/dist/server/mcp/bouncer-haiku.js +10 -5
- package/dist/server/mcp/bouncer-haiku.js.map +1 -1
- package/dist/server/mcp/bouncer-integration.d.ts +3 -1
- package/dist/server/mcp/bouncer-integration.d.ts.map +1 -1
- package/dist/server/mcp/bouncer-integration.js +16 -5
- package/dist/server/mcp/bouncer-integration.js.map +1 -1
- package/dist/server/mcp/server.js +3 -1
- package/dist/server/mcp/server.js.map +1 -1
- package/dist/server/services/plan/composer.d.ts +1 -1
- package/dist/server/services/plan/composer.d.ts.map +1 -1
- package/dist/server/services/plan/composer.js +2 -3
- package/dist/server/services/plan/composer.js.map +1 -1
- package/dist/server/services/plan/executor.d.ts +0 -3
- package/dist/server/services/plan/executor.d.ts.map +1 -1
- package/dist/server/services/plan/executor.js +1 -8
- package/dist/server/services/plan/executor.js.map +1 -1
- package/dist/server/services/plan/review-gate.d.ts.map +1 -1
- package/dist/server/services/plan/review-gate.js +19 -2
- package/dist/server/services/plan/review-gate.js.map +1 -1
- package/dist/server/services/plan/state-reconciler.d.ts +6 -0
- package/dist/server/services/plan/state-reconciler.d.ts.map +1 -1
- package/dist/server/services/plan/state-reconciler.js +68 -1
- package/dist/server/services/plan/state-reconciler.js.map +1 -1
- package/dist/server/services/platform.d.ts.map +1 -1
- package/dist/server/services/platform.js +17 -4
- package/dist/server/services/platform.js.map +1 -1
- package/dist/server/services/terminal/pty-manager.d.ts +2 -4
- package/dist/server/services/terminal/pty-manager.d.ts.map +1 -1
- package/dist/server/services/terminal/pty-manager.js +4 -8
- package/dist/server/services/terminal/pty-manager.js.map +1 -1
- package/dist/server/services/terminal/pty-utils.d.ts +2 -2
- package/dist/server/services/terminal/pty-utils.d.ts.map +1 -1
- package/dist/server/services/terminal/pty-utils.js +2 -2
- package/dist/server/services/terminal/pty-utils.js.map +1 -1
- package/dist/server/services/websocket/autocomplete.d.ts +1 -1
- package/dist/server/services/websocket/autocomplete.d.ts.map +1 -1
- package/dist/server/services/websocket/autocomplete.js +37 -24
- package/dist/server/services/websocket/autocomplete.js.map +1 -1
- package/dist/server/services/websocket/file-explorer-handlers.d.ts +2 -2
- package/dist/server/services/websocket/file-explorer-handlers.d.ts.map +1 -1
- package/dist/server/services/websocket/file-explorer-handlers.js +11 -4
- package/dist/server/services/websocket/file-explorer-handlers.js.map +1 -1
- package/dist/server/services/websocket/handler.d.ts.map +1 -1
- package/dist/server/services/websocket/handler.js +6 -1
- package/dist/server/services/websocket/handler.js.map +1 -1
- package/dist/server/services/websocket/plan-board-handlers.d.ts +5 -5
- package/dist/server/services/websocket/plan-board-handlers.d.ts.map +1 -1
- package/dist/server/services/websocket/plan-board-handlers.js.map +1 -1
- package/dist/server/services/websocket/plan-execution-handlers.d.ts +6 -6
- package/dist/server/services/websocket/plan-execution-handlers.d.ts.map +1 -1
- package/dist/server/services/websocket/plan-execution-handlers.js +1 -4
- package/dist/server/services/websocket/plan-execution-handlers.js.map +1 -1
- package/dist/server/services/websocket/plan-handlers.d.ts +1 -1
- package/dist/server/services/websocket/plan-handlers.d.ts.map +1 -1
- package/dist/server/services/websocket/plan-handlers.js.map +1 -1
- package/dist/server/services/websocket/plan-helpers.d.ts +1 -1
- package/dist/server/services/websocket/plan-helpers.d.ts.map +1 -1
- package/dist/server/services/websocket/plan-helpers.js.map +1 -1
- package/dist/server/services/websocket/plan-issue-handlers.d.ts +4 -4
- package/dist/server/services/websocket/plan-issue-handlers.d.ts.map +1 -1
- package/dist/server/services/websocket/plan-issue-handlers.js +10 -0
- package/dist/server/services/websocket/plan-issue-handlers.js.map +1 -1
- package/dist/server/services/websocket/plan-sprint-handlers.d.ts +3 -3
- package/dist/server/services/websocket/plan-sprint-handlers.d.ts.map +1 -1
- package/dist/server/services/websocket/plan-sprint-handlers.js.map +1 -1
- package/dist/server/services/websocket/quality-handlers.d.ts +1 -1
- package/dist/server/services/websocket/quality-handlers.d.ts.map +1 -1
- package/dist/server/services/websocket/quality-handlers.js +9 -5
- package/dist/server/services/websocket/quality-handlers.js.map +1 -1
- package/dist/server/services/websocket/quality-review-agent.d.ts.map +1 -1
- package/dist/server/services/websocket/quality-review-agent.js +7 -4
- package/dist/server/services/websocket/quality-review-agent.js.map +1 -1
- package/dist/server/services/websocket/session-handlers.d.ts +1 -1
- package/dist/server/services/websocket/session-handlers.d.ts.map +1 -1
- package/dist/server/services/websocket/session-handlers.js +5 -2
- package/dist/server/services/websocket/session-handlers.js.map +1 -1
- package/dist/server/services/websocket/terminal-handlers.d.ts +1 -1
- package/dist/server/services/websocket/terminal-handlers.d.ts.map +1 -1
- package/dist/server/services/websocket/terminal-handlers.js +6 -5
- package/dist/server/services/websocket/terminal-handlers.js.map +1 -1
- package/dist/server/services/websocket/types.d.ts +2 -2
- package/dist/server/services/websocket/types.d.ts.map +1 -1
- package/dist/server/utils/port.d.ts +0 -11
- package/dist/server/utils/port.d.ts.map +1 -1
- package/dist/server/utils/port.js +0 -31
- package/dist/server/utils/port.js.map +1 -1
- package/package.json +1 -2
- package/server/cli/headless/claude-invoker-process.ts +5 -12
- package/server/cli/headless/claude-invoker.ts +1 -1
- package/server/cli/headless/mcp-config.ts +31 -4
- package/server/cli/headless/runner.ts +0 -1
- package/server/cli/headless/types.ts +1 -4
- package/server/cli/improvisation-retry.ts +0 -2
- package/server/cli/improvisation-session-manager.ts +45 -10
- package/server/index.ts +16 -2
- package/server/mcp/bouncer-haiku.ts +11 -5
- package/server/mcp/bouncer-integration.ts +14 -5
- package/server/mcp/server.ts +3 -1
- package/server/services/plan/composer.ts +1 -3
- package/server/services/plan/executor.ts +1 -9
- package/server/services/plan/review-gate.ts +13 -2
- package/server/services/plan/state-reconciler.ts +70 -1
- package/server/services/platform.ts +16 -4
- package/server/services/terminal/pty-manager.ts +5 -10
- package/server/services/terminal/pty-utils.ts +2 -2
- package/server/services/websocket/autocomplete.ts +48 -26
- package/server/services/websocket/file-explorer-handlers.ts +14 -7
- package/server/services/websocket/handler.ts +8 -2
- package/server/services/websocket/plan-board-handlers.ts +5 -5
- package/server/services/websocket/plan-execution-handlers.ts +7 -10
- package/server/services/websocket/plan-handlers.ts +1 -1
- package/server/services/websocket/plan-helpers.ts +1 -1
- package/server/services/websocket/plan-issue-handlers.ts +14 -4
- package/server/services/websocket/plan-sprint-handlers.ts +3 -3
- package/server/services/websocket/quality-handlers.ts +9 -5
- package/server/services/websocket/quality-review-agent.ts +7 -4
- package/server/services/websocket/session-handlers.ts +8 -3
- package/server/services/websocket/terminal-handlers.ts +7 -8
- package/server/services/websocket/types.ts +2 -2
- package/server/utils/port.ts +0 -41
- package/dist/server/mcp/bouncer-sandbox.d.ts +0 -60
- package/dist/server/mcp/bouncer-sandbox.d.ts.map +0 -1
- package/dist/server/mcp/bouncer-sandbox.js +0 -182
- package/dist/server/mcp/bouncer-sandbox.js.map +0 -1
- package/dist/server/services/credentials.d.ts +0 -39
- package/dist/server/services/credentials.d.ts.map +0 -1
- package/dist/server/services/credentials.js +0 -110
- package/dist/server/services/credentials.js.map +0 -1
- package/dist/server/services/sandbox-utils.d.ts +0 -8
- package/dist/server/services/sandbox-utils.d.ts.map +0 -1
- package/dist/server/services/sandbox-utils.js +0 -75
- package/dist/server/services/sandbox-utils.js.map +0 -1
- package/server/mcp/bouncer-sandbox.ts +0 -214
- package/server/services/credentials.ts +0 -134
- package/server/services/sandbox-utils.ts +0 -82
|
@@ -8,10 +8,10 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { existsSync, readdirSync, statSync } from 'node:fs';
|
|
11
|
-
import { join, } from 'node:path';
|
|
11
|
+
import { join, normalize, resolve } from 'node:path';
|
|
12
12
|
import Fuse, { type FuseResult } from 'fuse.js';
|
|
13
13
|
import {
|
|
14
|
-
CACHE_TTL_MS,
|
|
14
|
+
CACHE_TTL_MS,
|
|
15
15
|
directoryCache,
|
|
16
16
|
getFileType,
|
|
17
17
|
isIgnored,
|
|
@@ -100,6 +100,30 @@ function shouldIncludeEntry(
|
|
|
100
100
|
return true;
|
|
101
101
|
}
|
|
102
102
|
|
|
103
|
+
interface PathScope {
|
|
104
|
+
scopedDir: string;
|
|
105
|
+
searchQuery: string;
|
|
106
|
+
pathPrefix: string;
|
|
107
|
+
maxDepth: number;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Parse a partial path into its directory scope, search query, and depth limit. */
|
|
111
|
+
function resolvePathScope(cleanPath: string, workingDir: string, sandboxed?: boolean): PathScope | null {
|
|
112
|
+
const lastSlashIndex = cleanPath.lastIndexOf('/');
|
|
113
|
+
if (lastSlashIndex === -1) {
|
|
114
|
+
return { scopedDir: workingDir, searchQuery: cleanPath, pathPrefix: '', maxDepth: cleanPath === '' ? 4 : 10 };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const dirPath = cleanPath.substring(0, lastSlashIndex);
|
|
118
|
+
if (sandboxed && !isPathWithinDir(dirPath, workingDir)) return null;
|
|
119
|
+
|
|
120
|
+
const candidateDir = join(workingDir, dirPath);
|
|
121
|
+
if (existsSync(candidateDir) && statSync(candidateDir).isDirectory()) {
|
|
122
|
+
return { scopedDir: candidateDir, searchQuery: cleanPath.substring(lastSlashIndex + 1), pathPrefix: `${dirPath}/`, maxDepth: 3 };
|
|
123
|
+
}
|
|
124
|
+
return { scopedDir: workingDir, searchQuery: cleanPath, pathPrefix: '', maxDepth: 10 };
|
|
125
|
+
}
|
|
126
|
+
|
|
103
127
|
export class AutocompleteService {
|
|
104
128
|
private frecencyData: FrecencyData = {};
|
|
105
129
|
|
|
@@ -175,12 +199,18 @@ export class AutocompleteService {
|
|
|
175
199
|
/**
|
|
176
200
|
* Get file completions for autocomplete with directory-scoped navigation
|
|
177
201
|
*/
|
|
178
|
-
getFileCompletions(partialPath: string, workingDir: string): AutocompleteResult[] {
|
|
202
|
+
getFileCompletions(partialPath: string, workingDir: string, sandboxed?: boolean): AutocompleteResult[] {
|
|
179
203
|
try {
|
|
180
204
|
// Handle @ symbol prefix for file autocomplete
|
|
181
205
|
const isAtSymbol = partialPath.startsWith('@');
|
|
182
206
|
const cleanPath = isAtSymbol ? partialPath.substring(1) : partialPath;
|
|
183
207
|
|
|
208
|
+
// Sandboxed users: block path traversal outside the working directory.
|
|
209
|
+
// Resolves the target path and checks it stays within workingDir boundaries.
|
|
210
|
+
if (sandboxed && cleanPath && !isPathWithinDir(cleanPath, workingDir)) {
|
|
211
|
+
return [];
|
|
212
|
+
}
|
|
213
|
+
|
|
184
214
|
// Parse .gitignore patterns
|
|
185
215
|
const gitignorePatterns = parseGitignore(workingDir);
|
|
186
216
|
|
|
@@ -189,28 +219,10 @@ export class AutocompleteService {
|
|
|
189
219
|
return this.getDirectoryContentsEnhanced(cleanPath, workingDir, gitignorePatterns);
|
|
190
220
|
}
|
|
191
221
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
let scopedDir = workingDir;
|
|
195
|
-
let searchQuery = cleanPath;
|
|
196
|
-
let pathPrefix = '';
|
|
197
|
-
let maxDepth = 10;
|
|
198
|
-
|
|
199
|
-
if (lastSlashIndex !== -1) {
|
|
200
|
-
const dirPath = cleanPath.substring(0, lastSlashIndex);
|
|
201
|
-
const candidateDir = join(workingDir, dirPath);
|
|
202
|
-
|
|
203
|
-
if (existsSync(candidateDir) && statSync(candidateDir).isDirectory()) {
|
|
204
|
-
scopedDir = candidateDir;
|
|
205
|
-
searchQuery = cleanPath.substring(lastSlashIndex + 1);
|
|
206
|
-
pathPrefix = `${dirPath}/`;
|
|
207
|
-
maxDepth = 3;
|
|
208
|
-
}
|
|
209
|
-
} else if (cleanPath === '') {
|
|
210
|
-
maxDepth = 4;
|
|
211
|
-
}
|
|
222
|
+
const scope = resolvePathScope(cleanPath, workingDir, sandboxed);
|
|
223
|
+
if (!scope) return [];
|
|
212
224
|
|
|
213
|
-
const filesWithMetadata = this.getFilesWithCache(scopedDir, gitignorePatterns, maxDepth, pathPrefix);
|
|
225
|
+
const filesWithMetadata = this.getFilesWithCache(scope.scopedDir, gitignorePatterns, scope.maxDepth, scope.pathPrefix);
|
|
214
226
|
|
|
215
227
|
// Track which files are recent
|
|
216
228
|
const recentFiles = new Set<string>();
|
|
@@ -220,9 +232,9 @@ export class AutocompleteService {
|
|
|
220
232
|
}
|
|
221
233
|
}
|
|
222
234
|
|
|
223
|
-
const scoredMatches = searchQuery === ''
|
|
235
|
+
const scoredMatches = scope.searchQuery === ''
|
|
224
236
|
? this.scoreEmptyQuery(filesWithMetadata)
|
|
225
|
-
: this.scoreWithQuery(filesWithMetadata, searchQuery, recentFiles);
|
|
237
|
+
: this.scoreWithQuery(filesWithMetadata, scope.searchQuery, recentFiles);
|
|
226
238
|
|
|
227
239
|
const results: AutocompleteResult[] = scoredMatches.slice(0, 15).map(file => {
|
|
228
240
|
const displayPath = file.isDirectory ? `${file.relativePath}/` : file.relativePath;
|
|
@@ -440,3 +452,13 @@ export class AutocompleteService {
|
|
|
440
452
|
}
|
|
441
453
|
}
|
|
442
454
|
}
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* Check if a relative path resolves to a location within the working directory.
|
|
458
|
+
* Used to prevent path traversal (../) in sandboxed autocomplete.
|
|
459
|
+
*/
|
|
460
|
+
function isPathWithinDir(relativePath: string, workingDir: string): boolean {
|
|
461
|
+
const resolved = normalize(resolve(workingDir, relativePath));
|
|
462
|
+
const normalizedWorkDir = resolve(workingDir);
|
|
463
|
+
return resolved === normalizedWorkDir || resolved.startsWith(`${normalizedWorkDir}/`);
|
|
464
|
+
}
|
|
@@ -16,24 +16,31 @@ import { readFileContent } from './file-utils.js';
|
|
|
16
16
|
import type { HandlerContext } from './handler-context.js';
|
|
17
17
|
import type { WebSocketMessage, WebSocketResponse, WSContext } from './types.js';
|
|
18
18
|
|
|
19
|
-
export function handleFileMessage(ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage, tabId: string, workingDir: string, permission?: '
|
|
19
|
+
export function handleFileMessage(ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage, tabId: string, workingDir: string, permission?: 'view'): void {
|
|
20
|
+
const isSandboxed = !!permission;
|
|
20
21
|
switch (msg.type) {
|
|
21
22
|
case 'autocomplete':
|
|
22
23
|
if (!msg.data?.partialPath) throw new Error('Partial path is required');
|
|
23
|
-
ctx.send(ws, { type: 'autocomplete', tabId, data: { completions: ctx.autocompleteService.getFileCompletions(msg.data.partialPath, workingDir) } });
|
|
24
|
+
ctx.send(ws, { type: 'autocomplete', tabId, data: { completions: ctx.autocompleteService.getFileCompletions(msg.data.partialPath, workingDir, isSandboxed || undefined) } });
|
|
24
25
|
break;
|
|
25
26
|
case 'readFile':
|
|
26
27
|
handleReadFile(ctx, ws, msg, tabId, workingDir, permission);
|
|
27
28
|
break;
|
|
28
29
|
case 'recordSelection':
|
|
29
|
-
if (msg.data?.filePath)
|
|
30
|
+
if (msg.data?.filePath) {
|
|
31
|
+
if (isSandboxed) {
|
|
32
|
+
const validation = validatePathWithinWorkingDir(msg.data.filePath, workingDir);
|
|
33
|
+
if (!validation.valid) break; // Silently ignore out-of-bounds selections
|
|
34
|
+
}
|
|
35
|
+
ctx.recordFileSelection(msg.data.filePath);
|
|
36
|
+
}
|
|
30
37
|
break;
|
|
31
38
|
}
|
|
32
39
|
}
|
|
33
40
|
|
|
34
|
-
function handleReadFile(ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage, tabId: string, workingDir: string, permission?: '
|
|
41
|
+
function handleReadFile(ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage, tabId: string, workingDir: string, permission?: 'view'): void {
|
|
35
42
|
if (!msg.data?.filePath) throw new Error('File path is required');
|
|
36
|
-
const isSandboxed = permission
|
|
43
|
+
const isSandboxed = !!permission;
|
|
37
44
|
if (isSandboxed) {
|
|
38
45
|
const validation = validatePathWithinWorkingDir(msg.data.filePath, workingDir);
|
|
39
46
|
if (!validation.valid) {
|
|
@@ -51,8 +58,8 @@ function sendFileResult(ctx: HandlerContext, ws: WSContext, type: WebSocketRespo
|
|
|
51
58
|
ctx.send(ws, { type, tabId, data });
|
|
52
59
|
}
|
|
53
60
|
|
|
54
|
-
export function handleFileExplorerMessage(ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage, tabId: string, workingDir: string, permission?: '
|
|
55
|
-
const isSandboxed = permission
|
|
61
|
+
export function handleFileExplorerMessage(ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage, tabId: string, workingDir: string, permission?: 'view'): void {
|
|
62
|
+
const isSandboxed = !!permission;
|
|
56
63
|
const handlers: Record<string, () => void> = {
|
|
57
64
|
listDirectory: () => {
|
|
58
65
|
if (isSandboxed && msg.data?.dirPath) {
|
|
@@ -140,7 +140,7 @@ export class WebSocketImproviseHandler implements HandlerContext {
|
|
|
140
140
|
fileUploadStart: 'fileUpload', fileUploadChunk: 'fileUpload', fileUploadComplete: 'fileUpload', fileUploadCancel: 'fileUpload',
|
|
141
141
|
};
|
|
142
142
|
|
|
143
|
-
private async dispatchMessage(ws: WSContext, msg: WebSocketMessage, tabId: string, workingDir: string, permission?: '
|
|
143
|
+
private async dispatchMessage(ws: WSContext, msg: WebSocketMessage, tabId: string, workingDir: string, permission?: 'view'): Promise<void> {
|
|
144
144
|
// Handle messages with custom inline logic first
|
|
145
145
|
switch (msg.type) {
|
|
146
146
|
case 'ping':
|
|
@@ -185,7 +185,7 @@ export class WebSocketImproviseHandler implements HandlerContext {
|
|
|
185
185
|
case 'session': return handleSessionMessage(this, ws, msg, tabId, permission);
|
|
186
186
|
case 'history': return handleHistoryMessage(this, ws, msg, tabId, workingDir);
|
|
187
187
|
case 'file': return handleFileMessage(this, ws, msg, tabId, effectiveDir, permission);
|
|
188
|
-
case 'terminal': return handleTerminalMessage(this, ws, msg, tabId, workingDir
|
|
188
|
+
case 'terminal': return handleTerminalMessage(this, ws, msg, tabId, workingDir);
|
|
189
189
|
case 'fileExplorer': return handleFileExplorerMessage(this, ws, msg, tabId, effectiveDir, permission);
|
|
190
190
|
case 'git': return handleGitMessage(this, ws, msg, tabId, workingDir);
|
|
191
191
|
case 'quality': return handleQualityMessage(this, ws, msg, tabId, workingDir, permission);
|
|
@@ -233,6 +233,12 @@ export class WebSocketImproviseHandler implements HandlerContext {
|
|
|
233
233
|
this.connections.delete(ws);
|
|
234
234
|
this.allConnections.delete(ws);
|
|
235
235
|
cleanupTerminalSubscribers(this, ws);
|
|
236
|
+
|
|
237
|
+
// Clean up file upload handler when no connections remain
|
|
238
|
+
if (this.allConnections.size === 0 && this.fileUploadHandler) {
|
|
239
|
+
this.fileUploadHandler.destroy();
|
|
240
|
+
this.fileUploadHandler = null;
|
|
241
|
+
}
|
|
236
242
|
}
|
|
237
243
|
|
|
238
244
|
send(ws: WSContext, response: WebSocketResponse): void {
|
|
@@ -16,7 +16,7 @@ import type { WebSocketMessage, WSContext } from './types.js';
|
|
|
16
16
|
|
|
17
17
|
export function handleCreateBoard(
|
|
18
18
|
ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage,
|
|
19
|
-
workingDir: string, permission?: '
|
|
19
|
+
workingDir: string, permission?: 'view',
|
|
20
20
|
): void {
|
|
21
21
|
if (denyIfViewOnly(ctx, ws, permission)) return;
|
|
22
22
|
|
|
@@ -99,7 +99,7 @@ paused: false
|
|
|
99
99
|
|
|
100
100
|
export function handleUpdateBoard(
|
|
101
101
|
ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage,
|
|
102
|
-
workingDir: string, permission?: '
|
|
102
|
+
workingDir: string, permission?: 'view',
|
|
103
103
|
): void {
|
|
104
104
|
if (denyIfViewOnly(ctx, ws, permission)) return;
|
|
105
105
|
|
|
@@ -133,7 +133,7 @@ export function handleUpdateBoard(
|
|
|
133
133
|
|
|
134
134
|
export function handleArchiveBoard(
|
|
135
135
|
ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage,
|
|
136
|
-
workingDir: string, permission?: '
|
|
136
|
+
workingDir: string, permission?: 'view',
|
|
137
137
|
): void {
|
|
138
138
|
if (denyIfViewOnly(ctx, ws, permission)) return;
|
|
139
139
|
|
|
@@ -204,7 +204,7 @@ export function handleGetBoardState(
|
|
|
204
204
|
|
|
205
205
|
export function handleReorderBoards(
|
|
206
206
|
ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage,
|
|
207
|
-
workingDir: string, permission?: '
|
|
207
|
+
workingDir: string, permission?: 'view',
|
|
208
208
|
): void {
|
|
209
209
|
if (denyIfViewOnly(ctx, ws, permission)) return;
|
|
210
210
|
|
|
@@ -229,7 +229,7 @@ export function handleReorderBoards(
|
|
|
229
229
|
|
|
230
230
|
export function handleSetActiveBoard(
|
|
231
231
|
ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage,
|
|
232
|
-
workingDir: string, permission?: '
|
|
232
|
+
workingDir: string, permission?: 'view',
|
|
233
233
|
): void {
|
|
234
234
|
if (denyIfViewOnly(ctx, ws, permission)) return;
|
|
235
235
|
|
|
@@ -14,7 +14,7 @@ import type { WebSocketMessage, WSContext } from './types.js';
|
|
|
14
14
|
|
|
15
15
|
export function handlePrompt(
|
|
16
16
|
ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage,
|
|
17
|
-
workingDir: string, permission?: '
|
|
17
|
+
workingDir: string, permission?: 'view',
|
|
18
18
|
): void {
|
|
19
19
|
if (denyIfViewOnly(ctx, ws, permission)) return;
|
|
20
20
|
|
|
@@ -24,8 +24,7 @@ export function handlePrompt(
|
|
|
24
24
|
ctx.send(ws, { type: 'planError', data: { error: 'Prompt required' } });
|
|
25
25
|
return;
|
|
26
26
|
}
|
|
27
|
-
|
|
28
|
-
handlePlanPrompt(ctx, ws, prompt, workingDir, boardId, sandboxed).catch(error => {
|
|
27
|
+
handlePlanPrompt(ctx, ws, prompt, workingDir, boardId).catch(error => {
|
|
29
28
|
ctx.send(ws, {
|
|
30
29
|
type: 'planError',
|
|
31
30
|
data: { error: error instanceof Error ? error.message : String(error) },
|
|
@@ -96,12 +95,11 @@ function wireExecutorEvents(executor: PlanExecutor, ctx: HandlerContext, working
|
|
|
96
95
|
|
|
97
96
|
export function handleExecute(
|
|
98
97
|
ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage,
|
|
99
|
-
workingDir: string, permission?: '
|
|
98
|
+
workingDir: string, permission?: 'view',
|
|
100
99
|
): void {
|
|
101
100
|
if (denyIfViewOnly(ctx, ws, permission)) return;
|
|
102
101
|
|
|
103
102
|
const executor = getExecutor(workingDir);
|
|
104
|
-
executor.setSandboxed(permission === 'control');
|
|
105
103
|
|
|
106
104
|
if (executor.getStatus() === 'executing' || executor.getStatus() === 'starting') {
|
|
107
105
|
ctx.send(ws, { type: 'planError', data: { error: 'Execution already in progress' } });
|
|
@@ -123,7 +121,7 @@ export function handleExecute(
|
|
|
123
121
|
|
|
124
122
|
export function handleExecuteEpic(
|
|
125
123
|
ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage,
|
|
126
|
-
workingDir: string, permission?: '
|
|
124
|
+
workingDir: string, permission?: 'view',
|
|
127
125
|
): void {
|
|
128
126
|
if (denyIfViewOnly(ctx, ws, permission)) return;
|
|
129
127
|
|
|
@@ -134,7 +132,6 @@ export function handleExecuteEpic(
|
|
|
134
132
|
}
|
|
135
133
|
|
|
136
134
|
const executor = getExecutor(workingDir);
|
|
137
|
-
executor.setSandboxed(permission === 'control');
|
|
138
135
|
|
|
139
136
|
if (executor.getStatus() === 'executing' || executor.getStatus() === 'starting') {
|
|
140
137
|
ctx.send(ws, { type: 'planError', data: { error: 'Execution already in progress' } });
|
|
@@ -154,7 +151,7 @@ export function handleExecuteEpic(
|
|
|
154
151
|
|
|
155
152
|
export function handlePause(
|
|
156
153
|
ctx: HandlerContext, ws: WSContext,
|
|
157
|
-
workingDir: string, permission?: '
|
|
154
|
+
workingDir: string, permission?: 'view',
|
|
158
155
|
): void {
|
|
159
156
|
if (denyIfViewOnly(ctx, ws, permission)) return;
|
|
160
157
|
const executor = executorCache.get(workingDir);
|
|
@@ -163,7 +160,7 @@ export function handlePause(
|
|
|
163
160
|
|
|
164
161
|
export function handleStop(
|
|
165
162
|
ctx: HandlerContext, ws: WSContext,
|
|
166
|
-
workingDir: string, permission?: '
|
|
163
|
+
workingDir: string, permission?: 'view',
|
|
167
164
|
): void {
|
|
168
165
|
if (denyIfViewOnly(ctx, ws, permission)) return;
|
|
169
166
|
const executor = executorCache.get(workingDir);
|
|
@@ -172,7 +169,7 @@ export function handleStop(
|
|
|
172
169
|
|
|
173
170
|
export function handleResume(
|
|
174
171
|
ctx: HandlerContext, ws: WSContext,
|
|
175
|
-
workingDir: string, permission?: '
|
|
172
|
+
workingDir: string, permission?: 'view',
|
|
176
173
|
): void {
|
|
177
174
|
if (denyIfViewOnly(ctx, ws, permission)) return;
|
|
178
175
|
const executor = executorCache.get(workingDir);
|
|
@@ -24,7 +24,7 @@ export function handlePlanMessage(
|
|
|
24
24
|
msg: WebSocketMessage,
|
|
25
25
|
_tabId: string,
|
|
26
26
|
workingDir: string,
|
|
27
|
-
permission?: '
|
|
27
|
+
permission?: 'view',
|
|
28
28
|
): void {
|
|
29
29
|
const handlers: Record<string, () => void> = {
|
|
30
30
|
planInit: () => handlePlanInit(ctx, ws, workingDir),
|
|
@@ -23,7 +23,7 @@ export function resolvePlanPath(workingDir: string, relativePath: string): strin
|
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
/** Guard for write operations — returns true if denied. */
|
|
26
|
-
export function denyIfViewOnly(ctx: HandlerContext, ws: WSContext, permission?: '
|
|
26
|
+
export function denyIfViewOnly(ctx: HandlerContext, ws: WSContext, permission?: 'view'): boolean {
|
|
27
27
|
if (permission === 'view') {
|
|
28
28
|
ctx.send(ws, { type: 'planError', data: { error: 'Permission denied' } });
|
|
29
29
|
return true;
|
|
@@ -5,6 +5,7 @@ import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from '
|
|
|
5
5
|
import { basename, join } from 'node:path';
|
|
6
6
|
import { replaceFrontMatterField } from '../plan/front-matter.js';
|
|
7
7
|
import { defaultPmDir, getNextId, parseBoardDirectory, parsePlanDirectory, parseSingleIssue, parseSingleMilestone, parseSingleSprint, planDirExists, resolvePmDir } from '../plan/parser.js';
|
|
8
|
+
import { tryCompleteParentEpic } from '../plan/state-reconciler.js';
|
|
8
9
|
import type { HandlerContext } from './handler-context.js';
|
|
9
10
|
import { buildIssueMarkdown, denyIfViewOnly, formatYamlValue, getWatcher, resolvePlanPath, scaffoldPmDirectory } from './plan-helpers.js';
|
|
10
11
|
import type { WebSocketMessage, WSContext } from './types.js';
|
|
@@ -108,7 +109,7 @@ function resolveBacklogContext(pmDir: string, workingDir: string, boardId?: stri
|
|
|
108
109
|
|
|
109
110
|
export function handleCreateIssue(
|
|
110
111
|
ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage,
|
|
111
|
-
workingDir: string, permission?: '
|
|
112
|
+
workingDir: string, permission?: 'view',
|
|
112
113
|
): void {
|
|
113
114
|
if (denyIfViewOnly(ctx, ws, permission)) return;
|
|
114
115
|
|
|
@@ -135,7 +136,7 @@ export function handleCreateIssue(
|
|
|
135
136
|
|
|
136
137
|
export function handleUpdateIssue(
|
|
137
138
|
ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage,
|
|
138
|
-
workingDir: string, permission?: '
|
|
139
|
+
workingDir: string, permission?: 'view',
|
|
139
140
|
): void {
|
|
140
141
|
if (denyIfViewOnly(ctx, ws, permission)) return;
|
|
141
142
|
|
|
@@ -166,11 +167,20 @@ export function handleUpdateIssue(
|
|
|
166
167
|
|
|
167
168
|
const issue = parseSingleIssue(workingDir, path);
|
|
168
169
|
ctx.broadcastToAll({ type: 'planIssueUpdated', data: issue });
|
|
170
|
+
|
|
171
|
+
// Auto-complete parent epic if all its children are now done
|
|
172
|
+
if (issue) {
|
|
173
|
+
const epicPath = tryCompleteParentEpic(workingDir, issue);
|
|
174
|
+
if (epicPath) {
|
|
175
|
+
const epic = parseSingleIssue(workingDir, epicPath);
|
|
176
|
+
if (epic) ctx.broadcastToAll({ type: 'planIssueUpdated', data: epic });
|
|
177
|
+
}
|
|
178
|
+
}
|
|
169
179
|
}
|
|
170
180
|
|
|
171
181
|
export function handleDeleteIssue(
|
|
172
182
|
ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage,
|
|
173
|
-
workingDir: string, permission?: '
|
|
183
|
+
workingDir: string, permission?: 'view',
|
|
174
184
|
): void {
|
|
175
185
|
if (denyIfViewOnly(ctx, ws, permission)) return;
|
|
176
186
|
|
|
@@ -192,7 +202,7 @@ export function handleDeleteIssue(
|
|
|
192
202
|
|
|
193
203
|
export function handleScaffold(
|
|
194
204
|
ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage,
|
|
195
|
-
workingDir: string, permission?: '
|
|
205
|
+
workingDir: string, permission?: 'view',
|
|
196
206
|
): void {
|
|
197
207
|
if (denyIfViewOnly(ctx, ws, permission)) return;
|
|
198
208
|
|
|
@@ -60,7 +60,7 @@ function assignIssuesToSprint(workingDir: string, issues: Issue[], issueIds: str
|
|
|
60
60
|
|
|
61
61
|
export function handleCreateSprint(
|
|
62
62
|
ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage,
|
|
63
|
-
workingDir: string, permission?: '
|
|
63
|
+
workingDir: string, permission?: 'view',
|
|
64
64
|
): void {
|
|
65
65
|
if (denyIfViewOnly(ctx, ws, permission)) return;
|
|
66
66
|
|
|
@@ -116,7 +116,7 @@ function updateFileField(filePath: string, field: string, value: string): void {
|
|
|
116
116
|
|
|
117
117
|
export function handleActivateSprint(
|
|
118
118
|
ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage,
|
|
119
|
-
workingDir: string, permission?: '
|
|
119
|
+
workingDir: string, permission?: 'view',
|
|
120
120
|
): void {
|
|
121
121
|
if (denyIfViewOnly(ctx, ws, permission)) return;
|
|
122
122
|
|
|
@@ -162,7 +162,7 @@ export function handleActivateSprint(
|
|
|
162
162
|
|
|
163
163
|
export function handleCompleteSprint(
|
|
164
164
|
ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage,
|
|
165
|
-
workingDir: string, permission?: '
|
|
165
|
+
workingDir: string, permission?: 'view',
|
|
166
166
|
): void {
|
|
167
167
|
if (denyIfViewOnly(ctx, ws, permission)) return;
|
|
168
168
|
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* - quality-fix-agent.ts — AI fix prompt, progress tracking, handler
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
import { join } from 'node:path';
|
|
13
|
+
import { join, resolve } from 'node:path';
|
|
14
14
|
import { validatePathWithinWorkingDir } from '../pathUtils.js';
|
|
15
15
|
import type { HandlerContext } from './handler-context.js';
|
|
16
16
|
import type { FindingForFix } from './quality-fix-agent.js';
|
|
@@ -36,8 +36,12 @@ function getPersistence(workingDir: string): QualityPersistence {
|
|
|
36
36
|
|
|
37
37
|
function resolvePath(workingDir: string, dirPath?: string): string {
|
|
38
38
|
if (!dirPath || dirPath === '.' || dirPath === './') return workingDir;
|
|
39
|
-
|
|
40
|
-
|
|
39
|
+
const resolved = dirPath.startsWith('/') ? dirPath : join(workingDir, dirPath);
|
|
40
|
+
// Ensure path is within working directory even for non-sandboxed users
|
|
41
|
+
const normalizedResolved = resolve(resolved);
|
|
42
|
+
const normalizedWorkingDir = resolve(workingDir);
|
|
43
|
+
if (!normalizedResolved.startsWith(normalizedWorkingDir)) return workingDir;
|
|
44
|
+
return normalizedResolved;
|
|
41
45
|
}
|
|
42
46
|
|
|
43
47
|
/**
|
|
@@ -68,9 +72,9 @@ export function handleQualityMessage(
|
|
|
68
72
|
msg: WebSocketMessage,
|
|
69
73
|
_tabId: string,
|
|
70
74
|
workingDir: string,
|
|
71
|
-
permission?: '
|
|
75
|
+
permission?: 'view',
|
|
72
76
|
): void {
|
|
73
|
-
const isSandboxed = permission
|
|
77
|
+
const isSandboxed = !!permission;
|
|
74
78
|
const sendPathError = (path: string, error: string) => {
|
|
75
79
|
ctx.send(ws, { type: 'qualityError', data: { path, error } });
|
|
76
80
|
};
|
|
@@ -571,8 +571,11 @@ function persistReviewResults(
|
|
|
571
571
|
): import('./quality-service.js').QualityResults | null {
|
|
572
572
|
const persistence = getPersistence(workingDir);
|
|
573
573
|
const existingReport = persistence.loadReport(reportPath);
|
|
574
|
+
// CodeReviewFinding is structurally compatible with QualityFinding (category is a narrower union)
|
|
575
|
+
const findings = reviewResult.findings as import('./quality-types.js').QualityFinding[];
|
|
576
|
+
const findingsRecord = reviewResult.findings as unknown[] as Record<string, unknown>[];
|
|
574
577
|
if (!existingReport) {
|
|
575
|
-
persistence.saveCodeReview(reportPath,
|
|
578
|
+
persistence.saveCodeReview(reportPath, findingsRecord, reviewResult.summary);
|
|
576
579
|
return null;
|
|
577
580
|
}
|
|
578
581
|
|
|
@@ -582,17 +585,17 @@ function persistReviewResults(
|
|
|
582
585
|
...existingReport,
|
|
583
586
|
overall: reviewResult.score,
|
|
584
587
|
grade: reviewResult.grade,
|
|
585
|
-
codeReview:
|
|
588
|
+
codeReview: findings,
|
|
586
589
|
scoreRationale: reviewResult.scoreRationale ?? undefined,
|
|
587
590
|
};
|
|
588
591
|
} else {
|
|
589
592
|
updatedResults = recomputeWithAiReview(existingReport, reviewResult.findings);
|
|
590
|
-
updatedResults = { ...updatedResults, codeReview:
|
|
593
|
+
updatedResults = { ...updatedResults, codeReview: findings };
|
|
591
594
|
}
|
|
592
595
|
|
|
593
596
|
persistence.saveReport(reportPath, updatedResults);
|
|
594
597
|
persistence.appendHistory(updatedResults, reportPath);
|
|
595
|
-
persistence.saveCodeReview(reportPath,
|
|
598
|
+
persistence.saveCodeReview(reportPath, findingsRecord, reviewResult.summary);
|
|
596
599
|
return updatedResults;
|
|
597
600
|
}
|
|
598
601
|
|
|
@@ -178,15 +178,20 @@ function mergePreUploadedAttachments(ctx: HandlerContext, tabId: string, inlineA
|
|
|
178
178
|
return merged;
|
|
179
179
|
}
|
|
180
180
|
|
|
181
|
-
|
|
181
|
+
const WRITE_OPS = new Set(['execute', 'cancel', 'new', 'approve', 'reject']);
|
|
182
|
+
|
|
183
|
+
export function handleSessionMessage(ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage, tabId: string, permission?: 'view'): void {
|
|
184
|
+
if (permission === 'view' && WRITE_OPS.has(msg.type)) {
|
|
185
|
+
throw new Error('View-only users cannot perform write operations');
|
|
186
|
+
}
|
|
187
|
+
|
|
182
188
|
switch (msg.type) {
|
|
183
189
|
case 'execute': {
|
|
184
190
|
if (!msg.data?.prompt) throw new Error('Prompt is required');
|
|
185
191
|
const session = requireSession(ctx, ws, tabId);
|
|
186
|
-
const sandboxed = permission === 'control' || permission === 'view';
|
|
187
192
|
const worktreeDir = ctx.gitDirectories.get(tabId);
|
|
188
193
|
const attachments = mergePreUploadedAttachments(ctx, tabId, msg.data.attachments);
|
|
189
|
-
session.executePrompt(msg.data.prompt, attachments, {
|
|
194
|
+
session.executePrompt(msg.data.prompt, attachments, { workingDir: worktreeDir });
|
|
190
195
|
break;
|
|
191
196
|
}
|
|
192
197
|
case 'cancel': {
|
|
@@ -7,11 +7,11 @@ import { getPTYManager } from '../terminal/pty-manager.js';
|
|
|
7
7
|
import type { HandlerContext } from './handler-context.js';
|
|
8
8
|
import type { WebSocketMessage, WSContext } from './types.js';
|
|
9
9
|
|
|
10
|
-
export function handleTerminalMessage(ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage, tabId: string, workingDir: string
|
|
10
|
+
export async function handleTerminalMessage(ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage, tabId: string, workingDir: string): Promise<void> {
|
|
11
11
|
const termId = msg.terminalId || tabId;
|
|
12
12
|
switch (msg.type) {
|
|
13
13
|
case 'terminalInit':
|
|
14
|
-
handleTerminalInit(ctx, ws, termId, workingDir, msg.data?.shell, msg.data?.cols, msg.data?.rows
|
|
14
|
+
await handleTerminalInit(ctx, ws, termId, workingDir, msg.data?.shell, msg.data?.cols, msg.data?.rows);
|
|
15
15
|
break;
|
|
16
16
|
case 'terminalReconnect':
|
|
17
17
|
handleTerminalReconnect(ctx, ws, termId);
|
|
@@ -31,7 +31,7 @@ export function handleTerminalMessage(ctx: HandlerContext, ws: WSContext, msg: W
|
|
|
31
31
|
}
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
-
function handleTerminalInit(
|
|
34
|
+
async function handleTerminalInit(
|
|
35
35
|
ctx: HandlerContext,
|
|
36
36
|
ws: WSContext,
|
|
37
37
|
terminalId: string,
|
|
@@ -39,8 +39,7 @@ function handleTerminalInit(
|
|
|
39
39
|
requestedShell?: string,
|
|
40
40
|
cols?: number,
|
|
41
41
|
rows?: number,
|
|
42
|
-
|
|
43
|
-
): void {
|
|
42
|
+
): Promise<void> {
|
|
44
43
|
const ptyManager = getPTYManager();
|
|
45
44
|
|
|
46
45
|
if (!ptyManager.isPtyAvailable()) {
|
|
@@ -59,13 +58,12 @@ function handleTerminalInit(
|
|
|
59
58
|
setupTerminalBroadcastListeners(ctx, terminalId);
|
|
60
59
|
|
|
61
60
|
try {
|
|
62
|
-
const { shell, cwd, isReconnect, platform } = ptyManager.create(
|
|
61
|
+
const { shell, cwd, isReconnect, platform } = await ptyManager.create(
|
|
63
62
|
terminalId,
|
|
64
63
|
workingDir,
|
|
65
64
|
cols || 80,
|
|
66
65
|
rows || 24,
|
|
67
66
|
requestedShell,
|
|
68
|
-
{ sandboxed: permission === 'control' || permission === 'view' }
|
|
69
67
|
);
|
|
70
68
|
|
|
71
69
|
if (!isReconnect) {
|
|
@@ -94,11 +92,12 @@ function handleTerminalInit(
|
|
|
94
92
|
is_reconnect: isReconnect,
|
|
95
93
|
});
|
|
96
94
|
} catch (error: unknown) {
|
|
95
|
+
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
97
96
|
console.error(`[WebSocketImproviseHandler] Failed to create terminal:`, error);
|
|
98
97
|
ctx.send(ws, {
|
|
99
98
|
type: 'terminalError',
|
|
100
99
|
terminalId,
|
|
101
|
-
data: { error:
|
|
100
|
+
data: { error: errorMsg || 'Failed to create terminal' }
|
|
102
101
|
});
|
|
103
102
|
removeTerminalSubscriber(ctx, terminalId, ws);
|
|
104
103
|
}
|
|
@@ -158,8 +158,8 @@ export interface WebSocketMessage {
|
|
|
158
158
|
terminalId?: string;
|
|
159
159
|
// biome-ignore lint/suspicious/noExplicitAny: message envelope carries heterogeneous payloads
|
|
160
160
|
data?: any;
|
|
161
|
-
/** Injected by server relay for
|
|
162
|
-
_permission?: '
|
|
161
|
+
/** Injected by server relay for view-only shared users */
|
|
162
|
+
_permission?: 'view';
|
|
163
163
|
}
|
|
164
164
|
|
|
165
165
|
export interface WebSocketResponse {
|
package/server/utils/port.ts
CHANGED
|
@@ -59,44 +59,3 @@ export async function findAvailablePort(startPort: number, maxTries: number = 20
|
|
|
59
59
|
}
|
|
60
60
|
throw new Error(`No available ports found between ${startPort} and ${startPort + maxTries}`)
|
|
61
61
|
}
|
|
62
|
-
|
|
63
|
-
/**
|
|
64
|
-
* Find an available port pair for frontend and backend
|
|
65
|
-
* Frontend = EVEN port (3000, 3002, 3004...)
|
|
66
|
-
* Backend = ODD port (3001, 3003, 3005...)
|
|
67
|
-
*
|
|
68
|
-
* Checks all candidate ports in parallel for fast detection.
|
|
69
|
-
*/
|
|
70
|
-
export async function findAvailablePortPair(startPort: number = 3000, maxPairs: number = 20): Promise<{ frontend: number; backend: number }> {
|
|
71
|
-
// Ensure startPort is even
|
|
72
|
-
const basePort = startPort % 2 === 0 ? startPort : startPort + 1
|
|
73
|
-
|
|
74
|
-
// Generate all candidate pairs
|
|
75
|
-
const pairs: { frontend: number; backend: number }[] = []
|
|
76
|
-
for (let i = 0; i < maxPairs; i++) {
|
|
77
|
-
pairs.push({
|
|
78
|
-
frontend: basePort + (i * 2), // 3000, 3002, 3004...
|
|
79
|
-
backend: basePort + (i * 2) + 1 // 3001, 3003, 3005...
|
|
80
|
-
})
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
// Check all ports in parallel (both frontend and backend ports)
|
|
84
|
-
const allPorts = pairs.flatMap(p => [p.frontend, p.backend])
|
|
85
|
-
const results = await Promise.all(
|
|
86
|
-
allPorts.map(async (port) => ({ port, available: await isPortAvailable(port) }))
|
|
87
|
-
)
|
|
88
|
-
|
|
89
|
-
// Build a set of available ports for O(1) lookup
|
|
90
|
-
const availablePorts = new Set(
|
|
91
|
-
results.filter(r => r.available).map(r => r.port)
|
|
92
|
-
)
|
|
93
|
-
|
|
94
|
-
// Find first pair where both ports are available
|
|
95
|
-
for (const pair of pairs) {
|
|
96
|
-
if (availablePorts.has(pair.frontend) && availablePorts.has(pair.backend)) {
|
|
97
|
-
return pair
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
throw new Error(`No available port pairs found starting from ${startPort}`)
|
|
102
|
-
}
|