Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions apps/server/src/routes/worktree/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,13 @@ import {
createGetAvailableEditorsHandler,
createRefreshEditorsHandler,
} from './routes/open-in-editor.js';
import {
createOpenInTerminalHandler,
createGetAvailableTerminalsHandler,
createGetDefaultTerminalHandler,
createRefreshTerminalsHandler,
createOpenInExternalTerminalHandler,
} from './routes/open-in-terminal.js';
import { createInitGitHandler } from './routes/init-git.js';
import { createMigrateHandler } from './routes/migrate.js';
import { createStartDevHandler } from './routes/start-dev.js';
Expand Down Expand Up @@ -97,9 +104,25 @@ export function createWorktreeRoutes(
);
router.post('/switch-branch', requireValidWorktree, createSwitchBranchHandler());
router.post('/open-in-editor', validatePathParams('worktreePath'), createOpenInEditorHandler());
router.post(
'/open-in-terminal',
validatePathParams('worktreePath'),
createOpenInTerminalHandler()
);
router.get('/default-editor', createGetDefaultEditorHandler());
router.get('/available-editors', createGetAvailableEditorsHandler());
router.post('/refresh-editors', createRefreshEditorsHandler());

// External terminal routes
router.get('/available-terminals', createGetAvailableTerminalsHandler());
router.get('/default-terminal', createGetDefaultTerminalHandler());
router.post('/refresh-terminals', createRefreshTerminalsHandler());
router.post(
'/open-in-external-terminal',
validatePathParams('worktreePath'),
createOpenInExternalTerminalHandler()
);

router.post('/init-git', validatePathParams('projectPath'), createInitGitHandler());
router.post('/migrate', createMigrateHandler());
router.post(
Expand Down
181 changes: 181 additions & 0 deletions apps/server/src/routes/worktree/routes/open-in-terminal.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
/**
* Terminal endpoints for opening worktree directories in terminals
*
* POST /open-in-terminal - Open in system default terminal (integrated)
* GET /available-terminals - List all available external terminals
* GET /default-terminal - Get the default external terminal
* POST /refresh-terminals - Clear terminal cache and re-detect
* POST /open-in-external-terminal - Open a directory in an external terminal
*/

import type { Request, Response } from 'express';
import { isAbsolute } from 'path';
import {
openInTerminal,
clearTerminalCache,
detectAllTerminals,
detectDefaultTerminal,
openInExternalTerminal,
} from '@automaker/platform';
import { createLogger } from '@automaker/utils';
import { getErrorMessage, logError } from '../common.js';

const logger = createLogger('open-in-terminal');

/**
* Handler to open in system default terminal (integrated terminal behavior)
*/
export function createOpenInTerminalHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { worktreePath } = req.body as {
worktreePath: string;
};

if (!worktreePath || typeof worktreePath !== 'string') {
res.status(400).json({
success: false,
error: 'worktreePath required and must be a string',
});
return;
}

// Security: Validate that worktreePath is an absolute path
if (!isAbsolute(worktreePath)) {
res.status(400).json({
success: false,
error: 'worktreePath must be an absolute path',
});
return;
}

// Use the platform utility to open in terminal
const result = await openInTerminal(worktreePath);
res.json({
success: true,
result: {
message: `Opened terminal in ${worktreePath}`,
terminalName: result.terminalName,
},
});
} catch (error) {
logError(error, 'Open in terminal failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

/**
* Handler to get all available external terminals
*/
export function createGetAvailableTerminalsHandler() {
return async (_req: Request, res: Response): Promise<void> => {
try {
const terminals = await detectAllTerminals();
res.json({
success: true,
result: {
terminals,
},
});
} catch (error) {
logError(error, 'Get available terminals failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

/**
* Handler to get the default external terminal
*/
export function createGetDefaultTerminalHandler() {
return async (_req: Request, res: Response): Promise<void> => {
try {
const terminal = await detectDefaultTerminal();
res.json({
success: true,
result: terminal
? {
terminalId: terminal.id,
terminalName: terminal.name,
terminalCommand: terminal.command,
}
: null,
});
} catch (error) {
logError(error, 'Get default terminal failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

/**
* Handler to refresh the terminal cache and re-detect available terminals
* Useful when the user has installed/uninstalled terminals
*/
export function createRefreshTerminalsHandler() {
return async (_req: Request, res: Response): Promise<void> => {
try {
// Clear the cache
clearTerminalCache();

// Re-detect terminals (this will repopulate the cache)
const terminals = await detectAllTerminals();

logger.info(`Terminal cache refreshed, found ${terminals.length} terminals`);

res.json({
success: true,
result: {
terminals,
message: `Found ${terminals.length} available external terminals`,
},
});
} catch (error) {
logError(error, 'Refresh terminals failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

/**
* Handler to open a directory in an external terminal
*/
export function createOpenInExternalTerminalHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { worktreePath, terminalId } = req.body as {
worktreePath: string;
terminalId?: string;
};

if (!worktreePath || typeof worktreePath !== 'string') {
res.status(400).json({
success: false,
error: 'worktreePath required and must be a string',
});
return;
}

if (!isAbsolute(worktreePath)) {
res.status(400).json({
success: false,
error: 'worktreePath must be an absolute path',
});
return;
}

const result = await openInExternalTerminal(worktreePath, terminalId);
res.json({
success: true,
result: {
message: `Opened ${worktreePath} in ${result.terminalName}`,
terminalName: result.terminalName,
},
});
Comment on lines +144 to +175
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Search for openInExternalTerminal function in platform terminal.ts
rg -n -C5 "openInExternalTerminal" libs/platform/src/terminal.ts

Repository: AutoMaker-Org/automaker

Length of output: 543


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Get more context on the openInExternalTerminal implementation
rg -n -A 30 "export async function openInExternalTerminal" libs/platform/src/terminal.ts

Repository: AutoMaker-Org/automaker

Length of output: 996


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Check the findTerminalById function to see how it handles the terminalId parameter
rg -n -B2 -A15 "function findTerminalById|export.*findTerminalById" libs/platform/src/terminal.ts

Repository: AutoMaker-Org/automaker

Length of output: 904


Validate terminalId type before delegating.

If a client sends a non-string terminalId, it would pass the truthiness check but silently fail in findTerminalById() due to string comparison mismatch, causing unexpected fallback behavior instead of returning a clear 400 error. Add a lightweight runtime check.

🛠️ Proposed fix
       const { worktreePath, terminalId } = req.body as {
         worktreePath: string;
         terminalId?: string;
       };

       if (!worktreePath || typeof worktreePath !== 'string') {
         res.status(400).json({
           success: false,
           error: 'worktreePath required and must be a string',
         });
         return;
       }

+      if (terminalId !== undefined && typeof terminalId !== 'string') {
+        res.status(400).json({
+          success: false,
+          error: 'terminalId must be a string',
+        });
+        return;
+      }
+
       if (!isAbsolute(worktreePath)) {
         res.status(400).json({
           success: false,
           error: 'worktreePath must be an absolute path',
         });
         return;
       }
🤖 Prompt for AI Agents
In `@apps/server/src/routes/worktree/routes/open-in-terminal.ts` around lines 144
- 175, The handler createOpenInExternalTerminalHandler must validate
terminalId's runtime type before calling openInExternalTerminal: if terminalId
is present but not a string, respond with 400 and an error like "terminalId must
be a string" to avoid silent mismatches in findTerminalById; add this
lightweight check immediately after validating worktreePath and before invoking
openInExternalTerminal so only string terminalId values are delegated.

} catch (error) {
logError(error, 'Open in external terminal failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}
Loading