mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-03-20 23:13:07 +00:00
Compare commits
60 Commits
00f9891237
...
v1.0.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e10c73649c | ||
|
|
583c3eb4a6 | ||
|
|
6408f514a4 | ||
|
|
6b97219f55 | ||
|
|
09a4d3f15a | ||
|
|
51e9a23ba1 | ||
|
|
0330c70261 | ||
|
|
e7504b247f | ||
|
|
9305ecc242 | ||
|
|
2f071a1ba3 | ||
|
|
1d732916f1 | ||
|
|
629fd24d9f | ||
|
|
72cb942788 | ||
|
|
91bff21d58 | ||
|
|
dfa719079f | ||
|
|
28becb177b | ||
|
|
f785f1204b | ||
|
|
f3edfbf24e | ||
|
|
3ddf26f666 | ||
|
|
c81ea768a7 | ||
|
|
0e020f7e4a | ||
|
|
0a5540c9a2 | ||
|
|
7df2182818 | ||
|
|
ee52333636 | ||
|
|
47bd7a76cf | ||
|
|
ae10dea2bf | ||
|
|
be4153c374 | ||
|
|
a144a63c51 | ||
|
|
205f662022 | ||
|
|
53d07fefb8 | ||
|
|
2d907938cc | ||
|
|
15ca1eb6d3 | ||
|
|
4ee160fae4 | ||
|
|
4ba0026aa1 | ||
|
|
983eb21faa | ||
|
|
df9a6314da | ||
|
|
6903d3c508 | ||
|
|
5c441f2313 | ||
|
|
d30296d559 | ||
|
|
e6e04d57bc | ||
|
|
829c16181b | ||
|
|
13261b7e8c | ||
|
|
854ba6ec74 | ||
|
|
bddf1a4bf8 | ||
|
|
887e2ea76b | ||
|
|
dd4c738e91 | ||
|
|
43c19c70ca | ||
|
|
cb99c4b4e8 | ||
|
|
9af63bc1ef | ||
|
|
f4e87d4c25 | ||
|
|
c7f515adde | ||
|
|
1df778a9db | ||
|
|
cb44f8a717 | ||
|
|
7fcf3c1e1f | ||
|
|
de021f96bf | ||
|
|
8bb10632b1 | ||
|
|
bea26a6b61 | ||
|
|
ac2e8cfa88 | ||
|
|
7d5bc722fa | ||
|
|
7765a12868 |
17
.github/actions/setup-project/action.yml
vendored
17
.github/actions/setup-project/action.yml
vendored
@@ -25,17 +25,24 @@ runs:
|
|||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
cache-dependency-path: package-lock.json
|
cache-dependency-path: package-lock.json
|
||||||
|
|
||||||
- name: Check for SSH URLs in lockfile
|
|
||||||
if: inputs.check-lockfile == 'true'
|
|
||||||
shell: bash
|
|
||||||
run: npm run lint:lockfile
|
|
||||||
|
|
||||||
- name: Configure Git for HTTPS
|
- name: Configure Git for HTTPS
|
||||||
shell: bash
|
shell: bash
|
||||||
# Convert SSH URLs to HTTPS for git dependencies (e.g., @electron/node-gyp)
|
# Convert SSH URLs to HTTPS for git dependencies (e.g., @electron/node-gyp)
|
||||||
# This is needed because SSH authentication isn't available in CI
|
# This is needed because SSH authentication isn't available in CI
|
||||||
run: git config --global url."https://github.com/".insteadOf "git@github.com:"
|
run: git config --global url."https://github.com/".insteadOf "git@github.com:"
|
||||||
|
|
||||||
|
- name: Auto-fix SSH URLs in lockfile
|
||||||
|
if: inputs.check-lockfile == 'true'
|
||||||
|
shell: bash
|
||||||
|
# Auto-fix any git+ssh:// URLs in package-lock.json before linting
|
||||||
|
# This handles cases where npm reintroduces SSH URLs for git dependencies
|
||||||
|
run: node scripts/fix-lockfile-urls.mjs
|
||||||
|
|
||||||
|
- name: Check for SSH URLs in lockfile
|
||||||
|
if: inputs.check-lockfile == 'true'
|
||||||
|
shell: bash
|
||||||
|
run: npm run lint:lockfile
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
shell: bash
|
shell: bash
|
||||||
# Use npm install instead of npm ci to correctly resolve platform-specific
|
# Use npm install instead of npm ci to correctly resolve platform-specific
|
||||||
|
|||||||
18
.github/workflows/e2e-tests.yml
vendored
18
.github/workflows/e2e-tests.yml
vendored
@@ -46,7 +46,8 @@ jobs:
|
|||||||
echo "SERVER_PID=$SERVER_PID" >> $GITHUB_ENV
|
echo "SERVER_PID=$SERVER_PID" >> $GITHUB_ENV
|
||||||
|
|
||||||
env:
|
env:
|
||||||
PORT: 3008
|
PORT: 3108
|
||||||
|
TEST_SERVER_PORT: 3108
|
||||||
NODE_ENV: test
|
NODE_ENV: test
|
||||||
# Use a deterministic API key so Playwright can log in reliably
|
# Use a deterministic API key so Playwright can log in reliably
|
||||||
AUTOMAKER_API_KEY: test-api-key-for-e2e-tests
|
AUTOMAKER_API_KEY: test-api-key-for-e2e-tests
|
||||||
@@ -81,13 +82,13 @@ jobs:
|
|||||||
|
|
||||||
# Wait for health endpoint
|
# Wait for health endpoint
|
||||||
for i in {1..60}; do
|
for i in {1..60}; do
|
||||||
if curl -s -f http://localhost:3008/api/health > /dev/null 2>&1; then
|
if curl -s -f http://localhost:3108/api/health > /dev/null 2>&1; then
|
||||||
echo "Backend server is ready!"
|
echo "Backend server is ready!"
|
||||||
echo "=== Backend logs ==="
|
echo "=== Backend logs ==="
|
||||||
cat backend.log
|
cat backend.log
|
||||||
echo ""
|
echo ""
|
||||||
echo "Health check response:"
|
echo "Health check response:"
|
||||||
curl -s http://localhost:3008/api/health | jq . 2>/dev/null || echo "Health check: $(curl -s http://localhost:3008/api/health 2>/dev/null || echo 'No response')"
|
curl -s http://localhost:3108/api/health | jq . 2>/dev/null || echo "Health check: $(curl -s http://localhost:3108/api/health 2>/dev/null || echo 'No response')"
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -111,11 +112,11 @@ jobs:
|
|||||||
ps aux | grep -E "(node|tsx)" | grep -v grep || echo "No node processes found"
|
ps aux | grep -E "(node|tsx)" | grep -v grep || echo "No node processes found"
|
||||||
echo ""
|
echo ""
|
||||||
echo "=== Port status ==="
|
echo "=== Port status ==="
|
||||||
netstat -tlnp 2>/dev/null | grep :3008 || echo "Port 3008 not listening"
|
netstat -tlnp 2>/dev/null | grep :3108 || echo "Port 3108 not listening"
|
||||||
lsof -i :3008 2>/dev/null || echo "lsof not available or port not in use"
|
lsof -i :3108 2>/dev/null || echo "lsof not available or port not in use"
|
||||||
echo ""
|
echo ""
|
||||||
echo "=== Health endpoint test ==="
|
echo "=== Health endpoint test ==="
|
||||||
curl -v http://localhost:3008/api/health 2>&1 || echo "Health endpoint failed"
|
curl -v http://localhost:3108/api/health 2>&1 || echo "Health endpoint failed"
|
||||||
|
|
||||||
# Kill the server process if it's still hanging
|
# Kill the server process if it's still hanging
|
||||||
if kill -0 $SERVER_PID 2>/dev/null; then
|
if kill -0 $SERVER_PID 2>/dev/null; then
|
||||||
@@ -132,7 +133,8 @@ jobs:
|
|||||||
run: npm run test --workspace=apps/ui
|
run: npm run test --workspace=apps/ui
|
||||||
env:
|
env:
|
||||||
CI: true
|
CI: true
|
||||||
VITE_SERVER_URL: http://localhost:3008
|
VITE_SERVER_URL: http://localhost:3108
|
||||||
|
SERVER_URL: http://localhost:3108
|
||||||
VITE_SKIP_SETUP: 'true'
|
VITE_SKIP_SETUP: 'true'
|
||||||
# Keep UI-side login/defaults consistent
|
# Keep UI-side login/defaults consistent
|
||||||
AUTOMAKER_API_KEY: test-api-key-for-e2e-tests
|
AUTOMAKER_API_KEY: test-api-key-for-e2e-tests
|
||||||
@@ -147,7 +149,7 @@ jobs:
|
|||||||
ps aux | grep -E "(node|tsx)" | grep -v grep || echo "No node processes found"
|
ps aux | grep -E "(node|tsx)" | grep -v grep || echo "No node processes found"
|
||||||
echo ""
|
echo ""
|
||||||
echo "=== Port status ==="
|
echo "=== Port status ==="
|
||||||
netstat -tlnp 2>/dev/null | grep :3008 || echo "Port 3008 not listening"
|
netstat -tlnp 2>/dev/null | grep :3108 || echo "Port 3108 not listening"
|
||||||
|
|
||||||
- name: Upload Playwright report
|
- name: Upload Playwright report
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
|
|||||||
@@ -38,6 +38,18 @@ else
|
|||||||
export PATH="$PATH:/usr/local/bin:/opt/homebrew/bin:/usr/bin"
|
export PATH="$PATH:/usr/local/bin:/opt/homebrew/bin:/usr/bin"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Auto-fix git+ssh:// URLs in package-lock.json if it's being committed
|
||||||
|
# This prevents CI failures from SSH URLs that npm introduces for git dependencies
|
||||||
|
if git diff --cached --name-only | grep -q "^package-lock.json$"; then
|
||||||
|
if command -v node >/dev/null 2>&1; then
|
||||||
|
if grep -q "git+ssh://" package-lock.json 2>/dev/null; then
|
||||||
|
echo "Fixing git+ssh:// URLs in package-lock.json..."
|
||||||
|
node scripts/fix-lockfile-urls.mjs
|
||||||
|
git add package-lock.json
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
# Run lint-staged - works with or without nvm
|
# Run lint-staged - works with or without nvm
|
||||||
# Prefer npx, fallback to npm exec, both work with system-installed Node.js
|
# Prefer npx, fallback to npm exec, both work with system-installed Node.js
|
||||||
if command -v npx >/dev/null 2>&1; then
|
if command -v npx >/dev/null 2>&1; then
|
||||||
|
|||||||
@@ -209,9 +209,10 @@ COPY libs ./libs
|
|||||||
COPY apps/ui ./apps/ui
|
COPY apps/ui ./apps/ui
|
||||||
|
|
||||||
# Build packages in dependency order, then build UI
|
# Build packages in dependency order, then build UI
|
||||||
# VITE_SERVER_URL tells the UI where to find the API server
|
# When VITE_SERVER_URL is empty, the UI uses relative URLs (e.g., /api/...) which nginx proxies
|
||||||
# Use ARG to allow overriding at build time: --build-arg VITE_SERVER_URL=http://api.example.com
|
# to the server container. This avoids CORS issues entirely in Docker Compose setups.
|
||||||
ARG VITE_SERVER_URL=http://localhost:3008
|
# Override at build time if needed: --build-arg VITE_SERVER_URL=http://api.example.com
|
||||||
|
ARG VITE_SERVER_URL=
|
||||||
ENV VITE_SKIP_ELECTRON=true
|
ENV VITE_SKIP_ELECTRON=true
|
||||||
ENV VITE_SERVER_URL=${VITE_SERVER_URL}
|
ENV VITE_SERVER_URL=${VITE_SERVER_URL}
|
||||||
RUN npm run build:packages && npm run build --workspace=apps/ui
|
RUN npm run build:packages && npm run build --workspace=apps/ui
|
||||||
|
|||||||
2
OPENCODE_CONFIG_CONTENT
Normal file
2
OPENCODE_CONFIG_CONTENT
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://opencode.ai/config.json",}
|
||||||
@@ -52,6 +52,12 @@ HOST=0.0.0.0
|
|||||||
# Port to run the server on
|
# Port to run the server on
|
||||||
PORT=3008
|
PORT=3008
|
||||||
|
|
||||||
|
# Port to run the server on for testing
|
||||||
|
TEST_SERVER_PORT=3108
|
||||||
|
|
||||||
|
# Port to run the UI on for testing
|
||||||
|
TEST_PORT=3107
|
||||||
|
|
||||||
# Data directory for sessions and metadata
|
# Data directory for sessions and metadata
|
||||||
DATA_DIR=./data
|
DATA_DIR=./data
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@automaker/server",
|
"name": "@automaker/server",
|
||||||
"version": "0.13.0",
|
"version": "0.15.0",
|
||||||
"description": "Backend server for Automaker - provides API for both web and Electron modes",
|
"description": "Backend server for Automaker - provides API for both web and Electron modes",
|
||||||
"author": "AutoMaker Team",
|
"author": "AutoMaker Team",
|
||||||
"license": "SEE LICENSE IN LICENSE",
|
"license": "SEE LICENSE IN LICENSE",
|
||||||
|
|||||||
@@ -66,6 +66,10 @@ import { createCodexRoutes } from './routes/codex/index.js';
|
|||||||
import { CodexUsageService } from './services/codex-usage-service.js';
|
import { CodexUsageService } from './services/codex-usage-service.js';
|
||||||
import { CodexAppServerService } from './services/codex-app-server-service.js';
|
import { CodexAppServerService } from './services/codex-app-server-service.js';
|
||||||
import { CodexModelCacheService } from './services/codex-model-cache-service.js';
|
import { CodexModelCacheService } from './services/codex-model-cache-service.js';
|
||||||
|
import { createZaiRoutes } from './routes/zai/index.js';
|
||||||
|
import { ZaiUsageService } from './services/zai-usage-service.js';
|
||||||
|
import { createGeminiRoutes } from './routes/gemini/index.js';
|
||||||
|
import { GeminiUsageService } from './services/gemini-usage-service.js';
|
||||||
import { createGitHubRoutes } from './routes/github/index.js';
|
import { createGitHubRoutes } from './routes/github/index.js';
|
||||||
import { createContextRoutes } from './routes/context/index.js';
|
import { createContextRoutes } from './routes/context/index.js';
|
||||||
import { createBacklogPlanRoutes } from './routes/backlog-plan/index.js';
|
import { createBacklogPlanRoutes } from './routes/backlog-plan/index.js';
|
||||||
@@ -263,6 +267,26 @@ app.use(
|
|||||||
// CORS configuration
|
// CORS configuration
|
||||||
// When using credentials (cookies), origin cannot be '*'
|
// When using credentials (cookies), origin cannot be '*'
|
||||||
// We dynamically allow the requesting origin for local development
|
// We dynamically allow the requesting origin for local development
|
||||||
|
|
||||||
|
// Check if origin is a local/private network address
|
||||||
|
function isLocalOrigin(origin: string): boolean {
|
||||||
|
try {
|
||||||
|
const url = new URL(origin);
|
||||||
|
const hostname = url.hostname;
|
||||||
|
return (
|
||||||
|
hostname === 'localhost' ||
|
||||||
|
hostname === '127.0.0.1' ||
|
||||||
|
hostname === '[::1]' ||
|
||||||
|
hostname === '0.0.0.0' ||
|
||||||
|
hostname.startsWith('192.168.') ||
|
||||||
|
hostname.startsWith('10.') ||
|
||||||
|
/^172\.(1[6-9]|2[0-9]|3[0-1])\./.test(hostname)
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
app.use(
|
app.use(
|
||||||
cors({
|
cors({
|
||||||
origin: (origin, callback) => {
|
origin: (origin, callback) => {
|
||||||
@@ -273,35 +297,25 @@ app.use(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// If CORS_ORIGIN is set, use it (can be comma-separated list)
|
// If CORS_ORIGIN is set, use it (can be comma-separated list)
|
||||||
const allowedOrigins = process.env.CORS_ORIGIN?.split(',').map((o) => o.trim());
|
const allowedOrigins = process.env.CORS_ORIGIN?.split(',')
|
||||||
if (allowedOrigins && allowedOrigins.length > 0 && allowedOrigins[0] !== '*') {
|
.map((o) => o.trim())
|
||||||
if (allowedOrigins.includes(origin)) {
|
.filter(Boolean);
|
||||||
callback(null, origin);
|
if (allowedOrigins && allowedOrigins.length > 0) {
|
||||||
} else {
|
if (allowedOrigins.includes('*')) {
|
||||||
callback(new Error('Not allowed by CORS'));
|
callback(null, true);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
return;
|
if (allowedOrigins.includes(origin)) {
|
||||||
}
|
|
||||||
|
|
||||||
// For local development, allow all localhost/loopback origins (any port)
|
|
||||||
try {
|
|
||||||
const url = new URL(origin);
|
|
||||||
const hostname = url.hostname;
|
|
||||||
|
|
||||||
if (
|
|
||||||
hostname === 'localhost' ||
|
|
||||||
hostname === '127.0.0.1' ||
|
|
||||||
hostname === '::1' ||
|
|
||||||
hostname === '0.0.0.0' ||
|
|
||||||
hostname.startsWith('192.168.') ||
|
|
||||||
hostname.startsWith('10.') ||
|
|
||||||
hostname.startsWith('172.')
|
|
||||||
) {
|
|
||||||
callback(null, origin);
|
callback(null, origin);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch (err) {
|
// Fall through to local network check below
|
||||||
// Ignore URL parsing errors
|
}
|
||||||
|
|
||||||
|
// Allow all localhost/loopback/private network origins (any port)
|
||||||
|
if (isLocalOrigin(origin)) {
|
||||||
|
callback(null, origin);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reject other origins by default for security
|
// Reject other origins by default for security
|
||||||
@@ -328,6 +342,8 @@ const claudeUsageService = new ClaudeUsageService();
|
|||||||
const codexAppServerService = new CodexAppServerService();
|
const codexAppServerService = new CodexAppServerService();
|
||||||
const codexModelCacheService = new CodexModelCacheService(DATA_DIR, codexAppServerService);
|
const codexModelCacheService = new CodexModelCacheService(DATA_DIR, codexAppServerService);
|
||||||
const codexUsageService = new CodexUsageService(codexAppServerService);
|
const codexUsageService = new CodexUsageService(codexAppServerService);
|
||||||
|
const zaiUsageService = new ZaiUsageService();
|
||||||
|
const geminiUsageService = new GeminiUsageService();
|
||||||
const mcpTestService = new MCPTestService(settingsService);
|
const mcpTestService = new MCPTestService(settingsService);
|
||||||
const ideationService = new IdeationService(events, settingsService, featureLoader);
|
const ideationService = new IdeationService(events, settingsService, featureLoader);
|
||||||
|
|
||||||
@@ -372,7 +388,7 @@ eventHookService.initialize(events, settingsService, eventHistoryService, featur
|
|||||||
let globalSettings: Awaited<ReturnType<typeof settingsService.getGlobalSettings>> | null = null;
|
let globalSettings: Awaited<ReturnType<typeof settingsService.getGlobalSettings>> | null = null;
|
||||||
try {
|
try {
|
||||||
globalSettings = await settingsService.getGlobalSettings();
|
globalSettings = await settingsService.getGlobalSettings();
|
||||||
} catch (err) {
|
} catch {
|
||||||
logger.warn('Failed to load global settings, using defaults');
|
logger.warn('Failed to load global settings, using defaults');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -390,7 +406,7 @@ eventHookService.initialize(events, settingsService, eventHistoryService, featur
|
|||||||
const enableRequestLog = globalSettings.enableRequestLogging ?? true;
|
const enableRequestLog = globalSettings.enableRequestLogging ?? true;
|
||||||
setRequestLoggingEnabled(enableRequestLog);
|
setRequestLoggingEnabled(enableRequestLog);
|
||||||
logger.info(`HTTP request logging: ${enableRequestLog ? 'enabled' : 'disabled'}`);
|
logger.info(`HTTP request logging: ${enableRequestLog ? 'enabled' : 'disabled'}`);
|
||||||
} catch (err) {
|
} catch {
|
||||||
logger.warn('Failed to apply logging settings, using defaults');
|
logger.warn('Failed to apply logging settings, using defaults');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -417,6 +433,22 @@ eventHookService.initialize(events, settingsService, eventHistoryService, featur
|
|||||||
} else {
|
} else {
|
||||||
logger.info('[STARTUP] Feature state reconciliation complete - no stale states found');
|
logger.info('[STARTUP] Feature state reconciliation complete - no stale states found');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Resume interrupted features in the background after reconciliation.
|
||||||
|
// This uses the saved execution state to identify features that were running
|
||||||
|
// before the restart (their statuses have been reset to ready/backlog by
|
||||||
|
// reconciliation above). Running in background so it doesn't block startup.
|
||||||
|
if (totalReconciled > 0) {
|
||||||
|
for (const project of globalSettings.projects) {
|
||||||
|
autoModeService.resumeInterruptedFeatures(project.path).catch((err) => {
|
||||||
|
logger.warn(
|
||||||
|
`[STARTUP] Failed to resume interrupted features for ${project.path}:`,
|
||||||
|
err
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
logger.info('[STARTUP] Initiated background resume of interrupted features');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.warn('[STARTUP] Failed to reconcile feature states:', err);
|
logger.warn('[STARTUP] Failed to reconcile feature states:', err);
|
||||||
@@ -473,6 +505,8 @@ app.use('/api/terminal', createTerminalRoutes());
|
|||||||
app.use('/api/settings', createSettingsRoutes(settingsService));
|
app.use('/api/settings', createSettingsRoutes(settingsService));
|
||||||
app.use('/api/claude', createClaudeRoutes(claudeUsageService));
|
app.use('/api/claude', createClaudeRoutes(claudeUsageService));
|
||||||
app.use('/api/codex', createCodexRoutes(codexUsageService, codexModelCacheService));
|
app.use('/api/codex', createCodexRoutes(codexUsageService, codexModelCacheService));
|
||||||
|
app.use('/api/zai', createZaiRoutes(zaiUsageService, settingsService));
|
||||||
|
app.use('/api/gemini', createGeminiRoutes(geminiUsageService, events));
|
||||||
app.use('/api/github', createGitHubRoutes(events, settingsService));
|
app.use('/api/github', createGitHubRoutes(events, settingsService));
|
||||||
app.use('/api/context', createContextRoutes(settingsService));
|
app.use('/api/context', createContextRoutes(settingsService));
|
||||||
app.use('/api/backlog-plan', createBacklogPlanRoutes(events, settingsService));
|
app.use('/api/backlog-plan', createBacklogPlanRoutes(events, settingsService));
|
||||||
@@ -575,7 +609,7 @@ wss.on('connection', (ws: WebSocket) => {
|
|||||||
logger.info('Sending event to client:', {
|
logger.info('Sending event to client:', {
|
||||||
type,
|
type,
|
||||||
messageLength: message.length,
|
messageLength: message.length,
|
||||||
sessionId: (payload as any)?.sessionId,
|
sessionId: (payload as Record<string, unknown>)?.sessionId,
|
||||||
});
|
});
|
||||||
ws.send(message);
|
ws.send(message);
|
||||||
} else {
|
} else {
|
||||||
@@ -641,8 +675,15 @@ terminalWss.on('connection', (ws: WebSocket, req: import('http').IncomingMessage
|
|||||||
// Check if session exists
|
// Check if session exists
|
||||||
const session = terminalService.getSession(sessionId);
|
const session = terminalService.getSession(sessionId);
|
||||||
if (!session) {
|
if (!session) {
|
||||||
logger.info(`Session ${sessionId} not found`);
|
logger.warn(
|
||||||
ws.close(4004, 'Session not found');
|
`Terminal session ${sessionId} not found. ` +
|
||||||
|
`The session may have exited, been deleted, or was never created. ` +
|
||||||
|
`Active terminal sessions: ${terminalService.getSessionCount()}`
|
||||||
|
);
|
||||||
|
ws.close(
|
||||||
|
4004,
|
||||||
|
'Session not found. The terminal session may have expired or been closed. Please create a new terminal.'
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,9 +8,6 @@ import { spawn, execSync } from 'child_process';
|
|||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import * as os from 'os';
|
import * as os from 'os';
|
||||||
import { createLogger } from '@automaker/utils';
|
|
||||||
|
|
||||||
const logger = createLogger('CliDetection');
|
|
||||||
|
|
||||||
export interface CliInfo {
|
export interface CliInfo {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -86,7 +83,7 @@ export async function detectCli(
|
|||||||
options: CliDetectionOptions = {}
|
options: CliDetectionOptions = {}
|
||||||
): Promise<CliDetectionResult> {
|
): Promise<CliDetectionResult> {
|
||||||
const config = CLI_CONFIGS[provider];
|
const config = CLI_CONFIGS[provider];
|
||||||
const { timeout = 5000, includeWsl = false, wslDistribution } = options;
|
const { timeout = 5000 } = options;
|
||||||
const issues: string[] = [];
|
const issues: string[] = [];
|
||||||
|
|
||||||
const cliInfo: CliInfo = {
|
const cliInfo: CliInfo = {
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export interface ErrorClassification {
|
|||||||
suggestedAction?: string;
|
suggestedAction?: string;
|
||||||
retryable: boolean;
|
retryable: boolean;
|
||||||
provider?: string;
|
provider?: string;
|
||||||
context?: Record<string, any>;
|
context?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ErrorPattern {
|
export interface ErrorPattern {
|
||||||
@@ -180,7 +180,7 @@ const ERROR_PATTERNS: ErrorPattern[] = [
|
|||||||
export function classifyError(
|
export function classifyError(
|
||||||
error: unknown,
|
error: unknown,
|
||||||
provider?: string,
|
provider?: string,
|
||||||
context?: Record<string, any>
|
context?: Record<string, unknown>
|
||||||
): ErrorClassification {
|
): ErrorClassification {
|
||||||
const errorText = getErrorText(error);
|
const errorText = getErrorText(error);
|
||||||
|
|
||||||
@@ -281,18 +281,19 @@ function getErrorText(error: unknown): string {
|
|||||||
|
|
||||||
if (typeof error === 'object' && error !== null) {
|
if (typeof error === 'object' && error !== null) {
|
||||||
// Handle structured error objects
|
// Handle structured error objects
|
||||||
const errorObj = error as any;
|
const errorObj = error as Record<string, unknown>;
|
||||||
|
|
||||||
if (errorObj.message) {
|
if (typeof errorObj.message === 'string') {
|
||||||
return errorObj.message;
|
return errorObj.message;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (errorObj.error?.message) {
|
const nestedError = errorObj.error;
|
||||||
return errorObj.error.message;
|
if (typeof nestedError === 'object' && nestedError !== null && 'message' in nestedError) {
|
||||||
|
return String((nestedError as Record<string, unknown>).message);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (errorObj.error) {
|
if (nestedError) {
|
||||||
return typeof errorObj.error === 'string' ? errorObj.error : JSON.stringify(errorObj.error);
|
return typeof nestedError === 'string' ? nestedError : JSON.stringify(nestedError);
|
||||||
}
|
}
|
||||||
|
|
||||||
return JSON.stringify(error);
|
return JSON.stringify(error);
|
||||||
@@ -307,7 +308,7 @@ function getErrorText(error: unknown): string {
|
|||||||
export function createErrorResponse(
|
export function createErrorResponse(
|
||||||
error: unknown,
|
error: unknown,
|
||||||
provider?: string,
|
provider?: string,
|
||||||
context?: Record<string, any>
|
context?: Record<string, unknown>
|
||||||
): {
|
): {
|
||||||
success: false;
|
success: false;
|
||||||
error: string;
|
error: string;
|
||||||
@@ -335,7 +336,7 @@ export function logError(
|
|||||||
error: unknown,
|
error: unknown,
|
||||||
provider?: string,
|
provider?: string,
|
||||||
operation?: string,
|
operation?: string,
|
||||||
additionalContext?: Record<string, any>
|
additionalContext?: Record<string, unknown>
|
||||||
): void {
|
): void {
|
||||||
const classification = classifyError(error, provider, {
|
const classification = classifyError(error, provider, {
|
||||||
operation,
|
operation,
|
||||||
|
|||||||
37
apps/server/src/lib/exec-utils.ts
Normal file
37
apps/server/src/lib/exec-utils.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
/**
|
||||||
|
* Shared execution utilities
|
||||||
|
*
|
||||||
|
* Common helpers for spawning child processes with the correct environment.
|
||||||
|
* Used by both route handlers and service layers.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createLogger } from '@automaker/utils';
|
||||||
|
|
||||||
|
const logger = createLogger('ExecUtils');
|
||||||
|
|
||||||
|
// Extended PATH to include common tool installation locations
|
||||||
|
export const extendedPath = [
|
||||||
|
process.env.PATH,
|
||||||
|
'/opt/homebrew/bin',
|
||||||
|
'/usr/local/bin',
|
||||||
|
'/home/linuxbrew/.linuxbrew/bin',
|
||||||
|
`${process.env.HOME}/.local/bin`,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(':');
|
||||||
|
|
||||||
|
export const execEnv = {
|
||||||
|
...process.env,
|
||||||
|
PATH: extendedPath,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getErrorMessage(error: unknown): string {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return error.message;
|
||||||
|
}
|
||||||
|
return String(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function logError(error: unknown, context: string): void {
|
||||||
|
logger.error(`${context}:`, error);
|
||||||
|
}
|
||||||
62
apps/server/src/lib/git-log-parser.ts
Normal file
62
apps/server/src/lib/git-log-parser.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
export interface CommitFields {
|
||||||
|
hash: string;
|
||||||
|
shortHash: string;
|
||||||
|
author: string;
|
||||||
|
authorEmail: string;
|
||||||
|
date: string;
|
||||||
|
subject: string;
|
||||||
|
body: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseGitLogOutput(output: string): CommitFields[] {
|
||||||
|
const commits: CommitFields[] = [];
|
||||||
|
|
||||||
|
// Split by NUL character to separate commits
|
||||||
|
const commitBlocks = output.split('\0').filter((block) => block.trim());
|
||||||
|
|
||||||
|
for (const block of commitBlocks) {
|
||||||
|
const allLines = block.split('\n');
|
||||||
|
|
||||||
|
// Skip leading empty lines that may appear at block boundaries
|
||||||
|
let startIndex = 0;
|
||||||
|
while (startIndex < allLines.length && allLines[startIndex].trim() === '') {
|
||||||
|
startIndex++;
|
||||||
|
}
|
||||||
|
const fields = allLines.slice(startIndex);
|
||||||
|
|
||||||
|
// Validate we have all expected fields (at least hash, shortHash, author, authorEmail, date, subject)
|
||||||
|
if (fields.length < 6) {
|
||||||
|
continue; // Skip malformed blocks
|
||||||
|
}
|
||||||
|
|
||||||
|
const commit: CommitFields = {
|
||||||
|
hash: fields[0].trim(),
|
||||||
|
shortHash: fields[1].trim(),
|
||||||
|
author: fields[2].trim(),
|
||||||
|
authorEmail: fields[3].trim(),
|
||||||
|
date: fields[4].trim(),
|
||||||
|
subject: fields[5].trim(),
|
||||||
|
body: fields.slice(6).join('\n').trim(),
|
||||||
|
};
|
||||||
|
|
||||||
|
commits.push(commit);
|
||||||
|
}
|
||||||
|
|
||||||
|
return commits;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a commit object from parsed fields, matching the expected API response format
|
||||||
|
*/
|
||||||
|
export function createCommitFromFields(fields: CommitFields, files?: string[]) {
|
||||||
|
return {
|
||||||
|
hash: fields.hash,
|
||||||
|
shortHash: fields.shortHash,
|
||||||
|
author: fields.author,
|
||||||
|
authorEmail: fields.authorEmail,
|
||||||
|
date: fields.date,
|
||||||
|
subject: fields.subject,
|
||||||
|
body: fields.body,
|
||||||
|
files: files || [],
|
||||||
|
};
|
||||||
|
}
|
||||||
208
apps/server/src/lib/git.ts
Normal file
208
apps/server/src/lib/git.ts
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
/**
|
||||||
|
* Shared git command execution utilities.
|
||||||
|
*
|
||||||
|
* This module provides the canonical `execGitCommand` helper and common
|
||||||
|
* git utilities used across services and routes. All consumers should
|
||||||
|
* import from here rather than defining their own copy.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import fs from 'fs/promises';
|
||||||
|
import path from 'path';
|
||||||
|
import { spawnProcess } from '@automaker/platform';
|
||||||
|
import { createLogger } from '@automaker/utils';
|
||||||
|
|
||||||
|
const logger = createLogger('GitLib');
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Secure Command Execution
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute git command with array arguments to prevent command injection.
|
||||||
|
* Uses spawnProcess from @automaker/platform for secure, cross-platform execution.
|
||||||
|
*
|
||||||
|
* @param args - Array of git command arguments (e.g., ['worktree', 'add', path])
|
||||||
|
* @param cwd - Working directory to execute the command in
|
||||||
|
* @param env - Optional additional environment variables to pass to the git process.
|
||||||
|
* These are merged on top of the current process environment. Pass
|
||||||
|
* `{ LC_ALL: 'C' }` to force git to emit English output regardless of the
|
||||||
|
* system locale so that text-based output parsing remains reliable.
|
||||||
|
* @param abortController - Optional AbortController to cancel the git process.
|
||||||
|
* When the controller is aborted the underlying process is sent SIGTERM and
|
||||||
|
* the returned promise rejects with an Error whose message is 'Process aborted'.
|
||||||
|
* @returns Promise resolving to stdout output
|
||||||
|
* @throws Error with stderr/stdout message if command fails. The thrown error
|
||||||
|
* also has `stdout` and `stderr` string properties for structured access.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* // Safe: no injection possible
|
||||||
|
* await execGitCommand(['branch', '-D', branchName], projectPath);
|
||||||
|
*
|
||||||
|
* // Force English output for reliable text parsing:
|
||||||
|
* await execGitCommand(['rebase', '--', 'main'], worktreePath, { LC_ALL: 'C' });
|
||||||
|
*
|
||||||
|
* // With a process-level timeout:
|
||||||
|
* const controller = new AbortController();
|
||||||
|
* const timerId = setTimeout(() => controller.abort(), 30_000);
|
||||||
|
* try {
|
||||||
|
* await execGitCommand(['fetch', '--all', '--quiet'], cwd, undefined, controller);
|
||||||
|
* } finally {
|
||||||
|
* clearTimeout(timerId);
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* // Instead of unsafe:
|
||||||
|
* // await execAsync(`git branch -D ${branchName}`, { cwd });
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export async function execGitCommand(
|
||||||
|
args: string[],
|
||||||
|
cwd: string,
|
||||||
|
env?: Record<string, string>,
|
||||||
|
abortController?: AbortController
|
||||||
|
): Promise<string> {
|
||||||
|
const result = await spawnProcess({
|
||||||
|
command: 'git',
|
||||||
|
args,
|
||||||
|
cwd,
|
||||||
|
...(env !== undefined ? { env } : {}),
|
||||||
|
...(abortController !== undefined ? { abortController } : {}),
|
||||||
|
});
|
||||||
|
|
||||||
|
// spawnProcess returns { stdout, stderr, exitCode }
|
||||||
|
if (result.exitCode === 0) {
|
||||||
|
return result.stdout;
|
||||||
|
} else {
|
||||||
|
const errorMessage =
|
||||||
|
result.stderr || result.stdout || `Git command failed with code ${result.exitCode}`;
|
||||||
|
throw Object.assign(new Error(errorMessage), {
|
||||||
|
stdout: result.stdout,
|
||||||
|
stderr: result.stderr,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Common Git Utilities
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current branch name for the given worktree.
|
||||||
|
*
|
||||||
|
* This is the canonical implementation shared across services. Services
|
||||||
|
* should import this rather than duplicating the logic locally.
|
||||||
|
*
|
||||||
|
* @param worktreePath - Path to the git worktree
|
||||||
|
* @returns The current branch name (trimmed)
|
||||||
|
*/
|
||||||
|
export async function getCurrentBranch(worktreePath: string): Promise<string> {
|
||||||
|
const branchOutput = await execGitCommand(['rev-parse', '--abbrev-ref', 'HEAD'], worktreePath);
|
||||||
|
return branchOutput.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Index Lock Recovery
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check whether an error message indicates a stale git index lock file.
|
||||||
|
*
|
||||||
|
* Git operations that write to the index (e.g. `git stash push`) will fail
|
||||||
|
* with "could not write index" or "Unable to create ... .lock" when a
|
||||||
|
* `.git/index.lock` file exists from a previously interrupted operation.
|
||||||
|
*
|
||||||
|
* @param errorMessage - The error string from a failed git command
|
||||||
|
* @returns true if the error looks like a stale index lock issue
|
||||||
|
*/
|
||||||
|
export function isIndexLockError(errorMessage: string): boolean {
|
||||||
|
const lower = errorMessage.toLowerCase();
|
||||||
|
return (
|
||||||
|
lower.includes('could not write index') ||
|
||||||
|
(lower.includes('unable to create') && lower.includes('index.lock')) ||
|
||||||
|
lower.includes('index.lock')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attempt to remove a stale `.git/index.lock` file for the given worktree.
|
||||||
|
*
|
||||||
|
* Uses `git rev-parse --git-dir` to locate the correct `.git` directory,
|
||||||
|
* which works for both regular repositories and linked worktrees.
|
||||||
|
*
|
||||||
|
* @param worktreePath - Path to the git worktree (or main repo)
|
||||||
|
* @returns true if a lock file was found and removed, false otherwise
|
||||||
|
*/
|
||||||
|
export async function removeStaleIndexLock(worktreePath: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
// Resolve the .git directory (handles worktrees correctly)
|
||||||
|
const gitDirRaw = await execGitCommand(['rev-parse', '--git-dir'], worktreePath);
|
||||||
|
const gitDir = path.resolve(worktreePath, gitDirRaw.trim());
|
||||||
|
const lockFilePath = path.join(gitDir, 'index.lock');
|
||||||
|
|
||||||
|
// Check if the lock file exists
|
||||||
|
try {
|
||||||
|
await fs.access(lockFilePath);
|
||||||
|
} catch {
|
||||||
|
// Lock file does not exist — nothing to remove
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove the stale lock file
|
||||||
|
await fs.unlink(lockFilePath);
|
||||||
|
logger.info('Removed stale index.lock file', { worktreePath, lockFilePath });
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('Failed to remove stale index.lock file', {
|
||||||
|
worktreePath,
|
||||||
|
error: err instanceof Error ? err.message : String(err),
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a git command with automatic retry when a stale index.lock is detected.
|
||||||
|
*
|
||||||
|
* If the command fails with an error indicating a locked index file, this
|
||||||
|
* helper will attempt to remove the stale `.git/index.lock` and retry the
|
||||||
|
* command exactly once.
|
||||||
|
*
|
||||||
|
* This is particularly useful for `git stash push` which writes to the
|
||||||
|
* index and commonly fails when a previous git operation was interrupted.
|
||||||
|
*
|
||||||
|
* @param args - Array of git command arguments
|
||||||
|
* @param cwd - Working directory to execute the command in
|
||||||
|
* @param env - Optional additional environment variables
|
||||||
|
* @returns Promise resolving to stdout output
|
||||||
|
* @throws The original error if retry also fails, or a non-lock error
|
||||||
|
*/
|
||||||
|
export async function execGitCommandWithLockRetry(
|
||||||
|
args: string[],
|
||||||
|
cwd: string,
|
||||||
|
env?: Record<string, string>
|
||||||
|
): Promise<string> {
|
||||||
|
try {
|
||||||
|
return await execGitCommand(args, cwd, env);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const err = error as { message?: string; stderr?: string };
|
||||||
|
const errorMessage = err.stderr || err.message || '';
|
||||||
|
|
||||||
|
if (!isIndexLockError(errorMessage)) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('Git command failed due to index lock, attempting cleanup and retry', {
|
||||||
|
cwd,
|
||||||
|
args: args.join(' '),
|
||||||
|
});
|
||||||
|
|
||||||
|
const removed = await removeStaleIndexLock(cwd);
|
||||||
|
if (!removed) {
|
||||||
|
// Could not remove the lock file — re-throw the original error
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retry the command once after removing the lock file
|
||||||
|
return await execGitCommand(args, cwd, env);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,11 +12,18 @@ export interface PermissionCheckResult {
|
|||||||
reason?: string;
|
reason?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Minimal shape of a Cursor tool call used for permission checking */
|
||||||
|
interface CursorToolCall {
|
||||||
|
shellToolCall?: { args?: { command: string } };
|
||||||
|
readToolCall?: { args?: { path: string } };
|
||||||
|
writeToolCall?: { args?: { path: string } };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if a tool call is allowed based on permissions
|
* Check if a tool call is allowed based on permissions
|
||||||
*/
|
*/
|
||||||
export function checkToolCallPermission(
|
export function checkToolCallPermission(
|
||||||
toolCall: any,
|
toolCall: CursorToolCall,
|
||||||
permissions: CursorCliConfigFile | null
|
permissions: CursorCliConfigFile | null
|
||||||
): PermissionCheckResult {
|
): PermissionCheckResult {
|
||||||
if (!permissions || !permissions.permissions) {
|
if (!permissions || !permissions.permissions) {
|
||||||
@@ -152,7 +159,11 @@ function matchesRule(toolName: string, rule: string): boolean {
|
|||||||
/**
|
/**
|
||||||
* Log permission violations
|
* Log permission violations
|
||||||
*/
|
*/
|
||||||
export function logPermissionViolation(toolCall: any, reason: string, sessionId?: string): void {
|
export function logPermissionViolation(
|
||||||
|
toolCall: CursorToolCall,
|
||||||
|
reason: string,
|
||||||
|
sessionId?: string
|
||||||
|
): void {
|
||||||
const sessionIdStr = sessionId ? ` [${sessionId}]` : '';
|
const sessionIdStr = sessionId ? ` [${sessionId}]` : '';
|
||||||
|
|
||||||
if (toolCall.shellToolCall?.args?.command) {
|
if (toolCall.shellToolCall?.args?.command) {
|
||||||
|
|||||||
@@ -133,12 +133,16 @@ export const TOOL_PRESETS = {
|
|||||||
'Read',
|
'Read',
|
||||||
'Write',
|
'Write',
|
||||||
'Edit',
|
'Edit',
|
||||||
|
'MultiEdit',
|
||||||
'Glob',
|
'Glob',
|
||||||
'Grep',
|
'Grep',
|
||||||
|
'LS',
|
||||||
'Bash',
|
'Bash',
|
||||||
'WebSearch',
|
'WebSearch',
|
||||||
'WebFetch',
|
'WebFetch',
|
||||||
'TodoWrite',
|
'TodoWrite',
|
||||||
|
'Task',
|
||||||
|
'Skill',
|
||||||
] as const,
|
] as const,
|
||||||
|
|
||||||
/** Tools for chat/interactive mode */
|
/** Tools for chat/interactive mode */
|
||||||
@@ -146,12 +150,16 @@ export const TOOL_PRESETS = {
|
|||||||
'Read',
|
'Read',
|
||||||
'Write',
|
'Write',
|
||||||
'Edit',
|
'Edit',
|
||||||
|
'MultiEdit',
|
||||||
'Glob',
|
'Glob',
|
||||||
'Grep',
|
'Grep',
|
||||||
|
'LS',
|
||||||
'Bash',
|
'Bash',
|
||||||
'WebSearch',
|
'WebSearch',
|
||||||
'WebFetch',
|
'WebFetch',
|
||||||
'TodoWrite',
|
'TodoWrite',
|
||||||
|
'Task',
|
||||||
|
'Skill',
|
||||||
] as const,
|
] as const,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
@@ -282,11 +290,15 @@ function buildThinkingOptions(thinkingLevel?: ThinkingLevel): Partial<Options> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build system prompt configuration based on autoLoadClaudeMd setting.
|
* Build system prompt and settingSources based on two independent settings:
|
||||||
* When autoLoadClaudeMd is true:
|
* - useClaudeCodeSystemPrompt: controls whether to use the 'claude_code' preset as the base prompt
|
||||||
* - Uses preset mode with 'claude_code' to enable CLAUDE.md auto-loading
|
* - autoLoadClaudeMd: controls whether to add settingSources for SDK to load CLAUDE.md files
|
||||||
* - If there's a custom systemPrompt, appends it to the preset
|
*
|
||||||
* - Sets settingSources to ['project'] for SDK to load CLAUDE.md files
|
* These combine independently (4 possible states):
|
||||||
|
* 1. Both ON: preset + settingSources (full Claude Code experience)
|
||||||
|
* 2. useClaudeCodeSystemPrompt ON, autoLoadClaudeMd OFF: preset only (no CLAUDE.md auto-loading)
|
||||||
|
* 3. useClaudeCodeSystemPrompt OFF, autoLoadClaudeMd ON: plain string + settingSources
|
||||||
|
* 4. Both OFF: plain string only
|
||||||
*
|
*
|
||||||
* @param config - The SDK options config
|
* @param config - The SDK options config
|
||||||
* @returns Object with systemPrompt and settingSources for SDK options
|
* @returns Object with systemPrompt and settingSources for SDK options
|
||||||
@@ -295,27 +307,34 @@ function buildClaudeMdOptions(config: CreateSdkOptionsConfig): {
|
|||||||
systemPrompt?: string | SystemPromptConfig;
|
systemPrompt?: string | SystemPromptConfig;
|
||||||
settingSources?: Array<'user' | 'project' | 'local'>;
|
settingSources?: Array<'user' | 'project' | 'local'>;
|
||||||
} {
|
} {
|
||||||
if (!config.autoLoadClaudeMd) {
|
|
||||||
// Standard mode - just pass through the system prompt as-is
|
|
||||||
return config.systemPrompt ? { systemPrompt: config.systemPrompt } : {};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Auto-load CLAUDE.md mode - use preset with settingSources
|
|
||||||
const result: {
|
const result: {
|
||||||
systemPrompt: SystemPromptConfig;
|
systemPrompt?: string | SystemPromptConfig;
|
||||||
settingSources: Array<'user' | 'project' | 'local'>;
|
settingSources?: Array<'user' | 'project' | 'local'>;
|
||||||
} = {
|
} = {};
|
||||||
systemPrompt: {
|
|
||||||
|
// Determine system prompt format based on useClaudeCodeSystemPrompt
|
||||||
|
if (config.useClaudeCodeSystemPrompt) {
|
||||||
|
// Use Claude Code's built-in system prompt as the base
|
||||||
|
const presetConfig: SystemPromptConfig = {
|
||||||
type: 'preset',
|
type: 'preset',
|
||||||
preset: 'claude_code',
|
preset: 'claude_code',
|
||||||
},
|
};
|
||||||
// Load both user (~/.claude/CLAUDE.md) and project (.claude/CLAUDE.md) settings
|
// If there's a custom system prompt, append it to the preset
|
||||||
settingSources: ['user', 'project'],
|
if (config.systemPrompt) {
|
||||||
};
|
presetConfig.append = config.systemPrompt;
|
||||||
|
}
|
||||||
|
result.systemPrompt = presetConfig;
|
||||||
|
} else {
|
||||||
|
// Standard mode - just pass through the system prompt as-is
|
||||||
|
if (config.systemPrompt) {
|
||||||
|
result.systemPrompt = config.systemPrompt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// If there's a custom system prompt, append it to the preset
|
// Determine settingSources based on autoLoadClaudeMd
|
||||||
if (config.systemPrompt) {
|
if (config.autoLoadClaudeMd) {
|
||||||
result.systemPrompt.append = config.systemPrompt;
|
// Load both user (~/.claude/CLAUDE.md) and project (.claude/CLAUDE.md) settings
|
||||||
|
result.settingSources = ['user', 'project'];
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
@@ -323,12 +342,14 @@ function buildClaudeMdOptions(config: CreateSdkOptionsConfig): {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* System prompt configuration for SDK options
|
* System prompt configuration for SDK options
|
||||||
* When using preset mode with claude_code, CLAUDE.md files are automatically loaded
|
* The 'claude_code' preset provides the system prompt only — it does NOT auto-load
|
||||||
|
* CLAUDE.md files. CLAUDE.md auto-loading is controlled independently by
|
||||||
|
* settingSources (set via autoLoadClaudeMd). These two settings are orthogonal.
|
||||||
*/
|
*/
|
||||||
export interface SystemPromptConfig {
|
export interface SystemPromptConfig {
|
||||||
/** Use preset mode with claude_code to enable CLAUDE.md auto-loading */
|
/** Use preset mode to select the base system prompt */
|
||||||
type: 'preset';
|
type: 'preset';
|
||||||
/** The preset to use - 'claude_code' enables CLAUDE.md loading */
|
/** The preset to use - 'claude_code' uses the Claude Code system prompt */
|
||||||
preset: 'claude_code';
|
preset: 'claude_code';
|
||||||
/** Optional additional prompt to append to the preset */
|
/** Optional additional prompt to append to the preset */
|
||||||
append?: string;
|
append?: string;
|
||||||
@@ -362,11 +383,19 @@ export interface CreateSdkOptionsConfig {
|
|||||||
/** Enable auto-loading of CLAUDE.md files via SDK's settingSources */
|
/** Enable auto-loading of CLAUDE.md files via SDK's settingSources */
|
||||||
autoLoadClaudeMd?: boolean;
|
autoLoadClaudeMd?: boolean;
|
||||||
|
|
||||||
|
/** Use Claude Code's built-in system prompt (claude_code preset) as the base prompt */
|
||||||
|
useClaudeCodeSystemPrompt?: boolean;
|
||||||
|
|
||||||
/** MCP servers to make available to the agent */
|
/** MCP servers to make available to the agent */
|
||||||
mcpServers?: Record<string, McpServerConfig>;
|
mcpServers?: Record<string, McpServerConfig>;
|
||||||
|
|
||||||
/** Extended thinking level for Claude models */
|
/** Extended thinking level for Claude models */
|
||||||
thinkingLevel?: ThinkingLevel;
|
thinkingLevel?: ThinkingLevel;
|
||||||
|
|
||||||
|
/** Optional user-configured max turns override (from settings).
|
||||||
|
* When provided, overrides the preset MAX_TURNS for the use case.
|
||||||
|
* Range: 1-2000. */
|
||||||
|
maxTurns?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Re-export MCP types from @automaker/types for convenience
|
// Re-export MCP types from @automaker/types for convenience
|
||||||
@@ -403,7 +432,7 @@ export function createSpecGenerationOptions(config: CreateSdkOptionsConfig): Opt
|
|||||||
// See: https://github.com/AutoMaker-Org/automaker/issues/149
|
// See: https://github.com/AutoMaker-Org/automaker/issues/149
|
||||||
permissionMode: 'default',
|
permissionMode: 'default',
|
||||||
model: getModelForUseCase('spec', config.model),
|
model: getModelForUseCase('spec', config.model),
|
||||||
maxTurns: MAX_TURNS.maximum,
|
maxTurns: config.maxTurns ?? MAX_TURNS.maximum,
|
||||||
cwd: config.cwd,
|
cwd: config.cwd,
|
||||||
allowedTools: [...TOOL_PRESETS.specGeneration],
|
allowedTools: [...TOOL_PRESETS.specGeneration],
|
||||||
...claudeMdOptions,
|
...claudeMdOptions,
|
||||||
@@ -437,7 +466,7 @@ export function createFeatureGenerationOptions(config: CreateSdkOptionsConfig):
|
|||||||
// Override permissionMode - feature generation only needs read-only tools
|
// Override permissionMode - feature generation only needs read-only tools
|
||||||
permissionMode: 'default',
|
permissionMode: 'default',
|
||||||
model: getModelForUseCase('features', config.model),
|
model: getModelForUseCase('features', config.model),
|
||||||
maxTurns: MAX_TURNS.quick,
|
maxTurns: config.maxTurns ?? MAX_TURNS.quick,
|
||||||
cwd: config.cwd,
|
cwd: config.cwd,
|
||||||
allowedTools: [...TOOL_PRESETS.readOnly],
|
allowedTools: [...TOOL_PRESETS.readOnly],
|
||||||
...claudeMdOptions,
|
...claudeMdOptions,
|
||||||
@@ -468,7 +497,7 @@ export function createSuggestionsOptions(config: CreateSdkOptionsConfig): Option
|
|||||||
return {
|
return {
|
||||||
...getBaseOptions(),
|
...getBaseOptions(),
|
||||||
model: getModelForUseCase('suggestions', config.model),
|
model: getModelForUseCase('suggestions', config.model),
|
||||||
maxTurns: MAX_TURNS.extended,
|
maxTurns: config.maxTurns ?? MAX_TURNS.extended,
|
||||||
cwd: config.cwd,
|
cwd: config.cwd,
|
||||||
allowedTools: [...TOOL_PRESETS.readOnly],
|
allowedTools: [...TOOL_PRESETS.readOnly],
|
||||||
...claudeMdOptions,
|
...claudeMdOptions,
|
||||||
@@ -506,7 +535,7 @@ export function createChatOptions(config: CreateSdkOptionsConfig): Options {
|
|||||||
return {
|
return {
|
||||||
...getBaseOptions(),
|
...getBaseOptions(),
|
||||||
model: getModelForUseCase('chat', effectiveModel),
|
model: getModelForUseCase('chat', effectiveModel),
|
||||||
maxTurns: MAX_TURNS.standard,
|
maxTurns: config.maxTurns ?? MAX_TURNS.standard,
|
||||||
cwd: config.cwd,
|
cwd: config.cwd,
|
||||||
allowedTools: [...TOOL_PRESETS.chat],
|
allowedTools: [...TOOL_PRESETS.chat],
|
||||||
...claudeMdOptions,
|
...claudeMdOptions,
|
||||||
@@ -541,7 +570,7 @@ export function createAutoModeOptions(config: CreateSdkOptionsConfig): Options {
|
|||||||
return {
|
return {
|
||||||
...getBaseOptions(),
|
...getBaseOptions(),
|
||||||
model: getModelForUseCase('auto', config.model),
|
model: getModelForUseCase('auto', config.model),
|
||||||
maxTurns: MAX_TURNS.maximum,
|
maxTurns: config.maxTurns ?? MAX_TURNS.maximum,
|
||||||
cwd: config.cwd,
|
cwd: config.cwd,
|
||||||
allowedTools: [...TOOL_PRESETS.fullAccess],
|
allowedTools: [...TOOL_PRESETS.fullAccess],
|
||||||
...claudeMdOptions,
|
...claudeMdOptions,
|
||||||
|
|||||||
@@ -33,9 +33,16 @@ import {
|
|||||||
|
|
||||||
const logger = createLogger('SettingsHelper');
|
const logger = createLogger('SettingsHelper');
|
||||||
|
|
||||||
|
/** Default number of agent turns used when no value is configured. */
|
||||||
|
export const DEFAULT_MAX_TURNS = 10000;
|
||||||
|
|
||||||
|
/** Upper bound for the max-turns clamp; values above this are capped here. */
|
||||||
|
export const MAX_ALLOWED_TURNS = 10000;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the autoLoadClaudeMd setting, with project settings taking precedence over global.
|
* Get the autoLoadClaudeMd setting, with project settings taking precedence over global.
|
||||||
* Returns false if settings service is not available.
|
* Falls back to global settings and defaults to true when unset.
|
||||||
|
* Returns true if settings service is not available.
|
||||||
*
|
*
|
||||||
* @param projectPath - Path to the project
|
* @param projectPath - Path to the project
|
||||||
* @param settingsService - Optional settings service instance
|
* @param settingsService - Optional settings service instance
|
||||||
@@ -48,8 +55,8 @@ export async function getAutoLoadClaudeMdSetting(
|
|||||||
logPrefix = '[SettingsHelper]'
|
logPrefix = '[SettingsHelper]'
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
if (!settingsService) {
|
if (!settingsService) {
|
||||||
logger.info(`${logPrefix} SettingsService not available, autoLoadClaudeMd disabled`);
|
logger.info(`${logPrefix} SettingsService not available, autoLoadClaudeMd defaulting to true`);
|
||||||
return false;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -64,7 +71,7 @@ export async function getAutoLoadClaudeMdSetting(
|
|||||||
|
|
||||||
// Fall back to global settings
|
// Fall back to global settings
|
||||||
const globalSettings = await settingsService.getGlobalSettings();
|
const globalSettings = await settingsService.getGlobalSettings();
|
||||||
const result = globalSettings.autoLoadClaudeMd ?? false;
|
const result = globalSettings.autoLoadClaudeMd ?? true;
|
||||||
logger.info(`${logPrefix} autoLoadClaudeMd from global settings: ${result}`);
|
logger.info(`${logPrefix} autoLoadClaudeMd from global settings: ${result}`);
|
||||||
return result;
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -73,6 +80,84 @@ export async function getAutoLoadClaudeMdSetting(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the useClaudeCodeSystemPrompt setting, with project settings taking precedence over global.
|
||||||
|
* Falls back to global settings and defaults to true when unset.
|
||||||
|
* Returns true if settings service is not available.
|
||||||
|
*
|
||||||
|
* @param projectPath - Path to the project
|
||||||
|
* @param settingsService - Optional settings service instance
|
||||||
|
* @param logPrefix - Prefix for log messages (e.g., '[AgentService]')
|
||||||
|
* @returns Promise resolving to the useClaudeCodeSystemPrompt setting value
|
||||||
|
*/
|
||||||
|
export async function getUseClaudeCodeSystemPromptSetting(
|
||||||
|
projectPath: string,
|
||||||
|
settingsService?: SettingsService | null,
|
||||||
|
logPrefix = '[SettingsHelper]'
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (!settingsService) {
|
||||||
|
logger.info(
|
||||||
|
`${logPrefix} SettingsService not available, useClaudeCodeSystemPrompt defaulting to true`
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Check project settings first (takes precedence)
|
||||||
|
const projectSettings = await settingsService.getProjectSettings(projectPath);
|
||||||
|
if (projectSettings.useClaudeCodeSystemPrompt !== undefined) {
|
||||||
|
logger.info(
|
||||||
|
`${logPrefix} useClaudeCodeSystemPrompt from project settings: ${projectSettings.useClaudeCodeSystemPrompt}`
|
||||||
|
);
|
||||||
|
return projectSettings.useClaudeCodeSystemPrompt;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to global settings
|
||||||
|
const globalSettings = await settingsService.getGlobalSettings();
|
||||||
|
const result = globalSettings.useClaudeCodeSystemPrompt ?? true;
|
||||||
|
logger.info(`${logPrefix} useClaudeCodeSystemPrompt from global settings: ${result}`);
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`${logPrefix} Failed to load useClaudeCodeSystemPrompt setting:`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the default max turns setting from global settings.
|
||||||
|
*
|
||||||
|
* Reads the user's configured `defaultMaxTurns` setting, which controls the maximum
|
||||||
|
* number of agent turns (tool-call round-trips) for feature execution.
|
||||||
|
*
|
||||||
|
* @param settingsService - Settings service instance (may be null)
|
||||||
|
* @param logPrefix - Logging prefix for debugging
|
||||||
|
* @returns The user's configured max turns, or {@link DEFAULT_MAX_TURNS} as default
|
||||||
|
*/
|
||||||
|
export async function getDefaultMaxTurnsSetting(
|
||||||
|
settingsService?: SettingsService | null,
|
||||||
|
logPrefix = '[SettingsHelper]'
|
||||||
|
): Promise<number> {
|
||||||
|
if (!settingsService) {
|
||||||
|
logger.info(
|
||||||
|
`${logPrefix} SettingsService not available, using default maxTurns=${DEFAULT_MAX_TURNS}`
|
||||||
|
);
|
||||||
|
return DEFAULT_MAX_TURNS;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const globalSettings = await settingsService.getGlobalSettings();
|
||||||
|
const raw = globalSettings.defaultMaxTurns;
|
||||||
|
const result = Number.isFinite(raw) ? (raw as number) : DEFAULT_MAX_TURNS;
|
||||||
|
// Clamp to valid range
|
||||||
|
const clamped = Math.max(1, Math.min(MAX_ALLOWED_TURNS, Math.floor(result)));
|
||||||
|
logger.debug(`${logPrefix} defaultMaxTurns from global settings: ${clamped}`);
|
||||||
|
return clamped;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`${logPrefix} Failed to load defaultMaxTurns setting:`, error);
|
||||||
|
return DEFAULT_MAX_TURNS;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Filters out CLAUDE.md from context files when autoLoadClaudeMd is enabled
|
* Filters out CLAUDE.md from context files when autoLoadClaudeMd is enabled
|
||||||
* and rebuilds the formatted prompt without it.
|
* and rebuilds the formatted prompt without it.
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ export async function readWorktreeMetadata(
|
|||||||
const metadataPath = getWorktreeMetadataPath(projectPath, branch);
|
const metadataPath = getWorktreeMetadataPath(projectPath, branch);
|
||||||
const content = (await secureFs.readFile(metadataPath, 'utf-8')) as string;
|
const content = (await secureFs.readFile(metadataPath, 'utf-8')) as string;
|
||||||
return JSON.parse(content) as WorktreeMetadata;
|
return JSON.parse(content) as WorktreeMetadata;
|
||||||
} catch (error) {
|
} catch (_error) {
|
||||||
// File doesn't exist or can't be read
|
// File doesn't exist or can't be read
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,11 +5,10 @@
|
|||||||
* with the provider architecture.
|
* with the provider architecture.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { query, type Options } from '@anthropic-ai/claude-agent-sdk';
|
import { query, type Options, type SDKUserMessage } from '@anthropic-ai/claude-agent-sdk';
|
||||||
import { BaseProvider } from './base-provider.js';
|
import { BaseProvider } from './base-provider.js';
|
||||||
import { classifyError, getUserFriendlyErrorMessage, createLogger } from '@automaker/utils';
|
import { classifyError, getUserFriendlyErrorMessage, createLogger } from '@automaker/utils';
|
||||||
|
import { getClaudeAuthIndicators } from '@automaker/platform';
|
||||||
const logger = createLogger('ClaudeProvider');
|
|
||||||
import {
|
import {
|
||||||
getThinkingTokenBudget,
|
getThinkingTokenBudget,
|
||||||
validateBareModelId,
|
validateBareModelId,
|
||||||
@@ -17,6 +16,14 @@ import {
|
|||||||
type ClaudeCompatibleProvider,
|
type ClaudeCompatibleProvider,
|
||||||
type Credentials,
|
type Credentials,
|
||||||
} from '@automaker/types';
|
} from '@automaker/types';
|
||||||
|
import type {
|
||||||
|
ExecuteOptions,
|
||||||
|
ProviderMessage,
|
||||||
|
InstallationStatus,
|
||||||
|
ModelDefinition,
|
||||||
|
} from './types.js';
|
||||||
|
|
||||||
|
const logger = createLogger('ClaudeProvider');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ProviderConfig - Union type for provider configuration
|
* ProviderConfig - Union type for provider configuration
|
||||||
@@ -25,29 +32,11 @@ import {
|
|||||||
* Both share the same connection settings structure.
|
* Both share the same connection settings structure.
|
||||||
*/
|
*/
|
||||||
type ProviderConfig = ClaudeApiProfile | ClaudeCompatibleProvider;
|
type ProviderConfig = ClaudeApiProfile | ClaudeCompatibleProvider;
|
||||||
import type {
|
|
||||||
ExecuteOptions,
|
|
||||||
ProviderMessage,
|
|
||||||
InstallationStatus,
|
|
||||||
ModelDefinition,
|
|
||||||
} from './types.js';
|
|
||||||
|
|
||||||
// Explicit allowlist of environment variables to pass to the SDK.
|
// System vars are always passed from process.env regardless of profile.
|
||||||
// Only these vars are passed - nothing else from process.env leaks through.
|
// Includes filesystem, locale, and temp directory vars that the Claude CLI
|
||||||
const ALLOWED_ENV_VARS = [
|
// needs internally for config resolution and temp file creation.
|
||||||
// Authentication
|
const SYSTEM_ENV_VARS = [
|
||||||
'ANTHROPIC_API_KEY',
|
|
||||||
'ANTHROPIC_AUTH_TOKEN',
|
|
||||||
// Endpoint configuration
|
|
||||||
'ANTHROPIC_BASE_URL',
|
|
||||||
'API_TIMEOUT_MS',
|
|
||||||
// Model mappings
|
|
||||||
'ANTHROPIC_DEFAULT_HAIKU_MODEL',
|
|
||||||
'ANTHROPIC_DEFAULT_SONNET_MODEL',
|
|
||||||
'ANTHROPIC_DEFAULT_OPUS_MODEL',
|
|
||||||
// Traffic control
|
|
||||||
'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC',
|
|
||||||
// System vars (always from process.env)
|
|
||||||
'PATH',
|
'PATH',
|
||||||
'HOME',
|
'HOME',
|
||||||
'SHELL',
|
'SHELL',
|
||||||
@@ -55,11 +44,13 @@ const ALLOWED_ENV_VARS = [
|
|||||||
'USER',
|
'USER',
|
||||||
'LANG',
|
'LANG',
|
||||||
'LC_ALL',
|
'LC_ALL',
|
||||||
|
'TMPDIR',
|
||||||
|
'XDG_CONFIG_HOME',
|
||||||
|
'XDG_DATA_HOME',
|
||||||
|
'XDG_CACHE_HOME',
|
||||||
|
'XDG_STATE_HOME',
|
||||||
];
|
];
|
||||||
|
|
||||||
// System vars are always passed from process.env regardless of profile
|
|
||||||
const SYSTEM_ENV_VARS = ['PATH', 'HOME', 'SHELL', 'TERM', 'USER', 'LANG', 'LC_ALL'];
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if the config is a ClaudeCompatibleProvider (new system)
|
* Check if the config is a ClaudeCompatibleProvider (new system)
|
||||||
* by checking for the 'models' array property
|
* by checking for the 'models' array property
|
||||||
@@ -204,7 +195,7 @@ export class ClaudeProvider extends BaseProvider {
|
|||||||
model,
|
model,
|
||||||
cwd,
|
cwd,
|
||||||
systemPrompt,
|
systemPrompt,
|
||||||
maxTurns = 100,
|
maxTurns = 1000,
|
||||||
allowedTools,
|
allowedTools,
|
||||||
abortController,
|
abortController,
|
||||||
conversationHistory,
|
conversationHistory,
|
||||||
@@ -237,6 +228,8 @@ export class ClaudeProvider extends BaseProvider {
|
|||||||
env: buildEnv(providerConfig, credentials),
|
env: buildEnv(providerConfig, credentials),
|
||||||
// Pass through allowedTools if provided by caller (decided by sdk-options.ts)
|
// Pass through allowedTools if provided by caller (decided by sdk-options.ts)
|
||||||
...(allowedTools && { allowedTools }),
|
...(allowedTools && { allowedTools }),
|
||||||
|
// Restrict available built-in tools if specified (tools: [] disables all tools)
|
||||||
|
...(options.tools && { tools: options.tools }),
|
||||||
// AUTONOMOUS MODE: Always bypass permissions for fully autonomous operation
|
// AUTONOMOUS MODE: Always bypass permissions for fully autonomous operation
|
||||||
permissionMode: 'bypassPermissions',
|
permissionMode: 'bypassPermissions',
|
||||||
allowDangerouslySkipPermissions: true,
|
allowDangerouslySkipPermissions: true,
|
||||||
@@ -258,14 +251,14 @@ export class ClaudeProvider extends BaseProvider {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Build prompt payload
|
// Build prompt payload
|
||||||
let promptPayload: string | AsyncIterable<any>;
|
let promptPayload: string | AsyncIterable<SDKUserMessage>;
|
||||||
|
|
||||||
if (Array.isArray(prompt)) {
|
if (Array.isArray(prompt)) {
|
||||||
// Multi-part prompt (with images)
|
// Multi-part prompt (with images)
|
||||||
promptPayload = (async function* () {
|
promptPayload = (async function* () {
|
||||||
const multiPartPrompt = {
|
const multiPartPrompt: SDKUserMessage = {
|
||||||
type: 'user' as const,
|
type: 'user' as const,
|
||||||
session_id: '',
|
session_id: sdkSessionId || '',
|
||||||
message: {
|
message: {
|
||||||
role: 'user' as const,
|
role: 'user' as const,
|
||||||
content: prompt,
|
content: prompt,
|
||||||
@@ -317,12 +310,16 @@ export class ClaudeProvider extends BaseProvider {
|
|||||||
? `${userMessage}\n\nTip: If you're running multiple features in auto-mode, consider reducing concurrency (maxConcurrency setting) to avoid hitting rate limits.`
|
? `${userMessage}\n\nTip: If you're running multiple features in auto-mode, consider reducing concurrency (maxConcurrency setting) to avoid hitting rate limits.`
|
||||||
: userMessage;
|
: userMessage;
|
||||||
|
|
||||||
const enhancedError = new Error(message);
|
const enhancedError = new Error(message) as Error & {
|
||||||
(enhancedError as any).originalError = error;
|
originalError: unknown;
|
||||||
(enhancedError as any).type = errorInfo.type;
|
type: string;
|
||||||
|
retryAfter?: number;
|
||||||
|
};
|
||||||
|
enhancedError.originalError = error;
|
||||||
|
enhancedError.type = errorInfo.type;
|
||||||
|
|
||||||
if (errorInfo.isRateLimit) {
|
if (errorInfo.isRateLimit) {
|
||||||
(enhancedError as any).retryAfter = errorInfo.retryAfter;
|
enhancedError.retryAfter = errorInfo.retryAfter;
|
||||||
}
|
}
|
||||||
|
|
||||||
throw enhancedError;
|
throw enhancedError;
|
||||||
@@ -334,13 +331,37 @@ export class ClaudeProvider extends BaseProvider {
|
|||||||
*/
|
*/
|
||||||
async detectInstallation(): Promise<InstallationStatus> {
|
async detectInstallation(): Promise<InstallationStatus> {
|
||||||
// Claude SDK is always available since it's a dependency
|
// Claude SDK is always available since it's a dependency
|
||||||
const hasApiKey = !!process.env.ANTHROPIC_API_KEY;
|
// Check all four supported auth methods, mirroring the logic in buildEnv():
|
||||||
|
// 1. ANTHROPIC_API_KEY environment variable
|
||||||
|
// 2. ANTHROPIC_AUTH_TOKEN environment variable
|
||||||
|
// 3. credentials?.apiKeys?.anthropic (credentials file, checked via platform indicators)
|
||||||
|
// 4. Claude Max CLI OAuth (SDK handles this automatically; detected via getClaudeAuthIndicators)
|
||||||
|
const hasEnvApiKey = !!process.env.ANTHROPIC_API_KEY;
|
||||||
|
const hasEnvAuthToken = !!process.env.ANTHROPIC_AUTH_TOKEN;
|
||||||
|
|
||||||
|
// Check credentials file and CLI OAuth indicators (same sources used by buildEnv)
|
||||||
|
let hasCredentialsApiKey = false;
|
||||||
|
let hasCliOAuth = false;
|
||||||
|
try {
|
||||||
|
const indicators = await getClaudeAuthIndicators();
|
||||||
|
hasCredentialsApiKey = !!indicators.credentials?.hasApiKey;
|
||||||
|
hasCliOAuth = !!(
|
||||||
|
indicators.credentials?.hasOAuthToken ||
|
||||||
|
indicators.hasStatsCacheWithActivity ||
|
||||||
|
(indicators.hasSettingsFile && indicators.hasProjectsSessions)
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// If we can't check indicators, fall back to env vars only
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasApiKey = hasEnvApiKey || hasCredentialsApiKey;
|
||||||
|
const authenticated = hasEnvApiKey || hasEnvAuthToken || hasCredentialsApiKey || hasCliOAuth;
|
||||||
|
|
||||||
const status: InstallationStatus = {
|
const status: InstallationStatus = {
|
||||||
installed: true,
|
installed: true,
|
||||||
method: 'sdk',
|
method: 'sdk',
|
||||||
hasApiKey,
|
hasApiKey,
|
||||||
authenticated: hasApiKey,
|
authenticated,
|
||||||
};
|
};
|
||||||
|
|
||||||
return status;
|
return status;
|
||||||
@@ -364,6 +385,18 @@ export class ClaudeProvider extends BaseProvider {
|
|||||||
tier: 'premium' as const,
|
tier: 'premium' as const,
|
||||||
default: true,
|
default: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'claude-sonnet-4-6',
|
||||||
|
name: 'Claude Sonnet 4.6',
|
||||||
|
modelString: 'claude-sonnet-4-6',
|
||||||
|
provider: 'anthropic',
|
||||||
|
description: 'Balanced performance and cost with enhanced reasoning',
|
||||||
|
contextWindow: 200000,
|
||||||
|
maxOutputTokens: 64000,
|
||||||
|
supportsVision: true,
|
||||||
|
supportsTools: true,
|
||||||
|
tier: 'standard' as const,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'claude-sonnet-4-20250514',
|
id: 'claude-sonnet-4-20250514',
|
||||||
name: 'Claude Sonnet 4',
|
name: 'Claude Sonnet 4',
|
||||||
|
|||||||
@@ -32,6 +32,19 @@ export const CODEX_MODELS: ModelDefinition[] = [
|
|||||||
default: true,
|
default: true,
|
||||||
hasReasoning: true,
|
hasReasoning: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: CODEX_MODEL_MAP.gpt53CodexSpark,
|
||||||
|
name: 'GPT-5.3-Codex-Spark',
|
||||||
|
modelString: CODEX_MODEL_MAP.gpt53CodexSpark,
|
||||||
|
provider: 'openai',
|
||||||
|
description: 'Near-instant real-time coding model, 1000+ tokens/sec.',
|
||||||
|
contextWindow: CONTEXT_WINDOW_256K,
|
||||||
|
maxOutputTokens: MAX_OUTPUT_32K,
|
||||||
|
supportsVision: true,
|
||||||
|
supportsTools: true,
|
||||||
|
tier: 'premium' as const,
|
||||||
|
hasReasoning: true,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: CODEX_MODEL_MAP.gpt52Codex,
|
id: CODEX_MODEL_MAP.gpt52Codex,
|
||||||
name: 'GPT-5.2-Codex',
|
name: 'GPT-5.2-Codex',
|
||||||
@@ -71,6 +84,45 @@ export const CODEX_MODELS: ModelDefinition[] = [
|
|||||||
tier: 'basic' as const,
|
tier: 'basic' as const,
|
||||||
hasReasoning: false,
|
hasReasoning: false,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: CODEX_MODEL_MAP.gpt51Codex,
|
||||||
|
name: 'GPT-5.1-Codex',
|
||||||
|
modelString: CODEX_MODEL_MAP.gpt51Codex,
|
||||||
|
provider: 'openai',
|
||||||
|
description: 'Original GPT-5.1 Codex agentic coding model.',
|
||||||
|
contextWindow: CONTEXT_WINDOW_256K,
|
||||||
|
maxOutputTokens: MAX_OUTPUT_32K,
|
||||||
|
supportsVision: true,
|
||||||
|
supportsTools: true,
|
||||||
|
tier: 'standard' as const,
|
||||||
|
hasReasoning: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: CODEX_MODEL_MAP.gpt5Codex,
|
||||||
|
name: 'GPT-5-Codex',
|
||||||
|
modelString: CODEX_MODEL_MAP.gpt5Codex,
|
||||||
|
provider: 'openai',
|
||||||
|
description: 'Original GPT-5 Codex model.',
|
||||||
|
contextWindow: CONTEXT_WINDOW_128K,
|
||||||
|
maxOutputTokens: MAX_OUTPUT_16K,
|
||||||
|
supportsVision: true,
|
||||||
|
supportsTools: true,
|
||||||
|
tier: 'standard' as const,
|
||||||
|
hasReasoning: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: CODEX_MODEL_MAP.gpt5CodexMini,
|
||||||
|
name: 'GPT-5-Codex-Mini',
|
||||||
|
modelString: CODEX_MODEL_MAP.gpt5CodexMini,
|
||||||
|
provider: 'openai',
|
||||||
|
description: 'Smaller, cheaper GPT-5 Codex variant.',
|
||||||
|
contextWindow: CONTEXT_WINDOW_128K,
|
||||||
|
maxOutputTokens: MAX_OUTPUT_16K,
|
||||||
|
supportsVision: true,
|
||||||
|
supportsTools: true,
|
||||||
|
tier: 'basic' as const,
|
||||||
|
hasReasoning: false,
|
||||||
|
},
|
||||||
|
|
||||||
// ========== General-Purpose GPT Models ==========
|
// ========== General-Purpose GPT Models ==========
|
||||||
{
|
{
|
||||||
@@ -99,6 +151,19 @@ export const CODEX_MODELS: ModelDefinition[] = [
|
|||||||
tier: 'standard' as const,
|
tier: 'standard' as const,
|
||||||
hasReasoning: true,
|
hasReasoning: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: CODEX_MODEL_MAP.gpt5,
|
||||||
|
name: 'GPT-5',
|
||||||
|
modelString: CODEX_MODEL_MAP.gpt5,
|
||||||
|
provider: 'openai',
|
||||||
|
description: 'Base GPT-5 model.',
|
||||||
|
contextWindow: CONTEXT_WINDOW_128K,
|
||||||
|
maxOutputTokens: MAX_OUTPUT_16K,
|
||||||
|
supportsVision: true,
|
||||||
|
supportsTools: true,
|
||||||
|
tier: 'standard' as const,
|
||||||
|
hasReasoning: true,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -30,11 +30,9 @@ import type {
|
|||||||
ModelDefinition,
|
ModelDefinition,
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
import {
|
import {
|
||||||
CODEX_MODEL_MAP,
|
|
||||||
supportsReasoningEffort,
|
supportsReasoningEffort,
|
||||||
validateBareModelId,
|
validateBareModelId,
|
||||||
calculateReasoningTimeout,
|
calculateReasoningTimeout,
|
||||||
DEFAULT_TIMEOUT_MS,
|
|
||||||
type CodexApprovalPolicy,
|
type CodexApprovalPolicy,
|
||||||
type CodexSandboxMode,
|
type CodexSandboxMode,
|
||||||
type CodexAuthStatus,
|
type CodexAuthStatus,
|
||||||
@@ -53,18 +51,14 @@ import { CODEX_MODELS } from './codex-models.js';
|
|||||||
|
|
||||||
const CODEX_COMMAND = 'codex';
|
const CODEX_COMMAND = 'codex';
|
||||||
const CODEX_EXEC_SUBCOMMAND = 'exec';
|
const CODEX_EXEC_SUBCOMMAND = 'exec';
|
||||||
|
const CODEX_RESUME_SUBCOMMAND = 'resume';
|
||||||
const CODEX_JSON_FLAG = '--json';
|
const CODEX_JSON_FLAG = '--json';
|
||||||
const CODEX_MODEL_FLAG = '--model';
|
const CODEX_MODEL_FLAG = '--model';
|
||||||
const CODEX_VERSION_FLAG = '--version';
|
const CODEX_VERSION_FLAG = '--version';
|
||||||
const CODEX_SANDBOX_FLAG = '--sandbox';
|
|
||||||
const CODEX_APPROVAL_FLAG = '--ask-for-approval';
|
|
||||||
const CODEX_SEARCH_FLAG = '--search';
|
|
||||||
const CODEX_OUTPUT_SCHEMA_FLAG = '--output-schema';
|
|
||||||
const CODEX_CONFIG_FLAG = '--config';
|
const CODEX_CONFIG_FLAG = '--config';
|
||||||
const CODEX_IMAGE_FLAG = '--image';
|
|
||||||
const CODEX_ADD_DIR_FLAG = '--add-dir';
|
const CODEX_ADD_DIR_FLAG = '--add-dir';
|
||||||
|
const CODEX_OUTPUT_SCHEMA_FLAG = '--output-schema';
|
||||||
const CODEX_SKIP_GIT_REPO_CHECK_FLAG = '--skip-git-repo-check';
|
const CODEX_SKIP_GIT_REPO_CHECK_FLAG = '--skip-git-repo-check';
|
||||||
const CODEX_RESUME_FLAG = 'resume';
|
|
||||||
const CODEX_REASONING_EFFORT_KEY = 'reasoning_effort';
|
const CODEX_REASONING_EFFORT_KEY = 'reasoning_effort';
|
||||||
const CODEX_YOLO_FLAG = '--dangerously-bypass-approvals-and-sandbox';
|
const CODEX_YOLO_FLAG = '--dangerously-bypass-approvals-and-sandbox';
|
||||||
const OPENAI_API_KEY_ENV = 'OPENAI_API_KEY';
|
const OPENAI_API_KEY_ENV = 'OPENAI_API_KEY';
|
||||||
@@ -104,11 +98,8 @@ const TEXT_ENCODING = 'utf-8';
|
|||||||
*
|
*
|
||||||
* @see calculateReasoningTimeout from @automaker/types
|
* @see calculateReasoningTimeout from @automaker/types
|
||||||
*/
|
*/
|
||||||
const CODEX_CLI_TIMEOUT_MS = DEFAULT_TIMEOUT_MS;
|
const CODEX_CLI_TIMEOUT_MS = 120000; // 2 minutes — matches CLI provider base timeout
|
||||||
const CODEX_FEATURE_GENERATION_BASE_TIMEOUT_MS = 300000; // 5 minutes for feature generation
|
const CODEX_FEATURE_GENERATION_BASE_TIMEOUT_MS = 300000; // 5 minutes for feature generation
|
||||||
const CONTEXT_WINDOW_256K = 256000;
|
|
||||||
const MAX_OUTPUT_32K = 32000;
|
|
||||||
const MAX_OUTPUT_16K = 16000;
|
|
||||||
const SYSTEM_PROMPT_SEPARATOR = '\n\n';
|
const SYSTEM_PROMPT_SEPARATOR = '\n\n';
|
||||||
const CODEX_INSTRUCTIONS_DIR = '.codex';
|
const CODEX_INSTRUCTIONS_DIR = '.codex';
|
||||||
const CODEX_INSTRUCTIONS_SECTION = 'Codex Project Instructions';
|
const CODEX_INSTRUCTIONS_SECTION = 'Codex Project Instructions';
|
||||||
@@ -136,11 +127,16 @@ const DEFAULT_ALLOWED_TOOLS = [
|
|||||||
'Read',
|
'Read',
|
||||||
'Write',
|
'Write',
|
||||||
'Edit',
|
'Edit',
|
||||||
|
'MultiEdit',
|
||||||
'Glob',
|
'Glob',
|
||||||
'Grep',
|
'Grep',
|
||||||
|
'LS',
|
||||||
'Bash',
|
'Bash',
|
||||||
'WebSearch',
|
'WebSearch',
|
||||||
'WebFetch',
|
'WebFetch',
|
||||||
|
'TodoWrite',
|
||||||
|
'Task',
|
||||||
|
'Skill',
|
||||||
] as const;
|
] as const;
|
||||||
const SEARCH_TOOL_NAMES = new Set(['WebSearch', 'WebFetch']);
|
const SEARCH_TOOL_NAMES = new Set(['WebSearch', 'WebFetch']);
|
||||||
const MIN_MAX_TURNS = 1;
|
const MIN_MAX_TURNS = 1;
|
||||||
@@ -210,16 +206,42 @@ function isSdkEligible(options: ExecuteOptions): boolean {
|
|||||||
return isNoToolsRequested(options) && !hasMcpServersConfigured(options);
|
return isNoToolsRequested(options) && !hasMcpServersConfigured(options);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isSdkEligibleWithApiKey(options: ExecuteOptions): boolean {
|
||||||
|
// When using an API key (not CLI OAuth), prefer SDK over CLI to avoid OAuth issues.
|
||||||
|
// SDK mode is used when MCP servers are not configured (MCP requires CLI).
|
||||||
|
// Tool requests are handled by the SDK, so we allow SDK mode even with tools.
|
||||||
|
return !hasMcpServersConfigured(options);
|
||||||
|
}
|
||||||
|
|
||||||
async function resolveCodexExecutionPlan(options: ExecuteOptions): Promise<CodexExecutionPlan> {
|
async function resolveCodexExecutionPlan(options: ExecuteOptions): Promise<CodexExecutionPlan> {
|
||||||
const cliPath = await findCodexCliPath();
|
const cliPath = await findCodexCliPath();
|
||||||
const authIndicators = await getCodexAuthIndicators();
|
const authIndicators = await getCodexAuthIndicators();
|
||||||
const openAiApiKey = await resolveOpenAiApiKey();
|
const openAiApiKey = await resolveOpenAiApiKey();
|
||||||
const hasApiKey = Boolean(openAiApiKey);
|
const hasApiKey = Boolean(openAiApiKey);
|
||||||
const cliAuthenticated = authIndicators.hasOAuthToken || authIndicators.hasApiKey || hasApiKey;
|
|
||||||
const sdkEligible = isSdkEligible(options);
|
|
||||||
const cliAvailable = Boolean(cliPath);
|
const cliAvailable = Boolean(cliPath);
|
||||||
|
// CLI OAuth login takes priority: if the user has logged in via `codex login`,
|
||||||
|
// use the CLI regardless of whether an API key is also stored.
|
||||||
|
// hasOAuthToken = OAuth session from `codex login`
|
||||||
|
// authIndicators.hasApiKey = API key stored in Codex's own auth file (via `codex login --api-key`)
|
||||||
|
// Both are "CLI-native" auth — distinct from an API key stored in Automaker's credentials.
|
||||||
|
const hasCliNativeAuth = authIndicators.hasOAuthToken || authIndicators.hasApiKey;
|
||||||
|
const sdkEligible = isSdkEligible(options);
|
||||||
|
|
||||||
if (hasApiKey) {
|
// If CLI is available and the user authenticated via the CLI (`codex login`),
|
||||||
|
// prefer CLI mode over SDK. This ensures `codex login` sessions take priority
|
||||||
|
// over API keys stored in Automaker's credentials.
|
||||||
|
if (cliAvailable && hasCliNativeAuth) {
|
||||||
|
return {
|
||||||
|
mode: CODEX_EXECUTION_MODE_CLI,
|
||||||
|
cliPath,
|
||||||
|
openAiApiKey,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// No CLI-native auth — prefer SDK when an API key is available.
|
||||||
|
// Using SDK with an API key avoids OAuth issues that can arise with the CLI.
|
||||||
|
// MCP servers still require CLI mode since the SDK doesn't support MCP.
|
||||||
|
if (hasApiKey && isSdkEligibleWithApiKey(options)) {
|
||||||
return {
|
return {
|
||||||
mode: CODEX_EXECUTION_MODE_SDK,
|
mode: CODEX_EXECUTION_MODE_SDK,
|
||||||
cliPath,
|
cliPath,
|
||||||
@@ -227,6 +249,16 @@ async function resolveCodexExecutionPlan(options: ExecuteOptions): Promise<Codex
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MCP servers are requested with an API key but no CLI-native auth — use CLI mode
|
||||||
|
// with the API key passed as an environment variable.
|
||||||
|
if (hasApiKey && cliAvailable) {
|
||||||
|
return {
|
||||||
|
mode: CODEX_EXECUTION_MODE_CLI,
|
||||||
|
cliPath,
|
||||||
|
openAiApiKey,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (sdkEligible) {
|
if (sdkEligible) {
|
||||||
if (!cliAvailable) {
|
if (!cliAvailable) {
|
||||||
throw new Error(ERROR_CODEX_SDK_AUTH_REQUIRED);
|
throw new Error(ERROR_CODEX_SDK_AUTH_REQUIRED);
|
||||||
@@ -237,15 +269,9 @@ async function resolveCodexExecutionPlan(options: ExecuteOptions): Promise<Codex
|
|||||||
throw new Error(ERROR_CODEX_CLI_REQUIRED);
|
throw new Error(ERROR_CODEX_CLI_REQUIRED);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!cliAuthenticated) {
|
// At this point, neither hasCliNativeAuth nor hasApiKey is true,
|
||||||
throw new Error(ERROR_CODEX_AUTH_REQUIRED);
|
// so authentication is required regardless.
|
||||||
}
|
throw new Error(ERROR_CODEX_AUTH_REQUIRED);
|
||||||
|
|
||||||
return {
|
|
||||||
mode: CODEX_EXECUTION_MODE_CLI,
|
|
||||||
cliPath,
|
|
||||||
openAiApiKey,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getEventType(event: Record<string, unknown>): string | null {
|
function getEventType(event: Record<string, unknown>): string | null {
|
||||||
@@ -335,9 +361,14 @@ function resolveSystemPrompt(systemPrompt?: unknown): string | null {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildPromptText(options: ExecuteOptions): string {
|
||||||
|
return typeof options.prompt === 'string'
|
||||||
|
? options.prompt
|
||||||
|
: extractTextFromContent(options.prompt);
|
||||||
|
}
|
||||||
|
|
||||||
function buildCombinedPrompt(options: ExecuteOptions, systemPromptText?: string | null): string {
|
function buildCombinedPrompt(options: ExecuteOptions, systemPromptText?: string | null): string {
|
||||||
const promptText =
|
const promptText = buildPromptText(options);
|
||||||
typeof options.prompt === 'string' ? options.prompt : extractTextFromContent(options.prompt);
|
|
||||||
const historyText = options.conversationHistory
|
const historyText = options.conversationHistory
|
||||||
? formatHistoryAsText(options.conversationHistory)
|
? formatHistoryAsText(options.conversationHistory)
|
||||||
: '';
|
: '';
|
||||||
@@ -350,6 +381,11 @@ function buildCombinedPrompt(options: ExecuteOptions, systemPromptText?: string
|
|||||||
return `${historyText}${systemSection}${HISTORY_HEADER}${promptText}`;
|
return `${historyText}${systemSection}${HISTORY_HEADER}${promptText}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildResumePrompt(options: ExecuteOptions): string {
|
||||||
|
const promptText = buildPromptText(options);
|
||||||
|
return `${HISTORY_HEADER}${promptText}`;
|
||||||
|
}
|
||||||
|
|
||||||
function formatConfigValue(value: string | number | boolean): string {
|
function formatConfigValue(value: string | number | boolean): string {
|
||||||
return String(value);
|
return String(value);
|
||||||
}
|
}
|
||||||
@@ -717,6 +753,16 @@ export class CodexProvider extends BaseProvider {
|
|||||||
);
|
);
|
||||||
const baseSystemPrompt = resolveSystemPrompt(options.systemPrompt);
|
const baseSystemPrompt = resolveSystemPrompt(options.systemPrompt);
|
||||||
const resolvedMaxTurns = resolveMaxTurns(options.maxTurns);
|
const resolvedMaxTurns = resolveMaxTurns(options.maxTurns);
|
||||||
|
if (resolvedMaxTurns === null && options.maxTurns === undefined) {
|
||||||
|
logger.warn(
|
||||||
|
`[executeQuery] maxTurns not provided — Codex CLI will use its internal default. ` +
|
||||||
|
`This may cause premature completion. Model: ${options.model}`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
logger.info(
|
||||||
|
`[executeQuery] maxTurns: requested=${options.maxTurns}, resolved=${resolvedMaxTurns}, model=${options.model}`
|
||||||
|
);
|
||||||
|
}
|
||||||
const resolvedAllowedTools = options.allowedTools ?? Array.from(DEFAULT_ALLOWED_TOOLS);
|
const resolvedAllowedTools = options.allowedTools ?? Array.from(DEFAULT_ALLOWED_TOOLS);
|
||||||
const restrictTools = !hasMcpServers || options.mcpUnrestrictedTools === false;
|
const restrictTools = !hasMcpServers || options.mcpUnrestrictedTools === false;
|
||||||
const wantsOutputSchema = Boolean(
|
const wantsOutputSchema = Boolean(
|
||||||
@@ -758,24 +804,27 @@ export class CodexProvider extends BaseProvider {
|
|||||||
options.cwd,
|
options.cwd,
|
||||||
codexSettings.sandboxMode !== 'danger-full-access'
|
codexSettings.sandboxMode !== 'danger-full-access'
|
||||||
);
|
);
|
||||||
const resolvedSandboxMode = sandboxCheck.enabled
|
|
||||||
? codexSettings.sandboxMode
|
|
||||||
: 'danger-full-access';
|
|
||||||
if (!sandboxCheck.enabled && sandboxCheck.message) {
|
if (!sandboxCheck.enabled && sandboxCheck.message) {
|
||||||
console.warn(`[CodexProvider] ${sandboxCheck.message}`);
|
console.warn(`[CodexProvider] ${sandboxCheck.message}`);
|
||||||
}
|
}
|
||||||
const searchEnabled =
|
const searchEnabled =
|
||||||
codexSettings.enableWebSearch || resolveSearchEnabled(resolvedAllowedTools, restrictTools);
|
codexSettings.enableWebSearch || resolveSearchEnabled(resolvedAllowedTools, restrictTools);
|
||||||
const outputSchemaPath = await writeOutputSchemaFile(options.cwd, options.outputFormat);
|
const isResumeQuery = Boolean(options.sdkSessionId);
|
||||||
const imageBlocks = codexSettings.enableImages ? extractImageBlocks(options.prompt) : [];
|
const schemaPath = isResumeQuery
|
||||||
const imagePaths = await writeImageFiles(options.cwd, imageBlocks);
|
? null
|
||||||
|
: await writeOutputSchemaFile(options.cwd, options.outputFormat);
|
||||||
|
const imageBlocks =
|
||||||
|
!isResumeQuery && codexSettings.enableImages ? extractImageBlocks(options.prompt) : [];
|
||||||
|
const imagePaths = isResumeQuery ? [] : await writeImageFiles(options.cwd, imageBlocks);
|
||||||
const approvalPolicy =
|
const approvalPolicy =
|
||||||
hasMcpServers && options.mcpAutoApproveTools !== undefined
|
hasMcpServers && options.mcpAutoApproveTools !== undefined
|
||||||
? options.mcpAutoApproveTools
|
? options.mcpAutoApproveTools
|
||||||
? 'never'
|
? 'never'
|
||||||
: 'on-request'
|
: 'on-request'
|
||||||
: codexSettings.approvalPolicy;
|
: codexSettings.approvalPolicy;
|
||||||
const promptText = buildCombinedPrompt(options, combinedSystemPrompt);
|
const promptText = isResumeQuery
|
||||||
|
? buildResumePrompt(options)
|
||||||
|
: buildCombinedPrompt(options, combinedSystemPrompt);
|
||||||
const commandPath = executionPlan.cliPath || CODEX_COMMAND;
|
const commandPath = executionPlan.cliPath || CODEX_COMMAND;
|
||||||
|
|
||||||
// Build config overrides for max turns and reasoning effort
|
// Build config overrides for max turns and reasoning effort
|
||||||
@@ -801,25 +850,43 @@ export class CodexProvider extends BaseProvider {
|
|||||||
overrides.push({ key: 'features.web_search_request', value: true });
|
overrides.push({ key: 'features.web_search_request', value: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
const configOverrides = buildConfigOverrides(overrides);
|
const configOverrideArgs = buildConfigOverrides(overrides);
|
||||||
const preExecArgs: string[] = [];
|
const preExecArgs: string[] = [];
|
||||||
|
|
||||||
// Add additional directories with write access
|
// Add additional directories with write access
|
||||||
if (codexSettings.additionalDirs && codexSettings.additionalDirs.length > 0) {
|
if (
|
||||||
|
!isResumeQuery &&
|
||||||
|
codexSettings.additionalDirs &&
|
||||||
|
codexSettings.additionalDirs.length > 0
|
||||||
|
) {
|
||||||
for (const dir of codexSettings.additionalDirs) {
|
for (const dir of codexSettings.additionalDirs) {
|
||||||
preExecArgs.push(CODEX_ADD_DIR_FLAG, dir);
|
preExecArgs.push(CODEX_ADD_DIR_FLAG, dir);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If images were written to disk, add the image directory so the CLI can access them.
|
||||||
|
// Note: imagePaths is set to [] when isResumeQuery is true, so this check is sufficient.
|
||||||
|
if (imagePaths.length > 0) {
|
||||||
|
const imageDir = path.join(options.cwd, CODEX_INSTRUCTIONS_DIR, IMAGE_TEMP_DIR);
|
||||||
|
preExecArgs.push(CODEX_ADD_DIR_FLAG, imageDir);
|
||||||
|
}
|
||||||
|
|
||||||
// Model is already bare (no prefix) - validated by executeQuery
|
// Model is already bare (no prefix) - validated by executeQuery
|
||||||
|
const codexCommand = isResumeQuery
|
||||||
|
? [CODEX_EXEC_SUBCOMMAND, CODEX_RESUME_SUBCOMMAND]
|
||||||
|
: [CODEX_EXEC_SUBCOMMAND];
|
||||||
|
|
||||||
const args = [
|
const args = [
|
||||||
CODEX_EXEC_SUBCOMMAND,
|
...codexCommand,
|
||||||
CODEX_YOLO_FLAG,
|
CODEX_YOLO_FLAG,
|
||||||
CODEX_SKIP_GIT_REPO_CHECK_FLAG,
|
CODEX_SKIP_GIT_REPO_CHECK_FLAG,
|
||||||
...preExecArgs,
|
...preExecArgs,
|
||||||
CODEX_MODEL_FLAG,
|
CODEX_MODEL_FLAG,
|
||||||
options.model,
|
options.model,
|
||||||
CODEX_JSON_FLAG,
|
CODEX_JSON_FLAG,
|
||||||
|
...configOverrideArgs,
|
||||||
|
...(schemaPath ? [CODEX_OUTPUT_SCHEMA_FLAG, schemaPath] : []),
|
||||||
|
...(options.sdkSessionId ? [options.sdkSessionId] : []),
|
||||||
'-', // Read prompt from stdin to avoid shell escaping issues
|
'-', // Read prompt from stdin to avoid shell escaping issues
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -866,16 +933,36 @@ export class CodexProvider extends BaseProvider {
|
|||||||
|
|
||||||
// Enhance error message with helpful context
|
// Enhance error message with helpful context
|
||||||
let enhancedError = errorText;
|
let enhancedError = errorText;
|
||||||
if (errorText.toLowerCase().includes('rate limit')) {
|
const errorLower = errorText.toLowerCase();
|
||||||
|
if (errorLower.includes('rate limit')) {
|
||||||
enhancedError = `${errorText}\n\nTip: You're being rate limited. Try reducing concurrent tasks or waiting a few minutes before retrying.`;
|
enhancedError = `${errorText}\n\nTip: You're being rate limited. Try reducing concurrent tasks or waiting a few minutes before retrying.`;
|
||||||
|
} else if (errorLower.includes('authentication') || errorLower.includes('unauthorized')) {
|
||||||
|
enhancedError = `${errorText}\n\nTip: Check that your OPENAI_API_KEY is set correctly or run 'codex login' to authenticate.`;
|
||||||
} else if (
|
} else if (
|
||||||
errorText.toLowerCase().includes('authentication') ||
|
errorLower.includes('model does not exist') ||
|
||||||
errorText.toLowerCase().includes('unauthorized')
|
errorLower.includes('requested model does not exist') ||
|
||||||
|
errorLower.includes('do not have access') ||
|
||||||
|
errorLower.includes('model_not_found') ||
|
||||||
|
errorLower.includes('invalid_model')
|
||||||
) {
|
) {
|
||||||
enhancedError = `${errorText}\n\nTip: Check that your OPENAI_API_KEY is set correctly or run 'codex auth login' to authenticate.`;
|
enhancedError =
|
||||||
|
`${errorText}\n\nTip: The model '${options.model}' may not be available on your OpenAI plan. ` +
|
||||||
|
`See https://platform.openai.com/docs/models for available models. ` +
|
||||||
|
`Some models require a ChatGPT Pro/Plus subscription—authenticate with 'codex login' instead of an API key.`;
|
||||||
} else if (
|
} else if (
|
||||||
errorText.toLowerCase().includes('not found') ||
|
errorLower.includes('stream disconnected') ||
|
||||||
errorText.toLowerCase().includes('command not found')
|
errorLower.includes('stream ended') ||
|
||||||
|
errorLower.includes('connection reset')
|
||||||
|
) {
|
||||||
|
enhancedError =
|
||||||
|
`${errorText}\n\nTip: The connection to OpenAI was interrupted. This can happen due to:\n` +
|
||||||
|
`- Network instability\n` +
|
||||||
|
`- The model not being available on your plan\n` +
|
||||||
|
`- Server-side timeouts for long-running requests\n` +
|
||||||
|
`Try again, or switch to a different model.`;
|
||||||
|
} else if (
|
||||||
|
errorLower.includes('command not found') ||
|
||||||
|
errorLower.includes('is not recognized as an internal or external command')
|
||||||
) {
|
) {
|
||||||
enhancedError = `${errorText}\n\nTip: Make sure the Codex CLI is installed. Run 'npm install -g @openai/codex-cli' to install.`;
|
enhancedError = `${errorText}\n\nTip: Make sure the Codex CLI is installed. Run 'npm install -g @openai/codex-cli' to install.`;
|
||||||
}
|
}
|
||||||
@@ -1033,7 +1120,6 @@ export class CodexProvider extends BaseProvider {
|
|||||||
async detectInstallation(): Promise<InstallationStatus> {
|
async detectInstallation(): Promise<InstallationStatus> {
|
||||||
const cliPath = await findCodexCliPath();
|
const cliPath = await findCodexCliPath();
|
||||||
const hasApiKey = Boolean(await resolveOpenAiApiKey());
|
const hasApiKey = Boolean(await resolveOpenAiApiKey());
|
||||||
const authIndicators = await getCodexAuthIndicators();
|
|
||||||
const installed = !!cliPath;
|
const installed = !!cliPath;
|
||||||
|
|
||||||
let version = '';
|
let version = '';
|
||||||
@@ -1045,7 +1131,7 @@ export class CodexProvider extends BaseProvider {
|
|||||||
cwd: process.cwd(),
|
cwd: process.cwd(),
|
||||||
});
|
});
|
||||||
version = result.stdout.trim();
|
version = result.stdout.trim();
|
||||||
} catch (error) {
|
} catch {
|
||||||
version = '';
|
version = '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ const SDK_HISTORY_HEADER = 'Current request:\n';
|
|||||||
const DEFAULT_RESPONSE_TEXT = '';
|
const DEFAULT_RESPONSE_TEXT = '';
|
||||||
const SDK_ERROR_DETAILS_LABEL = 'Details:';
|
const SDK_ERROR_DETAILS_LABEL = 'Details:';
|
||||||
|
|
||||||
|
type SdkReasoningEffort = 'minimal' | 'low' | 'medium' | 'high' | 'xhigh';
|
||||||
|
const SDK_REASONING_EFFORTS = new Set<string>(['minimal', 'low', 'medium', 'high', 'xhigh']);
|
||||||
|
|
||||||
type PromptBlock = {
|
type PromptBlock = {
|
||||||
type: string;
|
type: string;
|
||||||
text?: string;
|
text?: string;
|
||||||
@@ -99,38 +102,52 @@ export async function* executeCodexSdkQuery(
|
|||||||
const apiKey = resolveApiKey();
|
const apiKey = resolveApiKey();
|
||||||
const codex = new Codex({ apiKey });
|
const codex = new Codex({ apiKey });
|
||||||
|
|
||||||
|
// Build thread options with model
|
||||||
|
// The model must be passed to startThread/resumeThread so the SDK
|
||||||
|
// knows which model to use for the conversation. Without this,
|
||||||
|
// the SDK may use a default model that the user doesn't have access to.
|
||||||
|
const threadOptions: {
|
||||||
|
model?: string;
|
||||||
|
modelReasoningEffort?: SdkReasoningEffort;
|
||||||
|
} = {};
|
||||||
|
|
||||||
|
if (options.model) {
|
||||||
|
threadOptions.model = options.model;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add reasoning effort to thread options if model supports it
|
||||||
|
if (
|
||||||
|
options.reasoningEffort &&
|
||||||
|
options.model &&
|
||||||
|
supportsReasoningEffort(options.model) &&
|
||||||
|
options.reasoningEffort !== 'none' &&
|
||||||
|
SDK_REASONING_EFFORTS.has(options.reasoningEffort)
|
||||||
|
) {
|
||||||
|
threadOptions.modelReasoningEffort = options.reasoningEffort as SdkReasoningEffort;
|
||||||
|
}
|
||||||
|
|
||||||
// Resume existing thread or start new one
|
// Resume existing thread or start new one
|
||||||
let thread;
|
let thread;
|
||||||
if (options.sdkSessionId) {
|
if (options.sdkSessionId) {
|
||||||
try {
|
try {
|
||||||
thread = codex.resumeThread(options.sdkSessionId);
|
thread = codex.resumeThread(options.sdkSessionId, threadOptions);
|
||||||
} catch {
|
} catch {
|
||||||
// If resume fails, start a new thread
|
// If resume fails, start a new thread
|
||||||
thread = codex.startThread();
|
thread = codex.startThread(threadOptions);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
thread = codex.startThread();
|
thread = codex.startThread(threadOptions);
|
||||||
}
|
}
|
||||||
|
|
||||||
const promptText = buildPromptText(options, systemPrompt);
|
const promptText = buildPromptText(options, systemPrompt);
|
||||||
|
|
||||||
// Build run options with reasoning effort if supported
|
// Build run options
|
||||||
const runOptions: {
|
const runOptions: {
|
||||||
signal?: AbortSignal;
|
signal?: AbortSignal;
|
||||||
reasoning?: { effort: string };
|
|
||||||
} = {
|
} = {
|
||||||
signal: options.abortController?.signal,
|
signal: options.abortController?.signal,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Add reasoning effort if model supports it and reasoningEffort is specified
|
|
||||||
if (
|
|
||||||
options.reasoningEffort &&
|
|
||||||
supportsReasoningEffort(options.model) &&
|
|
||||||
options.reasoningEffort !== 'none'
|
|
||||||
) {
|
|
||||||
runOptions.reasoning = { effort: options.reasoningEffort };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Run the query
|
// Run the query
|
||||||
const result = await thread.run(promptText, runOptions);
|
const result = await thread.run(promptText, runOptions);
|
||||||
|
|
||||||
@@ -160,10 +177,42 @@ export async function* executeCodexSdkQuery(
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorInfo = classifyError(error);
|
const errorInfo = classifyError(error);
|
||||||
const userMessage = getUserFriendlyErrorMessage(error);
|
const userMessage = getUserFriendlyErrorMessage(error);
|
||||||
const combinedMessage = buildSdkErrorMessage(errorInfo.message, userMessage);
|
let combinedMessage = buildSdkErrorMessage(errorInfo.message, userMessage);
|
||||||
|
|
||||||
|
// Enhance error messages with actionable tips for common Codex issues
|
||||||
|
// Normalize inputs to avoid crashes from nullish values
|
||||||
|
const errorLower = (errorInfo?.message ?? '').toLowerCase();
|
||||||
|
const modelLabel = options?.model ?? '<unknown model>';
|
||||||
|
|
||||||
|
if (
|
||||||
|
errorLower.includes('does not exist') ||
|
||||||
|
errorLower.includes('model_not_found') ||
|
||||||
|
errorLower.includes('invalid_model')
|
||||||
|
) {
|
||||||
|
// Model not found - provide helpful guidance
|
||||||
|
combinedMessage +=
|
||||||
|
`\n\nTip: The model '${modelLabel}' may not be available on your OpenAI plan. ` +
|
||||||
|
`Some models (like gpt-5.3-codex) require a ChatGPT Pro/Plus subscription and OAuth login via 'codex login'. ` +
|
||||||
|
`Try using a different model (e.g., gpt-5.1 or gpt-5.2), or authenticate with 'codex login' instead of an API key.`;
|
||||||
|
} else if (
|
||||||
|
errorLower.includes('stream disconnected') ||
|
||||||
|
errorLower.includes('stream ended') ||
|
||||||
|
errorLower.includes('connection reset') ||
|
||||||
|
errorLower.includes('socket hang up')
|
||||||
|
) {
|
||||||
|
// Stream disconnection - provide helpful guidance
|
||||||
|
combinedMessage +=
|
||||||
|
`\n\nTip: The connection to OpenAI was interrupted. This can happen due to:\n` +
|
||||||
|
`- Network instability\n` +
|
||||||
|
`- The model not being available on your plan (try 'codex login' for OAuth authentication)\n` +
|
||||||
|
`- Server-side timeouts for long-running requests\n` +
|
||||||
|
`Try again, or switch to a different model.`;
|
||||||
|
}
|
||||||
|
|
||||||
console.error('[CodexSDK] executeQuery() error during execution:', {
|
console.error('[CodexSDK] executeQuery() error during execution:', {
|
||||||
type: errorInfo.type,
|
type: errorInfo.type,
|
||||||
message: errorInfo.message,
|
message: errorInfo.message,
|
||||||
|
model: options.model,
|
||||||
isRateLimit: errorInfo.isRateLimit,
|
isRateLimit: errorInfo.isRateLimit,
|
||||||
retryAfter: errorInfo.retryAfter,
|
retryAfter: errorInfo.retryAfter,
|
||||||
stack: error instanceof Error ? error.stack : undefined,
|
stack: error instanceof Error ? error.stack : undefined,
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import {
|
|||||||
type CopilotRuntimeModel,
|
type CopilotRuntimeModel,
|
||||||
} from '@automaker/types';
|
} from '@automaker/types';
|
||||||
import { createLogger, isAbortError } from '@automaker/utils';
|
import { createLogger, isAbortError } from '@automaker/utils';
|
||||||
|
import { resolveModelString } from '@automaker/model-resolver';
|
||||||
import { CopilotClient, type PermissionRequest } from '@github/copilot-sdk';
|
import { CopilotClient, type PermissionRequest } from '@github/copilot-sdk';
|
||||||
import {
|
import {
|
||||||
normalizeTodos,
|
normalizeTodos,
|
||||||
@@ -42,7 +43,7 @@ import {
|
|||||||
const logger = createLogger('CopilotProvider');
|
const logger = createLogger('CopilotProvider');
|
||||||
|
|
||||||
// Default bare model (without copilot- prefix) for SDK calls
|
// Default bare model (without copilot- prefix) for SDK calls
|
||||||
const DEFAULT_BARE_MODEL = 'claude-sonnet-4.5';
|
const DEFAULT_BARE_MODEL = 'claude-sonnet-4.6';
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// SDK Event Types (from @github/copilot-sdk)
|
// SDK Event Types (from @github/copilot-sdk)
|
||||||
@@ -85,10 +86,6 @@ interface SdkToolExecutionEndEvent extends SdkEvent {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SdkSessionIdleEvent extends SdkEvent {
|
|
||||||
type: 'session.idle';
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SdkSessionErrorEvent extends SdkEvent {
|
interface SdkSessionErrorEvent extends SdkEvent {
|
||||||
type: 'session.error';
|
type: 'session.error';
|
||||||
data: {
|
data: {
|
||||||
@@ -120,6 +117,12 @@ export interface CopilotError extends Error {
|
|||||||
suggestion?: string;
|
suggestion?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CopilotSession = Awaited<ReturnType<CopilotClient['createSession']>>;
|
||||||
|
type CopilotSessionOptions = Parameters<CopilotClient['createSession']>[0];
|
||||||
|
type ResumableCopilotClient = CopilotClient & {
|
||||||
|
resumeSession?: (sessionId: string, options: CopilotSessionOptions) => Promise<CopilotSession>;
|
||||||
|
};
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Tool Name Normalization
|
// Tool Name Normalization
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -386,9 +389,14 @@ export class CopilotProvider extends CliProvider {
|
|||||||
|
|
||||||
case 'session.error': {
|
case 'session.error': {
|
||||||
const errorEvent = sdkEvent as SdkSessionErrorEvent;
|
const errorEvent = sdkEvent as SdkSessionErrorEvent;
|
||||||
|
const enrichedError =
|
||||||
|
errorEvent.data.message ||
|
||||||
|
(errorEvent.data.code
|
||||||
|
? `Copilot agent error (code: ${errorEvent.data.code})`
|
||||||
|
: 'Copilot agent error');
|
||||||
return {
|
return {
|
||||||
type: 'error',
|
type: 'error',
|
||||||
error: errorEvent.data.message || 'Unknown error',
|
error: enrichedError,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -520,7 +528,11 @@ export class CopilotProvider extends CliProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const promptText = this.extractPromptText(options);
|
const promptText = this.extractPromptText(options);
|
||||||
const bareModel = options.model || DEFAULT_BARE_MODEL;
|
// resolveModelString may return dash-separated canonical names (e.g. "claude-sonnet-4-6"),
|
||||||
|
// but the Copilot SDK expects dot-separated version suffixes (e.g. "claude-sonnet-4.6").
|
||||||
|
// Normalize by converting the last dash-separated numeric pair to dot notation.
|
||||||
|
const resolvedModel = resolveModelString(options.model || DEFAULT_BARE_MODEL);
|
||||||
|
const bareModel = resolvedModel.replace(/-(\d+)-(\d+)$/, '-$1.$2');
|
||||||
const workingDirectory = options.cwd || process.cwd();
|
const workingDirectory = options.cwd || process.cwd();
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
@@ -558,12 +570,14 @@ export class CopilotProvider extends CliProvider {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Declare session outside try so it's accessible in the catch block for cleanup.
|
||||||
|
let session: CopilotSession | undefined;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await client.start();
|
await client.start();
|
||||||
logger.debug(`CopilotClient started with cwd: ${workingDirectory}`);
|
logger.debug(`CopilotClient started with cwd: ${workingDirectory}`);
|
||||||
|
|
||||||
// Create session with streaming enabled for real-time events
|
const sessionOptions: CopilotSessionOptions = {
|
||||||
const session = await client.createSession({
|
|
||||||
model: bareModel,
|
model: bareModel,
|
||||||
streaming: true,
|
streaming: true,
|
||||||
// AUTONOMOUS MODE: Auto-approve all permission requests.
|
// AUTONOMOUS MODE: Auto-approve all permission requests.
|
||||||
@@ -576,13 +590,33 @@ export class CopilotProvider extends CliProvider {
|
|||||||
logger.debug(`Permission request: ${request.kind}`);
|
logger.debug(`Permission request: ${request.kind}`);
|
||||||
return { kind: 'approved' };
|
return { kind: 'approved' };
|
||||||
},
|
},
|
||||||
});
|
};
|
||||||
|
|
||||||
const sessionId = session.sessionId;
|
// Resume the previous Copilot session when possible; otherwise create a fresh one.
|
||||||
logger.debug(`Session created: ${sessionId}`);
|
const resumableClient = client as ResumableCopilotClient;
|
||||||
|
let sessionResumed = false;
|
||||||
|
if (options.sdkSessionId && typeof resumableClient.resumeSession === 'function') {
|
||||||
|
try {
|
||||||
|
session = await resumableClient.resumeSession(options.sdkSessionId, sessionOptions);
|
||||||
|
sessionResumed = true;
|
||||||
|
logger.debug(`Resumed Copilot session: ${session.sessionId}`);
|
||||||
|
} catch (resumeError) {
|
||||||
|
logger.warn(
|
||||||
|
`Failed to resume Copilot session "${options.sdkSessionId}", creating a new session: ${resumeError}`
|
||||||
|
);
|
||||||
|
session = await client.createSession(sessionOptions);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
session = await client.createSession(sessionOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
// session is always assigned by this point (both branches above assign it)
|
||||||
|
const activeSession = session!;
|
||||||
|
const sessionId = activeSession.sessionId;
|
||||||
|
logger.debug(`Session ${sessionResumed ? 'resumed' : 'created'}: ${sessionId}`);
|
||||||
|
|
||||||
// Set up event handler to push events to queue
|
// Set up event handler to push events to queue
|
||||||
session.on((event: SdkEvent) => {
|
activeSession.on((event: SdkEvent) => {
|
||||||
logger.debug(`SDK event: ${event.type}`);
|
logger.debug(`SDK event: ${event.type}`);
|
||||||
|
|
||||||
if (event.type === 'session.idle') {
|
if (event.type === 'session.idle') {
|
||||||
@@ -600,7 +634,7 @@ export class CopilotProvider extends CliProvider {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Send the prompt (non-blocking)
|
// Send the prompt (non-blocking)
|
||||||
await session.send({ prompt: promptText });
|
await activeSession.send({ prompt: promptText });
|
||||||
|
|
||||||
// Process events as they arrive
|
// Process events as they arrive
|
||||||
while (!sessionComplete || eventQueue.length > 0) {
|
while (!sessionComplete || eventQueue.length > 0) {
|
||||||
@@ -608,7 +642,7 @@ export class CopilotProvider extends CliProvider {
|
|||||||
|
|
||||||
// Check for errors first (before processing events to avoid race condition)
|
// Check for errors first (before processing events to avoid race condition)
|
||||||
if (sessionError) {
|
if (sessionError) {
|
||||||
await session.destroy();
|
await activeSession.destroy();
|
||||||
await client.stop();
|
await client.stop();
|
||||||
throw sessionError;
|
throw sessionError;
|
||||||
}
|
}
|
||||||
@@ -628,11 +662,19 @@ export class CopilotProvider extends CliProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Cleanup
|
// Cleanup
|
||||||
await session.destroy();
|
await activeSession.destroy();
|
||||||
await client.stop();
|
await client.stop();
|
||||||
logger.debug('CopilotClient stopped successfully');
|
logger.debug('CopilotClient stopped successfully');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Ensure client is stopped on error
|
// Ensure session is destroyed and client is stopped on error to prevent leaks.
|
||||||
|
// The session may have been created/resumed before the error occurred.
|
||||||
|
if (session) {
|
||||||
|
try {
|
||||||
|
await session.destroy();
|
||||||
|
} catch (sessionCleanupError) {
|
||||||
|
logger.debug(`Failed to destroy session during cleanup: ${sessionCleanupError}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await client.stop();
|
await client.stop();
|
||||||
} catch (cleanupError) {
|
} catch (cleanupError) {
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ import type {
|
|||||||
} from './types.js';
|
} from './types.js';
|
||||||
import { validateBareModelId } from '@automaker/types';
|
import { validateBareModelId } from '@automaker/types';
|
||||||
import { validateApiKey } from '../lib/auth-utils.js';
|
import { validateApiKey } from '../lib/auth-utils.js';
|
||||||
import { getEffectivePermissions } from '../services/cursor-config-service.js';
|
import { getEffectivePermissions, detectProfile } from '../services/cursor-config-service.js';
|
||||||
import {
|
import {
|
||||||
type CursorStreamEvent,
|
type CursorStreamEvent,
|
||||||
type CursorSystemEvent,
|
type CursorSystemEvent,
|
||||||
@@ -69,6 +69,7 @@ interface CursorToolHandler<TArgs = unknown, TResult = unknown> {
|
|||||||
* Registry of Cursor tool handlers
|
* Registry of Cursor tool handlers
|
||||||
* Each handler knows how to normalize its specific tool call type
|
* Each handler knows how to normalize its specific tool call type
|
||||||
*/
|
*/
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- handler registry stores heterogeneous tool type parameters
|
||||||
const CURSOR_TOOL_HANDLERS: Record<string, CursorToolHandler<any, any>> = {
|
const CURSOR_TOOL_HANDLERS: Record<string, CursorToolHandler<any, any>> = {
|
||||||
readToolCall: {
|
readToolCall: {
|
||||||
name: 'Read',
|
name: 'Read',
|
||||||
@@ -449,6 +450,11 @@ export class CursorProvider extends CliProvider {
|
|||||||
cliArgs.push('--model', model);
|
cliArgs.push('--model', model);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Resume an existing chat when a provider session ID is available
|
||||||
|
if (options.sdkSessionId) {
|
||||||
|
cliArgs.push('--resume', options.sdkSessionId);
|
||||||
|
}
|
||||||
|
|
||||||
// Use '-' to indicate reading prompt from stdin
|
// Use '-' to indicate reading prompt from stdin
|
||||||
cliArgs.push('-');
|
cliArgs.push('-');
|
||||||
|
|
||||||
@@ -556,10 +562,14 @@ export class CursorProvider extends CliProvider {
|
|||||||
const resultEvent = cursorEvent as CursorResultEvent;
|
const resultEvent = cursorEvent as CursorResultEvent;
|
||||||
|
|
||||||
if (resultEvent.is_error) {
|
if (resultEvent.is_error) {
|
||||||
|
const errorText = resultEvent.error || resultEvent.result || '';
|
||||||
|
const enrichedError =
|
||||||
|
errorText ||
|
||||||
|
`Cursor agent failed (duration: ${resultEvent.duration_ms}ms, subtype: ${resultEvent.subtype}, session: ${resultEvent.session_id ?? 'none'})`;
|
||||||
return {
|
return {
|
||||||
type: 'error',
|
type: 'error',
|
||||||
session_id: resultEvent.session_id,
|
session_id: resultEvent.session_id,
|
||||||
error: resultEvent.error || resultEvent.result || 'Unknown error',
|
error: enrichedError,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -877,8 +887,12 @@ export class CursorProvider extends CliProvider {
|
|||||||
|
|
||||||
logger.debug(`CursorProvider.executeQuery called with model: "${options.model}"`);
|
logger.debug(`CursorProvider.executeQuery called with model: "${options.model}"`);
|
||||||
|
|
||||||
// Get effective permissions for this project
|
// Get effective permissions for this project and detect the active profile
|
||||||
const effectivePermissions = await getEffectivePermissions(options.cwd || process.cwd());
|
const effectivePermissions = await getEffectivePermissions(options.cwd || process.cwd());
|
||||||
|
const activeProfile = detectProfile(effectivePermissions);
|
||||||
|
logger.debug(
|
||||||
|
`Active permission profile: ${activeProfile ?? 'none'}, permissions: ${JSON.stringify(effectivePermissions)}`
|
||||||
|
);
|
||||||
|
|
||||||
// Debug: log raw events when AUTOMAKER_DEBUG_RAW_OUTPUT is enabled
|
// Debug: log raw events when AUTOMAKER_DEBUG_RAW_OUTPUT is enabled
|
||||||
const debugRawEvents =
|
const debugRawEvents =
|
||||||
|
|||||||
@@ -20,12 +20,11 @@ import type {
|
|||||||
ProviderMessage,
|
ProviderMessage,
|
||||||
InstallationStatus,
|
InstallationStatus,
|
||||||
ModelDefinition,
|
ModelDefinition,
|
||||||
ContentBlock,
|
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
import { validateBareModelId } from '@automaker/types';
|
import { validateBareModelId } from '@automaker/types';
|
||||||
import { GEMINI_MODEL_MAP, type GeminiAuthStatus } from '@automaker/types';
|
import { GEMINI_MODEL_MAP, type GeminiAuthStatus } from '@automaker/types';
|
||||||
import { createLogger, isAbortError } from '@automaker/utils';
|
import { createLogger, isAbortError } from '@automaker/utils';
|
||||||
import { spawnJSONLProcess } from '@automaker/platform';
|
import { spawnJSONLProcess, type SubprocessOptions } from '@automaker/platform';
|
||||||
import { normalizeTodos } from './tool-normalization.js';
|
import { normalizeTodos } from './tool-normalization.js';
|
||||||
|
|
||||||
// Create logger for this module
|
// Create logger for this module
|
||||||
@@ -264,6 +263,14 @@ export class GeminiProvider extends CliProvider {
|
|||||||
// Use explicit approval-mode for clearer semantics
|
// Use explicit approval-mode for clearer semantics
|
||||||
cliArgs.push('--approval-mode', 'yolo');
|
cliArgs.push('--approval-mode', 'yolo');
|
||||||
|
|
||||||
|
// Force headless (non-interactive) mode with --prompt flag.
|
||||||
|
// The actual prompt content is passed via stdin (see buildSubprocessOptions()),
|
||||||
|
// but we MUST include -p to trigger headless mode. Without it, Gemini CLI
|
||||||
|
// starts in interactive mode which adds significant startup overhead
|
||||||
|
// (interactive REPL setup, extra context loading, etc.).
|
||||||
|
// Per Gemini CLI docs: stdin content is "appended to" the -p value.
|
||||||
|
cliArgs.push('--prompt', '');
|
||||||
|
|
||||||
// Explicitly include the working directory in allowed workspace directories
|
// Explicitly include the working directory in allowed workspace directories
|
||||||
// This ensures Gemini CLI allows file operations in the project directory,
|
// This ensures Gemini CLI allows file operations in the project directory,
|
||||||
// even if it has a different workspace cached from a previous session
|
// even if it has a different workspace cached from a previous session
|
||||||
@@ -271,13 +278,15 @@ export class GeminiProvider extends CliProvider {
|
|||||||
cliArgs.push('--include-directories', options.cwd);
|
cliArgs.push('--include-directories', options.cwd);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Resume an existing Gemini session when one is available
|
||||||
|
if (options.sdkSessionId) {
|
||||||
|
cliArgs.push('--resume', options.sdkSessionId);
|
||||||
|
}
|
||||||
|
|
||||||
// Note: Gemini CLI doesn't have a --thinking-level flag.
|
// Note: Gemini CLI doesn't have a --thinking-level flag.
|
||||||
// Thinking capabilities are determined by the model selection (e.g., gemini-2.5-pro).
|
// Thinking capabilities are determined by the model selection (e.g., gemini-2.5-pro).
|
||||||
// The model handles thinking internally based on the task complexity.
|
// The model handles thinking internally based on the task complexity.
|
||||||
|
|
||||||
// The prompt will be passed as the last positional argument
|
|
||||||
// We'll append it in executeQuery after extracting the text
|
|
||||||
|
|
||||||
return cliArgs;
|
return cliArgs;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -372,10 +381,13 @@ export class GeminiProvider extends CliProvider {
|
|||||||
const resultEvent = geminiEvent as GeminiResultEvent;
|
const resultEvent = geminiEvent as GeminiResultEvent;
|
||||||
|
|
||||||
if (resultEvent.status === 'error') {
|
if (resultEvent.status === 'error') {
|
||||||
|
const enrichedError =
|
||||||
|
resultEvent.error ||
|
||||||
|
`Gemini agent failed (duration: ${resultEvent.stats?.duration_ms ?? 'unknown'}ms, session: ${resultEvent.session_id ?? 'none'})`;
|
||||||
return {
|
return {
|
||||||
type: 'error',
|
type: 'error',
|
||||||
session_id: resultEvent.session_id,
|
session_id: resultEvent.session_id,
|
||||||
error: resultEvent.error || 'Unknown error',
|
error: enrichedError,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -392,10 +404,12 @@ export class GeminiProvider extends CliProvider {
|
|||||||
|
|
||||||
case 'error': {
|
case 'error': {
|
||||||
const errorEvent = geminiEvent as GeminiResultEvent;
|
const errorEvent = geminiEvent as GeminiResultEvent;
|
||||||
|
const enrichedError =
|
||||||
|
errorEvent.error || `Gemini agent failed (session: ${errorEvent.session_id ?? 'none'})`;
|
||||||
return {
|
return {
|
||||||
type: 'error',
|
type: 'error',
|
||||||
session_id: errorEvent.session_id,
|
session_id: errorEvent.session_id,
|
||||||
error: errorEvent.error || 'Unknown error',
|
error: enrichedError,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -409,6 +423,32 @@ export class GeminiProvider extends CliProvider {
|
|||||||
// CliProvider Overrides
|
// CliProvider Overrides
|
||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build subprocess options with stdin data for prompt and speed-optimized env vars.
|
||||||
|
*
|
||||||
|
* Passes the prompt via stdin instead of --prompt CLI arg to:
|
||||||
|
* - Avoid shell argument size limits with large prompts (system prompt + context)
|
||||||
|
* - Avoid shell escaping issues with special characters in prompts
|
||||||
|
* - Match the pattern used by Cursor, OpenCode, and Codex providers
|
||||||
|
*
|
||||||
|
* Also injects environment variables to reduce Gemini CLI startup overhead:
|
||||||
|
* - GEMINI_TELEMETRY_ENABLED=false: Disables OpenTelemetry collection
|
||||||
|
*/
|
||||||
|
protected buildSubprocessOptions(options: ExecuteOptions, cliArgs: string[]): SubprocessOptions {
|
||||||
|
const subprocessOptions = super.buildSubprocessOptions(options, cliArgs);
|
||||||
|
|
||||||
|
// Pass prompt via stdin to avoid shell interpretation of special characters
|
||||||
|
// and shell argument size limits with large system prompts + context files
|
||||||
|
subprocessOptions.stdinData = this.extractPromptText(options);
|
||||||
|
|
||||||
|
// Disable telemetry to reduce startup overhead
|
||||||
|
if (subprocessOptions.env) {
|
||||||
|
subprocessOptions.env['GEMINI_TELEMETRY_ENABLED'] = 'false';
|
||||||
|
}
|
||||||
|
|
||||||
|
return subprocessOptions;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Override error mapping for Gemini-specific error codes
|
* Override error mapping for Gemini-specific error codes
|
||||||
*/
|
*/
|
||||||
@@ -518,14 +558,21 @@ export class GeminiProvider extends CliProvider {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract prompt text to pass as positional argument
|
// Ensure .geminiignore exists in the working directory to prevent Gemini CLI
|
||||||
const promptText = this.extractPromptText(options);
|
// from scanning .git and node_modules directories during startup. This reduces
|
||||||
|
// startup time significantly (reported: 35s → 11s) by skipping large directories
|
||||||
|
// that Gemini CLI would otherwise traverse for context discovery.
|
||||||
|
await this.ensureGeminiIgnore(options.cwd || process.cwd());
|
||||||
|
|
||||||
// Build CLI args and append the prompt as the last positional argument
|
// Embed system prompt into the user prompt so Gemini CLI receives
|
||||||
const cliArgs = this.buildCliArgs(options);
|
// project context (CLAUDE.md, CODE_QUALITY.md, etc.) that would
|
||||||
cliArgs.push(promptText); // Gemini CLI uses positional args for the prompt
|
// otherwise be silently dropped since Gemini CLI has no --system-prompt flag.
|
||||||
|
const effectiveOptions = this.embedSystemPromptIntoPrompt(options);
|
||||||
|
|
||||||
const subprocessOptions = this.buildSubprocessOptions(options, cliArgs);
|
// Build CLI args for headless execution.
|
||||||
|
const cliArgs = this.buildCliArgs(effectiveOptions);
|
||||||
|
|
||||||
|
const subprocessOptions = this.buildSubprocessOptions(effectiveOptions, cliArgs);
|
||||||
|
|
||||||
let sessionId: string | undefined;
|
let sessionId: string | undefined;
|
||||||
|
|
||||||
@@ -578,6 +625,49 @@ export class GeminiProvider extends CliProvider {
|
|||||||
// Gemini-Specific Methods
|
// Gemini-Specific Methods
|
||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure a .geminiignore file exists in the working directory.
|
||||||
|
*
|
||||||
|
* Gemini CLI scans the working directory for context discovery during startup.
|
||||||
|
* Excluding .git and node_modules dramatically reduces startup time by preventing
|
||||||
|
* traversal of large directories (reported improvement: 35s → 11s).
|
||||||
|
*
|
||||||
|
* Only creates the file if it doesn't already exist to avoid overwriting user config.
|
||||||
|
*/
|
||||||
|
private async ensureGeminiIgnore(cwd: string): Promise<void> {
|
||||||
|
const ignorePath = path.join(cwd, '.geminiignore');
|
||||||
|
const content = [
|
||||||
|
'# Auto-generated by Automaker to speed up Gemini CLI startup',
|
||||||
|
'# Prevents Gemini CLI from scanning large directories during context discovery',
|
||||||
|
'.git',
|
||||||
|
'node_modules',
|
||||||
|
'dist',
|
||||||
|
'build',
|
||||||
|
'.next',
|
||||||
|
'.nuxt',
|
||||||
|
'coverage',
|
||||||
|
'.automaker',
|
||||||
|
'.worktrees',
|
||||||
|
'.vscode',
|
||||||
|
'.idea',
|
||||||
|
'*.lock',
|
||||||
|
'',
|
||||||
|
].join('\n');
|
||||||
|
try {
|
||||||
|
// Use 'wx' flag for atomic creation - fails if file exists (EEXIST)
|
||||||
|
await fs.writeFile(ignorePath, content, { encoding: 'utf-8', flag: 'wx' });
|
||||||
|
logger.debug(`Created .geminiignore at ${ignorePath}`);
|
||||||
|
} catch (writeError) {
|
||||||
|
// EEXIST means file already exists - that's fine, preserve user's file
|
||||||
|
if ((writeError as NodeJS.ErrnoException).code === 'EEXIST') {
|
||||||
|
logger.debug(`.geminiignore already exists at ${ignorePath}, preserving existing file`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Non-fatal: startup will just be slower without the ignore file
|
||||||
|
logger.debug(`Failed to create .geminiignore: ${writeError}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a GeminiError with details
|
* Create a GeminiError with details
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -192,6 +192,28 @@ export interface OpenCodeToolErrorEvent extends OpenCodeBaseEvent {
|
|||||||
part?: OpenCodePart & { error: string };
|
part?: OpenCodePart & { error: string };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tool use event - The actual format emitted by OpenCode CLI when a tool is invoked.
|
||||||
|
* Contains the tool name, call ID, and the complete state (input, output, status).
|
||||||
|
* Note: OpenCode CLI emits 'tool_use' (not 'tool_call') as the event type.
|
||||||
|
*/
|
||||||
|
export interface OpenCodeToolUseEvent extends OpenCodeBaseEvent {
|
||||||
|
type: 'tool_use';
|
||||||
|
part: OpenCodePart & {
|
||||||
|
type: 'tool';
|
||||||
|
callID?: string;
|
||||||
|
tool?: string;
|
||||||
|
state?: {
|
||||||
|
status?: string;
|
||||||
|
input?: unknown;
|
||||||
|
output?: string;
|
||||||
|
title?: string;
|
||||||
|
metadata?: unknown;
|
||||||
|
time?: { start: number; end: number };
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Union type of all OpenCode stream events
|
* Union type of all OpenCode stream events
|
||||||
*/
|
*/
|
||||||
@@ -200,6 +222,7 @@ export type OpenCodeStreamEvent =
|
|||||||
| OpenCodeStepStartEvent
|
| OpenCodeStepStartEvent
|
||||||
| OpenCodeStepFinishEvent
|
| OpenCodeStepFinishEvent
|
||||||
| OpenCodeToolCallEvent
|
| OpenCodeToolCallEvent
|
||||||
|
| OpenCodeToolUseEvent
|
||||||
| OpenCodeToolResultEvent
|
| OpenCodeToolResultEvent
|
||||||
| OpenCodeErrorEvent
|
| OpenCodeErrorEvent
|
||||||
| OpenCodeToolErrorEvent;
|
| OpenCodeToolErrorEvent;
|
||||||
@@ -311,8 +334,8 @@ export class OpencodeProvider extends CliProvider {
|
|||||||
* Arguments built:
|
* Arguments built:
|
||||||
* - 'run' subcommand for executing queries
|
* - 'run' subcommand for executing queries
|
||||||
* - '--format', 'json' for JSONL streaming output
|
* - '--format', 'json' for JSONL streaming output
|
||||||
* - '-c', '<cwd>' for working directory (using opencode's -c flag)
|
|
||||||
* - '--model', '<model>' for model selection (if specified)
|
* - '--model', '<model>' for model selection (if specified)
|
||||||
|
* - '--session', '<id>' for continuing an existing session (if sdkSessionId is set)
|
||||||
*
|
*
|
||||||
* The prompt is passed via stdin (piped) to avoid shell escaping issues.
|
* The prompt is passed via stdin (piped) to avoid shell escaping issues.
|
||||||
* OpenCode CLI automatically reads from stdin when input is piped.
|
* OpenCode CLI automatically reads from stdin when input is piped.
|
||||||
@@ -326,6 +349,14 @@ export class OpencodeProvider extends CliProvider {
|
|||||||
// Add JSON output format for JSONL parsing (not 'stream-json')
|
// Add JSON output format for JSONL parsing (not 'stream-json')
|
||||||
args.push('--format', 'json');
|
args.push('--format', 'json');
|
||||||
|
|
||||||
|
// Handle session resumption for conversation continuity.
|
||||||
|
// The opencode CLI supports `--session <id>` to continue an existing session.
|
||||||
|
// The sdkSessionId is captured from the sessionID field in previous stream events
|
||||||
|
// and persisted by AgentService for use in follow-up messages.
|
||||||
|
if (options.sdkSessionId) {
|
||||||
|
args.push('--session', options.sdkSessionId);
|
||||||
|
}
|
||||||
|
|
||||||
// Handle model selection
|
// Handle model selection
|
||||||
// Convert canonical prefix format (opencode-xxx) to CLI slash format (opencode/xxx)
|
// Convert canonical prefix format (opencode-xxx) to CLI slash format (opencode/xxx)
|
||||||
// OpenCode CLI expects provider/model format (e.g., 'opencode/big-model')
|
// OpenCode CLI expects provider/model format (e.g., 'opencode/big-model')
|
||||||
@@ -398,15 +429,225 @@ export class OpencodeProvider extends CliProvider {
|
|||||||
return subprocessOptions;
|
return subprocessOptions;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if an error message indicates a session-not-found condition.
|
||||||
|
*
|
||||||
|
* Centralizes the pattern matching for session errors to avoid duplication.
|
||||||
|
* Strips ANSI escape codes first since opencode CLI uses colored stderr output
|
||||||
|
* (e.g. "\x1b[91m\x1b[1mError: \x1b[0mSession not found").
|
||||||
|
*
|
||||||
|
* IMPORTANT: Patterns must be specific enough to avoid false positives.
|
||||||
|
* Generic patterns like "notfounderror" or "resource not found" match
|
||||||
|
* non-session errors (e.g. "ProviderModelNotFoundError") which would
|
||||||
|
* trigger unnecessary retries that fail identically, producing confusing
|
||||||
|
* error messages like "OpenCode session could not be created".
|
||||||
|
*
|
||||||
|
* @param errorText - Raw error text (may contain ANSI codes)
|
||||||
|
* @returns true if the error indicates the session was not found
|
||||||
|
*/
|
||||||
|
private static isSessionNotFoundError(errorText: string): boolean {
|
||||||
|
const cleaned = OpencodeProvider.stripAnsiCodes(errorText).toLowerCase();
|
||||||
|
|
||||||
|
// Explicit session-related phrases — high confidence
|
||||||
|
if (
|
||||||
|
cleaned.includes('session not found') ||
|
||||||
|
cleaned.includes('session does not exist') ||
|
||||||
|
cleaned.includes('invalid session') ||
|
||||||
|
cleaned.includes('session expired') ||
|
||||||
|
cleaned.includes('no such session')
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generic "NotFoundError" / "resource not found" are only session errors
|
||||||
|
// when the message also references a session path or session ID.
|
||||||
|
// Without this guard, errors like "ProviderModelNotFoundError" or
|
||||||
|
// "Resource not found: /path/to/config.json" would false-positive.
|
||||||
|
if (cleaned.includes('notfounderror') || cleaned.includes('resource not found')) {
|
||||||
|
return cleaned.includes('/session/') || /\bsession\b/.test(cleaned);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strip ANSI escape codes from a string.
|
||||||
|
*
|
||||||
|
* The OpenCode CLI uses colored stderr output (e.g. "\x1b[91m\x1b[1mError: \x1b[0m").
|
||||||
|
* These escape codes render as garbled text like "[91m[1mError: [0m" in the UI
|
||||||
|
* when passed through as-is. This utility removes them so error messages are
|
||||||
|
* clean and human-readable.
|
||||||
|
*/
|
||||||
|
private static stripAnsiCodes(text: string): string {
|
||||||
|
return text.replace(/\x1b\[[0-9;]*m/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean a CLI error message for display.
|
||||||
|
*
|
||||||
|
* Strips ANSI escape codes AND removes the redundant "Error: " prefix that
|
||||||
|
* the OpenCode CLI prepends to error messages in its colored stderr output
|
||||||
|
* (e.g. "\x1b[91m\x1b[1mError: \x1b[0mSession not found" → "Session not found").
|
||||||
|
*
|
||||||
|
* Without this, consumers that wrap the message in their own "Error: " prefix
|
||||||
|
* (like AgentService or AgentExecutor) produce garbled double-prefixed output:
|
||||||
|
* "Error: Error: Session not found".
|
||||||
|
*/
|
||||||
|
private static cleanErrorMessage(text: string): string {
|
||||||
|
let cleaned = OpencodeProvider.stripAnsiCodes(text).trim();
|
||||||
|
// Remove leading "Error: " prefix (case-insensitive) if present.
|
||||||
|
// The CLI formats errors as: \x1b[91m\x1b[1mError: \x1b[0m<actual message>
|
||||||
|
// After ANSI stripping this becomes: "Error: <actual message>"
|
||||||
|
cleaned = cleaned.replace(/^Error:\s*/i, '').trim();
|
||||||
|
return cleaned || text;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a query with automatic session resumption fallback.
|
||||||
|
*
|
||||||
|
* When a sdkSessionId is provided, the CLI receives `--session <id>`.
|
||||||
|
* If the session no longer exists on disk the CLI will fail with a
|
||||||
|
* "NotFoundError" / "Resource not found" / "Session not found" error.
|
||||||
|
*
|
||||||
|
* The opencode CLI writes this to **stderr** and exits non-zero.
|
||||||
|
* `spawnJSONLProcess` collects stderr and **yields** it as
|
||||||
|
* `{ type: 'error', error: <stderrText> }` — it is NOT thrown.
|
||||||
|
* After `normalizeEvent`, the error becomes a yielded `ProviderMessage`
|
||||||
|
* with `type: 'error'`. A simple try/catch therefore cannot intercept it.
|
||||||
|
*
|
||||||
|
* This override iterates the parent stream, intercepts yielded error
|
||||||
|
* messages that match the session-not-found pattern, and retries the
|
||||||
|
* entire query WITHOUT the `--session` flag so a fresh session is started.
|
||||||
|
*
|
||||||
|
* Session-not-found retry is ONLY attempted when `sdkSessionId` is set.
|
||||||
|
* Without the `--session` flag the CLI always creates a fresh session, so
|
||||||
|
* retrying without it would be identical to the first attempt and would
|
||||||
|
* fail the same way — producing a confusing "session could not be created"
|
||||||
|
* message for what is actually a different error (model not found, auth
|
||||||
|
* failure, etc.).
|
||||||
|
*
|
||||||
|
* All error messages (session or not) are cleaned of ANSI codes and the
|
||||||
|
* CLI's redundant "Error: " prefix before being yielded to consumers.
|
||||||
|
*
|
||||||
|
* After a successful retry, the consumer (AgentService) will receive a new
|
||||||
|
* session_id from the fresh stream events, which it persists to metadata —
|
||||||
|
* replacing the stale sdkSessionId and preventing repeated failures.
|
||||||
|
*/
|
||||||
|
async *executeQuery(options: ExecuteOptions): AsyncGenerator<ProviderMessage> {
|
||||||
|
// When no sdkSessionId is set, there is nothing to "retry without" — just
|
||||||
|
// stream normally and clean error messages as they pass through.
|
||||||
|
if (!options.sdkSessionId) {
|
||||||
|
for await (const msg of super.executeQuery(options)) {
|
||||||
|
// Clean error messages so consumers don't get ANSI or double "Error:" prefix
|
||||||
|
if (msg.type === 'error' && msg.error && typeof msg.error === 'string') {
|
||||||
|
msg.error = OpencodeProvider.cleanErrorMessage(msg.error);
|
||||||
|
}
|
||||||
|
yield msg;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// sdkSessionId IS set — the CLI will receive `--session <id>`.
|
||||||
|
// If that session no longer exists, intercept the error and retry fresh.
|
||||||
|
//
|
||||||
|
// To avoid buffering the entire stream in memory for long-lived sessions,
|
||||||
|
// we only buffer an initial window of messages until we observe a healthy
|
||||||
|
// (non-error) message. Once a healthy message is seen, we flush the buffer
|
||||||
|
// and switch to direct passthrough, while still watching for session errors
|
||||||
|
// via isSessionNotFoundError on any subsequent error messages.
|
||||||
|
const buffered: ProviderMessage[] = [];
|
||||||
|
let sessionError = false;
|
||||||
|
let seenHealthyMessage = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
for await (const msg of super.executeQuery(options)) {
|
||||||
|
if (msg.type === 'error') {
|
||||||
|
const errorText = msg.error || '';
|
||||||
|
if (OpencodeProvider.isSessionNotFoundError(errorText)) {
|
||||||
|
sessionError = true;
|
||||||
|
opencodeLogger.info(
|
||||||
|
`OpenCode session error detected (session "${options.sdkSessionId}") ` +
|
||||||
|
`— retrying without --session to start fresh`
|
||||||
|
);
|
||||||
|
break; // stop consuming the failed stream
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-session error — clean it
|
||||||
|
if (msg.error && typeof msg.error === 'string') {
|
||||||
|
msg.error = OpencodeProvider.cleanErrorMessage(msg.error);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// A non-error message is a healthy signal — stop buffering after this
|
||||||
|
seenHealthyMessage = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (seenHealthyMessage && buffered.length > 0) {
|
||||||
|
// Flush the pre-healthy buffer first, then switch to passthrough
|
||||||
|
for (const bufferedMsg of buffered) {
|
||||||
|
yield bufferedMsg;
|
||||||
|
}
|
||||||
|
buffered.length = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (seenHealthyMessage) {
|
||||||
|
// Passthrough mode — yield directly without buffering
|
||||||
|
yield msg;
|
||||||
|
} else {
|
||||||
|
// Still in initial window — buffer until we see a healthy message
|
||||||
|
buffered.push(msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Also handle thrown exceptions (e.g. from mapError in cli-provider)
|
||||||
|
const errMsg = error instanceof Error ? error.message : String(error);
|
||||||
|
if (OpencodeProvider.isSessionNotFoundError(errMsg)) {
|
||||||
|
sessionError = true;
|
||||||
|
opencodeLogger.info(
|
||||||
|
`OpenCode session error detected (thrown, session "${options.sdkSessionId}") ` +
|
||||||
|
`— retrying without --session to start fresh`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sessionError) {
|
||||||
|
// Retry the entire query without the stale session ID.
|
||||||
|
const retryOptions = { ...options, sdkSessionId: undefined };
|
||||||
|
opencodeLogger.info('Retrying OpenCode query without --session flag...');
|
||||||
|
|
||||||
|
// Stream the retry directly to the consumer.
|
||||||
|
// If the retry also fails, it's a genuine error (not session-related)
|
||||||
|
// and should be surfaced as-is rather than masked with a misleading
|
||||||
|
// "session could not be created" message.
|
||||||
|
for await (const retryMsg of super.executeQuery(retryOptions)) {
|
||||||
|
if (retryMsg.type === 'error' && retryMsg.error && typeof retryMsg.error === 'string') {
|
||||||
|
retryMsg.error = OpencodeProvider.cleanErrorMessage(retryMsg.error);
|
||||||
|
}
|
||||||
|
yield retryMsg;
|
||||||
|
}
|
||||||
|
} else if (buffered.length > 0) {
|
||||||
|
// No session error and still have buffered messages (stream ended before
|
||||||
|
// any healthy message was observed) — flush them to the consumer
|
||||||
|
for (const msg of buffered) {
|
||||||
|
yield msg;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// If seenHealthyMessage is true, all messages have already been yielded
|
||||||
|
// directly in passthrough mode — nothing left to flush.
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Normalize a raw CLI event to ProviderMessage format
|
* Normalize a raw CLI event to ProviderMessage format
|
||||||
*
|
*
|
||||||
* Maps OpenCode event types to the standard ProviderMessage structure:
|
* Maps OpenCode event types to the standard ProviderMessage structure:
|
||||||
* - text -> type: 'assistant', content with type: 'text'
|
* - text -> type: 'assistant', content with type: 'text'
|
||||||
* - step_start -> null (informational, no message needed)
|
* - step_start -> null (informational, no message needed)
|
||||||
* - step_finish with reason 'stop' -> type: 'result', subtype: 'success'
|
* - step_finish with reason 'stop'/'end_turn' -> type: 'result', subtype: 'success'
|
||||||
|
* - step_finish with reason 'tool-calls' -> null (intermediate step, not final)
|
||||||
* - step_finish with error -> type: 'error'
|
* - step_finish with error -> type: 'error'
|
||||||
* - tool_call -> type: 'assistant', content with type: 'tool_use'
|
* - tool_use -> type: 'assistant', content with type: 'tool_use' (OpenCode CLI format)
|
||||||
|
* - tool_call -> type: 'assistant', content with type: 'tool_use' (legacy format)
|
||||||
* - tool_result -> type: 'assistant', content with type: 'tool_result'
|
* - tool_result -> type: 'assistant', content with type: 'tool_result'
|
||||||
* - error -> type: 'error'
|
* - error -> type: 'error'
|
||||||
*
|
*
|
||||||
@@ -459,7 +700,7 @@ export class OpencodeProvider extends CliProvider {
|
|||||||
return {
|
return {
|
||||||
type: 'error',
|
type: 'error',
|
||||||
session_id: finishEvent.sessionID,
|
session_id: finishEvent.sessionID,
|
||||||
error: finishEvent.part.error,
|
error: OpencodeProvider.cleanErrorMessage(finishEvent.part.error),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -468,15 +709,40 @@ export class OpencodeProvider extends CliProvider {
|
|||||||
return {
|
return {
|
||||||
type: 'error',
|
type: 'error',
|
||||||
session_id: finishEvent.sessionID,
|
session_id: finishEvent.sessionID,
|
||||||
error: 'Step execution failed',
|
error: OpencodeProvider.cleanErrorMessage('Step execution failed'),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Successful completion (reason: 'stop' or 'end_turn')
|
// Intermediate step completion (reason: 'tool-calls') — the agent loop
|
||||||
|
// is continuing because the model requested tool calls. Skip these so
|
||||||
|
// consumers don't mistake them for final results.
|
||||||
|
if (finishEvent.part?.reason === 'tool-calls') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only treat an explicit allowlist of reasons as true success.
|
||||||
|
// Reasons like 'length' (context-window truncation) or 'content-filter'
|
||||||
|
// indicate the model stopped abnormally and must not be surfaced as
|
||||||
|
// successful completions.
|
||||||
|
const SUCCESS_REASONS = new Set(['stop', 'end_turn']);
|
||||||
|
const reason = finishEvent.part?.reason;
|
||||||
|
|
||||||
|
if (reason === undefined || SUCCESS_REASONS.has(reason)) {
|
||||||
|
// Final completion (reason: 'stop', 'end_turn', or unset)
|
||||||
|
return {
|
||||||
|
type: 'result',
|
||||||
|
subtype: 'success',
|
||||||
|
session_id: finishEvent.sessionID,
|
||||||
|
result: (finishEvent.part as OpenCodePart & { result?: string })?.result,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-success, non-tool-calls reason (e.g. 'length', 'content-filter')
|
||||||
return {
|
return {
|
||||||
type: 'result',
|
type: 'result',
|
||||||
subtype: 'success',
|
subtype: 'error',
|
||||||
session_id: finishEvent.sessionID,
|
session_id: finishEvent.sessionID,
|
||||||
|
error: `Step finished with non-success reason: ${reason}`,
|
||||||
result: (finishEvent.part as OpenCodePart & { result?: string })?.result,
|
result: (finishEvent.part as OpenCodePart & { result?: string })?.result,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -484,8 +750,10 @@ export class OpencodeProvider extends CliProvider {
|
|||||||
case 'tool_error': {
|
case 'tool_error': {
|
||||||
const toolErrorEvent = openCodeEvent as OpenCodeBaseEvent;
|
const toolErrorEvent = openCodeEvent as OpenCodeBaseEvent;
|
||||||
|
|
||||||
// Extract error message from part.error
|
// Extract error message from part.error and clean ANSI codes
|
||||||
const errorMessage = toolErrorEvent.part?.error || 'Tool execution failed';
|
const errorMessage = OpencodeProvider.cleanErrorMessage(
|
||||||
|
toolErrorEvent.part?.error || 'Tool execution failed'
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
type: 'error',
|
type: 'error',
|
||||||
@@ -494,6 +762,45 @@ export class OpencodeProvider extends CliProvider {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OpenCode CLI emits 'tool_use' events (not 'tool_call') when the model invokes a tool.
|
||||||
|
// The event format includes the tool name, call ID, and state with input/output.
|
||||||
|
// Handle both 'tool_use' (actual CLI format) and 'tool_call' (legacy/alternative) for robustness.
|
||||||
|
case 'tool_use': {
|
||||||
|
const toolUseEvent = openCodeEvent as OpenCodeToolUseEvent;
|
||||||
|
const part = toolUseEvent.part;
|
||||||
|
|
||||||
|
// Generate a tool use ID if not provided
|
||||||
|
const toolUseId = part?.callID || part?.call_id || generateToolUseId();
|
||||||
|
const toolName = part?.tool || part?.name || 'unknown';
|
||||||
|
|
||||||
|
const content: ContentBlock[] = [
|
||||||
|
{
|
||||||
|
type: 'tool_use',
|
||||||
|
name: toolName,
|
||||||
|
tool_use_id: toolUseId,
|
||||||
|
input: part?.state?.input || part?.args,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// If the tool has already completed (state.status === 'completed'), also emit the result
|
||||||
|
if (part?.state?.status === 'completed' && part?.state?.output) {
|
||||||
|
content.push({
|
||||||
|
type: 'tool_result',
|
||||||
|
tool_use_id: toolUseId,
|
||||||
|
content: part.state.output,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'assistant',
|
||||||
|
session_id: toolUseEvent.sessionID,
|
||||||
|
message: {
|
||||||
|
role: 'assistant',
|
||||||
|
content,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
case 'tool_call': {
|
case 'tool_call': {
|
||||||
const toolEvent = openCodeEvent as OpenCodeToolCallEvent;
|
const toolEvent = openCodeEvent as OpenCodeToolCallEvent;
|
||||||
|
|
||||||
@@ -560,6 +867,13 @@ export class OpencodeProvider extends CliProvider {
|
|||||||
errorMessage = errorEvent.part.error;
|
errorMessage = errorEvent.part.error;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clean error messages: strip ANSI escape codes AND the redundant "Error: "
|
||||||
|
// prefix the CLI adds. The OpenCode CLI outputs colored stderr like:
|
||||||
|
// \x1b[91m\x1b[1mError: \x1b[0mSession not found
|
||||||
|
// Without cleaning, consumers that wrap in their own "Error: " prefix
|
||||||
|
// produce "Error: Error: Session not found".
|
||||||
|
errorMessage = OpencodeProvider.cleanErrorMessage(errorMessage);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
type: 'error',
|
type: 'error',
|
||||||
session_id: errorEvent.sessionID,
|
session_id: errorEvent.sessionID,
|
||||||
@@ -623,9 +937,9 @@ export class OpencodeProvider extends CliProvider {
|
|||||||
default: true,
|
default: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'opencode/glm-4.7-free',
|
id: 'opencode/glm-5-free',
|
||||||
name: 'GLM 4.7 Free',
|
name: 'GLM 5 Free',
|
||||||
modelString: 'opencode/glm-4.7-free',
|
modelString: 'opencode/glm-5-free',
|
||||||
provider: 'opencode',
|
provider: 'opencode',
|
||||||
description: 'OpenCode free tier GLM model',
|
description: 'OpenCode free tier GLM model',
|
||||||
supportsTools: true,
|
supportsTools: true,
|
||||||
@@ -643,19 +957,19 @@ export class OpencodeProvider extends CliProvider {
|
|||||||
tier: 'basic',
|
tier: 'basic',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'opencode/grok-code',
|
id: 'opencode/kimi-k2.5-free',
|
||||||
name: 'Grok Code (Free)',
|
name: 'Kimi K2.5 Free',
|
||||||
modelString: 'opencode/grok-code',
|
modelString: 'opencode/kimi-k2.5-free',
|
||||||
provider: 'opencode',
|
provider: 'opencode',
|
||||||
description: 'OpenCode free tier Grok model for coding',
|
description: 'OpenCode free tier Kimi model for coding',
|
||||||
supportsTools: true,
|
supportsTools: true,
|
||||||
supportsVision: false,
|
supportsVision: false,
|
||||||
tier: 'basic',
|
tier: 'basic',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'opencode/minimax-m2.1-free',
|
id: 'opencode/minimax-m2.5-free',
|
||||||
name: 'MiniMax M2.1 Free',
|
name: 'MiniMax M2.5 Free',
|
||||||
modelString: 'opencode/minimax-m2.1-free',
|
modelString: 'opencode/minimax-m2.5-free',
|
||||||
provider: 'opencode',
|
provider: 'opencode',
|
||||||
description: 'OpenCode free tier MiniMax model',
|
description: 'OpenCode free tier MiniMax model',
|
||||||
supportsTools: true,
|
supportsTools: true,
|
||||||
@@ -777,7 +1091,7 @@ export class OpencodeProvider extends CliProvider {
|
|||||||
*
|
*
|
||||||
* OpenCode CLI output format (one model per line):
|
* OpenCode CLI output format (one model per line):
|
||||||
* opencode/big-pickle
|
* opencode/big-pickle
|
||||||
* opencode/glm-4.7-free
|
* opencode/glm-5-free
|
||||||
* anthropic/claude-3-5-haiku-20241022
|
* anthropic/claude-3-5-haiku-20241022
|
||||||
* github-copilot/claude-3.5-sonnet
|
* github-copilot/claude-3.5-sonnet
|
||||||
* ...
|
* ...
|
||||||
|
|||||||
@@ -16,8 +16,6 @@
|
|||||||
|
|
||||||
import { ProviderFactory } from './provider-factory.js';
|
import { ProviderFactory } from './provider-factory.js';
|
||||||
import type {
|
import type {
|
||||||
ProviderMessage,
|
|
||||||
ContentBlock,
|
|
||||||
ThinkingLevel,
|
ThinkingLevel,
|
||||||
ReasoningEffort,
|
ReasoningEffort,
|
||||||
ClaudeApiProfile,
|
ClaudeApiProfile,
|
||||||
@@ -96,7 +94,7 @@ export interface StreamingQueryOptions extends SimpleQueryOptions {
|
|||||||
/**
|
/**
|
||||||
* Default model to use when none specified
|
* Default model to use when none specified
|
||||||
*/
|
*/
|
||||||
const DEFAULT_MODEL = 'claude-sonnet-4-20250514';
|
const DEFAULT_MODEL = 'claude-sonnet-4-6';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Execute a simple query and return the text result
|
* Execute a simple query and return the text result
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export function createHistoryHandler(agentService: AgentService) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = agentService.getHistory(sessionId);
|
const result = await agentService.getHistory(sessionId);
|
||||||
res.json(result);
|
res.json(result);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logError(error, 'Get history failed');
|
logError(error, 'Get history failed');
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export function createQueueListHandler(agentService: AgentService) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = agentService.getQueue(sessionId);
|
const result = await agentService.getQueue(sessionId);
|
||||||
res.json(result);
|
res.json(result);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logError(error, 'List queue failed');
|
logError(error, 'List queue failed');
|
||||||
|
|||||||
@@ -53,7 +53,15 @@ export function createSendHandler(agentService: AgentService) {
|
|||||||
thinkingLevel,
|
thinkingLevel,
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
logger.error('Background error in sendMessage():', error);
|
const errorMsg = (error as Error).message || 'Unknown error';
|
||||||
|
logger.error(`Background error in sendMessage() for session ${sessionId}:`, errorMsg);
|
||||||
|
|
||||||
|
// Emit error via WebSocket so the UI is notified even though
|
||||||
|
// the HTTP response already returned 200. This is critical for
|
||||||
|
// session-not-found errors where sendMessage() throws before it
|
||||||
|
// can emit its own error event (no in-memory session to emit from).
|
||||||
|
agentService.emitSessionError(sessionId, errorMsg);
|
||||||
|
|
||||||
logError(error, 'Send message failed (background)');
|
logError(error, 'Send message failed (background)');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import type { Request, Response } from 'express';
|
|||||||
import { AgentService } from '../../../services/agent-service.js';
|
import { AgentService } from '../../../services/agent-service.js';
|
||||||
import { createLogger } from '@automaker/utils';
|
import { createLogger } from '@automaker/utils';
|
||||||
import { getErrorMessage, logError } from '../common.js';
|
import { getErrorMessage, logError } from '../common.js';
|
||||||
const logger = createLogger('Agent');
|
const _logger = createLogger('Agent');
|
||||||
|
|
||||||
export function createStartHandler(agentService: AgentService) {
|
export function createStartHandler(agentService: AgentService) {
|
||||||
return async (req: Request, res: Response): Promise<void> => {
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ export function logAuthStatus(context: string): void {
|
|||||||
*/
|
*/
|
||||||
export function logError(error: unknown, context: string): void {
|
export function logError(error: unknown, context: string): void {
|
||||||
logger.error(`❌ ${context}:`);
|
logger.error(`❌ ${context}:`);
|
||||||
logger.error('Error name:', (error as any)?.name);
|
logger.error('Error name:', (error as Error)?.name);
|
||||||
logger.error('Error message:', (error as Error)?.message);
|
logger.error('Error message:', (error as Error)?.message);
|
||||||
logger.error('Error stack:', (error as Error)?.stack);
|
logger.error('Error stack:', (error as Error)?.stack);
|
||||||
logger.error('Full error object:', JSON.stringify(error, Object.getOwnPropertyNames(error), 2));
|
logger.error('Full error object:', JSON.stringify(error, Object.getOwnPropertyNames(error), 2));
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ const DEFAULT_MAX_FEATURES = 50;
|
|||||||
* Timeout for Codex models when generating features (5 minutes).
|
* Timeout for Codex models when generating features (5 minutes).
|
||||||
* Codex models are slower and need more time to generate 50+ features.
|
* Codex models are slower and need more time to generate 50+ features.
|
||||||
*/
|
*/
|
||||||
const CODEX_FEATURE_GENERATION_TIMEOUT_MS = 300000; // 5 minutes
|
const _CODEX_FEATURE_GENERATION_TIMEOUT_MS = 300000; // 5 minutes
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Type for extracted features JSON response
|
* Type for extracted features JSON response
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ import {
|
|||||||
updateTechnologyStack,
|
updateTechnologyStack,
|
||||||
updateRoadmapPhaseStatus,
|
updateRoadmapPhaseStatus,
|
||||||
type ImplementedFeature,
|
type ImplementedFeature,
|
||||||
type RoadmapPhase,
|
|
||||||
} from '../../lib/xml-extractor.js';
|
} from '../../lib/xml-extractor.js';
|
||||||
import { getNotificationService } from '../../services/notification-service.js';
|
import { getNotificationService } from '../../services/notification-service.js';
|
||||||
|
|
||||||
|
|||||||
@@ -19,10 +19,11 @@ export function createAnalyzeProjectHandler(autoModeService: AutoModeServiceComp
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start analysis in background
|
// Kick off analysis in the background; attach a rejection handler so
|
||||||
autoModeService.analyzeProject(projectPath).catch((error) => {
|
// unhandled-promise warnings don't surface and errors are at least logged.
|
||||||
logger.error(`[AutoMode] Project analysis error:`, error);
|
// Synchronous throws (e.g. "not implemented") still propagate here.
|
||||||
});
|
const analysisPromise = autoModeService.analyzeProject(projectPath);
|
||||||
|
analysisPromise.catch((err) => logError(err, 'Background analyzeProject failed'));
|
||||||
|
|
||||||
res.json({ success: true, message: 'Project analysis started' });
|
res.json({ success: true, message: 'Project analysis started' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -26,23 +26,9 @@ export function createRunFeatureHandler(autoModeService: AutoModeServiceCompat)
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check per-worktree capacity before starting
|
// Note: No concurrency limit check here. Manual feature starts always run
|
||||||
const capacity = await autoModeService.checkWorktreeCapacity(projectPath, featureId);
|
// immediately and bypass the concurrency limit. Their presence IS counted
|
||||||
if (!capacity.hasCapacity) {
|
// by the auto-loop coordinator when deciding whether to dispatch new auto-mode tasks.
|
||||||
const worktreeDesc = capacity.branchName
|
|
||||||
? `worktree "${capacity.branchName}"`
|
|
||||||
: 'main worktree';
|
|
||||||
res.status(429).json({
|
|
||||||
success: false,
|
|
||||||
error: `Agent limit reached for ${worktreeDesc} (${capacity.currentAgents}/${capacity.maxAgents}). Wait for running tasks to complete or increase the limit.`,
|
|
||||||
details: {
|
|
||||||
currentAgents: capacity.currentAgents,
|
|
||||||
maxAgents: capacity.maxAgents,
|
|
||||||
branchName: capacity.branchName,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start execution in background
|
// Start execution in background
|
||||||
// executeFeature derives workDir from feature.branchName
|
// executeFeature derives workDir from feature.branchName
|
||||||
|
|||||||
@@ -114,9 +114,20 @@ export function mapBacklogPlanError(rawMessage: string): string {
|
|||||||
return 'Claude CLI could not be launched. Make sure the Claude CLI is installed and available in PATH, or check that Node.js is correctly installed. Try running "which claude" or "claude --version" in your terminal to verify.';
|
return 'Claude CLI could not be launched. Make sure the Claude CLI is installed and available in PATH, or check that Node.js is correctly installed. Try running "which claude" or "claude --version" in your terminal to verify.';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Claude Code process crash
|
// Claude Code process crash - extract exit code for diagnostics
|
||||||
if (rawMessage.includes('Claude Code process exited')) {
|
if (rawMessage.includes('Claude Code process exited')) {
|
||||||
return 'Claude exited unexpectedly. Try again. If it keeps happening, re-run `claude login` or update your API key in Setup.';
|
const exitCodeMatch = rawMessage.match(/exited with code (\d+)/);
|
||||||
|
const exitCode = exitCodeMatch ? exitCodeMatch[1] : 'unknown';
|
||||||
|
logger.error(`[BacklogPlan] Claude process exit code: ${exitCode}`);
|
||||||
|
return `Claude exited unexpectedly (exit code: ${exitCode}). This is usually a transient issue. Try again. If it keeps happening, re-run \`claude login\` or update your API key in Setup.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Claude Code process killed by signal
|
||||||
|
if (rawMessage.includes('Claude Code process terminated by signal')) {
|
||||||
|
const signalMatch = rawMessage.match(/terminated by signal (\w+)/);
|
||||||
|
const signal = signalMatch ? signalMatch[1] : 'unknown';
|
||||||
|
logger.error(`[BacklogPlan] Claude process terminated by signal: ${signal}`);
|
||||||
|
return `Claude was terminated by signal ${signal}. This may indicate a resource issue. Try again.`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rate limiting
|
// Rate limiting
|
||||||
|
|||||||
@@ -3,17 +3,22 @@
|
|||||||
*
|
*
|
||||||
* Model is configurable via phaseModels.backlogPlanningModel in settings
|
* Model is configurable via phaseModels.backlogPlanningModel in settings
|
||||||
* (defaults to Sonnet). Can be overridden per-call via model parameter.
|
* (defaults to Sonnet). Can be overridden per-call via model parameter.
|
||||||
|
*
|
||||||
|
* Includes automatic retry for transient CLI failures (e.g., "Claude Code
|
||||||
|
* process exited unexpectedly") to improve reliability.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { EventEmitter } from '../../lib/events.js';
|
import type { EventEmitter } from '../../lib/events.js';
|
||||||
import type { Feature, BacklogPlanResult, BacklogChange, DependencyUpdate } from '@automaker/types';
|
import type { Feature, BacklogPlanResult } from '@automaker/types';
|
||||||
import {
|
import {
|
||||||
DEFAULT_PHASE_MODELS,
|
DEFAULT_PHASE_MODELS,
|
||||||
isCursorModel,
|
isCursorModel,
|
||||||
stripProviderPrefix,
|
stripProviderPrefix,
|
||||||
type ThinkingLevel,
|
type ThinkingLevel,
|
||||||
|
type SystemPromptPreset,
|
||||||
} from '@automaker/types';
|
} from '@automaker/types';
|
||||||
import { resolvePhaseModel } from '@automaker/model-resolver';
|
import { resolvePhaseModel } from '@automaker/model-resolver';
|
||||||
|
import { getCurrentBranch } from '@automaker/git-utils';
|
||||||
import { FeatureLoader } from '../../services/feature-loader.js';
|
import { FeatureLoader } from '../../services/feature-loader.js';
|
||||||
import { ProviderFactory } from '../../providers/provider-factory.js';
|
import { ProviderFactory } from '../../providers/provider-factory.js';
|
||||||
import { extractJsonWithArray } from '../../lib/json-extractor.js';
|
import { extractJsonWithArray } from '../../lib/json-extractor.js';
|
||||||
@@ -27,10 +32,28 @@ import {
|
|||||||
import type { SettingsService } from '../../services/settings-service.js';
|
import type { SettingsService } from '../../services/settings-service.js';
|
||||||
import {
|
import {
|
||||||
getAutoLoadClaudeMdSetting,
|
getAutoLoadClaudeMdSetting,
|
||||||
|
getUseClaudeCodeSystemPromptSetting,
|
||||||
getPromptCustomization,
|
getPromptCustomization,
|
||||||
getPhaseModelWithOverrides,
|
getPhaseModelWithOverrides,
|
||||||
|
getProviderByModelId,
|
||||||
} from '../../lib/settings-helpers.js';
|
} from '../../lib/settings-helpers.js';
|
||||||
|
|
||||||
|
/** Maximum number of retry attempts for transient CLI failures */
|
||||||
|
const MAX_RETRIES = 2;
|
||||||
|
/** Delay between retries in milliseconds */
|
||||||
|
const RETRY_DELAY_MS = 2000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if an error is retryable (transient CLI process failure)
|
||||||
|
*/
|
||||||
|
function isRetryableError(error: unknown): boolean {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
return (
|
||||||
|
message.includes('Claude Code process exited') ||
|
||||||
|
message.includes('Claude Code process terminated by signal')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const featureLoader = new FeatureLoader();
|
const featureLoader = new FeatureLoader();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -84,6 +107,53 @@ function parsePlanResponse(response: string): BacklogPlanResult {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Try to parse a valid plan response without fallback behavior.
|
||||||
|
* Returns null if parsing fails.
|
||||||
|
*/
|
||||||
|
function tryParsePlanResponse(response: string): BacklogPlanResult | null {
|
||||||
|
if (!response || response.trim().length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return extractJsonWithArray<BacklogPlanResult>(response, 'changes', { logger });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Choose the most reliable response text between streamed assistant chunks
|
||||||
|
* and provider final result payload.
|
||||||
|
*/
|
||||||
|
function selectBestResponseText(accumulatedText: string, providerResultText: string): string {
|
||||||
|
const hasAccumulated = accumulatedText.trim().length > 0;
|
||||||
|
const hasProviderResult = providerResultText.trim().length > 0;
|
||||||
|
|
||||||
|
if (!hasProviderResult) {
|
||||||
|
return accumulatedText;
|
||||||
|
}
|
||||||
|
if (!hasAccumulated) {
|
||||||
|
return providerResultText;
|
||||||
|
}
|
||||||
|
|
||||||
|
const accumulatedParsed = tryParsePlanResponse(accumulatedText);
|
||||||
|
const providerParsed = tryParsePlanResponse(providerResultText);
|
||||||
|
|
||||||
|
if (providerParsed && !accumulatedParsed) {
|
||||||
|
logger.info('[BacklogPlan] Using provider result (parseable JSON)');
|
||||||
|
return providerResultText;
|
||||||
|
}
|
||||||
|
if (accumulatedParsed && !providerParsed) {
|
||||||
|
logger.info('[BacklogPlan] Keeping accumulated text (parseable JSON)');
|
||||||
|
return accumulatedText;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (providerResultText.length > accumulatedText.length) {
|
||||||
|
logger.info('[BacklogPlan] Using provider result (longer content)');
|
||||||
|
return providerResultText;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('[BacklogPlan] Keeping accumulated text (longer content)');
|
||||||
|
return accumulatedText;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate a backlog modification plan based on user prompt
|
* Generate a backlog modification plan based on user prompt
|
||||||
*/
|
*/
|
||||||
@@ -93,11 +163,40 @@ export async function generateBacklogPlan(
|
|||||||
events: EventEmitter,
|
events: EventEmitter,
|
||||||
abortController: AbortController,
|
abortController: AbortController,
|
||||||
settingsService?: SettingsService,
|
settingsService?: SettingsService,
|
||||||
model?: string
|
model?: string,
|
||||||
|
branchName?: string
|
||||||
): Promise<BacklogPlanResult> {
|
): Promise<BacklogPlanResult> {
|
||||||
try {
|
try {
|
||||||
// Load current features
|
// Load current features
|
||||||
const features = await featureLoader.getAll(projectPath);
|
const allFeatures = await featureLoader.getAll(projectPath);
|
||||||
|
|
||||||
|
// Filter features by branch if specified (worktree-scoped backlog)
|
||||||
|
let features: Feature[];
|
||||||
|
if (branchName) {
|
||||||
|
// Determine the primary branch so unassigned features show for the main worktree
|
||||||
|
let primaryBranch: string | null = null;
|
||||||
|
try {
|
||||||
|
primaryBranch = await getCurrentBranch(projectPath);
|
||||||
|
} catch {
|
||||||
|
// If git fails, fall back to 'main' so unassigned features are visible
|
||||||
|
// when branchName matches a common default branch name
|
||||||
|
primaryBranch = 'main';
|
||||||
|
}
|
||||||
|
const isMainBranch = branchName === primaryBranch;
|
||||||
|
|
||||||
|
features = allFeatures.filter((f) => {
|
||||||
|
if (!f.branchName) {
|
||||||
|
// Unassigned features belong to the main/primary worktree
|
||||||
|
return isMainBranch;
|
||||||
|
}
|
||||||
|
return f.branchName === branchName;
|
||||||
|
});
|
||||||
|
logger.info(
|
||||||
|
`[BacklogPlan] Filtered to ${features.length}/${allFeatures.length} features for branch: ${branchName}`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
features = allFeatures;
|
||||||
|
}
|
||||||
|
|
||||||
events.emit('backlog-plan:event', {
|
events.emit('backlog-plan:event', {
|
||||||
type: 'backlog_plan_progress',
|
type: 'backlog_plan_progress',
|
||||||
@@ -133,6 +232,35 @@ export async function generateBacklogPlan(
|
|||||||
effectiveModel = resolved.model;
|
effectiveModel = resolved.model;
|
||||||
thinkingLevel = resolved.thinkingLevel;
|
thinkingLevel = resolved.thinkingLevel;
|
||||||
credentials = await settingsService?.getCredentials();
|
credentials = await settingsService?.getCredentials();
|
||||||
|
// Resolve Claude-compatible provider when client sends a model (e.g. MiniMax, GLM)
|
||||||
|
if (settingsService) {
|
||||||
|
const providerResult = await getProviderByModelId(
|
||||||
|
effectiveModel,
|
||||||
|
settingsService,
|
||||||
|
'[BacklogPlan]'
|
||||||
|
);
|
||||||
|
if (providerResult.provider) {
|
||||||
|
claudeCompatibleProvider = providerResult.provider;
|
||||||
|
if (providerResult.credentials) {
|
||||||
|
credentials = providerResult.credentials;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Fallback: use phase settings provider if model lookup found nothing (e.g. model
|
||||||
|
// string format differs from provider's model id, but backlog planning phase has providerId).
|
||||||
|
if (!claudeCompatibleProvider) {
|
||||||
|
const phaseResult = await getPhaseModelWithOverrides(
|
||||||
|
'backlogPlanningModel',
|
||||||
|
settingsService,
|
||||||
|
projectPath,
|
||||||
|
'[BacklogPlan]'
|
||||||
|
);
|
||||||
|
const phaseResolved = resolvePhaseModel(phaseResult.phaseModel);
|
||||||
|
if (phaseResult.provider && phaseResolved.model === effectiveModel) {
|
||||||
|
claudeCompatibleProvider = phaseResult.provider;
|
||||||
|
credentials = phaseResult.credentials ?? credentials;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
} else if (settingsService) {
|
} else if (settingsService) {
|
||||||
// Use settings-based model with provider info
|
// Use settings-based model with provider info
|
||||||
const phaseResult = await getPhaseModelWithOverrides(
|
const phaseResult = await getPhaseModelWithOverrides(
|
||||||
@@ -162,17 +290,23 @@ export async function generateBacklogPlan(
|
|||||||
// Strip provider prefix - providers expect bare model IDs
|
// Strip provider prefix - providers expect bare model IDs
|
||||||
const bareModel = stripProviderPrefix(effectiveModel);
|
const bareModel = stripProviderPrefix(effectiveModel);
|
||||||
|
|
||||||
// Get autoLoadClaudeMd setting
|
// Get autoLoadClaudeMd and useClaudeCodeSystemPrompt settings
|
||||||
const autoLoadClaudeMd = await getAutoLoadClaudeMdSetting(
|
const autoLoadClaudeMd = await getAutoLoadClaudeMdSetting(
|
||||||
projectPath,
|
projectPath,
|
||||||
settingsService,
|
settingsService,
|
||||||
'[BacklogPlan]'
|
'[BacklogPlan]'
|
||||||
);
|
);
|
||||||
|
const useClaudeCodeSystemPrompt = await getUseClaudeCodeSystemPromptSetting(
|
||||||
|
projectPath,
|
||||||
|
settingsService,
|
||||||
|
'[BacklogPlan]'
|
||||||
|
);
|
||||||
|
|
||||||
// For Cursor models, we need to combine prompts with explicit instructions
|
// For Cursor models, we need to combine prompts with explicit instructions
|
||||||
// because Cursor doesn't support systemPrompt separation like Claude SDK
|
// because Cursor doesn't support systemPrompt separation like Claude SDK
|
||||||
let finalPrompt = userPrompt;
|
let finalPrompt = userPrompt;
|
||||||
let finalSystemPrompt: string | undefined = systemPrompt;
|
let finalSystemPrompt: string | SystemPromptPreset | undefined = systemPrompt;
|
||||||
|
let finalSettingSources: Array<'user' | 'project' | 'local'> | undefined;
|
||||||
|
|
||||||
if (isCursorModel(effectiveModel)) {
|
if (isCursorModel(effectiveModel)) {
|
||||||
logger.info('[BacklogPlan] Using Cursor model - adding explicit no-file-write instructions');
|
logger.info('[BacklogPlan] Using Cursor model - adding explicit no-file-write instructions');
|
||||||
@@ -187,54 +321,145 @@ CRITICAL INSTRUCTIONS:
|
|||||||
|
|
||||||
${userPrompt}`;
|
${userPrompt}`;
|
||||||
finalSystemPrompt = undefined; // System prompt is now embedded in the user prompt
|
finalSystemPrompt = undefined; // System prompt is now embedded in the user prompt
|
||||||
|
} else if (claudeCompatibleProvider) {
|
||||||
|
// Claude-compatible providers (MiniMax, GLM, etc.) use a plain API; do not use
|
||||||
|
// the claude_code preset (which is for Claude CLI/subprocess and can break the request).
|
||||||
|
finalSystemPrompt = systemPrompt;
|
||||||
|
} else if (useClaudeCodeSystemPrompt) {
|
||||||
|
// Use claude_code preset for native Claude so the SDK subprocess
|
||||||
|
// authenticates via CLI OAuth or API key the same way all other SDK calls do.
|
||||||
|
finalSystemPrompt = {
|
||||||
|
type: 'preset',
|
||||||
|
preset: 'claude_code',
|
||||||
|
append: systemPrompt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// Include settingSources when autoLoadClaudeMd is enabled
|
||||||
|
if (autoLoadClaudeMd) {
|
||||||
|
finalSettingSources = ['user', 'project'];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Execute the query
|
// Execute the query with retry logic for transient CLI failures
|
||||||
const stream = provider.executeQuery({
|
const queryOptions = {
|
||||||
prompt: finalPrompt,
|
prompt: finalPrompt,
|
||||||
model: bareModel,
|
model: bareModel,
|
||||||
cwd: projectPath,
|
cwd: projectPath,
|
||||||
systemPrompt: finalSystemPrompt,
|
systemPrompt: finalSystemPrompt,
|
||||||
maxTurns: 1,
|
maxTurns: 1,
|
||||||
allowedTools: [], // No tools needed for this
|
tools: [] as string[], // Disable all built-in tools - plan generation only needs text output
|
||||||
abortController,
|
abortController,
|
||||||
settingSources: autoLoadClaudeMd ? ['user', 'project'] : undefined,
|
settingSources: finalSettingSources,
|
||||||
readOnly: true, // Plan generation only generates text, doesn't write files
|
|
||||||
thinkingLevel, // Pass thinking level for extended thinking
|
thinkingLevel, // Pass thinking level for extended thinking
|
||||||
claudeCompatibleProvider, // Pass provider for alternative endpoint configuration
|
claudeCompatibleProvider, // Pass provider for alternative endpoint configuration
|
||||||
credentials, // Pass credentials for resolving 'credentials' apiKeySource
|
credentials, // Pass credentials for resolving 'credentials' apiKeySource
|
||||||
});
|
};
|
||||||
|
|
||||||
let responseText = '';
|
let responseText = '';
|
||||||
|
let bestResponseText = ''; // Preserve best response across all retry attempts
|
||||||
|
let recoveredResult: BacklogPlanResult | null = null;
|
||||||
|
let lastError: unknown = null;
|
||||||
|
|
||||||
for await (const msg of stream) {
|
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
||||||
if (abortController.signal.aborted) {
|
if (abortController.signal.aborted) {
|
||||||
throw new Error('Generation aborted');
|
throw new Error('Generation aborted');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (msg.type === 'assistant') {
|
if (attempt > 0) {
|
||||||
if (msg.message?.content) {
|
logger.info(
|
||||||
for (const block of msg.message.content) {
|
`[BacklogPlan] Retry attempt ${attempt}/${MAX_RETRIES} after transient failure`
|
||||||
if (block.type === 'text') {
|
);
|
||||||
responseText += block.text;
|
events.emit('backlog-plan:event', {
|
||||||
|
type: 'backlog_plan_progress',
|
||||||
|
content: `Retrying... (attempt ${attempt + 1}/${MAX_RETRIES + 1})`,
|
||||||
|
});
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS));
|
||||||
|
}
|
||||||
|
|
||||||
|
let accumulatedText = '';
|
||||||
|
let providerResultText = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stream = provider.executeQuery(queryOptions);
|
||||||
|
|
||||||
|
for await (const msg of stream) {
|
||||||
|
if (abortController.signal.aborted) {
|
||||||
|
throw new Error('Generation aborted');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.type === 'assistant') {
|
||||||
|
if (msg.message?.content) {
|
||||||
|
for (const block of msg.message.content) {
|
||||||
|
if (block.type === 'text') {
|
||||||
|
accumulatedText += block.text;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
} else if (msg.type === 'result' && msg.subtype === 'success' && msg.result) {
|
||||||
|
providerResultText = msg.result;
|
||||||
|
logger.info(
|
||||||
|
'[BacklogPlan] Received result from provider, length:',
|
||||||
|
providerResultText.length
|
||||||
|
);
|
||||||
|
logger.info('[BacklogPlan] Accumulated response length:', accumulatedText.length);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (msg.type === 'result' && msg.subtype === 'success' && msg.result) {
|
|
||||||
// Use result if it's a final accumulated message (from Cursor provider)
|
responseText = selectBestResponseText(accumulatedText, providerResultText);
|
||||||
logger.info('[BacklogPlan] Received result from Cursor, length:', msg.result.length);
|
|
||||||
logger.info('[BacklogPlan] Previous responseText length:', responseText.length);
|
// If we got here, the stream completed successfully
|
||||||
if (msg.result.length > responseText.length) {
|
lastError = null;
|
||||||
logger.info('[BacklogPlan] Using Cursor result (longer than accumulated text)');
|
break;
|
||||||
responseText = msg.result;
|
} catch (error) {
|
||||||
} else {
|
lastError = error;
|
||||||
logger.info('[BacklogPlan] Keeping accumulated text (longer than Cursor result)');
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
|
responseText = selectBestResponseText(accumulatedText, providerResultText);
|
||||||
|
|
||||||
|
// Preserve the best response text across all attempts so that if a retry
|
||||||
|
// crashes immediately (empty response), we can still recover from an earlier attempt
|
||||||
|
bestResponseText = selectBestResponseText(bestResponseText, responseText);
|
||||||
|
|
||||||
|
// Claude SDK can occasionally exit non-zero after emitting a complete response.
|
||||||
|
// If we already have valid JSON, recover instead of failing the entire planning flow.
|
||||||
|
if (isRetryableError(error)) {
|
||||||
|
const parsed = tryParsePlanResponse(bestResponseText);
|
||||||
|
if (parsed) {
|
||||||
|
logger.warn(
|
||||||
|
'[BacklogPlan] Recovered from transient CLI exit using accumulated valid response'
|
||||||
|
);
|
||||||
|
recoveredResult = parsed;
|
||||||
|
lastError = null;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// On final retryable failure, degrade gracefully if we have text from any attempt.
|
||||||
|
if (attempt >= MAX_RETRIES && bestResponseText.trim().length > 0) {
|
||||||
|
logger.warn(
|
||||||
|
'[BacklogPlan] Final retryable CLI failure with non-empty response, attempting fallback parse'
|
||||||
|
);
|
||||||
|
recoveredResult = parsePlanResponse(bestResponseText);
|
||||||
|
lastError = null;
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Only retry on transient CLI failures, not on user aborts or other errors
|
||||||
|
if (!isRetryableError(error) || attempt >= MAX_RETRIES) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.warn(
|
||||||
|
`[BacklogPlan] Transient CLI failure (attempt ${attempt + 1}/${MAX_RETRIES + 1}): ${errorMessage}`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If we exhausted retries, throw the last error
|
||||||
|
if (lastError) {
|
||||||
|
throw lastError;
|
||||||
|
}
|
||||||
|
|
||||||
// Parse the response
|
// Parse the response
|
||||||
const result = parsePlanResponse(responseText);
|
const result = recoveredResult ?? parsePlanResponse(responseText);
|
||||||
|
|
||||||
await saveBacklogPlan(projectPath, {
|
await saveBacklogPlan(projectPath, {
|
||||||
savedAt: new Date().toISOString(),
|
savedAt: new Date().toISOString(),
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
import type { BacklogPlanResult, BacklogChange, Feature } from '@automaker/types';
|
import type { BacklogPlanResult } from '@automaker/types';
|
||||||
import { FeatureLoader } from '../../../services/feature-loader.js';
|
import { FeatureLoader } from '../../../services/feature-loader.js';
|
||||||
import { clearBacklogPlan, getErrorMessage, logError, logger } from '../common.js';
|
import { clearBacklogPlan, getErrorMessage, logError, logger } from '../common.js';
|
||||||
|
|
||||||
@@ -58,6 +58,9 @@ export function createApplyHandler() {
|
|||||||
if (feature.dependencies?.includes(change.featureId)) {
|
if (feature.dependencies?.includes(change.featureId)) {
|
||||||
const newDeps = feature.dependencies.filter((d) => d !== change.featureId);
|
const newDeps = feature.dependencies.filter((d) => d !== change.featureId);
|
||||||
await featureLoader.update(projectPath, feature.id, { dependencies: newDeps });
|
await featureLoader.update(projectPath, feature.id, { dependencies: newDeps });
|
||||||
|
// Mutate the in-memory feature object so subsequent deletions use the updated
|
||||||
|
// dependency list and don't reintroduce already-removed dependency IDs.
|
||||||
|
feature.dependencies = newDeps;
|
||||||
logger.info(
|
logger.info(
|
||||||
`[BacklogPlan] Removed dependency ${change.featureId} from ${feature.id}`
|
`[BacklogPlan] Removed dependency ${change.featureId} from ${feature.id}`
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -17,10 +17,11 @@ import type { SettingsService } from '../../../services/settings-service.js';
|
|||||||
export function createGenerateHandler(events: EventEmitter, settingsService?: SettingsService) {
|
export function createGenerateHandler(events: EventEmitter, settingsService?: SettingsService) {
|
||||||
return async (req: Request, res: Response): Promise<void> => {
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const { projectPath, prompt, model } = req.body as {
|
const { projectPath, prompt, model, branchName } = req.body as {
|
||||||
projectPath: string;
|
projectPath: string;
|
||||||
prompt: string;
|
prompt: string;
|
||||||
model?: string;
|
model?: string;
|
||||||
|
branchName?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!projectPath) {
|
if (!projectPath) {
|
||||||
@@ -42,28 +43,30 @@ export function createGenerateHandler(events: EventEmitter, settingsService?: Se
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setRunningState(true);
|
const abortController = new AbortController();
|
||||||
|
setRunningState(true, abortController);
|
||||||
setRunningDetails({
|
setRunningDetails({
|
||||||
projectPath,
|
projectPath,
|
||||||
prompt,
|
prompt,
|
||||||
model,
|
model,
|
||||||
startedAt: new Date().toISOString(),
|
startedAt: new Date().toISOString(),
|
||||||
});
|
});
|
||||||
const abortController = new AbortController();
|
|
||||||
setRunningState(true, abortController);
|
|
||||||
|
|
||||||
// Start generation in background
|
// Start generation in background
|
||||||
// Note: generateBacklogPlan handles its own error event emission,
|
// Note: generateBacklogPlan handles its own error event emission
|
||||||
// so we only log here to avoid duplicate error toasts
|
// and state cleanup in its finally block, so we only log here
|
||||||
generateBacklogPlan(projectPath, prompt, events, abortController, settingsService, model)
|
generateBacklogPlan(
|
||||||
.catch((error) => {
|
projectPath,
|
||||||
// Just log - error event already emitted by generateBacklogPlan
|
prompt,
|
||||||
logError(error, 'Generate backlog plan failed (background)');
|
events,
|
||||||
})
|
abortController,
|
||||||
.finally(() => {
|
settingsService,
|
||||||
setRunningState(false, null);
|
model,
|
||||||
setRunningDetails(null);
|
branchName
|
||||||
});
|
).catch((error) => {
|
||||||
|
// Just log - error event already emitted by generateBacklogPlan
|
||||||
|
logError(error, 'Generate backlog plan failed (background)');
|
||||||
|
});
|
||||||
|
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -142,11 +142,33 @@ function mapDescribeImageError(rawMessage: string | undefined): {
|
|||||||
|
|
||||||
if (!rawMessage) return baseResponse;
|
if (!rawMessage) return baseResponse;
|
||||||
|
|
||||||
if (rawMessage.includes('Claude Code process exited')) {
|
if (
|
||||||
|
rawMessage.includes('Claude Code process exited') ||
|
||||||
|
rawMessage.includes('Claude Code process terminated by signal')
|
||||||
|
) {
|
||||||
|
const exitCodeMatch = rawMessage.match(/exited with code (\d+)/);
|
||||||
|
const signalMatch = rawMessage.match(/terminated by signal (\w+)/);
|
||||||
|
const detail = exitCodeMatch
|
||||||
|
? ` (exit code: ${exitCodeMatch[1]})`
|
||||||
|
: signalMatch
|
||||||
|
? ` (signal: ${signalMatch[1]})`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
// Crash/OS-kill signals suggest a process crash, not an auth failure —
|
||||||
|
// omit auth recovery advice and suggest retry/reporting instead.
|
||||||
|
const crashSignals = ['SIGSEGV', 'SIGABRT', 'SIGKILL', 'SIGBUS', 'SIGTRAP'];
|
||||||
|
const isCrashSignal = signalMatch ? crashSignals.includes(signalMatch[1]) : false;
|
||||||
|
|
||||||
|
if (isCrashSignal) {
|
||||||
|
return {
|
||||||
|
statusCode: 503,
|
||||||
|
userMessage: `Claude crashed unexpectedly${detail} while describing the image. This may be a transient condition. Please try again. If the problem persists, collect logs and report the issue.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
statusCode: 503,
|
statusCode: 503,
|
||||||
userMessage:
|
userMessage: `Claude exited unexpectedly${detail} while describing the image. This is usually a transient issue. Try again. If it keeps happening, re-run \`claude login\` or update your API key in Setup.`,
|
||||||
'Claude exited unexpectedly while describing the image. Try again. If it keeps happening, re-run `claude login` or update your API key in Setup so Claude can restart cleanly.',
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -33,13 +33,22 @@ export function createFeaturesRoutes(
|
|||||||
validatePathParams('projectPath'),
|
validatePathParams('projectPath'),
|
||||||
createListHandler(featureLoader, autoModeService)
|
createListHandler(featureLoader, autoModeService)
|
||||||
);
|
);
|
||||||
|
router.get(
|
||||||
|
'/list',
|
||||||
|
validatePathParams('projectPath'),
|
||||||
|
createListHandler(featureLoader, autoModeService)
|
||||||
|
);
|
||||||
router.post('/get', validatePathParams('projectPath'), createGetHandler(featureLoader));
|
router.post('/get', validatePathParams('projectPath'), createGetHandler(featureLoader));
|
||||||
router.post(
|
router.post(
|
||||||
'/create',
|
'/create',
|
||||||
validatePathParams('projectPath'),
|
validatePathParams('projectPath'),
|
||||||
createCreateHandler(featureLoader, events)
|
createCreateHandler(featureLoader, events)
|
||||||
);
|
);
|
||||||
router.post('/update', validatePathParams('projectPath'), createUpdateHandler(featureLoader));
|
router.post(
|
||||||
|
'/update',
|
||||||
|
validatePathParams('projectPath'),
|
||||||
|
createUpdateHandler(featureLoader, events)
|
||||||
|
);
|
||||||
router.post(
|
router.post(
|
||||||
'/bulk-update',
|
'/bulk-update',
|
||||||
validatePathParams('projectPath'),
|
validatePathParams('projectPath'),
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ interface ExportRequest {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createExportHandler(featureLoader: FeatureLoader) {
|
export function createExportHandler(_featureLoader: FeatureLoader) {
|
||||||
const exportService = getFeatureExportService();
|
const exportService = getFeatureExportService();
|
||||||
|
|
||||||
return async (req: Request, res: Response): Promise<void> => {
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ export function createGenerateTitleHandler(
|
|||||||
): (req: Request, res: Response) => Promise<void> {
|
): (req: Request, res: Response) => Promise<void> {
|
||||||
return async (req: Request, res: Response): Promise<void> => {
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const { description, projectPath } = req.body as GenerateTitleRequestBody;
|
const { description } = req.body as GenerateTitleRequestBody;
|
||||||
|
|
||||||
if (!description || typeof description !== 'string') {
|
if (!description || typeof description !== 'string') {
|
||||||
const response: GenerateTitleErrorResponse = {
|
const response: GenerateTitleErrorResponse = {
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ interface ConflictInfo {
|
|||||||
hasConflict: boolean;
|
hasConflict: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createImportHandler(featureLoader: FeatureLoader) {
|
export function createImportHandler(_featureLoader: FeatureLoader) {
|
||||||
const exportService = getFeatureExportService();
|
const exportService = getFeatureExportService();
|
||||||
|
|
||||||
return async (req: Request, res: Response): Promise<void> => {
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* POST /list endpoint - List all features for a project
|
* POST/GET /list endpoint - List all features for a project
|
||||||
|
*
|
||||||
|
* projectPath may come from req.body (POST) or req.query (GET fallback).
|
||||||
*
|
*
|
||||||
* Also performs orphan detection when a project is loaded to identify
|
* Also performs orphan detection when a project is loaded to identify
|
||||||
* features whose branches no longer exist. This runs on every project load/switch.
|
* features whose branches no longer exist. This runs on every project load/switch.
|
||||||
@@ -19,7 +21,17 @@ export function createListHandler(
|
|||||||
) {
|
) {
|
||||||
return async (req: Request, res: Response): Promise<void> => {
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const { projectPath } = req.body as { projectPath: string };
|
const bodyProjectPath =
|
||||||
|
typeof req.body === 'object' && req.body !== null
|
||||||
|
? (req.body as { projectPath?: unknown }).projectPath
|
||||||
|
: undefined;
|
||||||
|
const queryProjectPath = req.query.projectPath;
|
||||||
|
const projectPath =
|
||||||
|
typeof bodyProjectPath === 'string'
|
||||||
|
? bodyProjectPath
|
||||||
|
: typeof queryProjectPath === 'string'
|
||||||
|
? queryProjectPath
|
||||||
|
: undefined;
|
||||||
|
|
||||||
if (!projectPath) {
|
if (!projectPath) {
|
||||||
res.status(400).json({ success: false, error: 'projectPath is required' });
|
res.status(400).json({ success: false, error: 'projectPath is required' });
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
import { FeatureLoader } from '../../../services/feature-loader.js';
|
import { FeatureLoader } from '../../../services/feature-loader.js';
|
||||||
import type { Feature, FeatureStatus } from '@automaker/types';
|
import type { Feature, FeatureStatus } from '@automaker/types';
|
||||||
|
import type { EventEmitter } from '../../../lib/events.js';
|
||||||
import { getErrorMessage, logError } from '../common.js';
|
import { getErrorMessage, logError } from '../common.js';
|
||||||
import { createLogger } from '@automaker/utils';
|
import { createLogger } from '@automaker/utils';
|
||||||
|
|
||||||
@@ -13,7 +14,7 @@ const logger = createLogger('features/update');
|
|||||||
// Statuses that should trigger syncing to app_spec.txt
|
// Statuses that should trigger syncing to app_spec.txt
|
||||||
const SYNC_TRIGGER_STATUSES: FeatureStatus[] = ['verified', 'completed'];
|
const SYNC_TRIGGER_STATUSES: FeatureStatus[] = ['verified', 'completed'];
|
||||||
|
|
||||||
export function createUpdateHandler(featureLoader: FeatureLoader) {
|
export function createUpdateHandler(featureLoader: FeatureLoader, events?: EventEmitter) {
|
||||||
return async (req: Request, res: Response): Promise<void> => {
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const {
|
const {
|
||||||
@@ -54,8 +55,18 @@ export function createUpdateHandler(featureLoader: FeatureLoader) {
|
|||||||
preEnhancementDescription
|
preEnhancementDescription
|
||||||
);
|
);
|
||||||
|
|
||||||
// Trigger sync to app_spec.txt when status changes to verified or completed
|
// Emit completion event and sync to app_spec.txt when status transitions to verified/completed
|
||||||
if (newStatus && SYNC_TRIGGER_STATUSES.includes(newStatus) && previousStatus !== newStatus) {
|
if (newStatus && SYNC_TRIGGER_STATUSES.includes(newStatus) && previousStatus !== newStatus) {
|
||||||
|
events?.emit('feature:completed', {
|
||||||
|
featureId,
|
||||||
|
featureName: updated.title,
|
||||||
|
projectPath,
|
||||||
|
passes: true,
|
||||||
|
message:
|
||||||
|
newStatus === 'verified' ? 'Feature verified manually' : 'Feature completed manually',
|
||||||
|
executionMode: 'manual',
|
||||||
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const synced = await featureLoader.syncFeatureToAppSpec(projectPath, updated);
|
const synced = await featureLoader.syncFeatureToAppSpec(projectPath, updated);
|
||||||
if (synced) {
|
if (synced) {
|
||||||
|
|||||||
@@ -19,6 +19,10 @@ import { createBrowseHandler } from './routes/browse.js';
|
|||||||
import { createImageHandler } from './routes/image.js';
|
import { createImageHandler } from './routes/image.js';
|
||||||
import { createSaveBoardBackgroundHandler } from './routes/save-board-background.js';
|
import { createSaveBoardBackgroundHandler } from './routes/save-board-background.js';
|
||||||
import { createDeleteBoardBackgroundHandler } from './routes/delete-board-background.js';
|
import { createDeleteBoardBackgroundHandler } from './routes/delete-board-background.js';
|
||||||
|
import { createBrowseProjectFilesHandler } from './routes/browse-project-files.js';
|
||||||
|
import { createCopyHandler } from './routes/copy.js';
|
||||||
|
import { createMoveHandler } from './routes/move.js';
|
||||||
|
import { createDownloadHandler } from './routes/download.js';
|
||||||
|
|
||||||
export function createFsRoutes(_events: EventEmitter): Router {
|
export function createFsRoutes(_events: EventEmitter): Router {
|
||||||
const router = Router();
|
const router = Router();
|
||||||
@@ -37,6 +41,10 @@ export function createFsRoutes(_events: EventEmitter): Router {
|
|||||||
router.get('/image', createImageHandler());
|
router.get('/image', createImageHandler());
|
||||||
router.post('/save-board-background', createSaveBoardBackgroundHandler());
|
router.post('/save-board-background', createSaveBoardBackgroundHandler());
|
||||||
router.post('/delete-board-background', createDeleteBoardBackgroundHandler());
|
router.post('/delete-board-background', createDeleteBoardBackgroundHandler());
|
||||||
|
router.post('/browse-project-files', createBrowseProjectFilesHandler());
|
||||||
|
router.post('/copy', createCopyHandler());
|
||||||
|
router.post('/move', createMoveHandler());
|
||||||
|
router.post('/download', createDownloadHandler());
|
||||||
|
|
||||||
return router;
|
return router;
|
||||||
}
|
}
|
||||||
|
|||||||
191
apps/server/src/routes/fs/routes/browse-project-files.ts
Normal file
191
apps/server/src/routes/fs/routes/browse-project-files.ts
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
/**
|
||||||
|
* POST /browse-project-files endpoint - Browse files and directories within a project
|
||||||
|
*
|
||||||
|
* Unlike /browse which only lists directories (for project folder selection),
|
||||||
|
* this endpoint lists both files and directories relative to a project root.
|
||||||
|
* Used by the file selector for "Copy files to worktree" settings.
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - Lists both files and directories
|
||||||
|
* - Hides .git, .worktrees, node_modules, and other build artifacts
|
||||||
|
* - Returns entries relative to the project root
|
||||||
|
* - Supports navigating into subdirectories
|
||||||
|
* - Security: prevents path traversal outside project root
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Request, Response } from 'express';
|
||||||
|
import * as secureFs from '../../../lib/secure-fs.js';
|
||||||
|
import path from 'path';
|
||||||
|
import { PathNotAllowedError } from '@automaker/platform';
|
||||||
|
import { getErrorMessage, logError } from '../common.js';
|
||||||
|
|
||||||
|
// Directories to hide from the listing (build artifacts, caches, etc.)
|
||||||
|
const HIDDEN_DIRECTORIES = new Set([
|
||||||
|
'.git',
|
||||||
|
'.worktrees',
|
||||||
|
'node_modules',
|
||||||
|
'.automaker',
|
||||||
|
'__pycache__',
|
||||||
|
'.cache',
|
||||||
|
'.next',
|
||||||
|
'.nuxt',
|
||||||
|
'.svelte-kit',
|
||||||
|
'.turbo',
|
||||||
|
'.vercel',
|
||||||
|
'.output',
|
||||||
|
'coverage',
|
||||||
|
'.nyc_output',
|
||||||
|
'dist',
|
||||||
|
'build',
|
||||||
|
'out',
|
||||||
|
'.tmp',
|
||||||
|
'tmp',
|
||||||
|
'.venv',
|
||||||
|
'venv',
|
||||||
|
'target',
|
||||||
|
'vendor',
|
||||||
|
'.gradle',
|
||||||
|
'.idea',
|
||||||
|
'.vscode',
|
||||||
|
]);
|
||||||
|
|
||||||
|
interface ProjectFileEntry {
|
||||||
|
name: string;
|
||||||
|
relativePath: string;
|
||||||
|
isDirectory: boolean;
|
||||||
|
isFile: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createBrowseProjectFilesHandler() {
|
||||||
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const { projectPath, relativePath } = req.body as {
|
||||||
|
projectPath: string;
|
||||||
|
relativePath?: string; // Relative path within the project to browse (empty = project root)
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!projectPath) {
|
||||||
|
res.status(400).json({ success: false, error: 'projectPath is required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvedProjectPath = path.resolve(projectPath);
|
||||||
|
|
||||||
|
// Determine the target directory to browse
|
||||||
|
let targetPath = resolvedProjectPath;
|
||||||
|
let currentRelativePath = '';
|
||||||
|
|
||||||
|
if (relativePath) {
|
||||||
|
// Security: normalize and validate the relative path
|
||||||
|
const normalized = path.normalize(relativePath);
|
||||||
|
if (normalized.startsWith('..') || path.isAbsolute(normalized)) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Invalid relative path - must be within the project directory',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
targetPath = path.join(resolvedProjectPath, normalized);
|
||||||
|
currentRelativePath = normalized;
|
||||||
|
|
||||||
|
// Double-check the resolved path is within the project
|
||||||
|
// Use a separator-terminated prefix to prevent matching sibling dirs
|
||||||
|
// that share the same prefix (e.g. /projects/foo vs /projects/foobar).
|
||||||
|
const resolvedTarget = path.resolve(targetPath);
|
||||||
|
const projectPrefix = resolvedProjectPath.endsWith(path.sep)
|
||||||
|
? resolvedProjectPath
|
||||||
|
: resolvedProjectPath + path.sep;
|
||||||
|
if (!resolvedTarget.startsWith(projectPrefix) && resolvedTarget !== resolvedProjectPath) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Path traversal detected',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine parent relative path
|
||||||
|
let parentRelativePath: string | null = null;
|
||||||
|
if (currentRelativePath) {
|
||||||
|
const parent = path.dirname(currentRelativePath);
|
||||||
|
parentRelativePath = parent === '.' ? '' : parent;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stat = await secureFs.stat(targetPath);
|
||||||
|
|
||||||
|
if (!stat.isDirectory()) {
|
||||||
|
res.status(400).json({ success: false, error: 'Path is not a directory' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read directory contents
|
||||||
|
const dirEntries = await secureFs.readdir(targetPath, { withFileTypes: true });
|
||||||
|
|
||||||
|
// Filter and map entries
|
||||||
|
const entries: ProjectFileEntry[] = dirEntries
|
||||||
|
.filter((entry) => {
|
||||||
|
// Skip hidden directories (build artifacts, etc.)
|
||||||
|
if (entry.isDirectory() && HIDDEN_DIRECTORIES.has(entry.name)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Skip entries starting with . (hidden files) except common config files
|
||||||
|
// We keep hidden files visible since users often need .env, .eslintrc, etc.
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
.map((entry) => {
|
||||||
|
const entryRelativePath = currentRelativePath
|
||||||
|
? path.posix.join(currentRelativePath.replace(/\\/g, '/'), entry.name)
|
||||||
|
: entry.name;
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: entry.name,
|
||||||
|
relativePath: entryRelativePath,
|
||||||
|
isDirectory: entry.isDirectory(),
|
||||||
|
isFile: entry.isFile(),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
// Sort: directories first, then files, alphabetically within each group
|
||||||
|
.sort((a, b) => {
|
||||||
|
if (a.isDirectory !== b.isDirectory) {
|
||||||
|
return a.isDirectory ? -1 : 1;
|
||||||
|
}
|
||||||
|
return a.name.localeCompare(b.name);
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
currentRelativePath,
|
||||||
|
parentRelativePath,
|
||||||
|
entries,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Failed to read directory';
|
||||||
|
const isPermissionError = errorMessage.includes('EPERM') || errorMessage.includes('EACCES');
|
||||||
|
|
||||||
|
if (isPermissionError) {
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
currentRelativePath,
|
||||||
|
parentRelativePath,
|
||||||
|
entries: [],
|
||||||
|
warning: 'Permission denied - unable to read this directory',
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: errorMessage,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof PathNotAllowedError) {
|
||||||
|
res.status(403).json({ success: false, error: getErrorMessage(error) });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logError(error, 'Browse project files failed');
|
||||||
|
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
99
apps/server/src/routes/fs/routes/copy.ts
Normal file
99
apps/server/src/routes/fs/routes/copy.ts
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
/**
|
||||||
|
* POST /copy endpoint - Copy file or directory to a new location
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Request, Response } from 'express';
|
||||||
|
import * as secureFs from '../../../lib/secure-fs.js';
|
||||||
|
import path from 'path';
|
||||||
|
import { PathNotAllowedError } from '@automaker/platform';
|
||||||
|
import { mkdirSafe } from '@automaker/utils';
|
||||||
|
import { getErrorMessage, logError } from '../common.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recursively copy a directory and its contents
|
||||||
|
*/
|
||||||
|
async function copyDirectoryRecursive(src: string, dest: string): Promise<void> {
|
||||||
|
await mkdirSafe(dest);
|
||||||
|
const entries = await secureFs.readdir(src, { withFileTypes: true });
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
const srcPath = path.join(src, entry.name);
|
||||||
|
const destPath = path.join(dest, entry.name);
|
||||||
|
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
await copyDirectoryRecursive(srcPath, destPath);
|
||||||
|
} else {
|
||||||
|
await secureFs.copyFile(srcPath, destPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createCopyHandler() {
|
||||||
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const { sourcePath, destinationPath, overwrite } = req.body as {
|
||||||
|
sourcePath: string;
|
||||||
|
destinationPath: string;
|
||||||
|
overwrite?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!sourcePath || !destinationPath) {
|
||||||
|
res
|
||||||
|
.status(400)
|
||||||
|
.json({ success: false, error: 'sourcePath and destinationPath are required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent copying a folder into itself or its own descendant (infinite recursion)
|
||||||
|
const resolvedSrc = path.resolve(sourcePath);
|
||||||
|
const resolvedDest = path.resolve(destinationPath);
|
||||||
|
if (resolvedDest === resolvedSrc || resolvedDest.startsWith(resolvedSrc + path.sep)) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Cannot copy a folder into itself or one of its own descendants',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if destination already exists
|
||||||
|
try {
|
||||||
|
await secureFs.stat(destinationPath);
|
||||||
|
// Destination exists
|
||||||
|
if (!overwrite) {
|
||||||
|
res.status(409).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Destination already exists',
|
||||||
|
exists: true,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// If overwrite is true, remove the existing destination first to avoid merging
|
||||||
|
await secureFs.rm(destinationPath, { recursive: true });
|
||||||
|
} catch {
|
||||||
|
// Destination doesn't exist - good to proceed
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure parent directory exists
|
||||||
|
await mkdirSafe(path.dirname(path.resolve(destinationPath)));
|
||||||
|
|
||||||
|
// Check if source is a directory
|
||||||
|
const stats = await secureFs.stat(sourcePath);
|
||||||
|
|
||||||
|
if (stats.isDirectory()) {
|
||||||
|
await copyDirectoryRecursive(sourcePath, destinationPath);
|
||||||
|
} else {
|
||||||
|
await secureFs.copyFile(sourcePath, destinationPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof PathNotAllowedError) {
|
||||||
|
res.status(403).json({ success: false, error: getErrorMessage(error) });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logError(error, 'Copy file failed');
|
||||||
|
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
142
apps/server/src/routes/fs/routes/download.ts
Normal file
142
apps/server/src/routes/fs/routes/download.ts
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
/**
|
||||||
|
* POST /download endpoint - Download a file, or GET /download for streaming
|
||||||
|
* For folders, creates a zip archive on the fly
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Request, Response } from 'express';
|
||||||
|
import * as secureFs from '../../../lib/secure-fs.js';
|
||||||
|
import path from 'path';
|
||||||
|
import { PathNotAllowedError } from '@automaker/platform';
|
||||||
|
import { getErrorMessage, logError } from '../common.js';
|
||||||
|
import { createReadStream } from 'fs';
|
||||||
|
import { execFile } from 'child_process';
|
||||||
|
import { promisify } from 'util';
|
||||||
|
import { tmpdir } from 'os';
|
||||||
|
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get total size of a directory recursively
|
||||||
|
*/
|
||||||
|
async function getDirectorySize(dirPath: string): Promise<number> {
|
||||||
|
let totalSize = 0;
|
||||||
|
const entries = await secureFs.readdir(dirPath, { withFileTypes: true });
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
const entryPath = path.join(dirPath, entry.name);
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
totalSize += await getDirectorySize(entryPath);
|
||||||
|
} else {
|
||||||
|
const stats = await secureFs.stat(entryPath);
|
||||||
|
totalSize += Number(stats.size);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return totalSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDownloadHandler() {
|
||||||
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const { filePath } = req.body as { filePath: string };
|
||||||
|
|
||||||
|
if (!filePath) {
|
||||||
|
res.status(400).json({ success: false, error: 'filePath is required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats = await secureFs.stat(filePath);
|
||||||
|
const fileName = path.basename(filePath);
|
||||||
|
|
||||||
|
if (stats.isDirectory()) {
|
||||||
|
// For directories, create a zip archive
|
||||||
|
const dirSize = await getDirectorySize(filePath);
|
||||||
|
const MAX_DIR_SIZE = 100 * 1024 * 1024; // 100MB limit
|
||||||
|
|
||||||
|
if (dirSize > MAX_DIR_SIZE) {
|
||||||
|
res.status(413).json({
|
||||||
|
success: false,
|
||||||
|
error: `Directory is too large to download (${(dirSize / (1024 * 1024)).toFixed(1)}MB). Maximum size is ${MAX_DIR_SIZE / (1024 * 1024)}MB.`,
|
||||||
|
size: dirSize,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a temporary zip file
|
||||||
|
const zipFileName = `${fileName}.zip`;
|
||||||
|
const tmpZipPath = path.join(tmpdir(), `automaker-download-${Date.now()}-${zipFileName}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Use system zip command (available on macOS and Linux)
|
||||||
|
// Use execFile to avoid shell injection via user-provided paths
|
||||||
|
await execFileAsync('zip', ['-r', tmpZipPath, fileName], {
|
||||||
|
cwd: path.dirname(filePath),
|
||||||
|
maxBuffer: 50 * 1024 * 1024,
|
||||||
|
});
|
||||||
|
|
||||||
|
const zipStats = await secureFs.stat(tmpZipPath);
|
||||||
|
|
||||||
|
res.setHeader('Content-Type', 'application/zip');
|
||||||
|
res.setHeader('Content-Disposition', `attachment; filename="${zipFileName}"`);
|
||||||
|
res.setHeader('Content-Length', zipStats.size.toString());
|
||||||
|
res.setHeader('X-Directory-Size', dirSize.toString());
|
||||||
|
|
||||||
|
const stream = createReadStream(tmpZipPath);
|
||||||
|
stream.pipe(res);
|
||||||
|
|
||||||
|
stream.on('end', async () => {
|
||||||
|
// Cleanup temp file
|
||||||
|
try {
|
||||||
|
await secureFs.rm(tmpZipPath);
|
||||||
|
} catch {
|
||||||
|
// Ignore cleanup errors
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
stream.on('error', async (err) => {
|
||||||
|
logError(err, 'Download stream error');
|
||||||
|
try {
|
||||||
|
await secureFs.rm(tmpZipPath);
|
||||||
|
} catch {
|
||||||
|
// Ignore cleanup errors
|
||||||
|
}
|
||||||
|
if (!res.headersSent) {
|
||||||
|
res.status(500).json({ success: false, error: 'Stream error during download' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (zipError) {
|
||||||
|
// Cleanup on zip failure
|
||||||
|
try {
|
||||||
|
await secureFs.rm(tmpZipPath);
|
||||||
|
} catch {
|
||||||
|
// Ignore
|
||||||
|
}
|
||||||
|
throw zipError;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// For individual files, stream directly
|
||||||
|
res.setHeader('Content-Type', 'application/octet-stream');
|
||||||
|
res.setHeader('Content-Disposition', `attachment; filename="${fileName}"`);
|
||||||
|
res.setHeader('Content-Length', stats.size.toString());
|
||||||
|
|
||||||
|
const stream = createReadStream(filePath);
|
||||||
|
stream.pipe(res);
|
||||||
|
|
||||||
|
stream.on('error', (err) => {
|
||||||
|
logError(err, 'Download stream error');
|
||||||
|
if (!res.headersSent) {
|
||||||
|
res.status(500).json({ success: false, error: 'Stream error during download' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof PathNotAllowedError) {
|
||||||
|
res.status(403).json({ success: false, error: getErrorMessage(error) });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logError(error, 'Download failed');
|
||||||
|
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -35,9 +35,9 @@ export function createMkdirHandler() {
|
|||||||
error: 'Path exists and is not a directory',
|
error: 'Path exists and is not a directory',
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
} catch (statError: any) {
|
} catch (statError: unknown) {
|
||||||
// ENOENT means path doesn't exist - we should create it
|
// ENOENT means path doesn't exist - we should create it
|
||||||
if (statError.code !== 'ENOENT') {
|
if ((statError as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||||
// Some other error (could be ELOOP in parent path)
|
// Some other error (could be ELOOP in parent path)
|
||||||
throw statError;
|
throw statError;
|
||||||
}
|
}
|
||||||
@@ -47,7 +47,7 @@ export function createMkdirHandler() {
|
|||||||
await secureFs.mkdir(resolvedPath, { recursive: true });
|
await secureFs.mkdir(resolvedPath, { recursive: true });
|
||||||
|
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
} catch (error: any) {
|
} catch (error: unknown) {
|
||||||
// Path not allowed - return 403 Forbidden
|
// Path not allowed - return 403 Forbidden
|
||||||
if (error instanceof PathNotAllowedError) {
|
if (error instanceof PathNotAllowedError) {
|
||||||
res.status(403).json({ success: false, error: getErrorMessage(error) });
|
res.status(403).json({ success: false, error: getErrorMessage(error) });
|
||||||
@@ -55,7 +55,7 @@ export function createMkdirHandler() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Handle ELOOP specifically
|
// Handle ELOOP specifically
|
||||||
if (error.code === 'ELOOP') {
|
if ((error as NodeJS.ErrnoException).code === 'ELOOP') {
|
||||||
logError(error, 'Create directory failed - symlink loop detected');
|
logError(error, 'Create directory failed - symlink loop detected');
|
||||||
res.status(400).json({
|
res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
|
|||||||
79
apps/server/src/routes/fs/routes/move.ts
Normal file
79
apps/server/src/routes/fs/routes/move.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
/**
|
||||||
|
* POST /move endpoint - Move (rename) file or directory to a new location
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Request, Response } from 'express';
|
||||||
|
import * as secureFs from '../../../lib/secure-fs.js';
|
||||||
|
import path from 'path';
|
||||||
|
import { PathNotAllowedError } from '@automaker/platform';
|
||||||
|
import { mkdirSafe } from '@automaker/utils';
|
||||||
|
import { getErrorMessage, logError } from '../common.js';
|
||||||
|
|
||||||
|
export function createMoveHandler() {
|
||||||
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const { sourcePath, destinationPath, overwrite } = req.body as {
|
||||||
|
sourcePath: string;
|
||||||
|
destinationPath: string;
|
||||||
|
overwrite?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!sourcePath || !destinationPath) {
|
||||||
|
res
|
||||||
|
.status(400)
|
||||||
|
.json({ success: false, error: 'sourcePath and destinationPath are required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent moving to same location or into its own descendant
|
||||||
|
const resolvedSrc = path.resolve(sourcePath);
|
||||||
|
const resolvedDest = path.resolve(destinationPath);
|
||||||
|
if (resolvedDest === resolvedSrc) {
|
||||||
|
// No-op: source and destination are the same
|
||||||
|
res.json({ success: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (resolvedDest.startsWith(resolvedSrc + path.sep)) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Cannot move a folder into one of its own descendants',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if destination already exists
|
||||||
|
try {
|
||||||
|
await secureFs.stat(destinationPath);
|
||||||
|
// Destination exists
|
||||||
|
if (!overwrite) {
|
||||||
|
res.status(409).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Destination already exists',
|
||||||
|
exists: true,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// If overwrite is true, remove the existing destination first
|
||||||
|
await secureFs.rm(destinationPath, { recursive: true });
|
||||||
|
} catch {
|
||||||
|
// Destination doesn't exist - good to proceed
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure parent directory exists
|
||||||
|
await mkdirSafe(path.dirname(path.resolve(destinationPath)));
|
||||||
|
|
||||||
|
// Use rename for the move operation
|
||||||
|
await secureFs.rename(sourcePath, destinationPath);
|
||||||
|
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof PathNotAllowedError) {
|
||||||
|
res.status(403).json({ success: false, error: getErrorMessage(error) });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logError(error, 'Move file failed');
|
||||||
|
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -10,7 +10,11 @@ import { getErrorMessage, logError } from '../common.js';
|
|||||||
export function createResolveDirectoryHandler() {
|
export function createResolveDirectoryHandler() {
|
||||||
return async (req: Request, res: Response): Promise<void> => {
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const { directoryName, sampleFiles, fileCount } = req.body as {
|
const {
|
||||||
|
directoryName,
|
||||||
|
sampleFiles,
|
||||||
|
fileCount: _fileCount,
|
||||||
|
} = req.body as {
|
||||||
directoryName: string;
|
directoryName: string;
|
||||||
sampleFiles?: string[];
|
sampleFiles?: string[];
|
||||||
fileCount?: number;
|
fileCount?: number;
|
||||||
|
|||||||
@@ -11,10 +11,9 @@ import { getBoardDir } from '@automaker/platform';
|
|||||||
export function createSaveBoardBackgroundHandler() {
|
export function createSaveBoardBackgroundHandler() {
|
||||||
return async (req: Request, res: Response): Promise<void> => {
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const { data, filename, mimeType, projectPath } = req.body as {
|
const { data, filename, projectPath } = req.body as {
|
||||||
data: string;
|
data: string;
|
||||||
filename: string;
|
filename: string;
|
||||||
mimeType: string;
|
|
||||||
projectPath: string;
|
projectPath: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -12,10 +12,9 @@ import { sanitizeFilename } from '@automaker/utils';
|
|||||||
export function createSaveImageHandler() {
|
export function createSaveImageHandler() {
|
||||||
return async (req: Request, res: Response): Promise<void> => {
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const { data, filename, mimeType, projectPath } = req.body as {
|
const { data, filename, projectPath } = req.body as {
|
||||||
data: string;
|
data: string;
|
||||||
filename: string;
|
filename: string;
|
||||||
mimeType: string;
|
|
||||||
projectPath: string;
|
projectPath: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
import * as secureFs from '../../../lib/secure-fs.js';
|
import * as secureFs from '../../../lib/secure-fs.js';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { isPathAllowed, PathNotAllowedError, getAllowedRootDirectory } from '@automaker/platform';
|
import { isPathAllowed, getAllowedRootDirectory } from '@automaker/platform';
|
||||||
import { getErrorMessage, logError } from '../common.js';
|
import { getErrorMessage, logError } from '../common.js';
|
||||||
|
|
||||||
export function createValidatePathHandler() {
|
export function createValidatePathHandler() {
|
||||||
|
|||||||
@@ -24,7 +24,9 @@ export function createWriteHandler() {
|
|||||||
|
|
||||||
// Ensure parent directory exists (symlink-safe)
|
// Ensure parent directory exists (symlink-safe)
|
||||||
await mkdirSafe(path.dirname(path.resolve(filePath)));
|
await mkdirSafe(path.dirname(path.resolve(filePath)));
|
||||||
await secureFs.writeFile(filePath, content, 'utf-8');
|
// Default content to empty string if undefined/null to prevent writing
|
||||||
|
// "undefined" as literal text (e.g. when content field is missing from request)
|
||||||
|
await secureFs.writeFile(filePath, content ?? '', 'utf-8');
|
||||||
|
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
66
apps/server/src/routes/gemini/index.ts
Normal file
66
apps/server/src/routes/gemini/index.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import { Router, Request, Response } from 'express';
|
||||||
|
import { GeminiProvider } from '../../providers/gemini-provider.js';
|
||||||
|
import { GeminiUsageService } from '../../services/gemini-usage-service.js';
|
||||||
|
import { createLogger } from '@automaker/utils';
|
||||||
|
import type { EventEmitter } from '../../lib/events.js';
|
||||||
|
|
||||||
|
const logger = createLogger('Gemini');
|
||||||
|
|
||||||
|
export function createGeminiRoutes(
|
||||||
|
usageService: GeminiUsageService,
|
||||||
|
_events: EventEmitter
|
||||||
|
): Router {
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// Get current usage/quota data from Google Cloud API
|
||||||
|
router.get('/usage', async (_req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const usageData = await usageService.fetchUsageData();
|
||||||
|
|
||||||
|
res.json(usageData);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
logger.error('Error fetching Gemini usage:', error);
|
||||||
|
|
||||||
|
// Return error in a format the UI expects
|
||||||
|
res.status(200).json({
|
||||||
|
authenticated: false,
|
||||||
|
authMethod: 'none',
|
||||||
|
usedPercent: 0,
|
||||||
|
remainingPercent: 100,
|
||||||
|
lastUpdated: new Date().toISOString(),
|
||||||
|
error: `Failed to fetch Gemini usage: ${message}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check if Gemini is available
|
||||||
|
router.get('/status', async (_req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const provider = new GeminiProvider();
|
||||||
|
const status = await provider.detectInstallation();
|
||||||
|
|
||||||
|
// Derive authMethod from typed InstallationStatus fields
|
||||||
|
const authMethod = status.authenticated
|
||||||
|
? status.hasApiKey
|
||||||
|
? 'api_key'
|
||||||
|
: 'cli_login'
|
||||||
|
: 'none';
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
installed: status.installed,
|
||||||
|
version: status.version || null,
|
||||||
|
path: status.path || null,
|
||||||
|
authenticated: status.authenticated || false,
|
||||||
|
authMethod,
|
||||||
|
hasCredentialsFile: false,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
res.status(500).json({ success: false, error: message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
@@ -6,12 +6,22 @@ import { Router } from 'express';
|
|||||||
import { validatePathParams } from '../../middleware/validate-paths.js';
|
import { validatePathParams } from '../../middleware/validate-paths.js';
|
||||||
import { createDiffsHandler } from './routes/diffs.js';
|
import { createDiffsHandler } from './routes/diffs.js';
|
||||||
import { createFileDiffHandler } from './routes/file-diff.js';
|
import { createFileDiffHandler } from './routes/file-diff.js';
|
||||||
|
import { createStageFilesHandler } from './routes/stage-files.js';
|
||||||
|
import { createDetailsHandler } from './routes/details.js';
|
||||||
|
import { createEnhancedStatusHandler } from './routes/enhanced-status.js';
|
||||||
|
|
||||||
export function createGitRoutes(): Router {
|
export function createGitRoutes(): Router {
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
router.post('/diffs', validatePathParams('projectPath'), createDiffsHandler());
|
router.post('/diffs', validatePathParams('projectPath'), createDiffsHandler());
|
||||||
router.post('/file-diff', validatePathParams('projectPath', 'filePath'), createFileDiffHandler());
|
router.post('/file-diff', validatePathParams('projectPath', 'filePath'), createFileDiffHandler());
|
||||||
|
router.post(
|
||||||
|
'/stage-files',
|
||||||
|
validatePathParams('projectPath', 'files[]'),
|
||||||
|
createStageFilesHandler()
|
||||||
|
);
|
||||||
|
router.post('/details', validatePathParams('projectPath', 'filePath?'), createDetailsHandler());
|
||||||
|
router.post('/enhanced-status', validatePathParams('projectPath'), createEnhancedStatusHandler());
|
||||||
|
|
||||||
return router;
|
return router;
|
||||||
}
|
}
|
||||||
|
|||||||
248
apps/server/src/routes/git/routes/details.ts
Normal file
248
apps/server/src/routes/git/routes/details.ts
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
/**
|
||||||
|
* POST /details endpoint - Get detailed git info for a file or project
|
||||||
|
* Returns branch, last commit info, diff stats, and conflict status
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Request, Response } from 'express';
|
||||||
|
import { exec, execFile } from 'child_process';
|
||||||
|
import { promisify } from 'util';
|
||||||
|
import * as secureFs from '../../../lib/secure-fs.js';
|
||||||
|
import { getErrorMessage, logError } from '../common.js';
|
||||||
|
|
||||||
|
const execAsync = promisify(exec);
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
|
interface GitFileDetails {
|
||||||
|
branch: string;
|
||||||
|
lastCommitHash: string;
|
||||||
|
lastCommitMessage: string;
|
||||||
|
lastCommitAuthor: string;
|
||||||
|
lastCommitTimestamp: string;
|
||||||
|
linesAdded: number;
|
||||||
|
linesRemoved: number;
|
||||||
|
isConflicted: boolean;
|
||||||
|
isStaged: boolean;
|
||||||
|
isUnstaged: boolean;
|
||||||
|
statusLabel: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDetailsHandler() {
|
||||||
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const { projectPath, filePath } = req.body as {
|
||||||
|
projectPath: string;
|
||||||
|
filePath?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!projectPath) {
|
||||||
|
res.status(400).json({ success: false, error: 'projectPath required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get current branch
|
||||||
|
const { stdout: branchRaw } = await execAsync('git rev-parse --abbrev-ref HEAD', {
|
||||||
|
cwd: projectPath,
|
||||||
|
});
|
||||||
|
const branch = branchRaw.trim();
|
||||||
|
|
||||||
|
if (!filePath) {
|
||||||
|
// Project-level details - just return branch info
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
details: { branch },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get last commit info for this file
|
||||||
|
let lastCommitHash = '';
|
||||||
|
let lastCommitMessage = '';
|
||||||
|
let lastCommitAuthor = '';
|
||||||
|
let lastCommitTimestamp = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { stdout: logOutput } = await execFileAsync(
|
||||||
|
'git',
|
||||||
|
['log', '-1', '--format=%H|%s|%an|%aI', '--', filePath],
|
||||||
|
{ cwd: projectPath }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (logOutput.trim()) {
|
||||||
|
const parts = logOutput.trim().split('|');
|
||||||
|
lastCommitHash = parts[0] || '';
|
||||||
|
lastCommitMessage = parts[1] || '';
|
||||||
|
lastCommitAuthor = parts[2] || '';
|
||||||
|
lastCommitTimestamp = parts[3] || '';
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// File may not have any commits yet
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get diff stats (lines added/removed)
|
||||||
|
let linesAdded = 0;
|
||||||
|
let linesRemoved = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Check if file is untracked first
|
||||||
|
const { stdout: statusLine } = await execFileAsync(
|
||||||
|
'git',
|
||||||
|
['status', '--porcelain', '--', filePath],
|
||||||
|
{ cwd: projectPath }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (statusLine.trim().startsWith('??')) {
|
||||||
|
// Untracked file - count all lines as added using Node.js instead of shell
|
||||||
|
try {
|
||||||
|
const fileContent = (await secureFs.readFile(filePath, 'utf-8')).toString();
|
||||||
|
const lines = fileContent.split('\n');
|
||||||
|
// Don't count trailing empty line from final newline
|
||||||
|
linesAdded =
|
||||||
|
lines.length > 0 && lines[lines.length - 1] === ''
|
||||||
|
? lines.length - 1
|
||||||
|
: lines.length;
|
||||||
|
} catch {
|
||||||
|
// Ignore
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const { stdout: diffStatRaw } = await execFileAsync(
|
||||||
|
'git',
|
||||||
|
['diff', '--numstat', 'HEAD', '--', filePath],
|
||||||
|
{ cwd: projectPath }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (diffStatRaw.trim()) {
|
||||||
|
const parts = diffStatRaw.trim().split('\t');
|
||||||
|
linesAdded = parseInt(parts[0], 10) || 0;
|
||||||
|
linesRemoved = parseInt(parts[1], 10) || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also check staged diff stats
|
||||||
|
const { stdout: stagedDiffStatRaw } = await execFileAsync(
|
||||||
|
'git',
|
||||||
|
['diff', '--numstat', '--cached', '--', filePath],
|
||||||
|
{ cwd: projectPath }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (stagedDiffStatRaw.trim()) {
|
||||||
|
const parts = stagedDiffStatRaw.trim().split('\t');
|
||||||
|
linesAdded += parseInt(parts[0], 10) || 0;
|
||||||
|
linesRemoved += parseInt(parts[1], 10) || 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Diff might not be available
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get conflict and staging status
|
||||||
|
let isConflicted = false;
|
||||||
|
let isStaged = false;
|
||||||
|
let isUnstaged = false;
|
||||||
|
let statusLabel = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { stdout: statusOutput } = await execFileAsync(
|
||||||
|
'git',
|
||||||
|
['status', '--porcelain', '--', filePath],
|
||||||
|
{ cwd: projectPath }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (statusOutput.trim()) {
|
||||||
|
const indexStatus = statusOutput[0];
|
||||||
|
const workTreeStatus = statusOutput[1];
|
||||||
|
|
||||||
|
// Check for conflicts (both modified, unmerged states)
|
||||||
|
if (
|
||||||
|
indexStatus === 'U' ||
|
||||||
|
workTreeStatus === 'U' ||
|
||||||
|
(indexStatus === 'A' && workTreeStatus === 'A') ||
|
||||||
|
(indexStatus === 'D' && workTreeStatus === 'D')
|
||||||
|
) {
|
||||||
|
isConflicted = true;
|
||||||
|
statusLabel = 'Conflicted';
|
||||||
|
} else {
|
||||||
|
// Staged changes (index has a status)
|
||||||
|
if (indexStatus !== ' ' && indexStatus !== '?') {
|
||||||
|
isStaged = true;
|
||||||
|
}
|
||||||
|
// Unstaged changes (work tree has a status)
|
||||||
|
if (workTreeStatus !== ' ' && workTreeStatus !== '?') {
|
||||||
|
isUnstaged = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build status label
|
||||||
|
if (isStaged && isUnstaged) {
|
||||||
|
statusLabel = 'Staged + Modified';
|
||||||
|
} else if (isStaged) {
|
||||||
|
statusLabel = 'Staged';
|
||||||
|
} else {
|
||||||
|
const statusChar = workTreeStatus !== ' ' ? workTreeStatus : indexStatus;
|
||||||
|
switch (statusChar) {
|
||||||
|
case 'M':
|
||||||
|
statusLabel = 'Modified';
|
||||||
|
break;
|
||||||
|
case 'A':
|
||||||
|
statusLabel = 'Added';
|
||||||
|
break;
|
||||||
|
case 'D':
|
||||||
|
statusLabel = 'Deleted';
|
||||||
|
break;
|
||||||
|
case 'R':
|
||||||
|
statusLabel = 'Renamed';
|
||||||
|
break;
|
||||||
|
case 'C':
|
||||||
|
statusLabel = 'Copied';
|
||||||
|
break;
|
||||||
|
case '?':
|
||||||
|
statusLabel = 'Untracked';
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
statusLabel = statusChar || '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Status might not be available
|
||||||
|
}
|
||||||
|
|
||||||
|
const details: GitFileDetails = {
|
||||||
|
branch,
|
||||||
|
lastCommitHash,
|
||||||
|
lastCommitMessage,
|
||||||
|
lastCommitAuthor,
|
||||||
|
lastCommitTimestamp,
|
||||||
|
linesAdded,
|
||||||
|
linesRemoved,
|
||||||
|
isConflicted,
|
||||||
|
isStaged,
|
||||||
|
isUnstaged,
|
||||||
|
statusLabel,
|
||||||
|
};
|
||||||
|
|
||||||
|
res.json({ success: true, details });
|
||||||
|
} catch (innerError) {
|
||||||
|
logError(innerError, 'Git details failed');
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
details: {
|
||||||
|
branch: '',
|
||||||
|
lastCommitHash: '',
|
||||||
|
lastCommitMessage: '',
|
||||||
|
lastCommitAuthor: '',
|
||||||
|
lastCommitTimestamp: '',
|
||||||
|
linesAdded: 0,
|
||||||
|
linesRemoved: 0,
|
||||||
|
isConflicted: false,
|
||||||
|
isStaged: false,
|
||||||
|
isUnstaged: false,
|
||||||
|
statusLabel: '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logError(error, 'Get git details failed');
|
||||||
|
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@ export function createDiffsHandler() {
|
|||||||
diff: result.diff,
|
diff: result.diff,
|
||||||
files: result.files,
|
files: result.files,
|
||||||
hasChanges: result.hasChanges,
|
hasChanges: result.hasChanges,
|
||||||
|
...(result.mergeState ? { mergeState: result.mergeState } : {}),
|
||||||
});
|
});
|
||||||
} catch (innerError) {
|
} catch (innerError) {
|
||||||
logError(innerError, 'Git diff failed');
|
logError(innerError, 'Git diff failed');
|
||||||
|
|||||||
176
apps/server/src/routes/git/routes/enhanced-status.ts
Normal file
176
apps/server/src/routes/git/routes/enhanced-status.ts
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
/**
|
||||||
|
* POST /enhanced-status endpoint - Get enhanced git status with diff stats per file
|
||||||
|
* Returns per-file status with lines added/removed and staged/unstaged differentiation
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Request, Response } from 'express';
|
||||||
|
import { exec } from 'child_process';
|
||||||
|
import { promisify } from 'util';
|
||||||
|
import { getErrorMessage, logError } from '../common.js';
|
||||||
|
|
||||||
|
const execAsync = promisify(exec);
|
||||||
|
|
||||||
|
interface EnhancedFileStatus {
|
||||||
|
path: string;
|
||||||
|
indexStatus: string;
|
||||||
|
workTreeStatus: string;
|
||||||
|
isConflicted: boolean;
|
||||||
|
isStaged: boolean;
|
||||||
|
isUnstaged: boolean;
|
||||||
|
linesAdded: number;
|
||||||
|
linesRemoved: number;
|
||||||
|
statusLabel: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusLabel(indexStatus: string, workTreeStatus: string): string {
|
||||||
|
// Check for conflicts
|
||||||
|
if (
|
||||||
|
indexStatus === 'U' ||
|
||||||
|
workTreeStatus === 'U' ||
|
||||||
|
(indexStatus === 'A' && workTreeStatus === 'A') ||
|
||||||
|
(indexStatus === 'D' && workTreeStatus === 'D')
|
||||||
|
) {
|
||||||
|
return 'Conflicted';
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasStaged = indexStatus !== ' ' && indexStatus !== '?';
|
||||||
|
const hasUnstaged = workTreeStatus !== ' ' && workTreeStatus !== '?';
|
||||||
|
|
||||||
|
if (hasStaged && hasUnstaged) return 'Staged + Modified';
|
||||||
|
if (hasStaged) return 'Staged';
|
||||||
|
|
||||||
|
const statusChar = workTreeStatus !== ' ' ? workTreeStatus : indexStatus;
|
||||||
|
switch (statusChar) {
|
||||||
|
case 'M':
|
||||||
|
return 'Modified';
|
||||||
|
case 'A':
|
||||||
|
return 'Added';
|
||||||
|
case 'D':
|
||||||
|
return 'Deleted';
|
||||||
|
case 'R':
|
||||||
|
return 'Renamed';
|
||||||
|
case 'C':
|
||||||
|
return 'Copied';
|
||||||
|
case '?':
|
||||||
|
return 'Untracked';
|
||||||
|
default:
|
||||||
|
return statusChar || '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createEnhancedStatusHandler() {
|
||||||
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const { projectPath } = req.body as { projectPath: string };
|
||||||
|
|
||||||
|
if (!projectPath) {
|
||||||
|
res.status(400).json({ success: false, error: 'projectPath required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get current branch
|
||||||
|
const { stdout: branchRaw } = await execAsync('git rev-parse --abbrev-ref HEAD', {
|
||||||
|
cwd: projectPath,
|
||||||
|
});
|
||||||
|
const branch = branchRaw.trim();
|
||||||
|
|
||||||
|
// Get porcelain status for all files
|
||||||
|
const { stdout: statusOutput } = await execAsync('git status --porcelain', {
|
||||||
|
cwd: projectPath,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get diff numstat for working tree changes
|
||||||
|
let workTreeStats: Record<string, { added: number; removed: number }> = {};
|
||||||
|
try {
|
||||||
|
const { stdout: numstatRaw } = await execAsync('git diff --numstat', {
|
||||||
|
cwd: projectPath,
|
||||||
|
maxBuffer: 10 * 1024 * 1024,
|
||||||
|
});
|
||||||
|
for (const line of numstatRaw.trim().split('\n').filter(Boolean)) {
|
||||||
|
const parts = line.split('\t');
|
||||||
|
if (parts.length >= 3) {
|
||||||
|
const added = parseInt(parts[0], 10) || 0;
|
||||||
|
const removed = parseInt(parts[1], 10) || 0;
|
||||||
|
workTreeStats[parts[2]] = { added, removed };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get diff numstat for staged changes
|
||||||
|
let stagedStats: Record<string, { added: number; removed: number }> = {};
|
||||||
|
try {
|
||||||
|
const { stdout: stagedNumstatRaw } = await execAsync('git diff --numstat --cached', {
|
||||||
|
cwd: projectPath,
|
||||||
|
maxBuffer: 10 * 1024 * 1024,
|
||||||
|
});
|
||||||
|
for (const line of stagedNumstatRaw.trim().split('\n').filter(Boolean)) {
|
||||||
|
const parts = line.split('\t');
|
||||||
|
if (parts.length >= 3) {
|
||||||
|
const added = parseInt(parts[0], 10) || 0;
|
||||||
|
const removed = parseInt(parts[1], 10) || 0;
|
||||||
|
stagedStats[parts[2]] = { added, removed };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse status and build enhanced file list
|
||||||
|
const files: EnhancedFileStatus[] = [];
|
||||||
|
|
||||||
|
for (const line of statusOutput.split('\n').filter(Boolean)) {
|
||||||
|
if (line.length < 4) continue;
|
||||||
|
|
||||||
|
const indexStatus = line[0];
|
||||||
|
const workTreeStatus = line[1];
|
||||||
|
const filePath = line.substring(3).trim();
|
||||||
|
|
||||||
|
// Handle renamed files (format: "R old -> new")
|
||||||
|
const actualPath = filePath.includes(' -> ')
|
||||||
|
? filePath.split(' -> ')[1].trim()
|
||||||
|
: filePath;
|
||||||
|
|
||||||
|
const isConflicted =
|
||||||
|
indexStatus === 'U' ||
|
||||||
|
workTreeStatus === 'U' ||
|
||||||
|
(indexStatus === 'A' && workTreeStatus === 'A') ||
|
||||||
|
(indexStatus === 'D' && workTreeStatus === 'D');
|
||||||
|
|
||||||
|
const isStaged = indexStatus !== ' ' && indexStatus !== '?';
|
||||||
|
const isUnstaged = workTreeStatus !== ' ' && workTreeStatus !== '?';
|
||||||
|
|
||||||
|
// Combine diff stats from both working tree and staged
|
||||||
|
const wtStats = workTreeStats[actualPath] || { added: 0, removed: 0 };
|
||||||
|
const stStats = stagedStats[actualPath] || { added: 0, removed: 0 };
|
||||||
|
|
||||||
|
files.push({
|
||||||
|
path: actualPath,
|
||||||
|
indexStatus,
|
||||||
|
workTreeStatus,
|
||||||
|
isConflicted,
|
||||||
|
isStaged,
|
||||||
|
isUnstaged,
|
||||||
|
linesAdded: wtStats.added + stStats.added,
|
||||||
|
linesRemoved: wtStats.removed + stStats.removed,
|
||||||
|
statusLabel: getStatusLabel(indexStatus, workTreeStatus),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
branch,
|
||||||
|
files,
|
||||||
|
});
|
||||||
|
} catch (innerError) {
|
||||||
|
logError(innerError, 'Git enhanced status failed');
|
||||||
|
res.json({ success: true, branch: '', files: [] });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logError(error, 'Get enhanced status failed');
|
||||||
|
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
67
apps/server/src/routes/git/routes/stage-files.ts
Normal file
67
apps/server/src/routes/git/routes/stage-files.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
/**
|
||||||
|
* POST /stage-files endpoint - Stage or unstage files in the main project
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Request, Response } from 'express';
|
||||||
|
import { getErrorMessage, logError } from '../common.js';
|
||||||
|
import { stageFiles, StageFilesValidationError } from '../../../services/stage-files-service.js';
|
||||||
|
|
||||||
|
export function createStageFilesHandler() {
|
||||||
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const { projectPath, files, operation } = req.body as {
|
||||||
|
projectPath: string;
|
||||||
|
files: string[];
|
||||||
|
operation: 'stage' | 'unstage';
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!projectPath) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'projectPath required',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(files) || files.length === 0) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'files array required and must not be empty',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
if (typeof file !== 'string' || file.trim() === '') {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Each element of files must be a non-empty string',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (operation !== 'stage' && operation !== 'unstage') {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'operation must be "stage" or "unstage"',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await stageFiles(projectPath, files, operation);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
result,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof StageFilesValidationError) {
|
||||||
|
res.status(400).json({ success: false, error: error.message });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
logError(error, `${(req.body as { operation?: string })?.operation ?? 'stage'} files failed`);
|
||||||
|
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -9,6 +9,8 @@ import { createCheckGitHubRemoteHandler } from './routes/check-github-remote.js'
|
|||||||
import { createListIssuesHandler } from './routes/list-issues.js';
|
import { createListIssuesHandler } from './routes/list-issues.js';
|
||||||
import { createListPRsHandler } from './routes/list-prs.js';
|
import { createListPRsHandler } from './routes/list-prs.js';
|
||||||
import { createListCommentsHandler } from './routes/list-comments.js';
|
import { createListCommentsHandler } from './routes/list-comments.js';
|
||||||
|
import { createListPRReviewCommentsHandler } from './routes/list-pr-review-comments.js';
|
||||||
|
import { createResolvePRCommentHandler } from './routes/resolve-pr-comment.js';
|
||||||
import { createValidateIssueHandler } from './routes/validate-issue.js';
|
import { createValidateIssueHandler } from './routes/validate-issue.js';
|
||||||
import {
|
import {
|
||||||
createValidationStatusHandler,
|
createValidationStatusHandler,
|
||||||
@@ -29,6 +31,16 @@ export function createGitHubRoutes(
|
|||||||
router.post('/issues', validatePathParams('projectPath'), createListIssuesHandler());
|
router.post('/issues', validatePathParams('projectPath'), createListIssuesHandler());
|
||||||
router.post('/prs', validatePathParams('projectPath'), createListPRsHandler());
|
router.post('/prs', validatePathParams('projectPath'), createListPRsHandler());
|
||||||
router.post('/issue-comments', validatePathParams('projectPath'), createListCommentsHandler());
|
router.post('/issue-comments', validatePathParams('projectPath'), createListCommentsHandler());
|
||||||
|
router.post(
|
||||||
|
'/pr-review-comments',
|
||||||
|
validatePathParams('projectPath'),
|
||||||
|
createListPRReviewCommentsHandler()
|
||||||
|
);
|
||||||
|
router.post(
|
||||||
|
'/resolve-pr-comment',
|
||||||
|
validatePathParams('projectPath'),
|
||||||
|
createResolvePRCommentHandler()
|
||||||
|
);
|
||||||
router.post(
|
router.post(
|
||||||
'/validate-issue',
|
'/validate-issue',
|
||||||
validatePathParams('projectPath'),
|
validatePathParams('projectPath'),
|
||||||
|
|||||||
@@ -1,38 +1,14 @@
|
|||||||
/**
|
/**
|
||||||
* Common utilities for GitHub routes
|
* Common utilities for GitHub routes
|
||||||
|
*
|
||||||
|
* Re-exports shared utilities from lib/exec-utils so route consumers
|
||||||
|
* can continue importing from this module unchanged.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { exec } from 'child_process';
|
import { exec } from 'child_process';
|
||||||
import { promisify } from 'util';
|
import { promisify } from 'util';
|
||||||
import { createLogger } from '@automaker/utils';
|
|
||||||
|
|
||||||
const logger = createLogger('GitHub');
|
|
||||||
|
|
||||||
export const execAsync = promisify(exec);
|
export const execAsync = promisify(exec);
|
||||||
|
|
||||||
// Extended PATH to include common tool installation locations
|
// Re-export shared utilities from the canonical location
|
||||||
export const extendedPath = [
|
export { extendedPath, execEnv, getErrorMessage, logError } from '../../../lib/exec-utils.js';
|
||||||
process.env.PATH,
|
|
||||||
'/opt/homebrew/bin',
|
|
||||||
'/usr/local/bin',
|
|
||||||
'/home/linuxbrew/.linuxbrew/bin',
|
|
||||||
`${process.env.HOME}/.local/bin`,
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(':');
|
|
||||||
|
|
||||||
export const execEnv = {
|
|
||||||
...process.env,
|
|
||||||
PATH: extendedPath,
|
|
||||||
};
|
|
||||||
|
|
||||||
export function getErrorMessage(error: unknown): string {
|
|
||||||
if (error instanceof Error) {
|
|
||||||
return error.message;
|
|
||||||
}
|
|
||||||
return String(error);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function logError(error: unknown, context: string): void {
|
|
||||||
logger.error(`${context}:`, error);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
/**
|
||||||
|
* POST /pr-review-comments endpoint - Fetch review comments for a GitHub PR
|
||||||
|
*
|
||||||
|
* Fetches both regular PR comments and inline code review comments
|
||||||
|
* for a specific pull request, providing file path and line context.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Request, Response } from 'express';
|
||||||
|
import { getErrorMessage, logError } from './common.js';
|
||||||
|
import { checkGitHubRemote } from './check-github-remote.js';
|
||||||
|
import {
|
||||||
|
fetchPRReviewComments,
|
||||||
|
fetchReviewThreadResolvedStatus,
|
||||||
|
type PRReviewComment,
|
||||||
|
type ListPRReviewCommentsResult,
|
||||||
|
} from '../../../services/pr-review-comments.service.js';
|
||||||
|
|
||||||
|
// Re-export types so existing callers continue to work
|
||||||
|
export type { PRReviewComment, ListPRReviewCommentsResult };
|
||||||
|
// Re-export service functions so existing callers continue to work
|
||||||
|
export { fetchPRReviewComments, fetchReviewThreadResolvedStatus };
|
||||||
|
|
||||||
|
interface ListPRReviewCommentsRequest {
|
||||||
|
projectPath: string;
|
||||||
|
prNumber: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createListPRReviewCommentsHandler() {
|
||||||
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const { projectPath, prNumber } = req.body as ListPRReviewCommentsRequest;
|
||||||
|
|
||||||
|
if (!projectPath) {
|
||||||
|
res.status(400).json({ success: false, error: 'projectPath is required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!prNumber || typeof prNumber !== 'number') {
|
||||||
|
res
|
||||||
|
.status(400)
|
||||||
|
.json({ success: false, error: 'prNumber is required and must be a number' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this is a GitHub repo and get owner/repo
|
||||||
|
const remoteStatus = await checkGitHubRemote(projectPath);
|
||||||
|
if (!remoteStatus.hasGitHubRemote || !remoteStatus.owner || !remoteStatus.repo) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Project does not have a GitHub remote',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const comments = await fetchPRReviewComments(
|
||||||
|
projectPath,
|
||||||
|
remoteStatus.owner,
|
||||||
|
remoteStatus.repo,
|
||||||
|
prNumber
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
comments,
|
||||||
|
totalCount: comments.length,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logError(error, 'Fetch PR review comments failed');
|
||||||
|
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
66
apps/server/src/routes/github/routes/resolve-pr-comment.ts
Normal file
66
apps/server/src/routes/github/routes/resolve-pr-comment.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
/**
|
||||||
|
* POST /resolve-pr-comment endpoint - Resolve or unresolve a GitHub PR review thread
|
||||||
|
*
|
||||||
|
* Uses the GitHub GraphQL API to resolve or unresolve a review thread
|
||||||
|
* identified by its GraphQL node ID (threadId).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Request, Response } from 'express';
|
||||||
|
import { getErrorMessage, logError } from './common.js';
|
||||||
|
import { checkGitHubRemote } from './check-github-remote.js';
|
||||||
|
import { executeReviewThreadMutation } from '../../../services/github-pr-comment.service.js';
|
||||||
|
|
||||||
|
export interface ResolvePRCommentResult {
|
||||||
|
success: boolean;
|
||||||
|
isResolved?: boolean;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ResolvePRCommentRequest {
|
||||||
|
projectPath: string;
|
||||||
|
threadId: string;
|
||||||
|
resolve: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createResolvePRCommentHandler() {
|
||||||
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const { projectPath, threadId, resolve } = req.body as ResolvePRCommentRequest;
|
||||||
|
|
||||||
|
if (!projectPath) {
|
||||||
|
res.status(400).json({ success: false, error: 'projectPath is required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!threadId) {
|
||||||
|
res.status(400).json({ success: false, error: 'threadId is required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof resolve !== 'boolean') {
|
||||||
|
res.status(400).json({ success: false, error: 'resolve must be a boolean' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this is a GitHub repo
|
||||||
|
const remoteStatus = await checkGitHubRemote(projectPath);
|
||||||
|
if (!remoteStatus.hasGitHubRemote) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Project does not have a GitHub remote',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await executeReviewThreadMutation(projectPath, threadId, resolve);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
isResolved: result.isResolved,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logError(error, 'Resolve PR comment failed');
|
||||||
|
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -6,7 +6,6 @@ import type { Request, Response } from 'express';
|
|||||||
import type { EventEmitter } from '../../../lib/events.js';
|
import type { EventEmitter } from '../../../lib/events.js';
|
||||||
import type { IssueValidationEvent } from '@automaker/types';
|
import type { IssueValidationEvent } from '@automaker/types';
|
||||||
import {
|
import {
|
||||||
isValidationRunning,
|
|
||||||
getValidationStatus,
|
getValidationStatus,
|
||||||
getRunningValidations,
|
getRunningValidations,
|
||||||
abortValidation,
|
abortValidation,
|
||||||
@@ -15,7 +14,6 @@ import {
|
|||||||
logger,
|
logger,
|
||||||
} from './validation-common.js';
|
} from './validation-common.js';
|
||||||
import {
|
import {
|
||||||
readValidation,
|
|
||||||
getAllValidations,
|
getAllValidations,
|
||||||
getValidationWithFreshness,
|
getValidationWithFreshness,
|
||||||
deleteValidation,
|
deleteValidation,
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export function createProvidersHandler() {
|
|||||||
// Get installation status from all providers
|
// Get installation status from all providers
|
||||||
const statuses = await ProviderFactory.checkAllProviders();
|
const statuses = await ProviderFactory.checkAllProviders();
|
||||||
|
|
||||||
const providers: Record<string, any> = {
|
const providers: Record<string, Record<string, unknown>> = {
|
||||||
anthropic: {
|
anthropic: {
|
||||||
available: statuses.claude?.installed || false,
|
available: statuses.claude?.installed || false,
|
||||||
hasApiKey: !!process.env.ANTHROPIC_API_KEY,
|
hasApiKey: !!process.env.ANTHROPIC_API_KEY,
|
||||||
|
|||||||
@@ -46,16 +46,14 @@ export function createUpdateGlobalHandler(settingsService: SettingsService) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Minimal debug logging to help diagnose accidental wipes.
|
// Minimal debug logging to help diagnose accidental wipes.
|
||||||
const projectsLen = Array.isArray((updates as any).projects)
|
const projectsLen = Array.isArray(updates.projects) ? updates.projects.length : undefined;
|
||||||
? (updates as any).projects.length
|
const trashedLen = Array.isArray(updates.trashedProjects)
|
||||||
: undefined;
|
? updates.trashedProjects.length
|
||||||
const trashedLen = Array.isArray((updates as any).trashedProjects)
|
|
||||||
? (updates as any).trashedProjects.length
|
|
||||||
: undefined;
|
: undefined;
|
||||||
logger.info(
|
logger.info(
|
||||||
`[SERVER_SETTINGS_UPDATE] Request received: projects=${projectsLen ?? 'n/a'}, trashedProjects=${trashedLen ?? 'n/a'}, theme=${
|
`[SERVER_SETTINGS_UPDATE] Request received: projects=${projectsLen ?? 'n/a'}, trashedProjects=${trashedLen ?? 'n/a'}, theme=${
|
||||||
(updates as any).theme ?? 'n/a'
|
updates.theme ?? 'n/a'
|
||||||
}, localStorageMigrated=${(updates as any).localStorageMigrated ?? 'n/a'}`
|
}, localStorageMigrated=${updates.localStorageMigrated ?? 'n/a'}`
|
||||||
);
|
);
|
||||||
|
|
||||||
// Get old settings to detect theme changes
|
// Get old settings to detect theme changes
|
||||||
|
|||||||
@@ -4,13 +4,9 @@
|
|||||||
|
|
||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
import { getErrorMessage, logError } from '../common.js';
|
import { getErrorMessage, logError } from '../common.js';
|
||||||
import { exec } from 'child_process';
|
|
||||||
import { promisify } from 'util';
|
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
|
||||||
const execAsync = promisify(exec);
|
|
||||||
|
|
||||||
export function createAuthClaudeHandler() {
|
export function createAuthClaudeHandler() {
|
||||||
return async (_req: Request, res: Response): Promise<void> => {
|
return async (_req: Request, res: Response): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -4,13 +4,9 @@
|
|||||||
|
|
||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
import { logError, getErrorMessage } from '../common.js';
|
import { logError, getErrorMessage } from '../common.js';
|
||||||
import { exec } from 'child_process';
|
|
||||||
import { promisify } from 'util';
|
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
|
||||||
const execAsync = promisify(exec);
|
|
||||||
|
|
||||||
export function createAuthOpencodeHandler() {
|
export function createAuthOpencodeHandler() {
|
||||||
return async (_req: Request, res: Response): Promise<void> => {
|
return async (_req: Request, res: Response): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -10,9 +10,6 @@ import type { Request, Response } from 'express';
|
|||||||
import { CopilotProvider } from '../../../providers/copilot-provider.js';
|
import { CopilotProvider } from '../../../providers/copilot-provider.js';
|
||||||
import { getErrorMessage, logError } from '../common.js';
|
import { getErrorMessage, logError } from '../common.js';
|
||||||
import type { ModelDefinition } from '@automaker/types';
|
import type { ModelDefinition } from '@automaker/types';
|
||||||
import { createLogger } from '@automaker/utils';
|
|
||||||
|
|
||||||
const logger = createLogger('CopilotModelsRoute');
|
|
||||||
|
|
||||||
// Singleton provider instance for caching
|
// Singleton provider instance for caching
|
||||||
let providerInstance: CopilotProvider | null = null;
|
let providerInstance: CopilotProvider | null = null;
|
||||||
|
|||||||
@@ -14,9 +14,6 @@ import {
|
|||||||
} from '../../../providers/opencode-provider.js';
|
} from '../../../providers/opencode-provider.js';
|
||||||
import { getErrorMessage, logError } from '../common.js';
|
import { getErrorMessage, logError } from '../common.js';
|
||||||
import type { ModelDefinition } from '@automaker/types';
|
import type { ModelDefinition } from '@automaker/types';
|
||||||
import { createLogger } from '@automaker/utils';
|
|
||||||
|
|
||||||
const logger = createLogger('OpenCodeModelsRoute');
|
|
||||||
|
|
||||||
// Singleton provider instance for caching
|
// Singleton provider instance for caching
|
||||||
let providerInstance: OpencodeProvider | null = null;
|
let providerInstance: OpencodeProvider | null = null;
|
||||||
|
|||||||
@@ -110,6 +110,7 @@ export function createVerifyClaudeAuthHandler() {
|
|||||||
let authenticated = false;
|
let authenticated = false;
|
||||||
let errorMessage = '';
|
let errorMessage = '';
|
||||||
let receivedAnyContent = false;
|
let receivedAnyContent = false;
|
||||||
|
let cleanupEnv: (() => void) | undefined;
|
||||||
|
|
||||||
// Create secure auth session
|
// Create secure auth session
|
||||||
const sessionId = `claude-auth-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
const sessionId = `claude-auth-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||||
@@ -151,13 +152,13 @@ export function createVerifyClaudeAuthHandler() {
|
|||||||
AuthSessionManager.createSession(sessionId, authMethod || 'api_key', apiKey, 'anthropic');
|
AuthSessionManager.createSession(sessionId, authMethod || 'api_key', apiKey, 'anthropic');
|
||||||
|
|
||||||
// Create temporary environment override for SDK call
|
// Create temporary environment override for SDK call
|
||||||
const cleanupEnv = createTempEnvOverride(authEnv);
|
cleanupEnv = createTempEnvOverride(authEnv);
|
||||||
|
|
||||||
// Run a minimal query to verify authentication
|
// Run a minimal query to verify authentication
|
||||||
const stream = query({
|
const stream = query({
|
||||||
prompt: "Reply with only the word 'ok'",
|
prompt: "Reply with only the word 'ok'",
|
||||||
options: {
|
options: {
|
||||||
model: 'claude-sonnet-4-20250514',
|
model: 'claude-sonnet-4-6',
|
||||||
maxTurns: 1,
|
maxTurns: 1,
|
||||||
allowedTools: [],
|
allowedTools: [],
|
||||||
abortController,
|
abortController,
|
||||||
@@ -194,8 +195,10 @@ export function createVerifyClaudeAuthHandler() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check specifically for assistant messages with text content
|
// Check specifically for assistant messages with text content
|
||||||
if (msg.type === 'assistant' && (msg as any).message?.content) {
|
const msgRecord = msg as Record<string, unknown>;
|
||||||
const content = (msg as any).message.content;
|
const msgMessage = msgRecord.message as Record<string, unknown> | undefined;
|
||||||
|
if (msg.type === 'assistant' && msgMessage?.content) {
|
||||||
|
const content = msgMessage.content;
|
||||||
if (Array.isArray(content)) {
|
if (Array.isArray(content)) {
|
||||||
for (const block of content) {
|
for (const block of content) {
|
||||||
if (block.type === 'text' && block.text) {
|
if (block.type === 'text' && block.text) {
|
||||||
@@ -311,6 +314,8 @@ export function createVerifyClaudeAuthHandler() {
|
|||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
clearTimeout(timeoutId);
|
clearTimeout(timeoutId);
|
||||||
|
// Restore process.env to its original state
|
||||||
|
cleanupEnv?.();
|
||||||
// Clean up the auth session
|
// Clean up the auth session
|
||||||
AuthSessionManager.destroySession(sessionId);
|
AuthSessionManager.destroySession(sessionId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@
|
|||||||
import { randomBytes } from 'crypto';
|
import { randomBytes } from 'crypto';
|
||||||
import { createLogger } from '@automaker/utils';
|
import { createLogger } from '@automaker/utils';
|
||||||
import type { Request, Response, NextFunction } from 'express';
|
import type { Request, Response, NextFunction } from 'express';
|
||||||
import { getTerminalService } from '../../services/terminal-service.js';
|
|
||||||
|
|
||||||
const logger = createLogger('Terminal');
|
const logger = createLogger('Terminal');
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import {
|
|||||||
generateToken,
|
generateToken,
|
||||||
addToken,
|
addToken,
|
||||||
getTokenExpiryMs,
|
getTokenExpiryMs,
|
||||||
getErrorMessage,
|
|
||||||
} from '../common.js';
|
} from '../common.js';
|
||||||
|
|
||||||
export function createAuthHandler() {
|
export function createAuthHandler() {
|
||||||
|
|||||||
@@ -2,59 +2,26 @@
|
|||||||
* Common utilities for worktree routes
|
* Common utilities for worktree routes
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { createLogger } from '@automaker/utils';
|
import {
|
||||||
import { spawnProcess } from '@automaker/platform';
|
createLogger,
|
||||||
|
isValidBranchName,
|
||||||
|
isValidRemoteName,
|
||||||
|
MAX_BRANCH_NAME_LENGTH,
|
||||||
|
} from '@automaker/utils';
|
||||||
import { exec } from 'child_process';
|
import { exec } from 'child_process';
|
||||||
import { promisify } from 'util';
|
import { promisify } from 'util';
|
||||||
import { getErrorMessage as getErrorMessageShared, createLogError } from '../common.js';
|
import { getErrorMessage as getErrorMessageShared, createLogError } from '../common.js';
|
||||||
|
|
||||||
|
// Re-export execGitCommand from the canonical shared module so any remaining
|
||||||
|
// consumers that import from this file continue to work.
|
||||||
|
export { execGitCommand } from '../../lib/git.js';
|
||||||
|
|
||||||
const logger = createLogger('Worktree');
|
const logger = createLogger('Worktree');
|
||||||
export const execAsync = promisify(exec);
|
export const execAsync = promisify(exec);
|
||||||
|
|
||||||
// ============================================================================
|
// Re-export git validation utilities from the canonical shared module so
|
||||||
// Secure Command Execution
|
// existing consumers that import from this file continue to work.
|
||||||
// ============================================================================
|
export { isValidBranchName, isValidRemoteName, MAX_BRANCH_NAME_LENGTH };
|
||||||
|
|
||||||
/**
|
|
||||||
* Execute git command with array arguments to prevent command injection.
|
|
||||||
* Uses spawnProcess from @automaker/platform for secure, cross-platform execution.
|
|
||||||
*
|
|
||||||
* @param args - Array of git command arguments (e.g., ['worktree', 'add', path])
|
|
||||||
* @param cwd - Working directory to execute the command in
|
|
||||||
* @returns Promise resolving to stdout output
|
|
||||||
* @throws Error with stderr message if command fails
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```typescript
|
|
||||||
* // Safe: no injection possible
|
|
||||||
* await execGitCommand(['branch', '-D', branchName], projectPath);
|
|
||||||
*
|
|
||||||
* // Instead of unsafe:
|
|
||||||
* // await execAsync(`git branch -D ${branchName}`, { cwd });
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export async function execGitCommand(args: string[], cwd: string): Promise<string> {
|
|
||||||
const result = await spawnProcess({
|
|
||||||
command: 'git',
|
|
||||||
args,
|
|
||||||
cwd,
|
|
||||||
});
|
|
||||||
|
|
||||||
// spawnProcess returns { stdout, stderr, exitCode }
|
|
||||||
if (result.exitCode === 0) {
|
|
||||||
return result.stdout;
|
|
||||||
} else {
|
|
||||||
const errorMessage = result.stderr || `Git command failed with code ${result.exitCode}`;
|
|
||||||
throw new Error(errorMessage);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Constants
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/** Maximum allowed length for git branch names */
|
|
||||||
export const MAX_BRANCH_NAME_LENGTH = 250;
|
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Extended PATH configuration for Electron apps
|
// Extended PATH configuration for Electron apps
|
||||||
@@ -98,19 +65,6 @@ export const execEnv = {
|
|||||||
PATH: extendedPath,
|
PATH: extendedPath,
|
||||||
};
|
};
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Validation utilities
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate branch name to prevent command injection.
|
|
||||||
* Git branch names cannot contain: space, ~, ^, :, ?, *, [, \, or control chars.
|
|
||||||
* We also reject shell metacharacters for safety.
|
|
||||||
*/
|
|
||||||
export function isValidBranchName(name: string): boolean {
|
|
||||||
return /^[a-zA-Z0-9._\-/]+$/.test(name) && name.length < MAX_BRANCH_NAME_LENGTH;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if gh CLI is available on the system
|
* Check if gh CLI is available on the system
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -51,9 +51,25 @@ import {
|
|||||||
createDeleteInitScriptHandler,
|
createDeleteInitScriptHandler,
|
||||||
createRunInitScriptHandler,
|
createRunInitScriptHandler,
|
||||||
} from './routes/init-script.js';
|
} from './routes/init-script.js';
|
||||||
|
import { createCommitLogHandler } from './routes/commit-log.js';
|
||||||
import { createDiscardChangesHandler } from './routes/discard-changes.js';
|
import { createDiscardChangesHandler } from './routes/discard-changes.js';
|
||||||
import { createListRemotesHandler } from './routes/list-remotes.js';
|
import { createListRemotesHandler } from './routes/list-remotes.js';
|
||||||
import { createAddRemoteHandler } from './routes/add-remote.js';
|
import { createAddRemoteHandler } from './routes/add-remote.js';
|
||||||
|
import { createStashPushHandler } from './routes/stash-push.js';
|
||||||
|
import { createStashListHandler } from './routes/stash-list.js';
|
||||||
|
import { createStashApplyHandler } from './routes/stash-apply.js';
|
||||||
|
import { createStashDropHandler } from './routes/stash-drop.js';
|
||||||
|
import { createCherryPickHandler } from './routes/cherry-pick.js';
|
||||||
|
import { createBranchCommitLogHandler } from './routes/branch-commit-log.js';
|
||||||
|
import { createGeneratePRDescriptionHandler } from './routes/generate-pr-description.js';
|
||||||
|
import { createRebaseHandler } from './routes/rebase.js';
|
||||||
|
import { createAbortOperationHandler } from './routes/abort-operation.js';
|
||||||
|
import { createContinueOperationHandler } from './routes/continue-operation.js';
|
||||||
|
import { createStageFilesHandler } from './routes/stage-files.js';
|
||||||
|
import { createCheckChangesHandler } from './routes/check-changes.js';
|
||||||
|
import { createSetTrackingHandler } from './routes/set-tracking.js';
|
||||||
|
import { createSyncHandler } from './routes/sync.js';
|
||||||
|
import { createUpdatePRNumberHandler } from './routes/update-pr-number.js';
|
||||||
import type { SettingsService } from '../../services/settings-service.js';
|
import type { SettingsService } from '../../services/settings-service.js';
|
||||||
|
|
||||||
export function createWorktreeRoutes(
|
export function createWorktreeRoutes(
|
||||||
@@ -71,12 +87,22 @@ export function createWorktreeRoutes(
|
|||||||
'/merge',
|
'/merge',
|
||||||
validatePathParams('projectPath'),
|
validatePathParams('projectPath'),
|
||||||
requireValidProject,
|
requireValidProject,
|
||||||
createMergeHandler()
|
createMergeHandler(events)
|
||||||
|
);
|
||||||
|
router.post(
|
||||||
|
'/create',
|
||||||
|
validatePathParams('projectPath'),
|
||||||
|
createCreateHandler(events, settingsService)
|
||||||
);
|
);
|
||||||
router.post('/create', validatePathParams('projectPath'), createCreateHandler(events));
|
|
||||||
router.post('/delete', validatePathParams('projectPath', 'worktreePath'), createDeleteHandler());
|
router.post('/delete', validatePathParams('projectPath', 'worktreePath'), createDeleteHandler());
|
||||||
router.post('/create-pr', createCreatePRHandler());
|
router.post('/create-pr', createCreatePRHandler());
|
||||||
router.post('/pr-info', createPRInfoHandler());
|
router.post('/pr-info', createPRInfoHandler());
|
||||||
|
router.post(
|
||||||
|
'/update-pr-number',
|
||||||
|
validatePathParams('worktreePath', 'projectPath?'),
|
||||||
|
requireValidWorktree,
|
||||||
|
createUpdatePRNumberHandler()
|
||||||
|
);
|
||||||
router.post(
|
router.post(
|
||||||
'/commit',
|
'/commit',
|
||||||
validatePathParams('worktreePath'),
|
validatePathParams('worktreePath'),
|
||||||
@@ -101,11 +127,29 @@ export function createWorktreeRoutes(
|
|||||||
requireValidWorktree,
|
requireValidWorktree,
|
||||||
createPullHandler()
|
createPullHandler()
|
||||||
);
|
);
|
||||||
|
router.post(
|
||||||
|
'/sync',
|
||||||
|
validatePathParams('worktreePath'),
|
||||||
|
requireValidWorktree,
|
||||||
|
createSyncHandler()
|
||||||
|
);
|
||||||
|
router.post(
|
||||||
|
'/set-tracking',
|
||||||
|
validatePathParams('worktreePath'),
|
||||||
|
requireValidWorktree,
|
||||||
|
createSetTrackingHandler()
|
||||||
|
);
|
||||||
router.post(
|
router.post(
|
||||||
'/checkout-branch',
|
'/checkout-branch',
|
||||||
validatePathParams('worktreePath'),
|
validatePathParams('worktreePath'),
|
||||||
requireValidWorktree,
|
requireValidWorktree,
|
||||||
createCheckoutBranchHandler()
|
createCheckoutBranchHandler(events)
|
||||||
|
);
|
||||||
|
router.post(
|
||||||
|
'/check-changes',
|
||||||
|
validatePathParams('worktreePath'),
|
||||||
|
requireGitRepoOnly,
|
||||||
|
createCheckChangesHandler()
|
||||||
);
|
);
|
||||||
router.post(
|
router.post(
|
||||||
'/list-branches',
|
'/list-branches',
|
||||||
@@ -113,7 +157,12 @@ export function createWorktreeRoutes(
|
|||||||
requireValidWorktree,
|
requireValidWorktree,
|
||||||
createListBranchesHandler()
|
createListBranchesHandler()
|
||||||
);
|
);
|
||||||
router.post('/switch-branch', requireValidWorktree, createSwitchBranchHandler());
|
router.post(
|
||||||
|
'/switch-branch',
|
||||||
|
validatePathParams('worktreePath'),
|
||||||
|
requireValidWorktree,
|
||||||
|
createSwitchBranchHandler(events)
|
||||||
|
);
|
||||||
router.post('/open-in-editor', validatePathParams('worktreePath'), createOpenInEditorHandler());
|
router.post('/open-in-editor', validatePathParams('worktreePath'), createOpenInEditorHandler());
|
||||||
router.post(
|
router.post(
|
||||||
'/open-in-terminal',
|
'/open-in-terminal',
|
||||||
@@ -192,5 +241,95 @@ export function createWorktreeRoutes(
|
|||||||
createAddRemoteHandler()
|
createAddRemoteHandler()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Commit log route
|
||||||
|
router.post(
|
||||||
|
'/commit-log',
|
||||||
|
validatePathParams('worktreePath'),
|
||||||
|
requireValidWorktree,
|
||||||
|
createCommitLogHandler(events)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Stash routes
|
||||||
|
router.post(
|
||||||
|
'/stash-push',
|
||||||
|
validatePathParams('worktreePath'),
|
||||||
|
requireGitRepoOnly,
|
||||||
|
createStashPushHandler(events)
|
||||||
|
);
|
||||||
|
router.post(
|
||||||
|
'/stash-list',
|
||||||
|
validatePathParams('worktreePath'),
|
||||||
|
requireGitRepoOnly,
|
||||||
|
createStashListHandler(events)
|
||||||
|
);
|
||||||
|
router.post(
|
||||||
|
'/stash-apply',
|
||||||
|
validatePathParams('worktreePath'),
|
||||||
|
requireGitRepoOnly,
|
||||||
|
createStashApplyHandler(events)
|
||||||
|
);
|
||||||
|
router.post(
|
||||||
|
'/stash-drop',
|
||||||
|
validatePathParams('worktreePath'),
|
||||||
|
requireGitRepoOnly,
|
||||||
|
createStashDropHandler(events)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Cherry-pick route
|
||||||
|
router.post(
|
||||||
|
'/cherry-pick',
|
||||||
|
validatePathParams('worktreePath'),
|
||||||
|
requireValidWorktree,
|
||||||
|
createCherryPickHandler(events)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Generate PR description route
|
||||||
|
router.post(
|
||||||
|
'/generate-pr-description',
|
||||||
|
validatePathParams('worktreePath'),
|
||||||
|
requireGitRepoOnly,
|
||||||
|
createGeneratePRDescriptionHandler(settingsService)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Branch commit log route (get commits from a specific branch)
|
||||||
|
router.post(
|
||||||
|
'/branch-commit-log',
|
||||||
|
validatePathParams('worktreePath'),
|
||||||
|
requireValidWorktree,
|
||||||
|
createBranchCommitLogHandler(events)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Rebase route
|
||||||
|
router.post(
|
||||||
|
'/rebase',
|
||||||
|
validatePathParams('worktreePath'),
|
||||||
|
requireValidWorktree,
|
||||||
|
createRebaseHandler(events)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Abort in-progress merge/rebase/cherry-pick
|
||||||
|
router.post(
|
||||||
|
'/abort-operation',
|
||||||
|
validatePathParams('worktreePath'),
|
||||||
|
requireGitRepoOnly,
|
||||||
|
createAbortOperationHandler(events)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Continue in-progress merge/rebase/cherry-pick after resolving conflicts
|
||||||
|
router.post(
|
||||||
|
'/continue-operation',
|
||||||
|
validatePathParams('worktreePath'),
|
||||||
|
requireGitRepoOnly,
|
||||||
|
createContinueOperationHandler(events)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Stage/unstage files route
|
||||||
|
router.post(
|
||||||
|
'/stage-files',
|
||||||
|
validatePathParams('worktreePath', 'files[]'),
|
||||||
|
requireGitRepoOnly,
|
||||||
|
createStageFilesHandler()
|
||||||
|
);
|
||||||
|
|
||||||
return router;
|
return router;
|
||||||
}
|
}
|
||||||
|
|||||||
117
apps/server/src/routes/worktree/routes/abort-operation.ts
Normal file
117
apps/server/src/routes/worktree/routes/abort-operation.ts
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
/**
|
||||||
|
* POST /abort-operation endpoint - Abort an in-progress merge, rebase, or cherry-pick
|
||||||
|
*
|
||||||
|
* Detects which operation (merge, rebase, or cherry-pick) is in progress
|
||||||
|
* and aborts it, returning the repository to a clean state.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Request, Response } from 'express';
|
||||||
|
import path from 'path';
|
||||||
|
import * as fs from 'fs/promises';
|
||||||
|
import { getErrorMessage, logError, execAsync } from '../common.js';
|
||||||
|
import type { EventEmitter } from '../../../lib/events.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect what type of conflict operation is currently in progress
|
||||||
|
*/
|
||||||
|
async function detectOperation(
|
||||||
|
worktreePath: string
|
||||||
|
): Promise<'merge' | 'rebase' | 'cherry-pick' | null> {
|
||||||
|
try {
|
||||||
|
const { stdout: gitDirRaw } = await execAsync('git rev-parse --git-dir', {
|
||||||
|
cwd: worktreePath,
|
||||||
|
});
|
||||||
|
const gitDir = path.resolve(worktreePath, gitDirRaw.trim());
|
||||||
|
|
||||||
|
const [rebaseMergeExists, rebaseApplyExists, mergeHeadExists, cherryPickHeadExists] =
|
||||||
|
await Promise.all([
|
||||||
|
fs
|
||||||
|
.access(path.join(gitDir, 'rebase-merge'))
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false),
|
||||||
|
fs
|
||||||
|
.access(path.join(gitDir, 'rebase-apply'))
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false),
|
||||||
|
fs
|
||||||
|
.access(path.join(gitDir, 'MERGE_HEAD'))
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false),
|
||||||
|
fs
|
||||||
|
.access(path.join(gitDir, 'CHERRY_PICK_HEAD'))
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (rebaseMergeExists || rebaseApplyExists) return 'rebase';
|
||||||
|
if (mergeHeadExists) return 'merge';
|
||||||
|
if (cherryPickHeadExists) return 'cherry-pick';
|
||||||
|
return null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createAbortOperationHandler(events: EventEmitter) {
|
||||||
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const { worktreePath } = req.body as {
|
||||||
|
worktreePath: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!worktreePath) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'worktreePath is required',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvedWorktreePath = path.resolve(worktreePath);
|
||||||
|
|
||||||
|
// Detect what operation is in progress
|
||||||
|
const operation = await detectOperation(resolvedWorktreePath);
|
||||||
|
|
||||||
|
if (!operation) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'No merge, rebase, or cherry-pick in progress',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Abort the operation
|
||||||
|
let abortCommand: string;
|
||||||
|
switch (operation) {
|
||||||
|
case 'merge':
|
||||||
|
abortCommand = 'git merge --abort';
|
||||||
|
break;
|
||||||
|
case 'rebase':
|
||||||
|
abortCommand = 'git rebase --abort';
|
||||||
|
break;
|
||||||
|
case 'cherry-pick':
|
||||||
|
abortCommand = 'git cherry-pick --abort';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
await execAsync(abortCommand, { cwd: resolvedWorktreePath });
|
||||||
|
|
||||||
|
// Emit event
|
||||||
|
events.emit('conflict:aborted', {
|
||||||
|
worktreePath: resolvedWorktreePath,
|
||||||
|
operation,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
result: {
|
||||||
|
operation,
|
||||||
|
message: `${operation.charAt(0).toUpperCase() + operation.slice(1)} aborted successfully`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logError(error, 'Abort operation failed');
|
||||||
|
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
92
apps/server/src/routes/worktree/routes/branch-commit-log.ts
Normal file
92
apps/server/src/routes/worktree/routes/branch-commit-log.ts
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
/**
|
||||||
|
* POST /branch-commit-log endpoint - Get recent commit history for a specific branch
|
||||||
|
*
|
||||||
|
* Similar to commit-log but allows specifying a branch name to get commits from
|
||||||
|
* any branch, not just the currently checked out one. Useful for cherry-pick workflows
|
||||||
|
* where you need to browse commits from other branches.
|
||||||
|
*
|
||||||
|
* The handler only validates input, invokes the service, streams lifecycle events
|
||||||
|
* via the EventEmitter, and sends the final JSON response.
|
||||||
|
*
|
||||||
|
* Note: Git repository validation (isGitRepo, hasCommits) is handled by
|
||||||
|
* the requireValidWorktree middleware in index.ts
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Request, Response } from 'express';
|
||||||
|
import type { EventEmitter } from '../../../lib/events.js';
|
||||||
|
import { getErrorMessage, logError } from '../common.js';
|
||||||
|
import { getBranchCommitLog } from '../../../services/branch-commit-log-service.js';
|
||||||
|
import { isValidBranchName } from '@automaker/utils';
|
||||||
|
|
||||||
|
export function createBranchCommitLogHandler(events: EventEmitter) {
|
||||||
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
worktreePath,
|
||||||
|
branchName,
|
||||||
|
limit = 20,
|
||||||
|
} = req.body as {
|
||||||
|
worktreePath: string;
|
||||||
|
branchName?: string;
|
||||||
|
limit?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!worktreePath) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'worktreePath required',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate branchName before forwarding to execGitCommand.
|
||||||
|
// Reject values that start with '-', contain NUL, contain path-traversal
|
||||||
|
// sequences, or include characters outside the safe whitelist.
|
||||||
|
// An absent branchName is allowed (the service defaults it to HEAD).
|
||||||
|
if (branchName !== undefined && !isValidBranchName(branchName)) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Invalid branchName: value contains unsafe characters or sequences',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emit start event so the frontend can observe progress
|
||||||
|
events.emit('branchCommitLog:start', {
|
||||||
|
worktreePath,
|
||||||
|
branchName: branchName || 'HEAD',
|
||||||
|
limit,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delegate all Git work to the service
|
||||||
|
const result = await getBranchCommitLog(worktreePath, branchName, limit);
|
||||||
|
|
||||||
|
// Emit progress with the number of commits fetched
|
||||||
|
events.emit('branchCommitLog:progress', {
|
||||||
|
worktreePath,
|
||||||
|
branchName: result.branch,
|
||||||
|
commitsLoaded: result.total,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Emit done event
|
||||||
|
events.emit('branchCommitLog:done', {
|
||||||
|
worktreePath,
|
||||||
|
branchName: result.branch,
|
||||||
|
total: result.total,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
result,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
// Emit error event so the frontend can react
|
||||||
|
events.emit('branchCommitLog:error', {
|
||||||
|
error: getErrorMessage(error),
|
||||||
|
});
|
||||||
|
|
||||||
|
logError(error, 'Get branch commit log failed');
|
||||||
|
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -31,8 +31,8 @@ export async function getTrackedBranches(projectPath: string): Promise<TrackedBr
|
|||||||
const content = (await secureFs.readFile(filePath, 'utf-8')) as string;
|
const content = (await secureFs.readFile(filePath, 'utf-8')) as string;
|
||||||
const data: BranchTrackingData = JSON.parse(content);
|
const data: BranchTrackingData = JSON.parse(content);
|
||||||
return data.branches || [];
|
return data.branches || [];
|
||||||
} catch (error: any) {
|
} catch (error: unknown) {
|
||||||
if (error.code === 'ENOENT') {
|
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
logger.warn('Failed to read tracked branches:', error);
|
logger.warn('Failed to read tracked branches:', error);
|
||||||
|
|||||||
104
apps/server/src/routes/worktree/routes/check-changes.ts
Normal file
104
apps/server/src/routes/worktree/routes/check-changes.ts
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
/**
|
||||||
|
* POST /check-changes endpoint - Check for uncommitted changes in a worktree
|
||||||
|
*
|
||||||
|
* Returns a summary of staged, unstaged, and untracked files to help
|
||||||
|
* the user decide whether to stash before a branch operation.
|
||||||
|
*
|
||||||
|
* Note: Git repository validation (isGitRepo) is handled by
|
||||||
|
* the requireGitRepoOnly middleware in index.ts
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Request, Response } from 'express';
|
||||||
|
import { getErrorMessage, logError } from '../common.js';
|
||||||
|
import { execGitCommand } from '../../../lib/git.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse `git status --porcelain` output into categorised file lists.
|
||||||
|
*
|
||||||
|
* Porcelain format gives two status characters per line:
|
||||||
|
* XY filename
|
||||||
|
* where X is the index (staged) status and Y is the worktree (unstaged) status.
|
||||||
|
*
|
||||||
|
* - '?' in both columns → untracked
|
||||||
|
* - Non-space/non-'?' in X → staged change
|
||||||
|
* - Non-space/non-'?' in Y (when not untracked) → unstaged change
|
||||||
|
*
|
||||||
|
* A file can appear in both staged and unstaged if it was partially staged.
|
||||||
|
*/
|
||||||
|
function parseStatusOutput(stdout: string): {
|
||||||
|
staged: string[];
|
||||||
|
unstaged: string[];
|
||||||
|
untracked: string[];
|
||||||
|
} {
|
||||||
|
const staged: string[] = [];
|
||||||
|
const unstaged: string[] = [];
|
||||||
|
const untracked: string[] = [];
|
||||||
|
|
||||||
|
const lines = stdout.trim().split('\n').filter(Boolean);
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.length < 3) continue;
|
||||||
|
|
||||||
|
const x = line[0]; // index status
|
||||||
|
const y = line[1]; // worktree status
|
||||||
|
// Handle renames which use " -> " separator
|
||||||
|
const rawPath = line.slice(3);
|
||||||
|
const filePath = rawPath.includes(' -> ') ? rawPath.split(' -> ')[1] : rawPath;
|
||||||
|
|
||||||
|
if (x === '?' && y === '?') {
|
||||||
|
untracked.push(filePath);
|
||||||
|
} else {
|
||||||
|
if (x !== ' ' && x !== '?') {
|
||||||
|
staged.push(filePath);
|
||||||
|
}
|
||||||
|
if (y !== ' ' && y !== '?') {
|
||||||
|
unstaged.push(filePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { staged, unstaged, untracked };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createCheckChangesHandler() {
|
||||||
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const { worktreePath } = req.body as {
|
||||||
|
worktreePath: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!worktreePath) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'worktreePath required',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get porcelain status (includes staged, unstaged, and untracked files)
|
||||||
|
const stdout = await execGitCommand(['status', '--porcelain'], worktreePath);
|
||||||
|
|
||||||
|
const { staged, unstaged, untracked } = parseStatusOutput(stdout);
|
||||||
|
|
||||||
|
const hasChanges = staged.length > 0 || unstaged.length > 0 || untracked.length > 0;
|
||||||
|
|
||||||
|
// Deduplicate file paths across staged, unstaged, and untracked arrays
|
||||||
|
// to avoid double-counting partially staged files
|
||||||
|
const uniqueFilePaths = new Set([...staged, ...unstaged, ...untracked]);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
result: {
|
||||||
|
hasChanges,
|
||||||
|
staged,
|
||||||
|
unstaged,
|
||||||
|
untracked,
|
||||||
|
totalFiles: uniqueFilePaths.size,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logError(error, 'Check changes failed');
|
||||||
|
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,6 +1,14 @@
|
|||||||
/**
|
/**
|
||||||
* POST /checkout-branch endpoint - Create and checkout a new branch
|
* POST /checkout-branch endpoint - Create and checkout a new branch
|
||||||
*
|
*
|
||||||
|
* Supports automatic stash handling: when `stashChanges` is true, local changes
|
||||||
|
* are stashed before creating the branch and reapplied after. If the stash pop
|
||||||
|
* results in merge conflicts, returns a special response so the UI can create a
|
||||||
|
* conflict resolution task.
|
||||||
|
*
|
||||||
|
* Git business logic is delegated to checkout-branch-service.ts when stash
|
||||||
|
* handling is requested. Otherwise, falls back to the original simple flow.
|
||||||
|
*
|
||||||
* Note: Git repository validation (isGitRepo, hasCommits) is handled by
|
* Note: Git repository validation (isGitRepo, hasCommits) is handled by
|
||||||
* the requireValidWorktree middleware in index.ts.
|
* the requireValidWorktree middleware in index.ts.
|
||||||
* Path validation (ALLOWED_ROOT_DIRECTORY) is handled by validatePathParams
|
* Path validation (ALLOWED_ROOT_DIRECTORY) is handled by validatePathParams
|
||||||
@@ -10,14 +18,52 @@
|
|||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { stat } from 'fs/promises';
|
import { stat } from 'fs/promises';
|
||||||
import { getErrorMessage, logError, isValidBranchName, execGitCommand } from '../common.js';
|
import { getErrorMessage, logError, isValidBranchName } from '../common.js';
|
||||||
|
import { execGitCommand } from '../../../lib/git.js';
|
||||||
|
import type { EventEmitter } from '../../../lib/events.js';
|
||||||
|
import { performCheckoutBranch } from '../../../services/checkout-branch-service.js';
|
||||||
|
import { createLogger } from '@automaker/utils';
|
||||||
|
|
||||||
export function createCheckoutBranchHandler() {
|
const logger = createLogger('CheckoutBranchRoute');
|
||||||
|
|
||||||
|
/** Timeout for git fetch operations (30 seconds) */
|
||||||
|
const FETCH_TIMEOUT_MS = 30_000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch latest from all remotes (silently, with timeout).
|
||||||
|
* Non-fatal: fetch errors are logged and swallowed so the workflow continues.
|
||||||
|
*/
|
||||||
|
async function fetchRemotes(cwd: string): Promise<void> {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timerId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await execGitCommand(['fetch', '--all', '--quiet'], cwd, undefined, controller);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && error.message === 'Process aborted') {
|
||||||
|
logger.warn(
|
||||||
|
`fetchRemotes timed out after ${FETCH_TIMEOUT_MS}ms - continuing without latest remote refs`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
logger.warn(`fetchRemotes failed: ${getErrorMessage(error)} - continuing with local refs`);
|
||||||
|
}
|
||||||
|
// Non-fatal: continue with locally available refs
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timerId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createCheckoutBranchHandler(events?: EventEmitter) {
|
||||||
return async (req: Request, res: Response): Promise<void> => {
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const { worktreePath, branchName } = req.body as {
|
const { worktreePath, branchName, baseBranch, stashChanges, includeUntracked } = req.body as {
|
||||||
worktreePath: string;
|
worktreePath: string;
|
||||||
branchName: string;
|
branchName: string;
|
||||||
|
baseBranch?: string;
|
||||||
|
/** When true, stash local changes before checkout and reapply after */
|
||||||
|
stashChanges?: boolean;
|
||||||
|
/** When true, include untracked files in the stash (defaults to true) */
|
||||||
|
includeUntracked?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!worktreePath) {
|
if (!worktreePath) {
|
||||||
@@ -46,9 +92,17 @@ export function createCheckoutBranchHandler() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate base branch if provided
|
||||||
|
if (baseBranch && !isValidBranchName(baseBranch) && baseBranch !== 'HEAD') {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error:
|
||||||
|
'Invalid base branch name. Must contain only letters, numbers, dots, dashes, underscores, or slashes.',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Resolve and validate worktreePath to prevent traversal attacks.
|
// Resolve and validate worktreePath to prevent traversal attacks.
|
||||||
// The validatePathParams middleware checks against ALLOWED_ROOT_DIRECTORY,
|
|
||||||
// but we also resolve the path and verify it exists as a directory.
|
|
||||||
const resolvedPath = path.resolve(worktreePath);
|
const resolvedPath = path.resolve(worktreePath);
|
||||||
try {
|
try {
|
||||||
const stats = await stat(resolvedPath);
|
const stats = await stat(resolvedPath);
|
||||||
@@ -67,7 +121,46 @@ export function createCheckoutBranchHandler() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get current branch for reference (using argument array to avoid shell injection)
|
// Use the service for stash-aware checkout
|
||||||
|
if (stashChanges) {
|
||||||
|
const result = await performCheckoutBranch(
|
||||||
|
resolvedPath,
|
||||||
|
branchName,
|
||||||
|
baseBranch,
|
||||||
|
{
|
||||||
|
stashChanges: true,
|
||||||
|
includeUntracked: includeUntracked ?? true,
|
||||||
|
},
|
||||||
|
events
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
const statusCode = isBranchError(result.error) ? 400 : 500;
|
||||||
|
res.status(statusCode).json({
|
||||||
|
success: false,
|
||||||
|
error: result.error,
|
||||||
|
...(result.stashPopConflicts !== undefined && {
|
||||||
|
stashPopConflicts: result.stashPopConflicts,
|
||||||
|
}),
|
||||||
|
...(result.stashPopConflictMessage && {
|
||||||
|
stashPopConflictMessage: result.stashPopConflictMessage,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
result: result.result,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Original simple flow (no stash handling)
|
||||||
|
// Fetch latest remote refs before creating the branch so that
|
||||||
|
// base branch validation works for remote references like "origin/main"
|
||||||
|
await fetchRemotes(resolvedPath);
|
||||||
|
|
||||||
const currentBranchOutput = await execGitCommand(
|
const currentBranchOutput = await execGitCommand(
|
||||||
['rev-parse', '--abbrev-ref', 'HEAD'],
|
['rev-parse', '--abbrev-ref', 'HEAD'],
|
||||||
resolvedPath
|
resolvedPath
|
||||||
@@ -77,7 +170,6 @@ export function createCheckoutBranchHandler() {
|
|||||||
// Check if branch already exists
|
// Check if branch already exists
|
||||||
try {
|
try {
|
||||||
await execGitCommand(['rev-parse', '--verify', branchName], resolvedPath);
|
await execGitCommand(['rev-parse', '--verify', branchName], resolvedPath);
|
||||||
// Branch exists
|
|
||||||
res.status(400).json({
|
res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
error: `Branch '${branchName}' already exists`,
|
error: `Branch '${branchName}' already exists`,
|
||||||
@@ -87,8 +179,25 @@ export function createCheckoutBranchHandler() {
|
|||||||
// Branch doesn't exist, good to create
|
// Branch doesn't exist, good to create
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create and checkout the new branch (using argument array to avoid shell injection)
|
// If baseBranch is provided, verify it exists before using it
|
||||||
await execGitCommand(['checkout', '-b', branchName], resolvedPath);
|
if (baseBranch) {
|
||||||
|
try {
|
||||||
|
await execGitCommand(['rev-parse', '--verify', baseBranch], resolvedPath);
|
||||||
|
} catch {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: `Base branch '${baseBranch}' does not exist`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create and checkout the new branch
|
||||||
|
const checkoutArgs = ['checkout', '-b', branchName];
|
||||||
|
if (baseBranch) {
|
||||||
|
checkoutArgs.push(baseBranch);
|
||||||
|
}
|
||||||
|
await execGitCommand(checkoutArgs, resolvedPath);
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
@@ -99,8 +208,22 @@ export function createCheckoutBranchHandler() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
events?.emit('switch:error', {
|
||||||
|
error: getErrorMessage(error),
|
||||||
|
});
|
||||||
|
|
||||||
logError(error, 'Checkout branch failed');
|
logError(error, 'Checkout branch failed');
|
||||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine whether an error message represents a client error (400).
|
||||||
|
* Stash failures are server-side errors and are intentionally excluded here
|
||||||
|
* so they are returned as HTTP 500 rather than HTTP 400.
|
||||||
|
*/
|
||||||
|
function isBranchError(error?: string): boolean {
|
||||||
|
if (!error) return false;
|
||||||
|
return error.includes('already exists') || error.includes('does not exist');
|
||||||
|
}
|
||||||
|
|||||||
107
apps/server/src/routes/worktree/routes/cherry-pick.ts
Normal file
107
apps/server/src/routes/worktree/routes/cherry-pick.ts
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
/**
|
||||||
|
* POST /cherry-pick endpoint - Cherry-pick one or more commits into the current branch
|
||||||
|
*
|
||||||
|
* Applies commits from another branch onto the current branch.
|
||||||
|
* Supports single or multiple commit cherry-picks.
|
||||||
|
*
|
||||||
|
* Git business logic is delegated to cherry-pick-service.ts.
|
||||||
|
* Events are emitted at key lifecycle points for WebSocket subscribers.
|
||||||
|
* The global event emitter is passed into the service so all lifecycle
|
||||||
|
* events (started, success, conflict, abort, verify-failed) are broadcast
|
||||||
|
* to WebSocket clients.
|
||||||
|
*
|
||||||
|
* Note: Git repository validation (isGitRepo, hasCommits) is handled by
|
||||||
|
* the requireValidWorktree middleware in index.ts
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Request, Response } from 'express';
|
||||||
|
import path from 'path';
|
||||||
|
import { getErrorMessage, logError } from '../common.js';
|
||||||
|
import type { EventEmitter } from '../../../lib/events.js';
|
||||||
|
import { verifyCommits, runCherryPick } from '../../../services/cherry-pick-service.js';
|
||||||
|
|
||||||
|
export function createCherryPickHandler(events: EventEmitter) {
|
||||||
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const { worktreePath, commitHashes, options } = req.body as {
|
||||||
|
worktreePath: string;
|
||||||
|
commitHashes: string[];
|
||||||
|
options?: {
|
||||||
|
noCommit?: boolean;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!worktreePath) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'worktreePath is required',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize the path to prevent path traversal and ensure consistent paths
|
||||||
|
const resolvedWorktreePath = path.resolve(worktreePath);
|
||||||
|
|
||||||
|
if (!commitHashes || !Array.isArray(commitHashes) || commitHashes.length === 0) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'commitHashes array is required and must contain at least one commit hash',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate each commit hash format (should be hex string)
|
||||||
|
for (const hash of commitHashes) {
|
||||||
|
if (!/^[a-fA-F0-9]+$/.test(hash)) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: `Invalid commit hash format: "${hash}"`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify each commit exists via the service; emits cherry-pick:verify-failed if any hash is missing
|
||||||
|
const invalidHash = await verifyCommits(resolvedWorktreePath, commitHashes, events);
|
||||||
|
if (invalidHash !== null) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: `Commit "${invalidHash}" does not exist`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute the cherry-pick via the service.
|
||||||
|
// The service emits: cherry-pick:started, cherry-pick:success, cherry-pick:conflict,
|
||||||
|
// and cherry-pick:abort at the appropriate lifecycle points.
|
||||||
|
const result = await runCherryPick(resolvedWorktreePath, commitHashes, options, events);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
result: {
|
||||||
|
cherryPicked: result.cherryPicked,
|
||||||
|
commitHashes: result.commitHashes,
|
||||||
|
branch: result.branch,
|
||||||
|
message: result.message,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else if (result.hasConflicts) {
|
||||||
|
res.status(409).json({
|
||||||
|
success: false,
|
||||||
|
error: result.error,
|
||||||
|
hasConflicts: true,
|
||||||
|
aborted: result.aborted,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Emit failure event for unexpected (non-conflict) errors
|
||||||
|
events.emit('cherry-pick:failure', {
|
||||||
|
error: getErrorMessage(error),
|
||||||
|
});
|
||||||
|
|
||||||
|
logError(error, 'Cherry-pick failed');
|
||||||
|
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
72
apps/server/src/routes/worktree/routes/commit-log.ts
Normal file
72
apps/server/src/routes/worktree/routes/commit-log.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
/**
|
||||||
|
* POST /commit-log endpoint - Get recent commit history for a worktree
|
||||||
|
*
|
||||||
|
* The handler only validates input, invokes the service, streams lifecycle
|
||||||
|
* events via the EventEmitter, and sends the final JSON response.
|
||||||
|
*
|
||||||
|
* Git business logic is delegated to commit-log-service.ts.
|
||||||
|
* Events are emitted at key lifecycle points for WebSocket subscribers.
|
||||||
|
*
|
||||||
|
* Note: Git repository validation (isGitRepo, hasCommits) is handled by
|
||||||
|
* the requireValidWorktree middleware in index.ts
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Request, Response } from 'express';
|
||||||
|
import type { EventEmitter } from '../../../lib/events.js';
|
||||||
|
import { getErrorMessage, logError } from '../common.js';
|
||||||
|
import { getCommitLog } from '../../../services/commit-log-service.js';
|
||||||
|
|
||||||
|
export function createCommitLogHandler(events: EventEmitter) {
|
||||||
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const { worktreePath, limit = 20 } = req.body as {
|
||||||
|
worktreePath: string;
|
||||||
|
limit?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!worktreePath) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'worktreePath required',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emit start event so the frontend can observe progress
|
||||||
|
events.emit('commitLog:start', {
|
||||||
|
worktreePath,
|
||||||
|
limit,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delegate all Git work to the service
|
||||||
|
const result = await getCommitLog(worktreePath, limit);
|
||||||
|
|
||||||
|
// Emit progress with the number of commits fetched
|
||||||
|
events.emit('commitLog:progress', {
|
||||||
|
worktreePath,
|
||||||
|
branch: result.branch,
|
||||||
|
commitsLoaded: result.total,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Emit complete event
|
||||||
|
events.emit('commitLog:complete', {
|
||||||
|
worktreePath,
|
||||||
|
branch: result.branch,
|
||||||
|
total: result.total,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
result,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
// Emit error event so the frontend can react
|
||||||
|
events.emit('commitLog:error', {
|
||||||
|
error: getErrorMessage(error),
|
||||||
|
});
|
||||||
|
|
||||||
|
logError(error, 'Get commit log failed');
|
||||||
|
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -6,18 +6,20 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
import { exec } from 'child_process';
|
import { exec, execFile } from 'child_process';
|
||||||
import { promisify } from 'util';
|
import { promisify } from 'util';
|
||||||
import { getErrorMessage, logError } from '../common.js';
|
import { getErrorMessage, logError } from '../common.js';
|
||||||
|
|
||||||
const execAsync = promisify(exec);
|
const execAsync = promisify(exec);
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
export function createCommitHandler() {
|
export function createCommitHandler() {
|
||||||
return async (req: Request, res: Response): Promise<void> => {
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const { worktreePath, message } = req.body as {
|
const { worktreePath, message, files } = req.body as {
|
||||||
worktreePath: string;
|
worktreePath: string;
|
||||||
message: string;
|
message: string;
|
||||||
|
files?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!worktreePath || !message) {
|
if (!worktreePath || !message) {
|
||||||
@@ -44,11 +46,21 @@ export function createCommitHandler() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stage all changes
|
// Stage changes - either specific files or all changes
|
||||||
await execAsync('git add -A', { cwd: worktreePath });
|
if (files && files.length > 0) {
|
||||||
|
// Reset any previously staged changes first
|
||||||
|
await execFileAsync('git', ['reset', 'HEAD'], { cwd: worktreePath }).catch(() => {
|
||||||
|
// Ignore errors from reset (e.g., if nothing is staged)
|
||||||
|
});
|
||||||
|
// Stage only the selected files (args array avoids shell injection)
|
||||||
|
await execFileAsync('git', ['add', ...files], { cwd: worktreePath });
|
||||||
|
} else {
|
||||||
|
// Stage all changes (original behavior)
|
||||||
|
await execFileAsync('git', ['add', '-A'], { cwd: worktreePath });
|
||||||
|
}
|
||||||
|
|
||||||
// Create commit
|
// Create commit (pass message as arg to avoid shell injection)
|
||||||
await execAsync(`git commit -m "${message.replace(/"/g, '\\"')}"`, {
|
await execFileAsync('git', ['commit', '-m', message], {
|
||||||
cwd: worktreePath,
|
cwd: worktreePath,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
151
apps/server/src/routes/worktree/routes/continue-operation.ts
Normal file
151
apps/server/src/routes/worktree/routes/continue-operation.ts
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
/**
|
||||||
|
* POST /continue-operation endpoint - Continue an in-progress merge, rebase, or cherry-pick
|
||||||
|
*
|
||||||
|
* After conflicts have been resolved, this endpoint continues the operation.
|
||||||
|
* For merge: performs git commit (merge is auto-committed after conflict resolution)
|
||||||
|
* For rebase: runs git rebase --continue
|
||||||
|
* For cherry-pick: runs git cherry-pick --continue
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Request, Response } from 'express';
|
||||||
|
import path from 'path';
|
||||||
|
import * as fs from 'fs/promises';
|
||||||
|
import { getErrorMessage, logError, execAsync } from '../common.js';
|
||||||
|
import type { EventEmitter } from '../../../lib/events.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect what type of conflict operation is currently in progress
|
||||||
|
*/
|
||||||
|
async function detectOperation(
|
||||||
|
worktreePath: string
|
||||||
|
): Promise<'merge' | 'rebase' | 'cherry-pick' | null> {
|
||||||
|
try {
|
||||||
|
const { stdout: gitDirRaw } = await execAsync('git rev-parse --git-dir', {
|
||||||
|
cwd: worktreePath,
|
||||||
|
});
|
||||||
|
const gitDir = path.resolve(worktreePath, gitDirRaw.trim());
|
||||||
|
|
||||||
|
const [rebaseMergeExists, rebaseApplyExists, mergeHeadExists, cherryPickHeadExists] =
|
||||||
|
await Promise.all([
|
||||||
|
fs
|
||||||
|
.access(path.join(gitDir, 'rebase-merge'))
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false),
|
||||||
|
fs
|
||||||
|
.access(path.join(gitDir, 'rebase-apply'))
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false),
|
||||||
|
fs
|
||||||
|
.access(path.join(gitDir, 'MERGE_HEAD'))
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false),
|
||||||
|
fs
|
||||||
|
.access(path.join(gitDir, 'CHERRY_PICK_HEAD'))
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (rebaseMergeExists || rebaseApplyExists) return 'rebase';
|
||||||
|
if (mergeHeadExists) return 'merge';
|
||||||
|
if (cherryPickHeadExists) return 'cherry-pick';
|
||||||
|
return null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if there are still unmerged paths (unresolved conflicts)
|
||||||
|
*/
|
||||||
|
async function hasUnmergedPaths(worktreePath: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const { stdout: statusOutput } = await execAsync('git status --porcelain', {
|
||||||
|
cwd: worktreePath,
|
||||||
|
});
|
||||||
|
return statusOutput.split('\n').some((line) => /^(UU|AA|DD|AU|UA|DU|UD)/.test(line));
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createContinueOperationHandler(events: EventEmitter) {
|
||||||
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const { worktreePath } = req.body as {
|
||||||
|
worktreePath: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!worktreePath) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'worktreePath is required',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvedWorktreePath = path.resolve(worktreePath);
|
||||||
|
|
||||||
|
// Detect what operation is in progress
|
||||||
|
const operation = await detectOperation(resolvedWorktreePath);
|
||||||
|
|
||||||
|
if (!operation) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'No merge, rebase, or cherry-pick in progress',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for unresolved conflicts
|
||||||
|
if (await hasUnmergedPaths(resolvedWorktreePath)) {
|
||||||
|
res.status(409).json({
|
||||||
|
success: false,
|
||||||
|
error:
|
||||||
|
'There are still unresolved conflicts. Please resolve all conflicts before continuing.',
|
||||||
|
hasUnresolvedConflicts: true,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stage all resolved files first
|
||||||
|
await execAsync('git add -A', { cwd: resolvedWorktreePath });
|
||||||
|
|
||||||
|
// Continue the operation
|
||||||
|
let continueCommand: string;
|
||||||
|
switch (operation) {
|
||||||
|
case 'merge':
|
||||||
|
// For merge, we need to commit after resolving conflicts
|
||||||
|
continueCommand = 'git commit --no-edit';
|
||||||
|
break;
|
||||||
|
case 'rebase':
|
||||||
|
continueCommand = 'git rebase --continue';
|
||||||
|
break;
|
||||||
|
case 'cherry-pick':
|
||||||
|
continueCommand = 'git cherry-pick --continue';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
await execAsync(continueCommand, {
|
||||||
|
cwd: resolvedWorktreePath,
|
||||||
|
env: { ...process.env, GIT_EDITOR: 'true' }, // Prevent editor from opening
|
||||||
|
});
|
||||||
|
|
||||||
|
// Emit event
|
||||||
|
events.emit('conflict:resolved', {
|
||||||
|
worktreePath: resolvedWorktreePath,
|
||||||
|
operation,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
result: {
|
||||||
|
operation,
|
||||||
|
message: `${operation.charAt(0).toUpperCase() + operation.slice(1)} continued successfully`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logError(error, 'Continue operation failed');
|
||||||
|
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -9,27 +9,43 @@ import {
|
|||||||
execAsync,
|
execAsync,
|
||||||
execEnv,
|
execEnv,
|
||||||
isValidBranchName,
|
isValidBranchName,
|
||||||
|
isValidRemoteName,
|
||||||
isGhCliAvailable,
|
isGhCliAvailable,
|
||||||
} from '../common.js';
|
} from '../common.js';
|
||||||
|
import { execGitCommand } from '../../../lib/git.js';
|
||||||
|
import { spawnProcess } from '@automaker/platform';
|
||||||
import { updateWorktreePRInfo } from '../../../lib/worktree-metadata.js';
|
import { updateWorktreePRInfo } from '../../../lib/worktree-metadata.js';
|
||||||
import { createLogger } from '@automaker/utils';
|
import { createLogger } from '@automaker/utils';
|
||||||
import { validatePRState } from '@automaker/types';
|
import { validatePRState } from '@automaker/types';
|
||||||
|
import { resolvePrTarget } from '../../../services/pr-service.js';
|
||||||
|
|
||||||
const logger = createLogger('CreatePR');
|
const logger = createLogger('CreatePR');
|
||||||
|
|
||||||
export function createCreatePRHandler() {
|
export function createCreatePRHandler() {
|
||||||
return async (req: Request, res: Response): Promise<void> => {
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const { worktreePath, projectPath, commitMessage, prTitle, prBody, baseBranch, draft } =
|
const {
|
||||||
req.body as {
|
worktreePath,
|
||||||
worktreePath: string;
|
projectPath,
|
||||||
projectPath?: string;
|
commitMessage,
|
||||||
commitMessage?: string;
|
prTitle,
|
||||||
prTitle?: string;
|
prBody,
|
||||||
prBody?: string;
|
baseBranch,
|
||||||
baseBranch?: string;
|
draft,
|
||||||
draft?: boolean;
|
remote,
|
||||||
};
|
targetRemote,
|
||||||
|
} = req.body as {
|
||||||
|
worktreePath: string;
|
||||||
|
projectPath?: string;
|
||||||
|
commitMessage?: string;
|
||||||
|
prTitle?: string;
|
||||||
|
prBody?: string;
|
||||||
|
baseBranch?: string;
|
||||||
|
draft?: boolean;
|
||||||
|
remote?: string;
|
||||||
|
/** Remote to create the PR against (e.g. upstream). If not specified, inferred from repo setup. */
|
||||||
|
targetRemote?: string;
|
||||||
|
};
|
||||||
|
|
||||||
if (!worktreePath) {
|
if (!worktreePath) {
|
||||||
res.status(400).json({
|
res.status(400).json({
|
||||||
@@ -59,6 +75,52 @@ export function createCreatePRHandler() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Input validation: run all validation before any git write operations ---
|
||||||
|
|
||||||
|
// Validate remote names before use to prevent command injection
|
||||||
|
if (remote !== undefined && !isValidRemoteName(remote)) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Invalid remote name contains unsafe characters',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (targetRemote !== undefined && !isValidRemoteName(targetRemote)) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Invalid target remote name contains unsafe characters',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pushRemote = remote || 'origin';
|
||||||
|
|
||||||
|
// Resolve repository URL, fork workflow, and target remote information.
|
||||||
|
// This is needed for both the existing PR check and PR creation.
|
||||||
|
// Resolve early so validation errors are caught before any writes.
|
||||||
|
let repoUrl: string | null = null;
|
||||||
|
let upstreamRepo: string | null = null;
|
||||||
|
let originOwner: string | null = null;
|
||||||
|
try {
|
||||||
|
const prTarget = await resolvePrTarget({
|
||||||
|
worktreePath,
|
||||||
|
pushRemote,
|
||||||
|
targetRemote,
|
||||||
|
});
|
||||||
|
repoUrl = prTarget.repoUrl;
|
||||||
|
upstreamRepo = prTarget.upstreamRepo;
|
||||||
|
originOwner = prTarget.originOwner;
|
||||||
|
} catch (resolveErr) {
|
||||||
|
// resolvePrTarget throws for validation errors (unknown targetRemote, missing pushRemote)
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: getErrorMessage(resolveErr),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Validation complete — proceed with git operations ---
|
||||||
|
|
||||||
// Check for uncommitted changes
|
// Check for uncommitted changes
|
||||||
logger.debug(`Checking for uncommitted changes in: ${worktreePath}`);
|
logger.debug(`Checking for uncommitted changes in: ${worktreePath}`);
|
||||||
const { stdout: status } = await execAsync('git status --porcelain', {
|
const { stdout: status } = await execAsync('git status --porcelain', {
|
||||||
@@ -82,12 +144,9 @@ export function createCreatePRHandler() {
|
|||||||
logger.debug(`Running: git add -A`);
|
logger.debug(`Running: git add -A`);
|
||||||
await execAsync('git add -A', { cwd: worktreePath, env: execEnv });
|
await execAsync('git add -A', { cwd: worktreePath, env: execEnv });
|
||||||
|
|
||||||
// Create commit
|
// Create commit — pass message as a separate arg to avoid shell injection
|
||||||
logger.debug(`Running: git commit`);
|
logger.debug(`Running: git commit`);
|
||||||
await execAsync(`git commit -m "${message.replace(/"/g, '\\"')}"`, {
|
await execGitCommand(['commit', '-m', message], worktreePath);
|
||||||
cwd: worktreePath,
|
|
||||||
env: execEnv,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get commit hash
|
// Get commit hash
|
||||||
const { stdout: hashOutput } = await execAsync('git rev-parse HEAD', {
|
const { stdout: hashOutput } = await execAsync('git rev-parse HEAD', {
|
||||||
@@ -110,20 +169,19 @@ export function createCreatePRHandler() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Push the branch to remote
|
// Push the branch to remote (use selected remote or default to 'origin')
|
||||||
|
// Uses array-based execGitCommand to avoid shell injection from pushRemote/branchName.
|
||||||
let pushError: string | null = null;
|
let pushError: string | null = null;
|
||||||
try {
|
try {
|
||||||
await execAsync(`git push -u origin ${branchName}`, {
|
await execGitCommand(['push', pushRemote, branchName], worktreePath, execEnv);
|
||||||
cwd: worktreePath,
|
} catch {
|
||||||
env: execEnv,
|
|
||||||
});
|
|
||||||
} catch (error: unknown) {
|
|
||||||
// If push fails, try with --set-upstream
|
// If push fails, try with --set-upstream
|
||||||
try {
|
try {
|
||||||
await execAsync(`git push --set-upstream origin ${branchName}`, {
|
await execGitCommand(
|
||||||
cwd: worktreePath,
|
['push', '--set-upstream', pushRemote, branchName],
|
||||||
env: execEnv,
|
worktreePath,
|
||||||
});
|
execEnv
|
||||||
|
);
|
||||||
} catch (error2: unknown) {
|
} catch (error2: unknown) {
|
||||||
// Capture push error for reporting
|
// Capture push error for reporting
|
||||||
const err = error2 as { stderr?: string; message?: string };
|
const err = error2 as { stderr?: string; message?: string };
|
||||||
@@ -145,82 +203,11 @@ export function createCreatePRHandler() {
|
|||||||
const base = baseBranch || 'main';
|
const base = baseBranch || 'main';
|
||||||
const title = prTitle || branchName;
|
const title = prTitle || branchName;
|
||||||
const body = prBody || `Changes from branch ${branchName}`;
|
const body = prBody || `Changes from branch ${branchName}`;
|
||||||
const draftFlag = draft ? '--draft' : '';
|
|
||||||
|
|
||||||
let prUrl: string | null = null;
|
let prUrl: string | null = null;
|
||||||
let prError: string | null = null;
|
let prError: string | null = null;
|
||||||
let browserUrl: string | null = null;
|
let browserUrl: string | null = null;
|
||||||
let ghCliAvailable = false;
|
let ghCliAvailable = false;
|
||||||
|
|
||||||
// Get repository URL and detect fork workflow FIRST
|
|
||||||
// This is needed for both the existing PR check and PR creation
|
|
||||||
let repoUrl: string | null = null;
|
|
||||||
let upstreamRepo: string | null = null;
|
|
||||||
let originOwner: string | null = null;
|
|
||||||
try {
|
|
||||||
const { stdout: remotes } = await execAsync('git remote -v', {
|
|
||||||
cwd: worktreePath,
|
|
||||||
env: execEnv,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Parse remotes to detect fork workflow and get repo URL
|
|
||||||
const lines = remotes.split(/\r?\n/); // Handle both Unix and Windows line endings
|
|
||||||
for (const line of lines) {
|
|
||||||
// Try multiple patterns to match different remote URL formats
|
|
||||||
// Pattern 1: git@github.com:owner/repo.git (fetch)
|
|
||||||
// Pattern 2: https://github.com/owner/repo.git (fetch)
|
|
||||||
// Pattern 3: https://github.com/owner/repo (fetch)
|
|
||||||
let match = line.match(/^(\w+)\s+.*[:/]([^/]+)\/([^/\s]+?)(?:\.git)?\s+\(fetch\)/);
|
|
||||||
if (!match) {
|
|
||||||
// Try SSH format: git@github.com:owner/repo.git
|
|
||||||
match = line.match(/^(\w+)\s+git@[^:]+:([^/]+)\/([^\s]+?)(?:\.git)?\s+\(fetch\)/);
|
|
||||||
}
|
|
||||||
if (!match) {
|
|
||||||
// Try HTTPS format: https://github.com/owner/repo.git
|
|
||||||
match = line.match(
|
|
||||||
/^(\w+)\s+https?:\/\/[^/]+\/([^/]+)\/([^\s]+?)(?:\.git)?\s+\(fetch\)/
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (match) {
|
|
||||||
const [, remoteName, owner, repo] = match;
|
|
||||||
if (remoteName === 'upstream') {
|
|
||||||
upstreamRepo = `${owner}/${repo}`;
|
|
||||||
repoUrl = `https://github.com/${owner}/${repo}`;
|
|
||||||
} else if (remoteName === 'origin') {
|
|
||||||
originOwner = owner;
|
|
||||||
if (!repoUrl) {
|
|
||||||
repoUrl = `https://github.com/${owner}/${repo}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
// Couldn't parse remotes - will try fallback
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: Try to get repo URL from git config if remote parsing failed
|
|
||||||
if (!repoUrl) {
|
|
||||||
try {
|
|
||||||
const { stdout: originUrl } = await execAsync('git config --get remote.origin.url', {
|
|
||||||
cwd: worktreePath,
|
|
||||||
env: execEnv,
|
|
||||||
});
|
|
||||||
const url = originUrl.trim();
|
|
||||||
|
|
||||||
// Parse URL to extract owner/repo
|
|
||||||
// Handle both SSH (git@github.com:owner/repo.git) and HTTPS (https://github.com/owner/repo.git)
|
|
||||||
let match = url.match(/[:/]([^/]+)\/([^/\s]+?)(?:\.git)?$/);
|
|
||||||
if (match) {
|
|
||||||
const [, owner, repo] = match;
|
|
||||||
originOwner = owner;
|
|
||||||
repoUrl = `https://github.com/${owner}/${repo}`;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
// Failed to get repo URL from config
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if gh CLI is available (cross-platform)
|
// Check if gh CLI is available (cross-platform)
|
||||||
ghCliAvailable = await isGhCliAvailable();
|
ghCliAvailable = await isGhCliAvailable();
|
||||||
|
|
||||||
@@ -228,13 +215,16 @@ export function createCreatePRHandler() {
|
|||||||
if (repoUrl) {
|
if (repoUrl) {
|
||||||
const encodedTitle = encodeURIComponent(title);
|
const encodedTitle = encodeURIComponent(title);
|
||||||
const encodedBody = encodeURIComponent(body);
|
const encodedBody = encodeURIComponent(body);
|
||||||
|
// Encode base branch and head branch to handle special chars like # or %
|
||||||
|
const encodedBase = encodeURIComponent(base);
|
||||||
|
const encodedBranch = encodeURIComponent(branchName);
|
||||||
|
|
||||||
if (upstreamRepo && originOwner) {
|
if (upstreamRepo && originOwner) {
|
||||||
// Fork workflow: PR to upstream from origin
|
// Fork workflow (or cross-remote PR): PR to target from push remote
|
||||||
browserUrl = `https://github.com/${upstreamRepo}/compare/${base}...${originOwner}:${branchName}?expand=1&title=${encodedTitle}&body=${encodedBody}`;
|
browserUrl = `https://github.com/${upstreamRepo}/compare/${encodedBase}...${originOwner}:${encodedBranch}?expand=1&title=${encodedTitle}&body=${encodedBody}`;
|
||||||
} else {
|
} else {
|
||||||
// Regular repo
|
// Regular repo
|
||||||
browserUrl = `${repoUrl}/compare/${base}...${branchName}?expand=1&title=${encodedTitle}&body=${encodedBody}`;
|
browserUrl = `${repoUrl}/compare/${encodedBase}...${encodedBranch}?expand=1&title=${encodedTitle}&body=${encodedBody}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -244,18 +234,40 @@ export function createCreatePRHandler() {
|
|||||||
if (ghCliAvailable) {
|
if (ghCliAvailable) {
|
||||||
// First, check if a PR already exists for this branch using gh pr list
|
// First, check if a PR already exists for this branch using gh pr list
|
||||||
// This is more reliable than gh pr view as it explicitly searches by branch name
|
// This is more reliable than gh pr view as it explicitly searches by branch name
|
||||||
// For forks, we need to use owner:branch format for the head parameter
|
// For forks/cross-remote, we need to use owner:branch format for the head parameter
|
||||||
const headRef = upstreamRepo && originOwner ? `${originOwner}:${branchName}` : branchName;
|
const headRef = upstreamRepo && originOwner ? `${originOwner}:${branchName}` : branchName;
|
||||||
const repoArg = upstreamRepo ? ` --repo "${upstreamRepo}"` : '';
|
|
||||||
|
|
||||||
logger.debug(`Checking for existing PR for branch: ${branchName} (headRef: ${headRef})`);
|
logger.debug(`Checking for existing PR for branch: ${branchName} (headRef: ${headRef})`);
|
||||||
try {
|
try {
|
||||||
const listCmd = `gh pr list${repoArg} --head "${headRef}" --json number,title,url,state --limit 1`;
|
const listArgs = ['pr', 'list'];
|
||||||
logger.debug(`Running: ${listCmd}`);
|
if (upstreamRepo) {
|
||||||
const { stdout: existingPrOutput } = await execAsync(listCmd, {
|
listArgs.push('--repo', upstreamRepo);
|
||||||
|
}
|
||||||
|
listArgs.push(
|
||||||
|
'--head',
|
||||||
|
headRef,
|
||||||
|
'--json',
|
||||||
|
'number,title,url,state,createdAt',
|
||||||
|
'--limit',
|
||||||
|
'1'
|
||||||
|
);
|
||||||
|
logger.debug(`Running: gh ${listArgs.join(' ')}`);
|
||||||
|
const listResult = await spawnProcess({
|
||||||
|
command: 'gh',
|
||||||
|
args: listArgs,
|
||||||
cwd: worktreePath,
|
cwd: worktreePath,
|
||||||
env: execEnv,
|
env: execEnv,
|
||||||
});
|
});
|
||||||
|
if (listResult.exitCode !== 0) {
|
||||||
|
logger.error(
|
||||||
|
`gh pr list failed with exit code ${listResult.exitCode}: ` +
|
||||||
|
`stderr=${listResult.stderr}, stdout=${listResult.stdout}`
|
||||||
|
);
|
||||||
|
throw new Error(
|
||||||
|
`gh pr list failed (exit code ${listResult.exitCode}): ${listResult.stderr || listResult.stdout}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const existingPrOutput = listResult.stdout;
|
||||||
logger.debug(`gh pr list output: ${existingPrOutput}`);
|
logger.debug(`gh pr list output: ${existingPrOutput}`);
|
||||||
|
|
||||||
const existingPrs = JSON.parse(existingPrOutput);
|
const existingPrs = JSON.parse(existingPrOutput);
|
||||||
@@ -275,7 +287,7 @@ export function createCreatePRHandler() {
|
|||||||
url: existingPr.url,
|
url: existingPr.url,
|
||||||
title: existingPr.title || title,
|
title: existingPr.title || title,
|
||||||
state: validatePRState(existingPr.state),
|
state: validatePRState(existingPr.state),
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: existingPr.createdAt || new Date().toISOString(),
|
||||||
});
|
});
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`Stored existing PR info for branch ${branchName}: PR #${existingPr.number}`
|
`Stored existing PR info for branch ${branchName}: PR #${existingPr.number}`
|
||||||
@@ -291,27 +303,35 @@ export function createCreatePRHandler() {
|
|||||||
// Only create a new PR if one doesn't already exist
|
// Only create a new PR if one doesn't already exist
|
||||||
if (!prUrl) {
|
if (!prUrl) {
|
||||||
try {
|
try {
|
||||||
// Build gh pr create command
|
// Build gh pr create args as an array to avoid shell injection on
|
||||||
let prCmd = `gh pr create --base "${base}"`;
|
// title/body (backticks, $, \ were unsafe with string interpolation)
|
||||||
|
const prArgs = ['pr', 'create', '--base', base];
|
||||||
|
|
||||||
// If this is a fork (has upstream remote), specify the repo and head
|
// If this is a fork (has upstream remote), specify the repo and head
|
||||||
if (upstreamRepo && originOwner) {
|
if (upstreamRepo && originOwner) {
|
||||||
// For forks: --repo specifies where to create PR, --head specifies source
|
// For forks: --repo specifies where to create PR, --head specifies source
|
||||||
prCmd += ` --repo "${upstreamRepo}" --head "${originOwner}:${branchName}"`;
|
prArgs.push('--repo', upstreamRepo, '--head', `${originOwner}:${branchName}`);
|
||||||
} else {
|
} else {
|
||||||
// Not a fork, just specify the head branch
|
// Not a fork, just specify the head branch
|
||||||
prCmd += ` --head "${branchName}"`;
|
prArgs.push('--head', branchName);
|
||||||
}
|
}
|
||||||
|
|
||||||
prCmd += ` --title "${title.replace(/"/g, '\\"')}" --body "${body.replace(/"/g, '\\"')}" ${draftFlag}`;
|
prArgs.push('--title', title, '--body', body);
|
||||||
prCmd = prCmd.trim();
|
if (draft) prArgs.push('--draft');
|
||||||
|
|
||||||
logger.debug(`Creating PR with command: ${prCmd}`);
|
logger.debug(`Creating PR with args: gh ${prArgs.join(' ')}`);
|
||||||
const { stdout: prOutput } = await execAsync(prCmd, {
|
const prResult = await spawnProcess({
|
||||||
|
command: 'gh',
|
||||||
|
args: prArgs,
|
||||||
cwd: worktreePath,
|
cwd: worktreePath,
|
||||||
env: execEnv,
|
env: execEnv,
|
||||||
});
|
});
|
||||||
prUrl = prOutput.trim();
|
if (prResult.exitCode !== 0) {
|
||||||
|
throw Object.assign(new Error(prResult.stderr || 'gh pr create failed'), {
|
||||||
|
stderr: prResult.stderr,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
prUrl = prResult.stdout.trim();
|
||||||
logger.info(`PR created: ${prUrl}`);
|
logger.info(`PR created: ${prUrl}`);
|
||||||
|
|
||||||
// Extract PR number and store metadata for newly created PR
|
// Extract PR number and store metadata for newly created PR
|
||||||
@@ -345,11 +365,26 @@ export function createCreatePRHandler() {
|
|||||||
if (errorMessage.toLowerCase().includes('already exists')) {
|
if (errorMessage.toLowerCase().includes('already exists')) {
|
||||||
logger.debug(`PR already exists error - trying to fetch existing PR`);
|
logger.debug(`PR already exists error - trying to fetch existing PR`);
|
||||||
try {
|
try {
|
||||||
const { stdout: viewOutput } = await execAsync(
|
// Build args as an array to avoid shell injection.
|
||||||
`gh pr view --json number,title,url,state`,
|
// When upstreamRepo is set (fork/cross-remote workflow) we must
|
||||||
{ cwd: worktreePath, env: execEnv }
|
// query the upstream repository so we find the correct PR.
|
||||||
);
|
const viewArgs = ['pr', 'view', '--json', 'number,title,url,state,createdAt'];
|
||||||
const existingPr = JSON.parse(viewOutput);
|
if (upstreamRepo) {
|
||||||
|
viewArgs.push('--repo', upstreamRepo);
|
||||||
|
}
|
||||||
|
logger.debug(`Running: gh ${viewArgs.join(' ')}`);
|
||||||
|
const viewResult = await spawnProcess({
|
||||||
|
command: 'gh',
|
||||||
|
args: viewArgs,
|
||||||
|
cwd: worktreePath,
|
||||||
|
env: execEnv,
|
||||||
|
});
|
||||||
|
if (viewResult.exitCode !== 0) {
|
||||||
|
throw new Error(
|
||||||
|
`gh pr view failed (exit code ${viewResult.exitCode}): ${viewResult.stderr || viewResult.stdout}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const existingPr = JSON.parse(viewResult.stdout);
|
||||||
if (existingPr.url) {
|
if (existingPr.url) {
|
||||||
prUrl = existingPr.url;
|
prUrl = existingPr.url;
|
||||||
prNumber = existingPr.number;
|
prNumber = existingPr.number;
|
||||||
@@ -361,7 +396,7 @@ export function createCreatePRHandler() {
|
|||||||
url: existingPr.url,
|
url: existingPr.url,
|
||||||
title: existingPr.title || title,
|
title: existingPr.title || title,
|
||||||
state: validatePRState(existingPr.state),
|
state: validatePRState(existingPr.state),
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: existingPr.createdAt || new Date().toISOString(),
|
||||||
});
|
});
|
||||||
logger.debug(`Fetched and stored existing PR: #${existingPr.number}`);
|
logger.debug(`Fetched and stored existing PR: #${existingPr.number}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,8 @@
|
|||||||
* This endpoint handles worktree creation with proper checks:
|
* This endpoint handles worktree creation with proper checks:
|
||||||
* 1. First checks if git already has a worktree for the branch (anywhere)
|
* 1. First checks if git already has a worktree for the branch (anywhere)
|
||||||
* 2. If found, returns the existing worktree (no error)
|
* 2. If found, returns the existing worktree (no error)
|
||||||
* 3. Only creates a new worktree if none exists for the branch
|
* 3. Syncs the base branch from its remote tracking branch (fast-forward only)
|
||||||
|
* 4. Only creates a new worktree if none exists for the branch
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
@@ -13,6 +14,8 @@ import { promisify } from 'util';
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
import * as secureFs from '../../../lib/secure-fs.js';
|
import * as secureFs from '../../../lib/secure-fs.js';
|
||||||
import type { EventEmitter } from '../../../lib/events.js';
|
import type { EventEmitter } from '../../../lib/events.js';
|
||||||
|
import type { SettingsService } from '../../../services/settings-service.js';
|
||||||
|
import { WorktreeService } from '../../../services/worktree-service.js';
|
||||||
import { isGitRepo } from '@automaker/git-utils';
|
import { isGitRepo } from '@automaker/git-utils';
|
||||||
import {
|
import {
|
||||||
getErrorMessage,
|
getErrorMessage,
|
||||||
@@ -20,14 +23,21 @@ import {
|
|||||||
normalizePath,
|
normalizePath,
|
||||||
ensureInitialCommit,
|
ensureInitialCommit,
|
||||||
isValidBranchName,
|
isValidBranchName,
|
||||||
execGitCommand,
|
|
||||||
} from '../common.js';
|
} from '../common.js';
|
||||||
|
import { execGitCommand } from '../../../lib/git.js';
|
||||||
import { trackBranch } from './branch-tracking.js';
|
import { trackBranch } from './branch-tracking.js';
|
||||||
import { createLogger } from '@automaker/utils';
|
import { createLogger } from '@automaker/utils';
|
||||||
import { runInitScript } from '../../../services/init-script-service.js';
|
import { runInitScript } from '../../../services/init-script-service.js';
|
||||||
|
import {
|
||||||
|
syncBaseBranch,
|
||||||
|
type BaseBranchSyncResult,
|
||||||
|
} from '../../../services/branch-sync-service.js';
|
||||||
|
|
||||||
const logger = createLogger('Worktree');
|
const logger = createLogger('Worktree');
|
||||||
|
|
||||||
|
/** Timeout for git fetch operations (30 seconds) */
|
||||||
|
const FETCH_TIMEOUT_MS = 30_000;
|
||||||
|
|
||||||
const execAsync = promisify(exec);
|
const execAsync = promisify(exec);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -81,13 +91,15 @@ async function findExistingWorktreeForBranch(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createCreateHandler(events: EventEmitter) {
|
export function createCreateHandler(events: EventEmitter, settingsService?: SettingsService) {
|
||||||
|
const worktreeService = new WorktreeService();
|
||||||
|
|
||||||
return async (req: Request, res: Response): Promise<void> => {
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const { projectPath, branchName, baseBranch } = req.body as {
|
const { projectPath, branchName, baseBranch } = req.body as {
|
||||||
projectPath: string;
|
projectPath: string;
|
||||||
branchName: string;
|
branchName: string;
|
||||||
baseBranch?: string; // Optional base branch to create from (defaults to current HEAD)
|
baseBranch?: string; // Optional base branch to create from (defaults to current HEAD). Can be a remote branch like "origin/main".
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!projectPath || !branchName) {
|
if (!projectPath || !branchName) {
|
||||||
@@ -167,6 +179,71 @@ export function createCreateHandler(events: EventEmitter) {
|
|||||||
// Create worktrees directory if it doesn't exist
|
// Create worktrees directory if it doesn't exist
|
||||||
await secureFs.mkdir(worktreesDir, { recursive: true });
|
await secureFs.mkdir(worktreesDir, { recursive: true });
|
||||||
|
|
||||||
|
// Fetch latest from all remotes before creating the worktree.
|
||||||
|
// This ensures remote refs are up-to-date for:
|
||||||
|
// - Remote base branches (e.g. "origin/main")
|
||||||
|
// - Existing remote branches being checked out as worktrees
|
||||||
|
// - Branch existence checks against fresh remote state
|
||||||
|
logger.info('Fetching from all remotes before creating worktree');
|
||||||
|
try {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timerId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
||||||
|
try {
|
||||||
|
await execGitCommand(['fetch', '--all', '--quiet'], projectPath, undefined, controller);
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timerId);
|
||||||
|
}
|
||||||
|
} catch (fetchErr) {
|
||||||
|
// Non-fatal: log but continue — refs might already be cached locally
|
||||||
|
logger.warn(`Failed to fetch from remotes: ${getErrorMessage(fetchErr)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync the base branch with its remote tracking branch (fast-forward only).
|
||||||
|
// This ensures the new worktree starts from an up-to-date state rather than
|
||||||
|
// a potentially stale local copy. If the sync fails or the branch has diverged,
|
||||||
|
// we proceed with the local copy and inform the user.
|
||||||
|
const effectiveBase = baseBranch || 'HEAD';
|
||||||
|
let syncResult: BaseBranchSyncResult = { attempted: false, synced: false };
|
||||||
|
|
||||||
|
// Only sync if the base is a real branch (not 'HEAD')
|
||||||
|
// Pass skipFetch=true because we already fetched all remotes above.
|
||||||
|
if (effectiveBase !== 'HEAD') {
|
||||||
|
logger.info(`Syncing base branch '${effectiveBase}' before creating worktree`);
|
||||||
|
syncResult = await syncBaseBranch(projectPath, effectiveBase, true);
|
||||||
|
if (syncResult.attempted) {
|
||||||
|
if (syncResult.synced) {
|
||||||
|
logger.info(`Base branch sync result: ${syncResult.message}`);
|
||||||
|
} else {
|
||||||
|
logger.warn(`Base branch sync result: ${syncResult.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// When using HEAD, try to sync the currently checked-out branch
|
||||||
|
// Pass skipFetch=true because we already fetched all remotes above.
|
||||||
|
try {
|
||||||
|
const currentBranch = await execGitCommand(
|
||||||
|
['rev-parse', '--abbrev-ref', 'HEAD'],
|
||||||
|
projectPath
|
||||||
|
);
|
||||||
|
const trimmedBranch = currentBranch.trim();
|
||||||
|
if (trimmedBranch && trimmedBranch !== 'HEAD') {
|
||||||
|
logger.info(
|
||||||
|
`Syncing current branch '${trimmedBranch}' (HEAD) before creating worktree`
|
||||||
|
);
|
||||||
|
syncResult = await syncBaseBranch(projectPath, trimmedBranch, true);
|
||||||
|
if (syncResult.attempted) {
|
||||||
|
if (syncResult.synced) {
|
||||||
|
logger.info(`HEAD branch sync result: ${syncResult.message}`);
|
||||||
|
} else {
|
||||||
|
logger.warn(`HEAD branch sync result: ${syncResult.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Could not determine HEAD branch — skip sync
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Check if branch exists (using array arguments to prevent injection)
|
// Check if branch exists (using array arguments to prevent injection)
|
||||||
let branchExists = false;
|
let branchExists = false;
|
||||||
try {
|
try {
|
||||||
@@ -200,6 +277,33 @@ export function createCreateHandler(events: EventEmitter) {
|
|||||||
// normalizePath converts to forward slashes for API consistency
|
// normalizePath converts to forward slashes for API consistency
|
||||||
const absoluteWorktreePath = path.resolve(worktreePath);
|
const absoluteWorktreePath = path.resolve(worktreePath);
|
||||||
|
|
||||||
|
// Get the commit hash the new worktree is based on for logging
|
||||||
|
let baseCommitHash: string | undefined;
|
||||||
|
try {
|
||||||
|
const hash = await execGitCommand(['rev-parse', '--short', 'HEAD'], absoluteWorktreePath);
|
||||||
|
baseCommitHash = hash.trim();
|
||||||
|
} catch {
|
||||||
|
// Non-critical — just for logging
|
||||||
|
}
|
||||||
|
|
||||||
|
if (baseCommitHash) {
|
||||||
|
logger.info(`New worktree for '${branchName}' based on commit ${baseCommitHash}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy configured files into the new worktree before responding
|
||||||
|
// This runs synchronously to ensure files are in place before any init script
|
||||||
|
try {
|
||||||
|
await worktreeService.copyConfiguredFiles(
|
||||||
|
projectPath,
|
||||||
|
absoluteWorktreePath,
|
||||||
|
settingsService,
|
||||||
|
events
|
||||||
|
);
|
||||||
|
} catch (copyErr) {
|
||||||
|
// Log but don't fail worktree creation – files may be partially copied
|
||||||
|
logger.warn('Some configured files failed to copy to worktree:', copyErr);
|
||||||
|
}
|
||||||
|
|
||||||
// Respond immediately (non-blocking)
|
// Respond immediately (non-blocking)
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
@@ -207,6 +311,17 @@ export function createCreateHandler(events: EventEmitter) {
|
|||||||
path: normalizePath(absoluteWorktreePath),
|
path: normalizePath(absoluteWorktreePath),
|
||||||
branch: branchName,
|
branch: branchName,
|
||||||
isNew: !branchExists,
|
isNew: !branchExists,
|
||||||
|
baseCommitHash,
|
||||||
|
...(syncResult.attempted
|
||||||
|
? {
|
||||||
|
syncResult: {
|
||||||
|
synced: syncResult.synced,
|
||||||
|
remote: syncResult.remote,
|
||||||
|
message: syncResult.message,
|
||||||
|
diverged: syncResult.diverged,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -5,8 +5,10 @@
|
|||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
import { exec } from 'child_process';
|
import { exec } from 'child_process';
|
||||||
import { promisify } from 'util';
|
import { promisify } from 'util';
|
||||||
|
import fs from 'fs/promises';
|
||||||
import { isGitRepo } from '@automaker/git-utils';
|
import { isGitRepo } from '@automaker/git-utils';
|
||||||
import { getErrorMessage, logError, isValidBranchName, execGitCommand } from '../common.js';
|
import { getErrorMessage, logError, isValidBranchName } from '../common.js';
|
||||||
|
import { execGitCommand } from '../../../lib/git.js';
|
||||||
import { createLogger } from '@automaker/utils';
|
import { createLogger } from '@automaker/utils';
|
||||||
|
|
||||||
const execAsync = promisify(exec);
|
const execAsync = promisify(exec);
|
||||||
@@ -45,20 +47,79 @@ export function createDeleteHandler() {
|
|||||||
});
|
});
|
||||||
branchName = stdout.trim();
|
branchName = stdout.trim();
|
||||||
} catch {
|
} catch {
|
||||||
// Could not get branch name
|
// Could not get branch name - worktree directory may already be gone
|
||||||
|
logger.debug('Could not determine branch for worktree, directory may be missing');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove the worktree (using array arguments to prevent injection)
|
// Remove the worktree (using array arguments to prevent injection)
|
||||||
|
let removeSucceeded = false;
|
||||||
try {
|
try {
|
||||||
await execGitCommand(['worktree', 'remove', worktreePath, '--force'], projectPath);
|
await execGitCommand(['worktree', 'remove', worktreePath, '--force'], projectPath);
|
||||||
} catch (error) {
|
removeSucceeded = true;
|
||||||
// Try with prune if remove fails
|
} catch (removeError) {
|
||||||
await execGitCommand(['worktree', 'prune'], projectPath);
|
// `git worktree remove` can fail if the directory is already missing
|
||||||
|
// or in a bad state. Try pruning stale worktree entries as a fallback.
|
||||||
|
logger.debug('git worktree remove failed, trying prune', {
|
||||||
|
error: getErrorMessage(removeError),
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await execGitCommand(['worktree', 'prune'], projectPath);
|
||||||
|
|
||||||
|
// Verify the specific worktree is no longer registered after prune.
|
||||||
|
// `git worktree prune` exits 0 even if worktreePath was never registered,
|
||||||
|
// so we must explicitly check the worktree list to avoid false positives.
|
||||||
|
const { stdout: listOut } = await execAsync('git worktree list --porcelain', {
|
||||||
|
cwd: projectPath,
|
||||||
|
});
|
||||||
|
// Parse porcelain output and check for an exact path match.
|
||||||
|
// Using substring .includes() can produce false positives when one
|
||||||
|
// worktree path is a prefix of another (e.g. /foo vs /foobar).
|
||||||
|
const stillRegistered = listOut
|
||||||
|
.split('\n')
|
||||||
|
.filter((line) => line.startsWith('worktree '))
|
||||||
|
.map((line) => line.slice('worktree '.length).trim())
|
||||||
|
.some((registeredPath) => registeredPath === worktreePath);
|
||||||
|
if (stillRegistered) {
|
||||||
|
// Prune didn't clean up our entry - treat as failure
|
||||||
|
throw removeError;
|
||||||
|
}
|
||||||
|
removeSucceeded = true;
|
||||||
|
} catch (pruneError) {
|
||||||
|
// If pruneError is the original removeError re-thrown, propagate it
|
||||||
|
if (pruneError === removeError) {
|
||||||
|
throw removeError;
|
||||||
|
}
|
||||||
|
logger.warn('git worktree prune also failed', {
|
||||||
|
error: getErrorMessage(pruneError),
|
||||||
|
});
|
||||||
|
// If both remove and prune fail, still try to return success
|
||||||
|
// if the worktree directory no longer exists (it may have been
|
||||||
|
// manually deleted already).
|
||||||
|
let dirExists = false;
|
||||||
|
try {
|
||||||
|
await fs.access(worktreePath);
|
||||||
|
dirExists = true;
|
||||||
|
} catch {
|
||||||
|
// Directory doesn't exist
|
||||||
|
}
|
||||||
|
if (dirExists) {
|
||||||
|
// Directory still exists - this is a real failure
|
||||||
|
throw removeError;
|
||||||
|
}
|
||||||
|
// Directory is gone, treat as success
|
||||||
|
removeSucceeded = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Optionally delete the branch
|
// Optionally delete the branch (only if worktree was successfully removed)
|
||||||
let branchDeleted = false;
|
let branchDeleted = false;
|
||||||
if (deleteBranch && branchName && branchName !== 'main' && branchName !== 'master') {
|
if (
|
||||||
|
removeSucceeded &&
|
||||||
|
deleteBranch &&
|
||||||
|
branchName &&
|
||||||
|
branchName !== 'main' &&
|
||||||
|
branchName !== 'master'
|
||||||
|
) {
|
||||||
// Validate branch name to prevent command injection
|
// Validate branch name to prevent command injection
|
||||||
if (!isValidBranchName(branchName)) {
|
if (!isValidBranchName(branchName)) {
|
||||||
logger.warn(`Invalid branch name detected, skipping deletion: ${branchName}`);
|
logger.warn(`Invalid branch name detected, skipping deletion: ${branchName}`);
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ export function createDiffsHandler() {
|
|||||||
diff: result.diff,
|
diff: result.diff,
|
||||||
files: result.files,
|
files: result.files,
|
||||||
hasChanges: result.hasChanges,
|
hasChanges: result.hasChanges,
|
||||||
|
...(result.mergeState ? { mergeState: result.mergeState } : {}),
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -55,6 +56,7 @@ export function createDiffsHandler() {
|
|||||||
diff: result.diff,
|
diff: result.diff,
|
||||||
files: result.files,
|
files: result.files,
|
||||||
hasChanges: result.hasChanges,
|
hasChanges: result.hasChanges,
|
||||||
|
...(result.mergeState ? { mergeState: result.mergeState } : {}),
|
||||||
});
|
});
|
||||||
} catch (innerError) {
|
} catch (innerError) {
|
||||||
// Worktree doesn't exist - fallback to main project path
|
// Worktree doesn't exist - fallback to main project path
|
||||||
@@ -71,6 +73,7 @@ export function createDiffsHandler() {
|
|||||||
diff: result.diff,
|
diff: result.diff,
|
||||||
files: result.files,
|
files: result.files,
|
||||||
hasChanges: result.hasChanges,
|
hasChanges: result.hasChanges,
|
||||||
|
...(result.mergeState ? { mergeState: result.mergeState } : {}),
|
||||||
});
|
});
|
||||||
} catch (fallbackError) {
|
} catch (fallbackError) {
|
||||||
logError(fallbackError, 'Fallback to main project also failed');
|
logError(fallbackError, 'Fallback to main project also failed');
|
||||||
|
|||||||
@@ -1,27 +1,79 @@
|
|||||||
/**
|
/**
|
||||||
* POST /discard-changes endpoint - Discard all uncommitted changes in a worktree
|
* POST /discard-changes endpoint - Discard uncommitted changes in a worktree
|
||||||
*
|
*
|
||||||
* This performs a destructive operation that:
|
* Supports two modes:
|
||||||
* 1. Resets staged changes (git reset HEAD)
|
* 1. Discard ALL changes (when no files array is provided)
|
||||||
* 2. Discards modified tracked files (git checkout .)
|
* - Resets staged changes (git reset HEAD)
|
||||||
* 3. Removes untracked files and directories (git clean -fd)
|
* - Discards modified tracked files (git checkout .)
|
||||||
|
* - Removes untracked files and directories (git clean -ffd)
|
||||||
|
*
|
||||||
|
* 2. Discard SELECTED files (when files array is provided)
|
||||||
|
* - Unstages selected staged files (git reset HEAD -- <files>)
|
||||||
|
* - Reverts selected tracked file changes (git checkout -- <files>)
|
||||||
|
* - Removes selected untracked files (git clean -ffd -- <files>)
|
||||||
*
|
*
|
||||||
* Note: Git repository validation (isGitRepo) is handled by
|
* Note: Git repository validation (isGitRepo) is handled by
|
||||||
* the requireGitRepoOnly middleware in index.ts
|
* the requireGitRepoOnly middleware in index.ts
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
import { exec } from 'child_process';
|
import * as path from 'path';
|
||||||
import { promisify } from 'util';
|
import * as fs from 'fs';
|
||||||
import { getErrorMessage, logError } from '../common.js';
|
import { getErrorMessage, logError } from '@automaker/utils';
|
||||||
|
import { execGitCommand } from '../../../lib/git.js';
|
||||||
|
|
||||||
const execAsync = promisify(exec);
|
/**
|
||||||
|
* Validate that a file path does not escape the worktree directory.
|
||||||
|
* Prevents path traversal attacks (e.g., ../../etc/passwd) and
|
||||||
|
* rejects symlinks inside the worktree that point outside of it.
|
||||||
|
*/
|
||||||
|
function validateFilePath(filePath: string, worktreePath: string): boolean {
|
||||||
|
// Resolve the full path relative to the worktree (lexical resolution)
|
||||||
|
const resolved = path.resolve(worktreePath, filePath);
|
||||||
|
const normalizedWorktree = path.resolve(worktreePath);
|
||||||
|
|
||||||
|
// First, perform lexical prefix check
|
||||||
|
const lexicalOk =
|
||||||
|
resolved.startsWith(normalizedWorktree + path.sep) || resolved === normalizedWorktree;
|
||||||
|
if (!lexicalOk) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Then, attempt symlink-aware validation using realpath.
|
||||||
|
// This catches symlinks inside the worktree that point outside of it.
|
||||||
|
try {
|
||||||
|
const realResolved = fs.realpathSync(resolved);
|
||||||
|
const realWorktree = fs.realpathSync(normalizedWorktree);
|
||||||
|
return realResolved.startsWith(realWorktree + path.sep) || realResolved === realWorktree;
|
||||||
|
} catch {
|
||||||
|
// If realpath fails (e.g., target doesn't exist yet for untracked files),
|
||||||
|
// fall back to the lexical startsWith check which already passed above.
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a file path from git status --porcelain output, handling renames.
|
||||||
|
* For renamed files (R status), git reports "old_path -> new_path" and
|
||||||
|
* we need the new path to match what parseGitStatus() returns in git-utils.
|
||||||
|
*/
|
||||||
|
function parseFilePath(rawPath: string, indexStatus: string, workTreeStatus: string): string {
|
||||||
|
const trimmedPath = rawPath.trim();
|
||||||
|
if (indexStatus === 'R' || workTreeStatus === 'R') {
|
||||||
|
const arrowIndex = trimmedPath.indexOf(' -> ');
|
||||||
|
if (arrowIndex !== -1) {
|
||||||
|
return trimmedPath.slice(arrowIndex + 4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return trimmedPath;
|
||||||
|
}
|
||||||
|
|
||||||
export function createDiscardChangesHandler() {
|
export function createDiscardChangesHandler() {
|
||||||
return async (req: Request, res: Response): Promise<void> => {
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const { worktreePath } = req.body as {
|
const { worktreePath, files } = req.body as {
|
||||||
worktreePath: string;
|
worktreePath: string;
|
||||||
|
files?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!worktreePath) {
|
if (!worktreePath) {
|
||||||
@@ -33,9 +85,7 @@ export function createDiscardChangesHandler() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check for uncommitted changes first
|
// Check for uncommitted changes first
|
||||||
const { stdout: status } = await execAsync('git status --porcelain', {
|
const status = await execGitCommand(['status', '--porcelain'], worktreePath);
|
||||||
cwd: worktreePath,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!status.trim()) {
|
if (!status.trim()) {
|
||||||
res.json({
|
res.json({
|
||||||
@@ -48,61 +98,216 @@ export function createDiscardChangesHandler() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Count the files that will be affected
|
|
||||||
const lines = status.trim().split('\n').filter(Boolean);
|
|
||||||
const fileCount = lines.length;
|
|
||||||
|
|
||||||
// Get branch name before discarding
|
// Get branch name before discarding
|
||||||
const { stdout: branchOutput } = await execAsync('git rev-parse --abbrev-ref HEAD', {
|
const branchOutput = await execGitCommand(
|
||||||
cwd: worktreePath,
|
['rev-parse', '--abbrev-ref', 'HEAD'],
|
||||||
});
|
worktreePath
|
||||||
|
);
|
||||||
const branchName = branchOutput.trim();
|
const branchName = branchOutput.trim();
|
||||||
|
|
||||||
// Discard all changes:
|
// Parse the status output to categorize files
|
||||||
// 1. Reset any staged changes
|
// Git --porcelain format: XY PATH where X=index status, Y=worktree status
|
||||||
await execAsync('git reset HEAD', { cwd: worktreePath }).catch(() => {
|
// For renamed files: XY OLD_PATH -> NEW_PATH
|
||||||
// Ignore errors - might fail if there's nothing staged
|
const statusLines = status.trim().split('\n').filter(Boolean);
|
||||||
|
const allFiles = statusLines.map((line) => {
|
||||||
|
const fileStatus = line.substring(0, 2);
|
||||||
|
const rawPath = line.slice(3);
|
||||||
|
const indexStatus = fileStatus.charAt(0);
|
||||||
|
const workTreeStatus = fileStatus.charAt(1);
|
||||||
|
// Parse path consistently with parseGitStatus() in git-utils,
|
||||||
|
// which extracts the new path for renames
|
||||||
|
const filePath = parseFilePath(rawPath, indexStatus, workTreeStatus);
|
||||||
|
return { status: fileStatus, path: filePath };
|
||||||
});
|
});
|
||||||
|
|
||||||
// 2. Discard changes in tracked files
|
// Determine which files to discard
|
||||||
await execAsync('git checkout .', { cwd: worktreePath }).catch(() => {
|
const isSelectiveDiscard = files && files.length > 0 && files.length < allFiles.length;
|
||||||
// Ignore errors - might fail if there are no tracked changes
|
|
||||||
});
|
|
||||||
|
|
||||||
// 3. Remove untracked files and directories
|
if (isSelectiveDiscard) {
|
||||||
await execAsync('git clean -fd', { cwd: worktreePath }).catch(() => {
|
// Selective discard: only discard the specified files
|
||||||
// Ignore errors - might fail if there are no untracked files
|
const filesToDiscard = new Set(files);
|
||||||
});
|
|
||||||
|
|
||||||
// Verify all changes were discarded
|
// Validate all requested file paths stay within the worktree
|
||||||
const { stdout: finalStatus } = await execAsync('git status --porcelain', {
|
const invalidPaths = files.filter((f) => !validateFilePath(f, worktreePath));
|
||||||
cwd: worktreePath,
|
if (invalidPaths.length > 0) {
|
||||||
});
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: `Invalid file paths detected (path traversal): ${invalidPaths.join(', ')}`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Separate files into categories for proper git operations
|
||||||
|
const trackedModified: string[] = []; // Modified/deleted tracked files
|
||||||
|
const stagedFiles: string[] = []; // Files that are staged
|
||||||
|
const untrackedFiles: string[] = []; // Untracked files (?)
|
||||||
|
const warnings: string[] = [];
|
||||||
|
|
||||||
|
// Track which requested files were matched so we can handle unmatched ones
|
||||||
|
const matchedFiles = new Set<string>();
|
||||||
|
|
||||||
|
for (const file of allFiles) {
|
||||||
|
if (!filesToDiscard.has(file.path)) continue;
|
||||||
|
matchedFiles.add(file.path);
|
||||||
|
|
||||||
|
// file.status is the raw two-character XY git porcelain status (no trim)
|
||||||
|
// X = index/staging status, Y = worktree status
|
||||||
|
const xy = file.status.substring(0, 2);
|
||||||
|
const indexStatus = xy.charAt(0);
|
||||||
|
const workTreeStatus = xy.charAt(1);
|
||||||
|
|
||||||
|
if (indexStatus === '?' && workTreeStatus === '?') {
|
||||||
|
untrackedFiles.push(file.path);
|
||||||
|
} else if (indexStatus === 'A') {
|
||||||
|
// Staged-new file: must be reset (unstaged) then cleaned (deleted).
|
||||||
|
// Never pass to trackedModified — the file has no HEAD version to
|
||||||
|
// check out, so `git checkout --` would fail or do nothing.
|
||||||
|
stagedFiles.push(file.path);
|
||||||
|
untrackedFiles.push(file.path);
|
||||||
|
} else {
|
||||||
|
// Check if the file has staged changes (index status X)
|
||||||
|
if (indexStatus !== ' ' && indexStatus !== '?') {
|
||||||
|
stagedFiles.push(file.path);
|
||||||
|
}
|
||||||
|
// Check for working tree changes (worktree status Y): handles MM, MD, etc.
|
||||||
|
if (workTreeStatus !== ' ' && workTreeStatus !== '?') {
|
||||||
|
trackedModified.push(file.path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle files from the UI that didn't match any entry in allFiles.
|
||||||
|
// This can happen due to timing differences between the UI loading diffs
|
||||||
|
// and the discard request, or path format differences.
|
||||||
|
// Attempt to clean unmatched files directly as untracked files.
|
||||||
|
for (const requestedFile of files) {
|
||||||
|
if (!matchedFiles.has(requestedFile)) {
|
||||||
|
untrackedFiles.push(requestedFile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Unstage selected staged files (using execFile to bypass shell)
|
||||||
|
if (stagedFiles.length > 0) {
|
||||||
|
try {
|
||||||
|
await execGitCommand(['reset', 'HEAD', '--', ...stagedFiles], worktreePath);
|
||||||
|
} catch (error) {
|
||||||
|
const msg = getErrorMessage(error);
|
||||||
|
logError(error, `Failed to unstage files: ${msg}`);
|
||||||
|
warnings.push(`Failed to unstage some files: ${msg}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Revert selected tracked file changes
|
||||||
|
if (trackedModified.length > 0) {
|
||||||
|
try {
|
||||||
|
await execGitCommand(['checkout', '--', ...trackedModified], worktreePath);
|
||||||
|
} catch (error) {
|
||||||
|
const msg = getErrorMessage(error);
|
||||||
|
logError(error, `Failed to revert tracked files: ${msg}`);
|
||||||
|
warnings.push(`Failed to revert some tracked files: ${msg}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Remove selected untracked files
|
||||||
|
// Use -ffd (double force) to also handle nested git repositories
|
||||||
|
if (untrackedFiles.length > 0) {
|
||||||
|
try {
|
||||||
|
await execGitCommand(['clean', '-ffd', '--', ...untrackedFiles], worktreePath);
|
||||||
|
} catch (error) {
|
||||||
|
const msg = getErrorMessage(error);
|
||||||
|
logError(error, `Failed to clean untracked files: ${msg}`);
|
||||||
|
warnings.push(`Failed to remove some untracked files: ${msg}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileCount = files.length;
|
||||||
|
|
||||||
|
// Verify the remaining state
|
||||||
|
const finalStatus = await execGitCommand(['status', '--porcelain'], worktreePath);
|
||||||
|
|
||||||
|
const remainingCount = finalStatus.trim()
|
||||||
|
? finalStatus.trim().split('\n').filter(Boolean).length
|
||||||
|
: 0;
|
||||||
|
const actualDiscarded = allFiles.length - remainingCount;
|
||||||
|
|
||||||
|
let message =
|
||||||
|
actualDiscarded < fileCount
|
||||||
|
? `Discarded ${actualDiscarded} of ${fileCount} selected files, ${remainingCount} files remaining`
|
||||||
|
: `Discarded ${actualDiscarded} ${actualDiscarded === 1 ? 'file' : 'files'}`;
|
||||||
|
|
||||||
if (finalStatus.trim()) {
|
|
||||||
// Some changes couldn't be discarded (possibly ignored files or permission issues)
|
|
||||||
const remainingCount = finalStatus.trim().split('\n').filter(Boolean).length;
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
result: {
|
result: {
|
||||||
discarded: true,
|
discarded: true,
|
||||||
filesDiscarded: fileCount - remainingCount,
|
filesDiscarded: actualDiscarded,
|
||||||
filesRemaining: remainingCount,
|
filesRemaining: remainingCount,
|
||||||
branch: branchName,
|
branch: branchName,
|
||||||
message: `Discarded ${fileCount - remainingCount} files, ${remainingCount} files could not be removed`,
|
message,
|
||||||
|
...(warnings.length > 0 && { warnings }),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
res.json({
|
// Discard ALL changes (original behavior)
|
||||||
success: true,
|
const fileCount = allFiles.length;
|
||||||
result: {
|
const warnings: string[] = [];
|
||||||
discarded: true,
|
|
||||||
filesDiscarded: fileCount,
|
// 1. Reset any staged changes
|
||||||
filesRemaining: 0,
|
try {
|
||||||
branch: branchName,
|
await execGitCommand(['reset', 'HEAD'], worktreePath);
|
||||||
message: `Discarded ${fileCount} ${fileCount === 1 ? 'file' : 'files'}`,
|
} catch (error) {
|
||||||
},
|
const msg = getErrorMessage(error);
|
||||||
});
|
logError(error, `git reset HEAD failed: ${msg}`);
|
||||||
|
warnings.push(`Failed to unstage changes: ${msg}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Discard changes in tracked files
|
||||||
|
try {
|
||||||
|
await execGitCommand(['checkout', '.'], worktreePath);
|
||||||
|
} catch (error) {
|
||||||
|
const msg = getErrorMessage(error);
|
||||||
|
logError(error, `git checkout . failed: ${msg}`);
|
||||||
|
warnings.push(`Failed to revert tracked changes: ${msg}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Remove untracked files and directories
|
||||||
|
// Use -ffd (double force) to also handle nested git repositories
|
||||||
|
try {
|
||||||
|
await execGitCommand(['clean', '-ffd', '--'], worktreePath);
|
||||||
|
} catch (error) {
|
||||||
|
const msg = getErrorMessage(error);
|
||||||
|
logError(error, `git clean -ffd failed: ${msg}`);
|
||||||
|
warnings.push(`Failed to remove untracked files: ${msg}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify all changes were discarded
|
||||||
|
const finalStatus = await execGitCommand(['status', '--porcelain'], worktreePath);
|
||||||
|
|
||||||
|
if (finalStatus.trim()) {
|
||||||
|
const remainingCount = finalStatus.trim().split('\n').filter(Boolean).length;
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
result: {
|
||||||
|
discarded: true,
|
||||||
|
filesDiscarded: fileCount - remainingCount,
|
||||||
|
filesRemaining: remainingCount,
|
||||||
|
branch: branchName,
|
||||||
|
message: `Discarded ${fileCount - remainingCount} files, ${remainingCount} files could not be removed`,
|
||||||
|
...(warnings.length > 0 && { warnings }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
result: {
|
||||||
|
discarded: true,
|
||||||
|
filesDiscarded: fileCount,
|
||||||
|
filesRemaining: 0,
|
||||||
|
branch: branchName,
|
||||||
|
message: `Discarded ${fileCount} ${fileCount === 1 ? 'file' : 'files'}`,
|
||||||
|
...(warnings.length > 0 && { warnings }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logError(error, 'Discard changes failed');
|
logError(error, 'Discard changes failed');
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
import { exec } from 'child_process';
|
import { execFile } from 'child_process';
|
||||||
import { promisify } from 'util';
|
import { promisify } from 'util';
|
||||||
import { existsSync } from 'fs';
|
import { existsSync } from 'fs';
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
@@ -20,7 +20,7 @@ import { getErrorMessage, logError } from '../common.js';
|
|||||||
import { getPhaseModelWithOverrides } from '../../../lib/settings-helpers.js';
|
import { getPhaseModelWithOverrides } from '../../../lib/settings-helpers.js';
|
||||||
|
|
||||||
const logger = createLogger('GenerateCommitMessage');
|
const logger = createLogger('GenerateCommitMessage');
|
||||||
const execAsync = promisify(exec);
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
/** Timeout for AI provider calls in milliseconds (30 seconds) */
|
/** Timeout for AI provider calls in milliseconds (30 seconds) */
|
||||||
const AI_TIMEOUT_MS = 30_000;
|
const AI_TIMEOUT_MS = 30_000;
|
||||||
@@ -33,20 +33,39 @@ async function* withTimeout<T>(
|
|||||||
generator: AsyncIterable<T>,
|
generator: AsyncIterable<T>,
|
||||||
timeoutMs: number
|
timeoutMs: number
|
||||||
): AsyncGenerator<T, void, unknown> {
|
): AsyncGenerator<T, void, unknown> {
|
||||||
|
let timerId: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
|
||||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||||
setTimeout(() => reject(new Error(`AI provider timed out after ${timeoutMs}ms`)), timeoutMs);
|
timerId = setTimeout(
|
||||||
|
() => reject(new Error(`AI provider timed out after ${timeoutMs}ms`)),
|
||||||
|
timeoutMs
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
const iterator = generator[Symbol.asyncIterator]();
|
const iterator = generator[Symbol.asyncIterator]();
|
||||||
let done = false;
|
let done = false;
|
||||||
|
|
||||||
while (!done) {
|
try {
|
||||||
const result = await Promise.race([iterator.next(), timeoutPromise]);
|
while (!done) {
|
||||||
if (result.done) {
|
const result = await Promise.race([iterator.next(), timeoutPromise]).catch(async (err) => {
|
||||||
done = true;
|
// Capture the original error, then attempt to close the iterator.
|
||||||
} else {
|
// If iterator.return() throws, log it but rethrow the original error
|
||||||
yield result.value;
|
// so the timeout error (not the teardown error) is preserved.
|
||||||
|
try {
|
||||||
|
await iterator.return?.();
|
||||||
|
} catch (teardownErr) {
|
||||||
|
logger.warn('Error during iterator cleanup after timeout:', teardownErr);
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
});
|
||||||
|
if (result.done) {
|
||||||
|
done = true;
|
||||||
|
} else {
|
||||||
|
yield result.value;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timerId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,14 +136,14 @@ export function createGenerateCommitMessageHandler(
|
|||||||
let diff = '';
|
let diff = '';
|
||||||
try {
|
try {
|
||||||
// First try to get staged changes
|
// First try to get staged changes
|
||||||
const { stdout: stagedDiff } = await execAsync('git diff --cached', {
|
const { stdout: stagedDiff } = await execFileAsync('git', ['diff', '--cached'], {
|
||||||
cwd: worktreePath,
|
cwd: worktreePath,
|
||||||
maxBuffer: 1024 * 1024 * 5, // 5MB buffer
|
maxBuffer: 1024 * 1024 * 5, // 5MB buffer
|
||||||
});
|
});
|
||||||
|
|
||||||
// If no staged changes, get unstaged changes
|
// If no staged changes, get unstaged changes
|
||||||
if (!stagedDiff.trim()) {
|
if (!stagedDiff.trim()) {
|
||||||
const { stdout: unstagedDiff } = await execAsync('git diff', {
|
const { stdout: unstagedDiff } = await execFileAsync('git', ['diff'], {
|
||||||
cwd: worktreePath,
|
cwd: worktreePath,
|
||||||
maxBuffer: 1024 * 1024 * 5, // 5MB buffer
|
maxBuffer: 1024 * 1024 * 5, // 5MB buffer
|
||||||
});
|
});
|
||||||
@@ -213,14 +232,16 @@ export function createGenerateCommitMessageHandler(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (msg.type === 'result' && msg.subtype === 'success' && msg.result) {
|
} else if (msg.type === 'result' && msg.subtype === 'success' && msg.result) {
|
||||||
// Use result if available (some providers return final text here)
|
// Use result text if longer than accumulated text (consistent with simpleQuery pattern)
|
||||||
responseText = msg.result;
|
if (msg.result.length > responseText.length) {
|
||||||
|
responseText = msg.result;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const message = responseText.trim();
|
const message = responseText.trim();
|
||||||
|
|
||||||
if (!message || message.trim().length === 0) {
|
if (!message) {
|
||||||
logger.warn('Received empty response from model');
|
logger.warn('Received empty response from model');
|
||||||
const response: GenerateCommitMessageErrorResponse = {
|
const response: GenerateCommitMessageErrorResponse = {
|
||||||
success: false,
|
success: false,
|
||||||
|
|||||||
@@ -0,0 +1,491 @@
|
|||||||
|
/**
|
||||||
|
* POST /worktree/generate-pr-description endpoint - Generate an AI PR description from git diff
|
||||||
|
*
|
||||||
|
* Uses the configured model (via phaseModels.commitMessageModel) to generate a pull request
|
||||||
|
* title and description from the branch's changes compared to the base branch.
|
||||||
|
* Defaults to Claude Haiku for speed.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Request, Response } from 'express';
|
||||||
|
import { execFile } from 'child_process';
|
||||||
|
import { promisify } from 'util';
|
||||||
|
import { existsSync } from 'fs';
|
||||||
|
import { join } from 'path';
|
||||||
|
import { createLogger } from '@automaker/utils';
|
||||||
|
import { isCursorModel, stripProviderPrefix } from '@automaker/types';
|
||||||
|
import { resolvePhaseModel } from '@automaker/model-resolver';
|
||||||
|
import { ProviderFactory } from '../../../providers/provider-factory.js';
|
||||||
|
import type { SettingsService } from '../../../services/settings-service.js';
|
||||||
|
import { getErrorMessage, logError } from '../common.js';
|
||||||
|
import { getPhaseModelWithOverrides } from '../../../lib/settings-helpers.js';
|
||||||
|
|
||||||
|
const logger = createLogger('GeneratePRDescription');
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
|
/** Timeout for AI provider calls in milliseconds (30 seconds) */
|
||||||
|
const AI_TIMEOUT_MS = 30_000;
|
||||||
|
|
||||||
|
/** Max diff size to send to AI (characters) */
|
||||||
|
const MAX_DIFF_SIZE = 15_000;
|
||||||
|
|
||||||
|
const PR_DESCRIPTION_SYSTEM_PROMPT = `You are a pull request description generator. Your task is to create a clear, well-structured PR title and description based on the git diff and branch information provided.
|
||||||
|
|
||||||
|
IMPORTANT: Do NOT include any conversational text, explanations, or preamble. Do NOT say things like "I'll analyze..." or "Here is...". Output ONLY the structured format below and nothing else.
|
||||||
|
|
||||||
|
Output your response in EXACTLY this format (including the markers):
|
||||||
|
---TITLE---
|
||||||
|
<a concise PR title, 50-72 chars, imperative mood>
|
||||||
|
---BODY---
|
||||||
|
## Summary
|
||||||
|
<1-3 bullet points describing the key changes>
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
<Detailed list of what was changed and why>
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- Your ENTIRE response must start with ---TITLE--- and contain nothing before it
|
||||||
|
- The title should be concise and descriptive (50-72 characters)
|
||||||
|
- Use imperative mood for the title (e.g., "Add dark mode toggle" not "Added dark mode toggle")
|
||||||
|
- The description should explain WHAT changed and WHY
|
||||||
|
- Group related changes together
|
||||||
|
- Use markdown formatting for the body
|
||||||
|
- Do NOT include the branch name in the title
|
||||||
|
- Focus on the user-facing impact when possible
|
||||||
|
- If there are breaking changes, mention them prominently
|
||||||
|
- The diff may include both committed changes and uncommitted working directory changes. Treat all changes as part of the PR since uncommitted changes will be committed when the PR is created
|
||||||
|
- Do NOT distinguish between committed and uncommitted changes in the output - describe all changes as a unified set of PR changes
|
||||||
|
- EXCLUDE any files that are gitignored (e.g., node_modules, dist, build, .env files, lock files, generated files, binary artifacts, coverage reports, cache directories). These should not be mentioned in the description even if they appear in the diff
|
||||||
|
- Focus only on meaningful source code changes that are tracked by git and relevant to reviewers`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wraps an async generator with a timeout.
|
||||||
|
*/
|
||||||
|
async function* withTimeout<T>(
|
||||||
|
generator: AsyncIterable<T>,
|
||||||
|
timeoutMs: number
|
||||||
|
): AsyncGenerator<T, void, unknown> {
|
||||||
|
let timerId: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
|
||||||
|
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||||
|
timerId = setTimeout(
|
||||||
|
() => reject(new Error(`AI provider timed out after ${timeoutMs}ms`)),
|
||||||
|
timeoutMs
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const iterator = generator[Symbol.asyncIterator]();
|
||||||
|
let done = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
while (!done) {
|
||||||
|
const result = await Promise.race([iterator.next(), timeoutPromise]).catch(async (err) => {
|
||||||
|
// Timeout (or other error) — attempt to gracefully close the source generator
|
||||||
|
await iterator.return?.();
|
||||||
|
throw err;
|
||||||
|
});
|
||||||
|
if (result.done) {
|
||||||
|
done = true;
|
||||||
|
} else {
|
||||||
|
yield result.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timerId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GeneratePRDescriptionRequestBody {
|
||||||
|
worktreePath: string;
|
||||||
|
baseBranch?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GeneratePRDescriptionSuccessResponse {
|
||||||
|
success: true;
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GeneratePRDescriptionErrorResponse {
|
||||||
|
success: false;
|
||||||
|
error: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createGeneratePRDescriptionHandler(
|
||||||
|
settingsService?: SettingsService
|
||||||
|
): (req: Request, res: Response) => Promise<void> {
|
||||||
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const { worktreePath, baseBranch } = req.body as GeneratePRDescriptionRequestBody;
|
||||||
|
|
||||||
|
if (!worktreePath || typeof worktreePath !== 'string') {
|
||||||
|
const response: GeneratePRDescriptionErrorResponse = {
|
||||||
|
success: false,
|
||||||
|
error: 'worktreePath is required and must be a string',
|
||||||
|
};
|
||||||
|
res.status(400).json(response);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate that the directory exists
|
||||||
|
if (!existsSync(worktreePath)) {
|
||||||
|
const response: GeneratePRDescriptionErrorResponse = {
|
||||||
|
success: false,
|
||||||
|
error: 'worktreePath does not exist',
|
||||||
|
};
|
||||||
|
res.status(400).json(response);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate that it's a git repository
|
||||||
|
const gitPath = join(worktreePath, '.git');
|
||||||
|
if (!existsSync(gitPath)) {
|
||||||
|
const response: GeneratePRDescriptionErrorResponse = {
|
||||||
|
success: false,
|
||||||
|
error: 'worktreePath is not a git repository',
|
||||||
|
};
|
||||||
|
res.status(400).json(response);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate baseBranch to allow only safe branch name characters
|
||||||
|
if (baseBranch !== undefined && !/^[\w.\-/]+$/.test(baseBranch)) {
|
||||||
|
const response: GeneratePRDescriptionErrorResponse = {
|
||||||
|
success: false,
|
||||||
|
error: 'baseBranch contains invalid characters',
|
||||||
|
};
|
||||||
|
res.status(400).json(response);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Generating PR description for worktree: ${worktreePath}`);
|
||||||
|
|
||||||
|
// Get current branch name
|
||||||
|
const { stdout: branchOutput } = await execFileAsync(
|
||||||
|
'git',
|
||||||
|
['rev-parse', '--abbrev-ref', 'HEAD'],
|
||||||
|
{ cwd: worktreePath }
|
||||||
|
);
|
||||||
|
const branchName = branchOutput.trim();
|
||||||
|
|
||||||
|
// Determine the base branch for comparison
|
||||||
|
const base = baseBranch || 'main';
|
||||||
|
|
||||||
|
// Collect diffs in three layers and combine them:
|
||||||
|
// 1. Committed changes on the branch: `git diff base...HEAD`
|
||||||
|
// 2. Staged (cached) changes not yet committed: `git diff --cached`
|
||||||
|
// 3. Unstaged changes to tracked files: `git diff` (no --cached flag)
|
||||||
|
//
|
||||||
|
// Untracked files are intentionally excluded — they are typically build artifacts,
|
||||||
|
// planning files, hidden dotfiles, or other files unrelated to the PR.
|
||||||
|
// `git diff` and `git diff --cached` only show changes to files already tracked by git,
|
||||||
|
// which is exactly the correct scope.
|
||||||
|
//
|
||||||
|
// We combine all three sources and deduplicate by file path so that a file modified
|
||||||
|
// in commits AND with additional uncommitted changes is not double-counted.
|
||||||
|
|
||||||
|
/** Parse a unified diff into per-file hunks keyed by file path */
|
||||||
|
function parseDiffIntoFileHunks(diffText: string): Map<string, string> {
|
||||||
|
const fileHunks = new Map<string, string>();
|
||||||
|
if (!diffText.trim()) return fileHunks;
|
||||||
|
|
||||||
|
// Split on "diff --git" boundaries (keep the delimiter)
|
||||||
|
const sections = diffText.split(/(?=^diff --git )/m);
|
||||||
|
for (const section of sections) {
|
||||||
|
if (!section.trim()) continue;
|
||||||
|
// Use a back-reference pattern so the "b/" side must match the "a/" capture,
|
||||||
|
// correctly handling paths that contain " b/" in their name.
|
||||||
|
// Falls back to a two-capture pattern to handle renames (a/ and b/ differ).
|
||||||
|
const backrefMatch = section.match(/^diff --git a\/(.+) b\/\1$/m);
|
||||||
|
const renameMatch = !backrefMatch ? section.match(/^diff --git a\/(.+) b\/(.+)$/m) : null;
|
||||||
|
const match = backrefMatch || renameMatch;
|
||||||
|
if (match) {
|
||||||
|
// Prefer the backref capture (identical paths); for renames use the destination (match[2])
|
||||||
|
const filePath = backrefMatch ? match[1] : match[2];
|
||||||
|
// Merge hunks if the same file appears in multiple diff sources
|
||||||
|
const existing = fileHunks.get(filePath) ?? '';
|
||||||
|
fileHunks.set(filePath, existing + section);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fileHunks;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Step 1: committed changes (branch vs base) ---
|
||||||
|
let committedDiff = '';
|
||||||
|
try {
|
||||||
|
const { stdout } = await execFileAsync('git', ['diff', `${base}...HEAD`], {
|
||||||
|
cwd: worktreePath,
|
||||||
|
maxBuffer: 1024 * 1024 * 5,
|
||||||
|
});
|
||||||
|
committedDiff = stdout;
|
||||||
|
} catch {
|
||||||
|
// Base branch may not exist locally; try the remote tracking branch
|
||||||
|
try {
|
||||||
|
const { stdout } = await execFileAsync('git', ['diff', `origin/${base}...HEAD`], {
|
||||||
|
cwd: worktreePath,
|
||||||
|
maxBuffer: 1024 * 1024 * 5,
|
||||||
|
});
|
||||||
|
committedDiff = stdout;
|
||||||
|
} catch {
|
||||||
|
// Cannot compare against base — leave committedDiff empty; the uncommitted
|
||||||
|
// changes gathered below will still be included.
|
||||||
|
logger.warn(`Could not get committed diff against ${base} or origin/${base}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Step 2: staged changes (tracked files only) ---
|
||||||
|
let stagedDiff = '';
|
||||||
|
try {
|
||||||
|
const { stdout } = await execFileAsync('git', ['diff', '--cached'], {
|
||||||
|
cwd: worktreePath,
|
||||||
|
maxBuffer: 1024 * 1024 * 5,
|
||||||
|
});
|
||||||
|
stagedDiff = stdout;
|
||||||
|
} catch (err) {
|
||||||
|
// Non-fatal — staged diff is a best-effort supplement
|
||||||
|
logger.debug('Failed to get staged diff', err);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Step 3: unstaged changes (tracked files only) ---
|
||||||
|
let unstagedDiff = '';
|
||||||
|
try {
|
||||||
|
const { stdout } = await execFileAsync('git', ['diff'], {
|
||||||
|
cwd: worktreePath,
|
||||||
|
maxBuffer: 1024 * 1024 * 5,
|
||||||
|
});
|
||||||
|
unstagedDiff = stdout;
|
||||||
|
} catch (err) {
|
||||||
|
// Non-fatal — unstaged diff is a best-effort supplement
|
||||||
|
logger.debug('Failed to get unstaged diff', err);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Combine and deduplicate ---
|
||||||
|
// Build a map of filePath → diff content by concatenating hunks from all sources
|
||||||
|
// in chronological order (committed → staged → unstaged) so that no changes
|
||||||
|
// are lost when a file appears in multiple diff sources.
|
||||||
|
const combinedFileHunks = new Map<string, string>();
|
||||||
|
|
||||||
|
for (const source of [committedDiff, stagedDiff, unstagedDiff]) {
|
||||||
|
const hunks = parseDiffIntoFileHunks(source);
|
||||||
|
for (const [filePath, hunk] of hunks) {
|
||||||
|
if (combinedFileHunks.has(filePath)) {
|
||||||
|
combinedFileHunks.set(filePath, combinedFileHunks.get(filePath)! + hunk);
|
||||||
|
} else {
|
||||||
|
combinedFileHunks.set(filePath, hunk);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const diff = Array.from(combinedFileHunks.values()).join('');
|
||||||
|
|
||||||
|
// Log what files were included for observability
|
||||||
|
if (combinedFileHunks.size > 0) {
|
||||||
|
logger.info(`PR description scope: ${combinedFileHunks.size} file(s)`);
|
||||||
|
logger.debug(
|
||||||
|
`PR description scope files: ${Array.from(combinedFileHunks.keys()).join(', ')}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also get the commit log for context — always scoped to the selected base branch
|
||||||
|
// so the log only contains commits that are part of this PR.
|
||||||
|
// We do NOT fall back to an unscoped `git log` because that would include commits
|
||||||
|
// from the base branch itself and produce misleading AI context.
|
||||||
|
let commitLog = '';
|
||||||
|
try {
|
||||||
|
const { stdout: logOutput } = await execFileAsync(
|
||||||
|
'git',
|
||||||
|
['log', `${base}..HEAD`, '--oneline', '--no-decorate'],
|
||||||
|
{
|
||||||
|
cwd: worktreePath,
|
||||||
|
maxBuffer: 1024 * 1024,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
commitLog = logOutput.trim();
|
||||||
|
} catch {
|
||||||
|
// Base branch not available locally — try the remote tracking branch
|
||||||
|
try {
|
||||||
|
const { stdout: logOutput } = await execFileAsync(
|
||||||
|
'git',
|
||||||
|
['log', `origin/${base}..HEAD`, '--oneline', '--no-decorate'],
|
||||||
|
{
|
||||||
|
cwd: worktreePath,
|
||||||
|
maxBuffer: 1024 * 1024,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
commitLog = logOutput.trim();
|
||||||
|
} catch {
|
||||||
|
// Cannot scope commit log to base branch — leave empty rather than
|
||||||
|
// including unscoped commits that would pollute the AI context.
|
||||||
|
logger.warn(`Could not get commit log against ${base} or origin/${base}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!diff.trim() && !commitLog.trim()) {
|
||||||
|
const response: GeneratePRDescriptionErrorResponse = {
|
||||||
|
success: false,
|
||||||
|
error: 'No changes found to generate a PR description from',
|
||||||
|
};
|
||||||
|
res.status(400).json(response);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Truncate diff if too long
|
||||||
|
const truncatedDiff =
|
||||||
|
diff.length > MAX_DIFF_SIZE
|
||||||
|
? diff.substring(0, MAX_DIFF_SIZE) + '\n\n[... diff truncated ...]'
|
||||||
|
: diff;
|
||||||
|
|
||||||
|
// Build the user prompt
|
||||||
|
let userPrompt = `Generate a pull request title and description for the following changes.\n\nBranch: ${branchName}\nBase Branch: ${base}\n`;
|
||||||
|
|
||||||
|
if (commitLog) {
|
||||||
|
userPrompt += `\nCommit History:\n${commitLog}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (truncatedDiff) {
|
||||||
|
userPrompt += `\n\`\`\`diff\n${truncatedDiff}\n\`\`\``;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get model from phase settings with provider info
|
||||||
|
const {
|
||||||
|
phaseModel: phaseModelEntry,
|
||||||
|
provider: claudeCompatibleProvider,
|
||||||
|
credentials,
|
||||||
|
} = await getPhaseModelWithOverrides(
|
||||||
|
'commitMessageModel',
|
||||||
|
settingsService,
|
||||||
|
worktreePath,
|
||||||
|
'[GeneratePRDescription]'
|
||||||
|
);
|
||||||
|
const { model, thinkingLevel } = resolvePhaseModel(phaseModelEntry);
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`Using model for PR description: ${model}`,
|
||||||
|
claudeCompatibleProvider ? `via provider: ${claudeCompatibleProvider.name}` : 'direct API'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get provider for the model type
|
||||||
|
const aiProvider = ProviderFactory.getProviderForModel(model);
|
||||||
|
const bareModel = stripProviderPrefix(model);
|
||||||
|
|
||||||
|
// For Cursor models, combine prompts
|
||||||
|
const effectivePrompt = isCursorModel(model)
|
||||||
|
? `${PR_DESCRIPTION_SYSTEM_PROMPT}\n\n${userPrompt}`
|
||||||
|
: userPrompt;
|
||||||
|
const effectiveSystemPrompt = isCursorModel(model) ? undefined : PR_DESCRIPTION_SYSTEM_PROMPT;
|
||||||
|
|
||||||
|
logger.info(`Using ${aiProvider.getName()} provider for model: ${model}`);
|
||||||
|
|
||||||
|
let responseText = '';
|
||||||
|
const stream = aiProvider.executeQuery({
|
||||||
|
prompt: effectivePrompt,
|
||||||
|
model: bareModel,
|
||||||
|
cwd: worktreePath,
|
||||||
|
systemPrompt: effectiveSystemPrompt,
|
||||||
|
maxTurns: 1,
|
||||||
|
allowedTools: [],
|
||||||
|
readOnly: true,
|
||||||
|
thinkingLevel,
|
||||||
|
claudeCompatibleProvider,
|
||||||
|
credentials,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wrap with timeout
|
||||||
|
for await (const msg of withTimeout(stream, AI_TIMEOUT_MS)) {
|
||||||
|
if (msg.type === 'assistant' && msg.message?.content) {
|
||||||
|
for (const block of msg.message.content) {
|
||||||
|
if (block.type === 'text' && block.text) {
|
||||||
|
responseText += block.text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (msg.type === 'result' && msg.subtype === 'success' && msg.result) {
|
||||||
|
// Use result text if longer than accumulated text (consistent with simpleQuery pattern)
|
||||||
|
if (msg.result.length > responseText.length) {
|
||||||
|
responseText = msg.result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fullResponse = responseText.trim();
|
||||||
|
|
||||||
|
if (!fullResponse || fullResponse.length === 0) {
|
||||||
|
logger.warn('Received empty response from model');
|
||||||
|
const response: GeneratePRDescriptionErrorResponse = {
|
||||||
|
success: false,
|
||||||
|
error: 'Failed to generate PR description - empty response',
|
||||||
|
};
|
||||||
|
res.status(500).json(response);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the response to extract title and body.
|
||||||
|
// The model may include conversational preamble before the structured markers,
|
||||||
|
// so we search for the markers anywhere in the response, not just at the start.
|
||||||
|
let title = '';
|
||||||
|
let body = '';
|
||||||
|
|
||||||
|
const titleMatch = fullResponse.match(/---TITLE---\s*\n([\s\S]*?)(?=---BODY---|$)/);
|
||||||
|
const bodyMatch = fullResponse.match(/---BODY---\s*\n([\s\S]*?)$/);
|
||||||
|
|
||||||
|
if (titleMatch && bodyMatch) {
|
||||||
|
title = titleMatch[1].trim();
|
||||||
|
body = bodyMatch[1].trim();
|
||||||
|
} else {
|
||||||
|
// Fallback: try to extract meaningful content, skipping any conversational preamble.
|
||||||
|
// Common preamble patterns start with "I'll", "I will", "Here", "Let me", "Based on", etc.
|
||||||
|
const lines = fullResponse.split('\n').filter((line) => line.trim().length > 0);
|
||||||
|
|
||||||
|
// Skip lines that look like conversational preamble
|
||||||
|
let startIndex = 0;
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
const line = lines[i].trim();
|
||||||
|
// Check if this line looks like conversational AI preamble
|
||||||
|
if (
|
||||||
|
/^(I'll|I will|Here('s| is| are)|Let me|Based on|Looking at|Analyzing|Sure|OK|Okay|Of course)/i.test(
|
||||||
|
line
|
||||||
|
) ||
|
||||||
|
/^(The following|Below is|This (is|will)|After (analyzing|reviewing|looking))/i.test(
|
||||||
|
line
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
startIndex = i + 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use remaining lines after skipping preamble
|
||||||
|
const contentLines = lines.slice(startIndex);
|
||||||
|
if (contentLines.length > 0) {
|
||||||
|
title = contentLines[0].trim();
|
||||||
|
body = contentLines.slice(1).join('\n').trim();
|
||||||
|
} else {
|
||||||
|
// If all lines were filtered as preamble, use the original first non-empty line
|
||||||
|
title = lines[0]?.trim() || '';
|
||||||
|
body = lines.slice(1).join('\n').trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up title - remove any markdown headings, quotes, or marker artifacts
|
||||||
|
title = title
|
||||||
|
.replace(/^#+\s*/, '')
|
||||||
|
.replace(/^["']|["']$/g, '')
|
||||||
|
.replace(/^---\w+---\s*/, '');
|
||||||
|
|
||||||
|
logger.info(`Generated PR title: ${title.substring(0, 100)}...`);
|
||||||
|
|
||||||
|
const response: GeneratePRDescriptionSuccessResponse = {
|
||||||
|
success: true,
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
};
|
||||||
|
res.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
logError(error, 'Generate PR description failed');
|
||||||
|
const response: GeneratePRDescriptionErrorResponse = {
|
||||||
|
success: false,
|
||||||
|
error: getErrorMessage(error),
|
||||||
|
};
|
||||||
|
res.status(500).json(response);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -6,11 +6,13 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
import { exec } from 'child_process';
|
import { exec, execFile } from 'child_process';
|
||||||
import { promisify } from 'util';
|
import { promisify } from 'util';
|
||||||
import { getErrorMessage, logWorktreeError } from '../common.js';
|
import { getErrorMessage, logWorktreeError } from '../common.js';
|
||||||
|
import { getRemotesWithBranch } from '../../../services/worktree-service.js';
|
||||||
|
|
||||||
const execAsync = promisify(exec);
|
const execAsync = promisify(exec);
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
interface BranchInfo {
|
interface BranchInfo {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -92,6 +94,9 @@ export function createListBranchesHandler() {
|
|||||||
// Skip HEAD pointers like "origin/HEAD"
|
// Skip HEAD pointers like "origin/HEAD"
|
||||||
if (cleanName.includes('/HEAD')) return;
|
if (cleanName.includes('/HEAD')) return;
|
||||||
|
|
||||||
|
// Skip bare remote names without a branch (e.g. "origin" by itself)
|
||||||
|
if (!cleanName.includes('/')) return;
|
||||||
|
|
||||||
// Only add remote branches if a branch with the exact same name isn't already
|
// Only add remote branches if a branch with the exact same name isn't already
|
||||||
// in the list. This avoids duplicates if a local branch is named like a remote one.
|
// in the list. This avoids duplicates if a local branch is named like a remote one.
|
||||||
// Note: We intentionally include remote branches even when a local branch with the
|
// Note: We intentionally include remote branches even when a local branch with the
|
||||||
@@ -126,17 +131,28 @@ export function createListBranchesHandler() {
|
|||||||
let aheadCount = 0;
|
let aheadCount = 0;
|
||||||
let behindCount = 0;
|
let behindCount = 0;
|
||||||
let hasRemoteBranch = false;
|
let hasRemoteBranch = false;
|
||||||
|
let trackingRemote: string | undefined;
|
||||||
|
// List of remote names that have a branch matching the current branch name
|
||||||
|
let remotesWithBranch: string[] = [];
|
||||||
try {
|
try {
|
||||||
// First check if there's a remote tracking branch
|
// First check if there's a remote tracking branch
|
||||||
const { stdout: upstreamOutput } = await execAsync(
|
const { stdout: upstreamOutput } = await execFileAsync(
|
||||||
`git rev-parse --abbrev-ref ${currentBranch}@{upstream}`,
|
'git',
|
||||||
|
['rev-parse', '--abbrev-ref', `${currentBranch}@{upstream}`],
|
||||||
{ cwd: worktreePath }
|
{ cwd: worktreePath }
|
||||||
);
|
);
|
||||||
|
|
||||||
if (upstreamOutput.trim()) {
|
const upstreamRef = upstreamOutput.trim();
|
||||||
|
if (upstreamRef) {
|
||||||
hasRemoteBranch = true;
|
hasRemoteBranch = true;
|
||||||
const { stdout: aheadBehindOutput } = await execAsync(
|
// Extract the remote name from the upstream ref (e.g. "origin/main" -> "origin")
|
||||||
`git rev-list --left-right --count ${currentBranch}@{upstream}...HEAD`,
|
const slashIndex = upstreamRef.indexOf('/');
|
||||||
|
if (slashIndex !== -1) {
|
||||||
|
trackingRemote = upstreamRef.slice(0, slashIndex);
|
||||||
|
}
|
||||||
|
const { stdout: aheadBehindOutput } = await execFileAsync(
|
||||||
|
'git',
|
||||||
|
['rev-list', '--left-right', '--count', `${currentBranch}@{upstream}...HEAD`],
|
||||||
{ cwd: worktreePath }
|
{ cwd: worktreePath }
|
||||||
);
|
);
|
||||||
const [behind, ahead] = aheadBehindOutput.trim().split(/\s+/).map(Number);
|
const [behind, ahead] = aheadBehindOutput.trim().split(/\s+/).map(Number);
|
||||||
@@ -147,8 +163,9 @@ export function createListBranchesHandler() {
|
|||||||
// No upstream branch set - check if the branch exists on any remote
|
// No upstream branch set - check if the branch exists on any remote
|
||||||
try {
|
try {
|
||||||
// Check if there's a matching branch on origin (most common remote)
|
// Check if there's a matching branch on origin (most common remote)
|
||||||
const { stdout: remoteBranchOutput } = await execAsync(
|
const { stdout: remoteBranchOutput } = await execFileAsync(
|
||||||
`git ls-remote --heads origin ${currentBranch}`,
|
'git',
|
||||||
|
['ls-remote', '--heads', 'origin', currentBranch],
|
||||||
{ cwd: worktreePath, timeout: 5000 }
|
{ cwd: worktreePath, timeout: 5000 }
|
||||||
);
|
);
|
||||||
hasRemoteBranch = remoteBranchOutput.trim().length > 0;
|
hasRemoteBranch = remoteBranchOutput.trim().length > 0;
|
||||||
@@ -158,6 +175,12 @@ export function createListBranchesHandler() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check which remotes have a branch matching the current branch name.
|
||||||
|
// This helps the UI distinguish between "branch exists on tracking remote" vs
|
||||||
|
// "branch was pushed to a different remote" (e.g., pushed to 'upstream' but tracking 'origin').
|
||||||
|
// Use for-each-ref to check cached remote refs (already fetched above if includeRemote was true)
|
||||||
|
remotesWithBranch = await getRemotesWithBranch(worktreePath, currentBranch, hasAnyRemotes);
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
result: {
|
result: {
|
||||||
@@ -167,6 +190,8 @@ export function createListBranchesHandler() {
|
|||||||
behindCount,
|
behindCount,
|
||||||
hasRemoteBranch,
|
hasRemoteBranch,
|
||||||
hasAnyRemotes,
|
hasAnyRemotes,
|
||||||
|
trackingRemote,
|
||||||
|
remotesWithBranch,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -58,6 +58,90 @@ interface WorktreeInfo {
|
|||||||
hasChanges?: boolean;
|
hasChanges?: boolean;
|
||||||
changedFilesCount?: number;
|
changedFilesCount?: number;
|
||||||
pr?: WorktreePRInfo; // PR info if a PR has been created for this branch
|
pr?: WorktreePRInfo; // PR info if a PR has been created for this branch
|
||||||
|
/** Whether there are actual unresolved conflict files (conflictFiles.length > 0) */
|
||||||
|
hasConflicts?: boolean;
|
||||||
|
/** Type of git operation in progress (merge/rebase/cherry-pick), set independently of hasConflicts */
|
||||||
|
conflictType?: 'merge' | 'rebase' | 'cherry-pick';
|
||||||
|
/** List of files with conflicts */
|
||||||
|
conflictFiles?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect if a merge, rebase, or cherry-pick is in progress for a worktree.
|
||||||
|
* Checks for the presence of state files/directories that git creates
|
||||||
|
* during these operations.
|
||||||
|
*/
|
||||||
|
async function detectConflictState(worktreePath: string): Promise<{
|
||||||
|
hasConflicts: boolean;
|
||||||
|
conflictType?: 'merge' | 'rebase' | 'cherry-pick';
|
||||||
|
conflictFiles?: string[];
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
// Find the canonical .git directory for this worktree
|
||||||
|
const { stdout: gitDirRaw } = await execAsync('git rev-parse --git-dir', {
|
||||||
|
cwd: worktreePath,
|
||||||
|
timeout: 15000,
|
||||||
|
});
|
||||||
|
const gitDir = path.resolve(worktreePath, gitDirRaw.trim());
|
||||||
|
|
||||||
|
// Check for merge, rebase, and cherry-pick state files/directories
|
||||||
|
const [mergeHeadExists, rebaseMergeExists, rebaseApplyExists, cherryPickHeadExists] =
|
||||||
|
await Promise.all([
|
||||||
|
secureFs
|
||||||
|
.access(path.join(gitDir, 'MERGE_HEAD'))
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false),
|
||||||
|
secureFs
|
||||||
|
.access(path.join(gitDir, 'rebase-merge'))
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false),
|
||||||
|
secureFs
|
||||||
|
.access(path.join(gitDir, 'rebase-apply'))
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false),
|
||||||
|
secureFs
|
||||||
|
.access(path.join(gitDir, 'CHERRY_PICK_HEAD'))
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let conflictType: 'merge' | 'rebase' | 'cherry-pick' | undefined;
|
||||||
|
if (rebaseMergeExists || rebaseApplyExists) {
|
||||||
|
conflictType = 'rebase';
|
||||||
|
} else if (mergeHeadExists) {
|
||||||
|
conflictType = 'merge';
|
||||||
|
} else if (cherryPickHeadExists) {
|
||||||
|
conflictType = 'cherry-pick';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!conflictType) {
|
||||||
|
return { hasConflicts: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get list of conflicted files using machine-readable git status
|
||||||
|
let conflictFiles: string[] = [];
|
||||||
|
try {
|
||||||
|
const { stdout: statusOutput } = await execAsync('git diff --name-only --diff-filter=U', {
|
||||||
|
cwd: worktreePath,
|
||||||
|
timeout: 15000,
|
||||||
|
});
|
||||||
|
conflictFiles = statusOutput
|
||||||
|
.trim()
|
||||||
|
.split('\n')
|
||||||
|
.filter((f) => f.trim().length > 0);
|
||||||
|
} catch {
|
||||||
|
// Fall back to empty list if diff fails
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
hasConflicts: conflictFiles.length > 0,
|
||||||
|
conflictType,
|
||||||
|
conflictFiles,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
// If anything fails, assume no conflicts
|
||||||
|
return { hasConflicts: false };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getCurrentBranch(cwd: string): Promise<string> {
|
async function getCurrentBranch(cwd: string): Promise<string> {
|
||||||
@@ -373,7 +457,7 @@ export function createListHandler() {
|
|||||||
// Read all worktree metadata to get PR info
|
// Read all worktree metadata to get PR info
|
||||||
const allMetadata = await readAllWorktreeMetadata(projectPath);
|
const allMetadata = await readAllWorktreeMetadata(projectPath);
|
||||||
|
|
||||||
// If includeDetails is requested, fetch change status for each worktree
|
// If includeDetails is requested, fetch change status and conflict state for each worktree
|
||||||
if (includeDetails) {
|
if (includeDetails) {
|
||||||
for (const worktree of worktrees) {
|
for (const worktree of worktrees) {
|
||||||
try {
|
try {
|
||||||
@@ -390,6 +474,21 @@ export function createListHandler() {
|
|||||||
worktree.hasChanges = false;
|
worktree.hasChanges = false;
|
||||||
worktree.changedFilesCount = 0;
|
worktree.changedFilesCount = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Detect merge/rebase/cherry-pick in progress
|
||||||
|
try {
|
||||||
|
const conflictState = await detectConflictState(worktree.path);
|
||||||
|
// Always propagate conflictType so callers know an operation is in progress,
|
||||||
|
// even when there are no unresolved conflict files yet.
|
||||||
|
if (conflictState.conflictType) {
|
||||||
|
worktree.conflictType = conflictState.conflictType;
|
||||||
|
}
|
||||||
|
// hasConflicts is true only when there are actual unresolved files
|
||||||
|
worktree.hasConflicts = conflictState.hasConflicts;
|
||||||
|
worktree.conflictFiles = conflictState.conflictFiles;
|
||||||
|
} catch {
|
||||||
|
// Ignore conflict detection errors
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,15 +8,11 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
import { exec } from 'child_process';
|
import { getErrorMessage, logError } from '../common.js';
|
||||||
import { promisify } from 'util';
|
import type { EventEmitter } from '../../../lib/events.js';
|
||||||
import { getErrorMessage, logError, isValidBranchName, execGitCommand } from '../common.js';
|
import { performMerge } from '../../../services/merge-service.js';
|
||||||
import { createLogger } from '@automaker/utils';
|
|
||||||
|
|
||||||
const execAsync = promisify(exec);
|
export function createMergeHandler(events: EventEmitter) {
|
||||||
const logger = createLogger('Worktree');
|
|
||||||
|
|
||||||
export function createMergeHandler() {
|
|
||||||
return async (req: Request, res: Response): Promise<void> => {
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const { projectPath, branchName, worktreePath, targetBranch, options } = req.body as {
|
const { projectPath, branchName, worktreePath, targetBranch, options } = req.body as {
|
||||||
@@ -24,7 +20,12 @@ export function createMergeHandler() {
|
|||||||
branchName: string;
|
branchName: string;
|
||||||
worktreePath: string;
|
worktreePath: string;
|
||||||
targetBranch?: string; // Branch to merge into (defaults to 'main')
|
targetBranch?: string; // Branch to merge into (defaults to 'main')
|
||||||
options?: { squash?: boolean; message?: string; deleteWorktreeAndBranch?: boolean };
|
options?: {
|
||||||
|
squash?: boolean;
|
||||||
|
message?: string;
|
||||||
|
deleteWorktreeAndBranch?: boolean;
|
||||||
|
remote?: string;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!projectPath || !branchName || !worktreePath) {
|
if (!projectPath || !branchName || !worktreePath) {
|
||||||
@@ -38,102 +39,41 @@ export function createMergeHandler() {
|
|||||||
// Determine the target branch (default to 'main')
|
// Determine the target branch (default to 'main')
|
||||||
const mergeTo = targetBranch || 'main';
|
const mergeTo = targetBranch || 'main';
|
||||||
|
|
||||||
// Validate source branch exists
|
// Delegate all merge logic to the service
|
||||||
try {
|
const result = await performMerge(
|
||||||
await execAsync(`git rev-parse --verify ${branchName}`, { cwd: projectPath });
|
projectPath,
|
||||||
} catch {
|
branchName,
|
||||||
res.status(400).json({
|
worktreePath,
|
||||||
success: false,
|
mergeTo,
|
||||||
error: `Branch "${branchName}" does not exist`,
|
options,
|
||||||
});
|
events
|
||||||
return;
|
);
|
||||||
}
|
|
||||||
|
|
||||||
// Validate target branch exists
|
if (!result.success) {
|
||||||
try {
|
if (result.hasConflicts) {
|
||||||
await execAsync(`git rev-parse --verify ${mergeTo}`, { cwd: projectPath });
|
|
||||||
} catch {
|
|
||||||
res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
error: `Target branch "${mergeTo}" does not exist`,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Merge the feature branch into the target branch
|
|
||||||
const mergeCmd = options?.squash
|
|
||||||
? `git merge --squash ${branchName}`
|
|
||||||
: `git merge ${branchName} -m "${options?.message || `Merge ${branchName} into ${mergeTo}`}"`;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await execAsync(mergeCmd, { cwd: projectPath });
|
|
||||||
} catch (mergeError: unknown) {
|
|
||||||
// Check if this is a merge conflict
|
|
||||||
const err = mergeError as { stdout?: string; stderr?: string; message?: string };
|
|
||||||
const output = `${err.stdout || ''} ${err.stderr || ''} ${err.message || ''}`;
|
|
||||||
const hasConflicts =
|
|
||||||
output.includes('CONFLICT') || output.includes('Automatic merge failed');
|
|
||||||
|
|
||||||
if (hasConflicts) {
|
|
||||||
// Return conflict-specific error message that frontend can detect
|
// Return conflict-specific error message that frontend can detect
|
||||||
res.status(409).json({
|
res.status(409).json({
|
||||||
success: false,
|
success: false,
|
||||||
error: `Merge CONFLICT: Automatic merge of "${branchName}" into "${mergeTo}" failed. Please resolve conflicts manually.`,
|
error: result.error,
|
||||||
hasConflicts: true,
|
hasConflicts: true,
|
||||||
|
conflictFiles: result.conflictFiles,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Re-throw non-conflict errors to be handled by outer catch
|
// Non-conflict service errors (e.g. branch not found, invalid name)
|
||||||
throw mergeError;
|
res.status(400).json({
|
||||||
}
|
success: false,
|
||||||
|
error: result.error,
|
||||||
// If squash merge, need to commit
|
|
||||||
if (options?.squash) {
|
|
||||||
await execAsync(`git commit -m "${options?.message || `Merge ${branchName} (squash)`}"`, {
|
|
||||||
cwd: projectPath,
|
|
||||||
});
|
});
|
||||||
}
|
return;
|
||||||
|
|
||||||
// Optionally delete the worktree and branch after merging
|
|
||||||
let worktreeDeleted = false;
|
|
||||||
let branchDeleted = false;
|
|
||||||
|
|
||||||
if (options?.deleteWorktreeAndBranch) {
|
|
||||||
// Remove the worktree
|
|
||||||
try {
|
|
||||||
await execGitCommand(['worktree', 'remove', worktreePath, '--force'], projectPath);
|
|
||||||
worktreeDeleted = true;
|
|
||||||
} catch {
|
|
||||||
// Try with prune if remove fails
|
|
||||||
try {
|
|
||||||
await execGitCommand(['worktree', 'prune'], projectPath);
|
|
||||||
worktreeDeleted = true;
|
|
||||||
} catch {
|
|
||||||
logger.warn(`Failed to remove worktree: ${worktreePath}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete the branch (but not main/master)
|
|
||||||
if (branchName !== 'main' && branchName !== 'master') {
|
|
||||||
if (!isValidBranchName(branchName)) {
|
|
||||||
logger.warn(`Invalid branch name detected, skipping deletion: ${branchName}`);
|
|
||||||
} else {
|
|
||||||
try {
|
|
||||||
await execGitCommand(['branch', '-D', branchName], projectPath);
|
|
||||||
branchDeleted = true;
|
|
||||||
} catch {
|
|
||||||
logger.warn(`Failed to delete branch: ${branchName}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
mergedBranch: branchName,
|
mergedBranch: result.mergedBranch,
|
||||||
targetBranch: mergeTo,
|
targetBranch: result.targetBranch,
|
||||||
deleted: options?.deleteWorktreeAndBranch ? { worktreeDeleted, branchDeleted } : undefined,
|
deleted: result.deleted,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logError(error, 'Merge worktree failed');
|
logError(error, 'Merge worktree failed');
|
||||||
|
|||||||
@@ -1,22 +1,33 @@
|
|||||||
/**
|
/**
|
||||||
* POST /pull endpoint - Pull latest changes for a worktree/branch
|
* POST /pull endpoint - Pull latest changes for a worktree/branch
|
||||||
*
|
*
|
||||||
|
* Enhanced pull flow with stash management and conflict detection:
|
||||||
|
* 1. Checks for uncommitted local changes (staged and unstaged)
|
||||||
|
* 2. If local changes exist AND stashIfNeeded is true, automatically stashes them
|
||||||
|
* 3. Performs the git pull
|
||||||
|
* 4. If changes were stashed, attempts to reapply via git stash pop
|
||||||
|
* 5. Detects merge conflicts from both pull and stash reapplication
|
||||||
|
* 6. Returns structured conflict information for AI-assisted resolution
|
||||||
|
*
|
||||||
|
* Git business logic is delegated to pull-service.ts.
|
||||||
|
*
|
||||||
* Note: Git repository validation (isGitRepo, hasCommits) is handled by
|
* Note: Git repository validation (isGitRepo, hasCommits) is handled by
|
||||||
* the requireValidWorktree middleware in index.ts
|
* the requireValidWorktree middleware in index.ts
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
import { exec } from 'child_process';
|
|
||||||
import { promisify } from 'util';
|
|
||||||
import { getErrorMessage, logError } from '../common.js';
|
import { getErrorMessage, logError } from '../common.js';
|
||||||
|
import { performPull } from '../../../services/pull-service.js';
|
||||||
const execAsync = promisify(exec);
|
import type { PullResult } from '../../../services/pull-service.js';
|
||||||
|
|
||||||
export function createPullHandler() {
|
export function createPullHandler() {
|
||||||
return async (req: Request, res: Response): Promise<void> => {
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const { worktreePath } = req.body as {
|
const { worktreePath, remote, stashIfNeeded } = req.body as {
|
||||||
worktreePath: string;
|
worktreePath: string;
|
||||||
|
remote?: string;
|
||||||
|
/** When true, automatically stash local changes before pulling and reapply after */
|
||||||
|
stashIfNeeded?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!worktreePath) {
|
if (!worktreePath) {
|
||||||
@@ -27,67 +38,69 @@ export function createPullHandler() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get current branch name
|
// Execute the pull via the service
|
||||||
const { stdout: branchOutput } = await execAsync('git rev-parse --abbrev-ref HEAD', {
|
const result = await performPull(worktreePath, { remote, stashIfNeeded });
|
||||||
cwd: worktreePath,
|
|
||||||
});
|
|
||||||
const branchName = branchOutput.trim();
|
|
||||||
|
|
||||||
// Fetch latest from remote
|
// Map service result to HTTP response
|
||||||
await execAsync('git fetch origin', { cwd: worktreePath });
|
mapResultToResponse(res, result);
|
||||||
|
|
||||||
// Check if there are local changes that would be overwritten
|
|
||||||
const { stdout: status } = await execAsync('git status --porcelain', {
|
|
||||||
cwd: worktreePath,
|
|
||||||
});
|
|
||||||
const hasLocalChanges = status.trim().length > 0;
|
|
||||||
|
|
||||||
if (hasLocalChanges) {
|
|
||||||
res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
error: 'You have local changes. Please commit them before pulling.',
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pull latest changes
|
|
||||||
try {
|
|
||||||
const { stdout: pullOutput } = await execAsync(`git pull origin ${branchName}`, {
|
|
||||||
cwd: worktreePath,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Check if we pulled any changes
|
|
||||||
const alreadyUpToDate = pullOutput.includes('Already up to date');
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
result: {
|
|
||||||
branch: branchName,
|
|
||||||
pulled: !alreadyUpToDate,
|
|
||||||
message: alreadyUpToDate ? 'Already up to date' : 'Pulled latest changes',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (pullError: unknown) {
|
|
||||||
const err = pullError as { stderr?: string; message?: string };
|
|
||||||
const errorMsg = err.stderr || err.message || 'Pull failed';
|
|
||||||
|
|
||||||
// Check for common errors
|
|
||||||
if (errorMsg.includes('no tracking information')) {
|
|
||||||
res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
error: `Branch '${branchName}' has no upstream branch. Push it first or set upstream with: git branch --set-upstream-to=origin/${branchName}`,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
res.status(500).json({
|
|
||||||
success: false,
|
|
||||||
error: errorMsg,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logError(error, 'Pull failed');
|
logError(error, 'Pull failed');
|
||||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map a PullResult from the service to the appropriate HTTP response.
|
||||||
|
*
|
||||||
|
* - Successful results (including local-changes-detected info) → 200
|
||||||
|
* - Validation/state errors (detached HEAD, no upstream) → 400
|
||||||
|
* - Operational errors (fetch/stash/pull failures) → 500
|
||||||
|
*/
|
||||||
|
function mapResultToResponse(res: Response, result: PullResult): void {
|
||||||
|
if (!result.success && result.error) {
|
||||||
|
// Determine the appropriate HTTP status for errors
|
||||||
|
const statusCode = isClientError(result.error) ? 400 : 500;
|
||||||
|
res.status(statusCode).json({
|
||||||
|
success: false,
|
||||||
|
error: result.error,
|
||||||
|
...(result.stashRecoveryFailed && { stashRecoveryFailed: true }),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Success case (includes partial success like local changes detected, conflicts, etc.)
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
result: {
|
||||||
|
branch: result.branch,
|
||||||
|
pulled: result.pulled,
|
||||||
|
hasLocalChanges: result.hasLocalChanges,
|
||||||
|
localChangedFiles: result.localChangedFiles,
|
||||||
|
hasConflicts: result.hasConflicts,
|
||||||
|
conflictSource: result.conflictSource,
|
||||||
|
conflictFiles: result.conflictFiles,
|
||||||
|
stashed: result.stashed,
|
||||||
|
stashRestored: result.stashRestored,
|
||||||
|
message: result.message,
|
||||||
|
isMerge: result.isMerge,
|
||||||
|
isFastForward: result.isFastForward,
|
||||||
|
mergeAffectedFiles: result.mergeAffectedFiles,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine whether an error message represents a client error (400)
|
||||||
|
* vs a server error (500).
|
||||||
|
*
|
||||||
|
* Client errors are validation issues or invalid git state that the user
|
||||||
|
* needs to resolve (e.g. detached HEAD, no upstream, no tracking info).
|
||||||
|
*/
|
||||||
|
function isClientError(errorMessage: string): boolean {
|
||||||
|
return (
|
||||||
|
errorMessage.includes('detached HEAD') ||
|
||||||
|
errorMessage.includes('has no upstream branch') ||
|
||||||
|
errorMessage.includes('no tracking information')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,24 +1,24 @@
|
|||||||
/**
|
/**
|
||||||
* POST /push endpoint - Push a worktree branch to remote
|
* POST /push endpoint - Push a worktree branch to remote
|
||||||
*
|
*
|
||||||
|
* Git business logic is delegated to push-service.ts.
|
||||||
|
*
|
||||||
* Note: Git repository validation (isGitRepo, hasCommits) is handled by
|
* Note: Git repository validation (isGitRepo, hasCommits) is handled by
|
||||||
* the requireValidWorktree middleware in index.ts
|
* the requireValidWorktree middleware in index.ts
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
import { exec } from 'child_process';
|
|
||||||
import { promisify } from 'util';
|
|
||||||
import { getErrorMessage, logError } from '../common.js';
|
import { getErrorMessage, logError } from '../common.js';
|
||||||
|
import { performPush } from '../../../services/push-service.js';
|
||||||
const execAsync = promisify(exec);
|
|
||||||
|
|
||||||
export function createPushHandler() {
|
export function createPushHandler() {
|
||||||
return async (req: Request, res: Response): Promise<void> => {
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const { worktreePath, force, remote } = req.body as {
|
const { worktreePath, force, remote, autoResolve } = req.body as {
|
||||||
worktreePath: string;
|
worktreePath: string;
|
||||||
force?: boolean;
|
force?: boolean;
|
||||||
remote?: string;
|
remote?: string;
|
||||||
|
autoResolve?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!worktreePath) {
|
if (!worktreePath) {
|
||||||
@@ -29,34 +29,28 @@ export function createPushHandler() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get branch name
|
const result = await performPush(worktreePath, { remote, force, autoResolve });
|
||||||
const { stdout: branchOutput } = await execAsync('git rev-parse --abbrev-ref HEAD', {
|
|
||||||
cwd: worktreePath,
|
|
||||||
});
|
|
||||||
const branchName = branchOutput.trim();
|
|
||||||
|
|
||||||
// Use specified remote or default to 'origin'
|
if (!result.success) {
|
||||||
const targetRemote = remote || 'origin';
|
const statusCode = isClientError(result.error ?? '') ? 400 : 500;
|
||||||
|
res.status(statusCode).json({
|
||||||
// Push the branch
|
success: false,
|
||||||
const forceFlag = force ? '--force' : '';
|
error: result.error,
|
||||||
try {
|
diverged: result.diverged,
|
||||||
await execAsync(`git push -u ${targetRemote} ${branchName} ${forceFlag}`, {
|
hasConflicts: result.hasConflicts,
|
||||||
cwd: worktreePath,
|
conflictFiles: result.conflictFiles,
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
// Try setting upstream
|
|
||||||
await execAsync(`git push --set-upstream ${targetRemote} ${branchName} ${forceFlag}`, {
|
|
||||||
cwd: worktreePath,
|
|
||||||
});
|
});
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
result: {
|
result: {
|
||||||
branch: branchName,
|
branch: result.branch,
|
||||||
pushed: true,
|
pushed: result.pushed,
|
||||||
message: `Successfully pushed ${branchName} to ${targetRemote}`,
|
diverged: result.diverged,
|
||||||
|
autoResolved: result.autoResolved,
|
||||||
|
message: result.message,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -65,3 +59,15 @@ export function createPushHandler() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine whether an error message represents a client error (400)
|
||||||
|
* vs a server error (500).
|
||||||
|
*/
|
||||||
|
function isClientError(errorMessage: string): boolean {
|
||||||
|
return (
|
||||||
|
errorMessage.includes('detached HEAD') ||
|
||||||
|
errorMessage.includes('rejected') ||
|
||||||
|
errorMessage.includes('diverged')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user