claudedesk 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +431 -0
- package/config/repos.example.json +128 -0
- package/config/settings.example.json +64 -0
- package/config/skills/code-review.md +76 -0
- package/config/skills/full-check.md +26 -0
- package/config/skills/lint-fix.md +23 -0
- package/dist/api/agent-routes.d.ts +2 -0
- package/dist/api/agent-routes.d.ts.map +1 -0
- package/dist/api/agent-routes.js +251 -0
- package/dist/api/agent-routes.js.map +1 -0
- package/dist/api/app-routes.d.ts +2 -0
- package/dist/api/app-routes.d.ts.map +1 -0
- package/dist/api/app-routes.js +150 -0
- package/dist/api/app-routes.js.map +1 -0
- package/dist/api/docker-routes.d.ts +2 -0
- package/dist/api/docker-routes.d.ts.map +1 -0
- package/dist/api/docker-routes.js +167 -0
- package/dist/api/docker-routes.js.map +1 -0
- package/dist/api/middleware.d.ts +6 -0
- package/dist/api/middleware.d.ts.map +1 -0
- package/dist/api/middleware.js +293 -0
- package/dist/api/middleware.js.map +1 -0
- package/dist/api/pin-auth.d.ts +65 -0
- package/dist/api/pin-auth.d.ts.map +1 -0
- package/dist/api/pin-auth.js +218 -0
- package/dist/api/pin-auth.js.map +1 -0
- package/dist/api/routes.d.ts +2 -0
- package/dist/api/routes.d.ts.map +1 -0
- package/dist/api/routes.js +473 -0
- package/dist/api/routes.js.map +1 -0
- package/dist/api/settings-routes.d.ts +2 -0
- package/dist/api/settings-routes.d.ts.map +1 -0
- package/dist/api/settings-routes.js +570 -0
- package/dist/api/settings-routes.js.map +1 -0
- package/dist/api/skill-routes.d.ts +2 -0
- package/dist/api/skill-routes.d.ts.map +1 -0
- package/dist/api/skill-routes.js +88 -0
- package/dist/api/skill-routes.js.map +1 -0
- package/dist/api/terminal-routes.d.ts +2 -0
- package/dist/api/terminal-routes.d.ts.map +1 -0
- package/dist/api/terminal-routes.js +3524 -0
- package/dist/api/terminal-routes.js.map +1 -0
- package/dist/api/tunnel-routes.d.ts +2 -0
- package/dist/api/tunnel-routes.d.ts.map +1 -0
- package/dist/api/tunnel-routes.js +196 -0
- package/dist/api/tunnel-routes.js.map +1 -0
- package/dist/api/workspace-routes.d.ts +3 -0
- package/dist/api/workspace-routes.d.ts.map +1 -0
- package/dist/api/workspace-routes.js +649 -0
- package/dist/api/workspace-routes.js.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +276 -0
- package/dist/cli.js.map +1 -0
- package/dist/client/assets/index-B4r0njGe.js +780 -0
- package/dist/client/assets/index-CY_9MyE0.css +1 -0
- package/dist/client/favicon.svg +5 -0
- package/dist/client/icons/icon-192.svg +5 -0
- package/dist/client/icons/icon-512.svg +5 -0
- package/dist/client/icons/logo-with-message.png +0 -0
- package/dist/client/icons/logo.png +0 -0
- package/dist/client/index.html +25 -0
- package/dist/client/manifest.json +62 -0
- package/dist/client/sw.js +243 -0
- package/dist/config/agent-usage.d.ts +34 -0
- package/dist/config/agent-usage.d.ts.map +1 -0
- package/dist/config/agent-usage.js +87 -0
- package/dist/config/agent-usage.js.map +1 -0
- package/dist/config/repos.d.ts +34 -0
- package/dist/config/repos.d.ts.map +1 -0
- package/dist/config/repos.js +412 -0
- package/dist/config/repos.js.map +1 -0
- package/dist/config/settings.d.ts +634 -0
- package/dist/config/settings.d.ts.map +1 -0
- package/dist/config/settings.js +459 -0
- package/dist/config/settings.js.map +1 -0
- package/dist/config/skills.d.ts +18 -0
- package/dist/config/skills.d.ts.map +1 -0
- package/dist/config/skills.js +174 -0
- package/dist/config/skills.js.map +1 -0
- package/dist/config/workspaces.d.ts +961 -0
- package/dist/config/workspaces.d.ts.map +1 -0
- package/dist/config/workspaces.js +482 -0
- package/dist/config/workspaces.js.map +1 -0
- package/dist/core/app-manager.d.ts +85 -0
- package/dist/core/app-manager.d.ts.map +1 -0
- package/dist/core/app-manager.js +447 -0
- package/dist/core/app-manager.js.map +1 -0
- package/dist/core/claude-invoker.d.ts +49 -0
- package/dist/core/claude-invoker.d.ts.map +1 -0
- package/dist/core/claude-invoker.js +583 -0
- package/dist/core/claude-invoker.js.map +1 -0
- package/dist/core/claude-session-reader.d.ts +25 -0
- package/dist/core/claude-session-reader.d.ts.map +1 -0
- package/dist/core/claude-session-reader.js +184 -0
- package/dist/core/claude-session-reader.js.map +1 -0
- package/dist/core/claude-usage-query.d.ts +78 -0
- package/dist/core/claude-usage-query.d.ts.map +1 -0
- package/dist/core/claude-usage-query.js +294 -0
- package/dist/core/claude-usage-query.js.map +1 -0
- package/dist/core/git-credential-helper.d.ts +57 -0
- package/dist/core/git-credential-helper.d.ts.map +1 -0
- package/dist/core/git-credential-helper.js +176 -0
- package/dist/core/git-credential-helper.js.map +1 -0
- package/dist/core/git-sandbox.d.ts +135 -0
- package/dist/core/git-sandbox.d.ts.map +1 -0
- package/dist/core/git-sandbox.js +907 -0
- package/dist/core/git-sandbox.js.map +1 -0
- package/dist/core/github-integration.d.ts +66 -0
- package/dist/core/github-integration.d.ts.map +1 -0
- package/dist/core/github-integration.js +350 -0
- package/dist/core/github-integration.js.map +1 -0
- package/dist/core/github-oauth.d.ts +88 -0
- package/dist/core/github-oauth.d.ts.map +1 -0
- package/dist/core/github-oauth.js +244 -0
- package/dist/core/github-oauth.js.map +1 -0
- package/dist/core/gitlab-integration.d.ts +66 -0
- package/dist/core/gitlab-integration.d.ts.map +1 -0
- package/dist/core/gitlab-integration.js +353 -0
- package/dist/core/gitlab-integration.js.map +1 -0
- package/dist/core/gitlab-oauth.d.ts +100 -0
- package/dist/core/gitlab-oauth.d.ts.map +1 -0
- package/dist/core/gitlab-oauth.js +366 -0
- package/dist/core/gitlab-oauth.js.map +1 -0
- package/dist/core/insights-extractor.d.ts +68 -0
- package/dist/core/insights-extractor.d.ts.map +1 -0
- package/dist/core/insights-extractor.js +402 -0
- package/dist/core/insights-extractor.js.map +1 -0
- package/dist/core/logger.d.ts +27 -0
- package/dist/core/logger.d.ts.map +1 -0
- package/dist/core/logger.js +70 -0
- package/dist/core/logger.js.map +1 -0
- package/dist/core/process-runner.d.ts +27 -0
- package/dist/core/process-runner.d.ts.map +1 -0
- package/dist/core/process-runner.js +147 -0
- package/dist/core/process-runner.js.map +1 -0
- package/dist/core/project-detector.d.ts +30 -0
- package/dist/core/project-detector.d.ts.map +1 -0
- package/dist/core/project-detector.js +482 -0
- package/dist/core/project-detector.js.map +1 -0
- package/dist/core/qr-generator.d.ts +18 -0
- package/dist/core/qr-generator.d.ts.map +1 -0
- package/dist/core/qr-generator.js +61 -0
- package/dist/core/qr-generator.js.map +1 -0
- package/dist/core/remote-tunnel-manager.d.ts +59 -0
- package/dist/core/remote-tunnel-manager.d.ts.map +1 -0
- package/dist/core/remote-tunnel-manager.js +235 -0
- package/dist/core/remote-tunnel-manager.js.map +1 -0
- package/dist/core/shared-docker-manager.d.ts +41 -0
- package/dist/core/shared-docker-manager.d.ts.map +1 -0
- package/dist/core/shared-docker-manager.js +409 -0
- package/dist/core/shared-docker-manager.js.map +1 -0
- package/dist/core/skill-executor.d.ts +25 -0
- package/dist/core/skill-executor.d.ts.map +1 -0
- package/dist/core/skill-executor.js +171 -0
- package/dist/core/skill-executor.js.map +1 -0
- package/dist/core/terminal-session.d.ts +149 -0
- package/dist/core/terminal-session.d.ts.map +1 -0
- package/dist/core/terminal-session.js +2340 -0
- package/dist/core/terminal-session.js.map +1 -0
- package/dist/core/tunnel-manager.d.ts +35 -0
- package/dist/core/tunnel-manager.d.ts.map +1 -0
- package/dist/core/tunnel-manager.js +137 -0
- package/dist/core/tunnel-manager.js.map +1 -0
- package/dist/core/usage-manager.d.ts +57 -0
- package/dist/core/usage-manager.d.ts.map +1 -0
- package/dist/core/usage-manager.js +363 -0
- package/dist/core/usage-manager.js.map +1 -0
- package/dist/core/ws-manager.d.ts +39 -0
- package/dist/core/ws-manager.d.ts.map +1 -0
- package/dist/core/ws-manager.js +190 -0
- package/dist/core/ws-manager.js.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +229 -0
- package/dist/index.js.map +1 -0
- package/dist/types.d.ts +868 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +119 -0
- package/dist/types.js.map +1 -0
- package/package.json +96 -0
|
@@ -0,0 +1,3524 @@
|
|
|
1
|
+
import { Router } from 'express';
|
|
2
|
+
import { execSync } from 'child_process';
|
|
3
|
+
import { readFileSync, writeFileSync, existsSync, unlinkSync, rmSync, mkdirSync, renameSync, readdirSync, statSync } from 'fs';
|
|
4
|
+
import { tmpdir } from 'os';
|
|
5
|
+
import { join, extname } from 'path';
|
|
6
|
+
import { randomUUID } from 'crypto';
|
|
7
|
+
import multer from 'multer';
|
|
8
|
+
import { terminalSessionManager } from '../core/terminal-session.js';
|
|
9
|
+
import { repoRegistry } from '../config/repos.js';
|
|
10
|
+
import { gitSandbox } from '../core/git-sandbox.js';
|
|
11
|
+
import { workspaceManager } from '../config/workspaces.js';
|
|
12
|
+
import { getGitCredentialEnv, cleanupGitCredentialEnv } from '../core/git-credential-helper.js';
|
|
13
|
+
import { githubIntegration } from '../core/github-integration.js';
|
|
14
|
+
import { gitlabIntegration } from '../core/gitlab-integration.js';
|
|
15
|
+
import { usageManager } from '../core/usage-manager.js';
|
|
16
|
+
import { settingsManager } from '../config/settings.js';
|
|
17
|
+
import { queryClaudeQuota, clearQuotaCache } from '../core/claude-usage-query.js';
|
|
18
|
+
/**
|
|
19
|
+
* Try to refresh a GitLab token using the refresh token.
|
|
20
|
+
* Returns the new access token if successful, null otherwise.
|
|
21
|
+
* Also updates the workspace with the new token.
|
|
22
|
+
*/
|
|
23
|
+
function tryRefreshGitLabToken(workspaceId) {
|
|
24
|
+
const tokenData = workspaceManager.getGitLabToken(workspaceId);
|
|
25
|
+
if (!tokenData?.refreshToken) {
|
|
26
|
+
console.log(`[GitLab] No refresh token available for workspace ${workspaceId}`);
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
const settings = settingsManager.get();
|
|
30
|
+
const clientId = settings.gitlab?.clientId;
|
|
31
|
+
if (!clientId) {
|
|
32
|
+
console.log(`[GitLab] No GitLab clientId configured in settings`);
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
console.log(`[GitLab] Attempting to refresh token for workspace ${workspaceId}`);
|
|
36
|
+
const tempScriptPath = join(tmpdir(), `gitlab-refresh-${randomUUID()}.mjs`);
|
|
37
|
+
const tempResultPath = join(tmpdir(), `gitlab-refresh-result-${randomUUID()}.json`);
|
|
38
|
+
const scriptContent = `
|
|
39
|
+
import { writeFileSync } from 'fs';
|
|
40
|
+
try {
|
|
41
|
+
const response = await fetch('https://gitlab.com/oauth/token', {
|
|
42
|
+
method: 'POST',
|
|
43
|
+
headers: {
|
|
44
|
+
'Accept': 'application/json',
|
|
45
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
46
|
+
},
|
|
47
|
+
body: new URLSearchParams({
|
|
48
|
+
client_id: ${JSON.stringify(clientId)},
|
|
49
|
+
refresh_token: ${JSON.stringify(tokenData.refreshToken)},
|
|
50
|
+
grant_type: 'refresh_token',
|
|
51
|
+
}).toString(),
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const text = await response.text();
|
|
55
|
+
|
|
56
|
+
if (!response.ok) {
|
|
57
|
+
writeFileSync(${JSON.stringify(tempResultPath)}, JSON.stringify({ success: false, error: text }));
|
|
58
|
+
} else {
|
|
59
|
+
const data = JSON.parse(text);
|
|
60
|
+
if (data.access_token) {
|
|
61
|
+
writeFileSync(${JSON.stringify(tempResultPath)}, JSON.stringify({
|
|
62
|
+
success: true,
|
|
63
|
+
accessToken: data.access_token,
|
|
64
|
+
refreshToken: data.refresh_token,
|
|
65
|
+
expiresIn: data.expires_in
|
|
66
|
+
}));
|
|
67
|
+
} else {
|
|
68
|
+
writeFileSync(${JSON.stringify(tempResultPath)}, JSON.stringify({ success: false, error: 'No access_token in response' }));
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
} catch (e) {
|
|
72
|
+
writeFileSync(${JSON.stringify(tempResultPath)}, JSON.stringify({ success: false, error: e.message }));
|
|
73
|
+
}
|
|
74
|
+
`;
|
|
75
|
+
try {
|
|
76
|
+
writeFileSync(tempScriptPath, scriptContent);
|
|
77
|
+
execSync(`node "${tempScriptPath}"`, { encoding: 'utf-8', timeout: 15000 });
|
|
78
|
+
const resultJson = readFileSync(tempResultPath, 'utf-8');
|
|
79
|
+
const result = JSON.parse(resultJson);
|
|
80
|
+
if (result.success) {
|
|
81
|
+
// Get the current workspace to preserve other fields
|
|
82
|
+
const workspace = workspaceManager.get(workspaceId);
|
|
83
|
+
if (workspace?.gitlab) {
|
|
84
|
+
// Calculate expiry time
|
|
85
|
+
const expiresAt = result.expiresIn
|
|
86
|
+
? new Date(Date.now() + result.expiresIn * 1000).toISOString()
|
|
87
|
+
: null;
|
|
88
|
+
// Update the token in workspace
|
|
89
|
+
workspaceManager.setGitLabToken(workspaceId, result.accessToken, workspace.gitlab.username || 'unknown', workspace.gitlab.tokenScope || 'api', result.refreshToken, expiresAt);
|
|
90
|
+
console.log(`[GitLab] Token refreshed successfully for workspace ${workspaceId}`);
|
|
91
|
+
return result.accessToken;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
console.log(`[GitLab] Token refresh failed: ${result.error}`);
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
catch (error) {
|
|
98
|
+
console.error(`[GitLab] Token refresh error:`, error);
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
finally {
|
|
102
|
+
try {
|
|
103
|
+
unlinkSync(tempScriptPath);
|
|
104
|
+
}
|
|
105
|
+
catch { }
|
|
106
|
+
try {
|
|
107
|
+
unlinkSync(tempResultPath);
|
|
108
|
+
}
|
|
109
|
+
catch { }
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
// Configure multer for terminal attachments
|
|
113
|
+
const ATTACHMENTS_DIR = join(process.cwd(), 'temp', 'terminal-attachments');
|
|
114
|
+
if (!existsSync(ATTACHMENTS_DIR)) {
|
|
115
|
+
mkdirSync(ATTACHMENTS_DIR, { recursive: true });
|
|
116
|
+
}
|
|
117
|
+
const attachmentUpload = multer({
|
|
118
|
+
dest: ATTACHMENTS_DIR,
|
|
119
|
+
limits: {
|
|
120
|
+
fileSize: 20 * 1024 * 1024, // 20MB per file
|
|
121
|
+
files: 10, // Max 10 files per request
|
|
122
|
+
},
|
|
123
|
+
fileFilter: (_req, file, cb) => {
|
|
124
|
+
// Allowed MIME types
|
|
125
|
+
const allowedMimes = [
|
|
126
|
+
// Images
|
|
127
|
+
'image/png', 'image/jpeg', 'image/gif', 'image/webp',
|
|
128
|
+
// Documents
|
|
129
|
+
'application/pdf',
|
|
130
|
+
// Microsoft Office
|
|
131
|
+
'application/vnd.openxmlformats-officedocument.wordprocessingml.document', // .docx
|
|
132
|
+
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', // .xlsx
|
|
133
|
+
'application/vnd.openxmlformats-officedocument.presentationml.presentation', // .pptx
|
|
134
|
+
'application/msword', // .doc
|
|
135
|
+
'application/vnd.ms-excel', // .xls
|
|
136
|
+
'application/vnd.ms-powerpoint', // .ppt
|
|
137
|
+
// Text
|
|
138
|
+
'text/plain', 'text/markdown', 'text/csv',
|
|
139
|
+
// Code (text/* covers most)
|
|
140
|
+
'text/javascript', 'text/typescript', 'text/html', 'text/css',
|
|
141
|
+
'application/json', 'application/xml', 'text/xml',
|
|
142
|
+
'application/x-yaml', 'text/yaml',
|
|
143
|
+
];
|
|
144
|
+
// Also allow by extension for code files
|
|
145
|
+
const allowedExtensions = [
|
|
146
|
+
'.png', '.jpg', '.jpeg', '.gif', '.webp',
|
|
147
|
+
'.pdf',
|
|
148
|
+
// Office documents
|
|
149
|
+
'.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx',
|
|
150
|
+
'.txt', '.md', '.csv', '.json', '.xml', '.yaml', '.yml', '.toml',
|
|
151
|
+
'.js', '.ts', '.tsx', '.jsx', '.mjs', '.cjs',
|
|
152
|
+
'.py', '.rb', '.go', '.rs', '.java', '.kt', '.swift',
|
|
153
|
+
'.c', '.cpp', '.h', '.hpp', '.cs',
|
|
154
|
+
'.sql', '.sh', '.bash', '.zsh', '.ps1',
|
|
155
|
+
'.dockerfile', '.env', '.gitignore',
|
|
156
|
+
'.html', '.css', '.scss', '.sass', '.less',
|
|
157
|
+
];
|
|
158
|
+
const ext = extname(file.originalname).toLowerCase();
|
|
159
|
+
if (allowedMimes.includes(file.mimetype) || allowedExtensions.includes(ext)) {
|
|
160
|
+
cb(null, true);
|
|
161
|
+
}
|
|
162
|
+
else {
|
|
163
|
+
cb(new Error(`Unsupported file type: ${file.mimetype} (${ext})`));
|
|
164
|
+
}
|
|
165
|
+
},
|
|
166
|
+
});
|
|
167
|
+
export const terminalRouter = Router();
|
|
168
|
+
/**
|
|
169
|
+
* Resolve the correct repo for a session, supporting multi-repo sessions.
|
|
170
|
+
* For multi-repo sessions, accepts an optional repoId from request body/query.
|
|
171
|
+
* Falls back to primary repo (repoIds[0]) if not specified or invalid.
|
|
172
|
+
*/
|
|
173
|
+
function resolveSessionRepo(session, requestedRepoId) {
|
|
174
|
+
// If a specific repoId was requested and it's valid for this session, use it
|
|
175
|
+
if (requestedRepoId && session.repoIds.includes(requestedRepoId)) {
|
|
176
|
+
const repo = repoRegistry.get(requestedRepoId);
|
|
177
|
+
if (repo) {
|
|
178
|
+
return { repo, repoId: requestedRepoId };
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
// Fall back to primary repo (first in the list)
|
|
182
|
+
const primaryRepoId = session.repoIds[0];
|
|
183
|
+
const repo = repoRegistry.get(primaryRepoId);
|
|
184
|
+
if (repo) {
|
|
185
|
+
return { repo, repoId: primaryRepoId };
|
|
186
|
+
}
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Get the working directory for git operations.
|
|
191
|
+
* For worktree sessions, uses the worktree path instead of repo path.
|
|
192
|
+
*/
|
|
193
|
+
function getWorkingDir(session, repo) {
|
|
194
|
+
if (session.worktreeMode && session.worktreePath) {
|
|
195
|
+
return session.worktreePath;
|
|
196
|
+
}
|
|
197
|
+
return repo.path;
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Get exec options for git commands with OAuth credentials if available.
|
|
201
|
+
* Returns both the options and a cleanup function.
|
|
202
|
+
*/
|
|
203
|
+
function getGitExecOptions(workingDir, timeout = 60000) {
|
|
204
|
+
const creds = workspaceManager.getGitCredentialsForRepo(workingDir);
|
|
205
|
+
let gitCredEnv = null;
|
|
206
|
+
let env = { ...process.env };
|
|
207
|
+
if (creds.token && creds.platform) {
|
|
208
|
+
gitCredEnv = getGitCredentialEnv(creds.token, creds.platform, creds.username || undefined);
|
|
209
|
+
env = { ...env, ...gitCredEnv };
|
|
210
|
+
console.log(`[terminal-routes] Using ${creds.platform} OAuth for git operation`);
|
|
211
|
+
}
|
|
212
|
+
return {
|
|
213
|
+
options: {
|
|
214
|
+
cwd: workingDir,
|
|
215
|
+
encoding: 'utf-8',
|
|
216
|
+
timeout,
|
|
217
|
+
env,
|
|
218
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
219
|
+
},
|
|
220
|
+
cleanup: () => {
|
|
221
|
+
if (gitCredEnv) {
|
|
222
|
+
cleanupGitCredentialEnv(gitCredEnv);
|
|
223
|
+
}
|
|
224
|
+
},
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
// Create a new terminal session (supports both single repo and multi-repo, and worktree mode)
|
|
228
|
+
terminalRouter.post('/sessions', (req, res) => {
|
|
229
|
+
try {
|
|
230
|
+
const { repoId, repoIds, worktreeMode, branch, baseBranch, existingWorktreePath } = req.body;
|
|
231
|
+
// Support both single repoId and array of repoIds
|
|
232
|
+
let ids;
|
|
233
|
+
if (repoIds && Array.isArray(repoIds)) {
|
|
234
|
+
ids = repoIds;
|
|
235
|
+
}
|
|
236
|
+
else if (repoId && typeof repoId === 'string') {
|
|
237
|
+
ids = [repoId];
|
|
238
|
+
}
|
|
239
|
+
else {
|
|
240
|
+
res.status(400).json({ success: false, error: 'repoId or repoIds is required' });
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
if (ids.length === 0) {
|
|
244
|
+
res.status(400).json({ success: false, error: 'At least one repository is required' });
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
// Build worktree options if worktree mode is requested
|
|
248
|
+
let worktreeOptions;
|
|
249
|
+
if (worktreeMode) {
|
|
250
|
+
// Either use existing worktree or create new one
|
|
251
|
+
if (existingWorktreePath && typeof existingWorktreePath === 'string') {
|
|
252
|
+
// Use existing worktree
|
|
253
|
+
worktreeOptions = {
|
|
254
|
+
worktreeMode: true,
|
|
255
|
+
existingWorktreePath,
|
|
256
|
+
branch: '', // Will be detected from worktree
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
else if (branch && typeof branch === 'string') {
|
|
260
|
+
// Create new worktree
|
|
261
|
+
worktreeOptions = {
|
|
262
|
+
worktreeMode: true,
|
|
263
|
+
branch,
|
|
264
|
+
baseBranch,
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
else {
|
|
268
|
+
res.status(400).json({ success: false, error: 'Branch name or existingWorktreePath is required when worktreeMode is enabled' });
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
const session = terminalSessionManager.createSession(ids, worktreeOptions);
|
|
273
|
+
res.status(201).json({
|
|
274
|
+
success: true,
|
|
275
|
+
data: {
|
|
276
|
+
id: session.id,
|
|
277
|
+
repoIds: session.repoIds,
|
|
278
|
+
repoId: session.repoIds[0], // Backward compatibility
|
|
279
|
+
isMultiRepo: session.isMultiRepo,
|
|
280
|
+
status: session.status,
|
|
281
|
+
mode: session.mode,
|
|
282
|
+
messages: session.messages,
|
|
283
|
+
createdAt: session.createdAt,
|
|
284
|
+
lastActivityAt: session.lastActivityAt,
|
|
285
|
+
// Worktree fields
|
|
286
|
+
worktreeMode: session.worktreeMode,
|
|
287
|
+
worktreePath: session.worktreePath,
|
|
288
|
+
branch: session.branch,
|
|
289
|
+
baseBranch: session.baseBranch,
|
|
290
|
+
ownsWorktree: session.ownsWorktree,
|
|
291
|
+
},
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
catch (error) {
|
|
295
|
+
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
296
|
+
res.status(400).json({ success: false, error: errorMsg });
|
|
297
|
+
}
|
|
298
|
+
});
|
|
299
|
+
// Search across all session messages
|
|
300
|
+
terminalRouter.get('/sessions/search', (req, res) => {
|
|
301
|
+
try {
|
|
302
|
+
const query = req.query.q;
|
|
303
|
+
const limit = parseInt(req.query.limit) || 50;
|
|
304
|
+
if (!query || query.trim().length === 0) {
|
|
305
|
+
res.json({ success: true, data: [] });
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
const results = terminalSessionManager.searchMessages(query, limit);
|
|
309
|
+
res.json({
|
|
310
|
+
success: true,
|
|
311
|
+
data: results,
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
catch (error) {
|
|
315
|
+
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
316
|
+
res.status(500).json({ success: false, error: errorMsg });
|
|
317
|
+
}
|
|
318
|
+
});
|
|
319
|
+
// List all worktree sessions
|
|
320
|
+
terminalRouter.get('/sessions/worktrees', (_req, res) => {
|
|
321
|
+
try {
|
|
322
|
+
const sessions = terminalSessionManager.getWorktreeSessions();
|
|
323
|
+
res.json({
|
|
324
|
+
success: true,
|
|
325
|
+
data: sessions.map((session) => ({
|
|
326
|
+
id: session.id,
|
|
327
|
+
repoIds: session.repoIds,
|
|
328
|
+
repoId: session.repoIds[0],
|
|
329
|
+
status: session.status,
|
|
330
|
+
mode: session.mode,
|
|
331
|
+
messageCount: session.messages.length,
|
|
332
|
+
createdAt: session.createdAt,
|
|
333
|
+
lastActivityAt: session.lastActivityAt,
|
|
334
|
+
isBookmarked: session.isBookmarked,
|
|
335
|
+
name: session.name,
|
|
336
|
+
// Worktree specific fields
|
|
337
|
+
worktreeMode: session.worktreeMode,
|
|
338
|
+
worktreePath: session.worktreePath,
|
|
339
|
+
branch: session.branch,
|
|
340
|
+
baseBranch: session.baseBranch,
|
|
341
|
+
ownsWorktree: session.ownsWorktree,
|
|
342
|
+
})),
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
catch (error) {
|
|
346
|
+
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
347
|
+
res.status(500).json({ success: false, error: errorMsg });
|
|
348
|
+
}
|
|
349
|
+
});
|
|
350
|
+
// Get branches for a repository (for worktree creation UI)
|
|
351
|
+
// Pass ?fetch=true to fetch from remote first
|
|
352
|
+
terminalRouter.get('/repos/:repoId/branches', (req, res) => {
|
|
353
|
+
try {
|
|
354
|
+
const { repoId } = req.params;
|
|
355
|
+
const shouldFetch = req.query.fetch === 'true';
|
|
356
|
+
const repo = repoRegistry.get(repoId);
|
|
357
|
+
if (!repo) {
|
|
358
|
+
res.status(404).json({ success: false, error: 'Repository not found' });
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
let localBranches = [];
|
|
362
|
+
let remoteBranches = [];
|
|
363
|
+
let currentBranch = '';
|
|
364
|
+
// Fetch from remote first if requested (to get latest branches)
|
|
365
|
+
if (shouldFetch) {
|
|
366
|
+
try {
|
|
367
|
+
// Get exec options with credentials for authenticated fetch
|
|
368
|
+
const { options: execOptions, cleanup } = getGitExecOptions(repo.path, 60000);
|
|
369
|
+
try {
|
|
370
|
+
execSync('git fetch --all --prune', execOptions);
|
|
371
|
+
}
|
|
372
|
+
finally {
|
|
373
|
+
if (cleanup)
|
|
374
|
+
cleanup();
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
catch (fetchErr) {
|
|
378
|
+
// Log but don't fail - we can still show local branches
|
|
379
|
+
console.log('[branches] Fetch failed (may need auth):', fetchErr instanceof Error ? fetchErr.message : fetchErr);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
try {
|
|
383
|
+
// Get current branch
|
|
384
|
+
currentBranch = execSync('git branch --show-current', {
|
|
385
|
+
cwd: repo.path,
|
|
386
|
+
encoding: 'utf-8',
|
|
387
|
+
timeout: 5000,
|
|
388
|
+
}).trim();
|
|
389
|
+
// Get all local branches
|
|
390
|
+
const localOutput = execSync('git branch --format="%(refname:short)"', {
|
|
391
|
+
cwd: repo.path,
|
|
392
|
+
encoding: 'utf-8',
|
|
393
|
+
timeout: 10000,
|
|
394
|
+
});
|
|
395
|
+
localBranches = localOutput
|
|
396
|
+
.split('\n')
|
|
397
|
+
.map(b => b.trim())
|
|
398
|
+
.filter(b => b.length > 0);
|
|
399
|
+
// Get all remote branches (strip origin/ prefix for display)
|
|
400
|
+
const remoteOutput = execSync('git branch -r --format="%(refname:short)"', {
|
|
401
|
+
cwd: repo.path,
|
|
402
|
+
encoding: 'utf-8',
|
|
403
|
+
timeout: 10000,
|
|
404
|
+
});
|
|
405
|
+
remoteBranches = remoteOutput
|
|
406
|
+
.split('\n')
|
|
407
|
+
.map(b => b.trim())
|
|
408
|
+
.filter(b => b.length > 0 && !b.includes('HEAD'))
|
|
409
|
+
.map(b => b.replace(/^origin\//, '')); // Strip origin/ prefix
|
|
410
|
+
// Remove duplicates (branches that exist both locally and remotely)
|
|
411
|
+
remoteBranches = remoteBranches.filter(rb => !localBranches.includes(rb));
|
|
412
|
+
}
|
|
413
|
+
catch {
|
|
414
|
+
// Ignore errors - might not be a git repo
|
|
415
|
+
}
|
|
416
|
+
// Determine main branch
|
|
417
|
+
const mainBranch = gitSandbox.getMainBranch(repo.path);
|
|
418
|
+
// Combine branches: local first, then remote-only
|
|
419
|
+
const allBranches = [...localBranches, ...remoteBranches];
|
|
420
|
+
res.json({
|
|
421
|
+
success: true,
|
|
422
|
+
data: {
|
|
423
|
+
branches: allBranches,
|
|
424
|
+
localBranches,
|
|
425
|
+
remoteBranches,
|
|
426
|
+
currentBranch,
|
|
427
|
+
mainBranch,
|
|
428
|
+
},
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
catch (error) {
|
|
432
|
+
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
433
|
+
res.status(500).json({ success: false, error: errorMsg });
|
|
434
|
+
}
|
|
435
|
+
});
|
|
436
|
+
// List existing worktrees for a repository
|
|
437
|
+
terminalRouter.get('/repos/:repoId/worktrees', (req, res) => {
|
|
438
|
+
try {
|
|
439
|
+
const { repoId } = req.params;
|
|
440
|
+
const repo = repoRegistry.get(repoId);
|
|
441
|
+
if (!repo) {
|
|
442
|
+
res.status(404).json({ success: false, error: 'Repository not found' });
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
// Get list of worktree paths
|
|
446
|
+
const worktreePaths = gitSandbox.listWorktrees(repo.path);
|
|
447
|
+
// Get branch info for each worktree
|
|
448
|
+
const worktrees = worktreePaths.map((worktreePath) => {
|
|
449
|
+
let branch = '';
|
|
450
|
+
let isMain = false;
|
|
451
|
+
try {
|
|
452
|
+
// Get branch name for this worktree
|
|
453
|
+
branch = execSync('git rev-parse --abbrev-ref HEAD', {
|
|
454
|
+
cwd: worktreePath,
|
|
455
|
+
encoding: 'utf-8',
|
|
456
|
+
timeout: 5000,
|
|
457
|
+
}).trim();
|
|
458
|
+
// Check if this is the main worktree (same path as repo)
|
|
459
|
+
isMain = worktreePath.replace(/\\/g, '/') === repo.path.replace(/\\/g, '/');
|
|
460
|
+
}
|
|
461
|
+
catch {
|
|
462
|
+
// Ignore errors - might be a corrupted worktree
|
|
463
|
+
}
|
|
464
|
+
return {
|
|
465
|
+
path: worktreePath,
|
|
466
|
+
branch,
|
|
467
|
+
isMain,
|
|
468
|
+
};
|
|
469
|
+
});
|
|
470
|
+
// Filter out the main worktree and any without a branch (corrupted)
|
|
471
|
+
const usableWorktrees = worktrees.filter(wt => !wt.isMain && wt.branch);
|
|
472
|
+
res.json({
|
|
473
|
+
success: true,
|
|
474
|
+
data: usableWorktrees,
|
|
475
|
+
});
|
|
476
|
+
}
|
|
477
|
+
catch (error) {
|
|
478
|
+
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
479
|
+
res.status(500).json({ success: false, error: errorMsg });
|
|
480
|
+
}
|
|
481
|
+
});
|
|
482
|
+
// Get worktree info for a specific session
|
|
483
|
+
terminalRouter.get('/sessions/:id/worktree', (req, res) => {
|
|
484
|
+
try {
|
|
485
|
+
const { id } = req.params;
|
|
486
|
+
const info = terminalSessionManager.getWorktreeInfo(id);
|
|
487
|
+
if (!info) {
|
|
488
|
+
res.status(404).json({ success: false, error: 'Session not found' });
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
res.json({
|
|
492
|
+
success: true,
|
|
493
|
+
data: info,
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
catch (error) {
|
|
497
|
+
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
498
|
+
res.status(500).json({ success: false, error: errorMsg });
|
|
499
|
+
}
|
|
500
|
+
});
|
|
501
|
+
// List all sessions
|
|
502
|
+
terminalRouter.get('/sessions', (_req, res) => {
|
|
503
|
+
try {
|
|
504
|
+
const sessions = terminalSessionManager.getAllSessions();
|
|
505
|
+
res.json({
|
|
506
|
+
success: true,
|
|
507
|
+
data: sessions.map((session) => ({
|
|
508
|
+
id: session.id,
|
|
509
|
+
repoIds: session.repoIds,
|
|
510
|
+
repoId: session.repoIds[0], // Backward compatibility
|
|
511
|
+
isMultiRepo: session.isMultiRepo,
|
|
512
|
+
status: session.status,
|
|
513
|
+
mode: session.mode,
|
|
514
|
+
messageCount: session.messages.length,
|
|
515
|
+
lastMessage: session.messages[session.messages.length - 1]?.content.slice(0, 100),
|
|
516
|
+
createdAt: session.createdAt,
|
|
517
|
+
lastActivityAt: session.lastActivityAt,
|
|
518
|
+
isBookmarked: session.isBookmarked,
|
|
519
|
+
bookmarkedAt: session.bookmarkedAt,
|
|
520
|
+
name: session.name,
|
|
521
|
+
// Worktree fields
|
|
522
|
+
worktreeMode: session.worktreeMode,
|
|
523
|
+
branch: session.branch,
|
|
524
|
+
baseBranch: session.baseBranch,
|
|
525
|
+
ownsWorktree: session.ownsWorktree,
|
|
526
|
+
})),
|
|
527
|
+
});
|
|
528
|
+
}
|
|
529
|
+
catch (error) {
|
|
530
|
+
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
531
|
+
res.status(500).json({ success: false, error: errorMsg });
|
|
532
|
+
}
|
|
533
|
+
});
|
|
534
|
+
// Get a specific session with full history
|
|
535
|
+
terminalRouter.get('/sessions/:id', (req, res) => {
|
|
536
|
+
try {
|
|
537
|
+
const { id } = req.params;
|
|
538
|
+
const session = terminalSessionManager.getSession(id);
|
|
539
|
+
if (!session) {
|
|
540
|
+
res.status(404).json({ success: false, error: 'Session not found' });
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
res.json({
|
|
544
|
+
success: true,
|
|
545
|
+
data: {
|
|
546
|
+
id: session.id,
|
|
547
|
+
repoIds: session.repoIds,
|
|
548
|
+
repoId: session.repoIds[0], // Backward compatibility
|
|
549
|
+
isMultiRepo: session.isMultiRepo,
|
|
550
|
+
mergedFromSessionIds: session.mergedFromSessionIds,
|
|
551
|
+
status: session.status,
|
|
552
|
+
mode: session.mode,
|
|
553
|
+
messages: session.messages,
|
|
554
|
+
createdAt: session.createdAt,
|
|
555
|
+
lastActivityAt: session.lastActivityAt,
|
|
556
|
+
isBookmarked: session.isBookmarked,
|
|
557
|
+
bookmarkedAt: session.bookmarkedAt,
|
|
558
|
+
name: session.name,
|
|
559
|
+
// Worktree fields
|
|
560
|
+
worktreeMode: session.worktreeMode,
|
|
561
|
+
worktreePath: session.worktreePath,
|
|
562
|
+
branch: session.branch,
|
|
563
|
+
baseBranch: session.baseBranch,
|
|
564
|
+
ownsWorktree: session.ownsWorktree,
|
|
565
|
+
},
|
|
566
|
+
});
|
|
567
|
+
}
|
|
568
|
+
catch (error) {
|
|
569
|
+
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
570
|
+
res.status(500).json({ success: false, error: errorMsg });
|
|
571
|
+
}
|
|
572
|
+
});
|
|
573
|
+
// Upload file attachments for a session
|
|
574
|
+
terminalRouter.post('/sessions/:id/attachments', attachmentUpload.array('files', 10), (req, res) => {
|
|
575
|
+
const { id } = req.params;
|
|
576
|
+
const files = req.files;
|
|
577
|
+
try {
|
|
578
|
+
const session = terminalSessionManager.getSession(id);
|
|
579
|
+
if (!session) {
|
|
580
|
+
// Clean up uploaded files if session not found
|
|
581
|
+
files?.forEach((f) => existsSync(f.path) && unlinkSync(f.path));
|
|
582
|
+
res.status(404).json({ success: false, error: 'Session not found' });
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
585
|
+
if (!files || files.length === 0) {
|
|
586
|
+
res.status(400).json({ success: false, error: 'No files provided' });
|
|
587
|
+
return;
|
|
588
|
+
}
|
|
589
|
+
// Rename files with session prefix for easier cleanup
|
|
590
|
+
const attachments = files.map((file) => {
|
|
591
|
+
const newFilename = `${id}_${Date.now()}_${file.originalname.replace(/[^a-zA-Z0-9._-]/g, '_')}`;
|
|
592
|
+
const newPath = join(ATTACHMENTS_DIR, newFilename);
|
|
593
|
+
renameSync(file.path, newPath);
|
|
594
|
+
return {
|
|
595
|
+
id: randomUUID(),
|
|
596
|
+
originalName: file.originalname,
|
|
597
|
+
path: newPath,
|
|
598
|
+
size: file.size,
|
|
599
|
+
mimeType: file.mimetype,
|
|
600
|
+
uploadedAt: new Date().toISOString(),
|
|
601
|
+
};
|
|
602
|
+
});
|
|
603
|
+
res.json({
|
|
604
|
+
success: true,
|
|
605
|
+
data: { attachments },
|
|
606
|
+
});
|
|
607
|
+
}
|
|
608
|
+
catch (error) {
|
|
609
|
+
// Clean up files on error
|
|
610
|
+
files?.forEach((f) => existsSync(f.path) && unlinkSync(f.path));
|
|
611
|
+
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
612
|
+
res.status(500).json({ success: false, error: errorMsg });
|
|
613
|
+
}
|
|
614
|
+
});
|
|
615
|
+
// List attachments for a session
|
|
616
|
+
terminalRouter.get('/sessions/:id/attachments', (req, res) => {
|
|
617
|
+
const { id } = req.params;
|
|
618
|
+
try {
|
|
619
|
+
const session = terminalSessionManager.getSession(id);
|
|
620
|
+
if (!session) {
|
|
621
|
+
res.status(404).json({ success: false, error: 'Session not found' });
|
|
622
|
+
return;
|
|
623
|
+
}
|
|
624
|
+
// Find all files prefixed with this session ID
|
|
625
|
+
const files = readdirSync(ATTACHMENTS_DIR)
|
|
626
|
+
.filter((f) => f.startsWith(`${id}_`))
|
|
627
|
+
.map((filename) => {
|
|
628
|
+
const filePath = join(ATTACHMENTS_DIR, filename);
|
|
629
|
+
const stat = statSync(filePath);
|
|
630
|
+
// Extract original name from filename (format: sessionId_timestamp_originalName)
|
|
631
|
+
const parts = filename.split('_');
|
|
632
|
+
const originalName = parts.slice(2).join('_');
|
|
633
|
+
return {
|
|
634
|
+
id: filename,
|
|
635
|
+
originalName,
|
|
636
|
+
path: filePath,
|
|
637
|
+
size: stat.size,
|
|
638
|
+
uploadedAt: stat.mtime.toISOString(),
|
|
639
|
+
};
|
|
640
|
+
});
|
|
641
|
+
res.json({
|
|
642
|
+
success: true,
|
|
643
|
+
data: { attachments: files },
|
|
644
|
+
});
|
|
645
|
+
}
|
|
646
|
+
catch (error) {
|
|
647
|
+
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
648
|
+
res.status(500).json({ success: false, error: errorMsg });
|
|
649
|
+
}
|
|
650
|
+
});
|
|
651
|
+
// Delete a specific attachment
|
|
652
|
+
terminalRouter.delete('/sessions/:id/attachments/:attachmentId', (req, res) => {
|
|
653
|
+
const { id, attachmentId } = req.params;
|
|
654
|
+
try {
|
|
655
|
+
const session = terminalSessionManager.getSession(id);
|
|
656
|
+
if (!session) {
|
|
657
|
+
res.status(404).json({ success: false, error: 'Session not found' });
|
|
658
|
+
return;
|
|
659
|
+
}
|
|
660
|
+
// Ensure the attachment belongs to this session
|
|
661
|
+
if (!attachmentId.startsWith(`${id}_`)) {
|
|
662
|
+
res.status(403).json({ success: false, error: 'Attachment does not belong to this session' });
|
|
663
|
+
return;
|
|
664
|
+
}
|
|
665
|
+
const filePath = join(ATTACHMENTS_DIR, attachmentId);
|
|
666
|
+
if (existsSync(filePath)) {
|
|
667
|
+
unlinkSync(filePath);
|
|
668
|
+
res.json({ success: true, data: { deleted: attachmentId } });
|
|
669
|
+
}
|
|
670
|
+
else {
|
|
671
|
+
res.status(404).json({ success: false, error: 'Attachment not found' });
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
catch (error) {
|
|
675
|
+
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
676
|
+
res.status(500).json({ success: false, error: errorMsg });
|
|
677
|
+
}
|
|
678
|
+
});
|
|
679
|
+
// Send a message to a session (REST fallback for non-WebSocket clients)
|
|
680
|
+
terminalRouter.post('/sessions/:id/message', async (req, res) => {
|
|
681
|
+
try {
|
|
682
|
+
const { id } = req.params;
|
|
683
|
+
const { content } = req.body;
|
|
684
|
+
if (!content || typeof content !== 'string') {
|
|
685
|
+
res.status(400).json({ success: false, error: 'content is required' });
|
|
686
|
+
return;
|
|
687
|
+
}
|
|
688
|
+
const session = terminalSessionManager.getSession(id);
|
|
689
|
+
if (!session) {
|
|
690
|
+
res.status(404).json({ success: false, error: 'Session not found' });
|
|
691
|
+
return;
|
|
692
|
+
}
|
|
693
|
+
// This is async but we respond immediately
|
|
694
|
+
// Client should use WebSocket for real-time updates
|
|
695
|
+
terminalSessionManager.sendMessage(id, content).catch((err) => {
|
|
696
|
+
console.error(`[Terminal] Message send error:`, err);
|
|
697
|
+
});
|
|
698
|
+
res.json({
|
|
699
|
+
success: true,
|
|
700
|
+
data: { message: 'Message sent. Use WebSocket for real-time updates.' },
|
|
701
|
+
});
|
|
702
|
+
}
|
|
703
|
+
catch (error) {
|
|
704
|
+
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
705
|
+
res.status(400).json({ success: false, error: errorMsg });
|
|
706
|
+
}
|
|
707
|
+
});
|
|
708
|
+
// Set session mode
|
|
709
|
+
terminalRouter.patch('/sessions/:id/mode', (req, res) => {
|
|
710
|
+
try {
|
|
711
|
+
const { id } = req.params;
|
|
712
|
+
const { mode } = req.body;
|
|
713
|
+
if (mode !== 'plan' && mode !== 'direct') {
|
|
714
|
+
res.status(400).json({ success: false, error: 'mode must be "plan" or "direct"' });
|
|
715
|
+
return;
|
|
716
|
+
}
|
|
717
|
+
const session = terminalSessionManager.getSession(id);
|
|
718
|
+
if (!session) {
|
|
719
|
+
res.status(404).json({ success: false, error: 'Session not found' });
|
|
720
|
+
return;
|
|
721
|
+
}
|
|
722
|
+
terminalSessionManager.setMode(id, mode);
|
|
723
|
+
res.json({
|
|
724
|
+
success: true,
|
|
725
|
+
data: { mode },
|
|
726
|
+
});
|
|
727
|
+
}
|
|
728
|
+
catch (error) {
|
|
729
|
+
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
730
|
+
res.status(400).json({ success: false, error: errorMsg });
|
|
731
|
+
}
|
|
732
|
+
});
|
|
733
|
+
// Export session as markdown or JSON
|
|
734
|
+
terminalRouter.get('/sessions/:id/export', (req, res) => {
|
|
735
|
+
try {
|
|
736
|
+
const { id } = req.params;
|
|
737
|
+
const format = req.query.format || 'markdown';
|
|
738
|
+
if (format !== 'markdown' && format !== 'json') {
|
|
739
|
+
res.status(400).json({ success: false, error: 'format must be "markdown" or "json"' });
|
|
740
|
+
return;
|
|
741
|
+
}
|
|
742
|
+
const content = terminalSessionManager.exportSession(id, format);
|
|
743
|
+
if (!content) {
|
|
744
|
+
res.status(404).json({ success: false, error: 'Session not found' });
|
|
745
|
+
return;
|
|
746
|
+
}
|
|
747
|
+
const session = terminalSessionManager.getSession(id);
|
|
748
|
+
const filename = `session-${session?.name || session?.repoIds[0] || id}-${new Date().toISOString().split('T')[0]}`;
|
|
749
|
+
if (format === 'json') {
|
|
750
|
+
res.setHeader('Content-Type', 'application/json');
|
|
751
|
+
res.setHeader('Content-Disposition', `attachment; filename="${filename}.json"`);
|
|
752
|
+
}
|
|
753
|
+
else {
|
|
754
|
+
res.setHeader('Content-Type', 'text/markdown');
|
|
755
|
+
res.setHeader('Content-Disposition', `attachment; filename="${filename}.md"`);
|
|
756
|
+
}
|
|
757
|
+
res.send(content);
|
|
758
|
+
}
|
|
759
|
+
catch (error) {
|
|
760
|
+
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
761
|
+
res.status(500).json({ success: false, error: errorMsg });
|
|
762
|
+
}
|
|
763
|
+
});
|
|
764
|
+
// Toggle session bookmark
|
|
765
|
+
terminalRouter.patch('/sessions/:id/bookmark', (req, res) => {
|
|
766
|
+
try {
|
|
767
|
+
const { id } = req.params;
|
|
768
|
+
const { isBookmarked } = req.body;
|
|
769
|
+
if (typeof isBookmarked !== 'boolean') {
|
|
770
|
+
res.status(400).json({ success: false, error: 'isBookmarked must be a boolean' });
|
|
771
|
+
return;
|
|
772
|
+
}
|
|
773
|
+
const session = terminalSessionManager.setBookmark(id, isBookmarked);
|
|
774
|
+
if (!session) {
|
|
775
|
+
res.status(404).json({ success: false, error: 'Session not found' });
|
|
776
|
+
return;
|
|
777
|
+
}
|
|
778
|
+
res.json({
|
|
779
|
+
success: true,
|
|
780
|
+
data: {
|
|
781
|
+
id: session.id,
|
|
782
|
+
isBookmarked: session.isBookmarked,
|
|
783
|
+
bookmarkedAt: session.bookmarkedAt,
|
|
784
|
+
},
|
|
785
|
+
});
|
|
786
|
+
}
|
|
787
|
+
catch (error) {
|
|
788
|
+
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
789
|
+
res.status(400).json({ success: false, error: errorMsg });
|
|
790
|
+
}
|
|
791
|
+
});
|
|
792
|
+
// Cancel running operation
|
|
793
|
+
terminalRouter.post('/sessions/:id/cancel', (req, res) => {
|
|
794
|
+
try {
|
|
795
|
+
const { id } = req.params;
|
|
796
|
+
const session = terminalSessionManager.getSession(id);
|
|
797
|
+
if (!session) {
|
|
798
|
+
res.status(404).json({ success: false, error: 'Session not found' });
|
|
799
|
+
return;
|
|
800
|
+
}
|
|
801
|
+
terminalSessionManager.cancelSession(id);
|
|
802
|
+
res.json({
|
|
803
|
+
success: true,
|
|
804
|
+
data: { message: 'Cancelled' },
|
|
805
|
+
});
|
|
806
|
+
}
|
|
807
|
+
catch (error) {
|
|
808
|
+
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
809
|
+
res.status(400).json({ success: false, error: errorMsg });
|
|
810
|
+
}
|
|
811
|
+
});
|
|
812
|
+
// Clear session messages
|
|
813
|
+
terminalRouter.post('/sessions/:id/clear', (req, res) => {
|
|
814
|
+
try {
|
|
815
|
+
const { id } = req.params;
|
|
816
|
+
const session = terminalSessionManager.getSession(id);
|
|
817
|
+
if (!session) {
|
|
818
|
+
res.status(404).json({ success: false, error: 'Session not found' });
|
|
819
|
+
return;
|
|
820
|
+
}
|
|
821
|
+
terminalSessionManager.clearMessages(id);
|
|
822
|
+
res.json({
|
|
823
|
+
success: true,
|
|
824
|
+
data: { message: 'Messages cleared' },
|
|
825
|
+
});
|
|
826
|
+
}
|
|
827
|
+
catch (error) {
|
|
828
|
+
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
829
|
+
res.status(400).json({ success: false, error: errorMsg });
|
|
830
|
+
}
|
|
831
|
+
});
|
|
832
|
+
// Delete a session
|
|
833
|
+
// For worktree sessions:
|
|
834
|
+
// - ?deleteBranch=true to also delete the git branch
|
|
835
|
+
// - ?deleteWorktree=false to keep the worktree on disk (close session only)
|
|
836
|
+
terminalRouter.delete('/sessions/:id', (req, res) => {
|
|
837
|
+
try {
|
|
838
|
+
const { id } = req.params;
|
|
839
|
+
const deleteBranch = req.query.deleteBranch === 'true';
|
|
840
|
+
// Default to true for backwards compatibility - only skip worktree deletion if explicitly set to 'false'
|
|
841
|
+
const deleteWorktree = req.query.deleteWorktree !== 'false';
|
|
842
|
+
const session = terminalSessionManager.getSession(id);
|
|
843
|
+
if (!session) {
|
|
844
|
+
res.status(404).json({ success: false, error: 'Session not found' });
|
|
845
|
+
return;
|
|
846
|
+
}
|
|
847
|
+
const result = terminalSessionManager.deleteSession(id, deleteBranch, deleteWorktree);
|
|
848
|
+
res.json({
|
|
849
|
+
success: true,
|
|
850
|
+
data: {
|
|
851
|
+
message: 'Session deleted',
|
|
852
|
+
worktreeDeleted: result.worktreeDeleted,
|
|
853
|
+
branchDeleted: result.branchDeleted,
|
|
854
|
+
},
|
|
855
|
+
});
|
|
856
|
+
}
|
|
857
|
+
catch (error) {
|
|
858
|
+
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
859
|
+
res.status(400).json({ success: false, error: errorMsg });
|
|
860
|
+
}
|
|
861
|
+
});
|
|
862
|
+
// Merge multiple sessions into one multi-repo session
|
|
863
|
+
terminalRouter.post('/sessions/merge', (req, res) => {
|
|
864
|
+
try {
|
|
865
|
+
const { sessionIds } = req.body;
|
|
866
|
+
if (!sessionIds || !Array.isArray(sessionIds) || sessionIds.length < 2) {
|
|
867
|
+
res.status(400).json({
|
|
868
|
+
success: false,
|
|
869
|
+
error: 'sessionIds array with at least 2 session IDs is required',
|
|
870
|
+
});
|
|
871
|
+
return;
|
|
872
|
+
}
|
|
873
|
+
const session = terminalSessionManager.mergeSessions(sessionIds);
|
|
874
|
+
res.status(201).json({
|
|
875
|
+
success: true,
|
|
876
|
+
data: {
|
|
877
|
+
id: session.id,
|
|
878
|
+
repoIds: session.repoIds,
|
|
879
|
+
repoId: session.repoIds[0],
|
|
880
|
+
isMultiRepo: session.isMultiRepo,
|
|
881
|
+
mergedFromSessionIds: session.mergedFromSessionIds,
|
|
882
|
+
status: session.status,
|
|
883
|
+
mode: session.mode,
|
|
884
|
+
messages: session.messages,
|
|
885
|
+
createdAt: session.createdAt,
|
|
886
|
+
lastActivityAt: session.lastActivityAt,
|
|
887
|
+
},
|
|
888
|
+
});
|
|
889
|
+
}
|
|
890
|
+
catch (error) {
|
|
891
|
+
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
892
|
+
res.status(400).json({ success: false, error: errorMsg });
|
|
893
|
+
}
|
|
894
|
+
});
|
|
895
|
+
// Add a repository to an existing session
|
|
896
|
+
terminalRouter.post('/sessions/:id/add-repo', (req, res) => {
|
|
897
|
+
try {
|
|
898
|
+
const { id } = req.params;
|
|
899
|
+
const { repoId } = req.body;
|
|
900
|
+
if (!repoId || typeof repoId !== 'string') {
|
|
901
|
+
res.status(400).json({ success: false, error: 'repoId is required' });
|
|
902
|
+
return;
|
|
903
|
+
}
|
|
904
|
+
const session = terminalSessionManager.addRepoToSession(id, repoId);
|
|
905
|
+
res.json({
|
|
906
|
+
success: true,
|
|
907
|
+
data: {
|
|
908
|
+
id: session.id,
|
|
909
|
+
repoIds: session.repoIds,
|
|
910
|
+
isMultiRepo: session.isMultiRepo,
|
|
911
|
+
},
|
|
912
|
+
});
|
|
913
|
+
}
|
|
914
|
+
catch (error) {
|
|
915
|
+
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
916
|
+
res.status(400).json({ success: false, error: errorMsg });
|
|
917
|
+
}
|
|
918
|
+
});
|
|
919
|
+
// Remove a repository from a session
|
|
920
|
+
terminalRouter.post('/sessions/:id/remove-repo', (req, res) => {
|
|
921
|
+
try {
|
|
922
|
+
const { id } = req.params;
|
|
923
|
+
const { repoId } = req.body;
|
|
924
|
+
if (!repoId || typeof repoId !== 'string') {
|
|
925
|
+
res.status(400).json({ success: false, error: 'repoId is required' });
|
|
926
|
+
return;
|
|
927
|
+
}
|
|
928
|
+
const session = terminalSessionManager.removeRepoFromSession(id, repoId);
|
|
929
|
+
res.json({
|
|
930
|
+
success: true,
|
|
931
|
+
data: {
|
|
932
|
+
id: session.id,
|
|
933
|
+
repoIds: session.repoIds,
|
|
934
|
+
isMultiRepo: session.isMultiRepo,
|
|
935
|
+
},
|
|
936
|
+
});
|
|
937
|
+
}
|
|
938
|
+
catch (error) {
|
|
939
|
+
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
940
|
+
res.status(400).json({ success: false, error: errorMsg });
|
|
941
|
+
}
|
|
942
|
+
});
|
|
943
|
+
// Get git status for all repositories in a session (multi-repo support)
|
|
944
|
+
terminalRouter.get('/sessions/:id/multi-git-status', (req, res) => {
|
|
945
|
+
try {
|
|
946
|
+
const { id } = req.params;
|
|
947
|
+
const session = terminalSessionManager.getSession(id);
|
|
948
|
+
if (!session) {
|
|
949
|
+
res.status(404).json({ success: false, error: 'Session not found' });
|
|
950
|
+
return;
|
|
951
|
+
}
|
|
952
|
+
const repos = {};
|
|
953
|
+
for (const repoId of session.repoIds) {
|
|
954
|
+
const repo = repoRegistry.get(repoId);
|
|
955
|
+
if (!repo)
|
|
956
|
+
continue;
|
|
957
|
+
// For worktree sessions (single repo), use worktree path
|
|
958
|
+
const workingDir = session.worktreeMode && session.worktreePath
|
|
959
|
+
? session.worktreePath
|
|
960
|
+
: repo.path;
|
|
961
|
+
let branch = 'unknown';
|
|
962
|
+
let modified = 0;
|
|
963
|
+
let staged = 0;
|
|
964
|
+
let untracked = 0;
|
|
965
|
+
try {
|
|
966
|
+
branch = execSync('git branch --show-current', {
|
|
967
|
+
cwd: workingDir,
|
|
968
|
+
encoding: 'utf-8',
|
|
969
|
+
timeout: 5000,
|
|
970
|
+
}).trim();
|
|
971
|
+
}
|
|
972
|
+
catch {
|
|
973
|
+
// Ignore errors
|
|
974
|
+
}
|
|
975
|
+
try {
|
|
976
|
+
const status = execSync('git status --porcelain', {
|
|
977
|
+
cwd: workingDir,
|
|
978
|
+
encoding: 'utf-8',
|
|
979
|
+
timeout: 5000,
|
|
980
|
+
});
|
|
981
|
+
const lines = status.split('\n').filter((line) => line.trim());
|
|
982
|
+
for (const line of lines) {
|
|
983
|
+
const indexStatus = line[0];
|
|
984
|
+
const workTreeStatus = line[1];
|
|
985
|
+
if (indexStatus !== ' ' && indexStatus !== '?') {
|
|
986
|
+
staged++;
|
|
987
|
+
}
|
|
988
|
+
if (workTreeStatus === 'M' || workTreeStatus === 'D') {
|
|
989
|
+
modified++;
|
|
990
|
+
}
|
|
991
|
+
if (indexStatus === '?') {
|
|
992
|
+
untracked++;
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
catch {
|
|
997
|
+
// Ignore errors
|
|
998
|
+
}
|
|
999
|
+
repos[repoId] = {
|
|
1000
|
+
repoId,
|
|
1001
|
+
repoPath: workingDir,
|
|
1002
|
+
branch,
|
|
1003
|
+
modified,
|
|
1004
|
+
staged,
|
|
1005
|
+
untracked,
|
|
1006
|
+
worktreeMode: session.worktreeMode,
|
|
1007
|
+
};
|
|
1008
|
+
}
|
|
1009
|
+
res.json({
|
|
1010
|
+
success: true,
|
|
1011
|
+
data: {
|
|
1012
|
+
isMultiRepo: session.isMultiRepo,
|
|
1013
|
+
worktreeMode: session.worktreeMode,
|
|
1014
|
+
repos,
|
|
1015
|
+
},
|
|
1016
|
+
});
|
|
1017
|
+
}
|
|
1018
|
+
catch (error) {
|
|
1019
|
+
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
1020
|
+
res.status(500).json({ success: false, error: errorMsg });
|
|
1021
|
+
}
|
|
1022
|
+
});
|
|
1023
|
+
// Get git status for a session's repository
|
|
1024
|
+
terminalRouter.get('/sessions/:id/git-status', (req, res) => {
|
|
1025
|
+
try {
|
|
1026
|
+
const { id } = req.params;
|
|
1027
|
+
const requestedRepoId = req.query.repoId;
|
|
1028
|
+
const session = terminalSessionManager.getSession(id);
|
|
1029
|
+
if (!session) {
|
|
1030
|
+
res.status(404).json({ success: false, error: 'Session not found' });
|
|
1031
|
+
return;
|
|
1032
|
+
}
|
|
1033
|
+
const resolved = resolveSessionRepo(session, requestedRepoId);
|
|
1034
|
+
if (!resolved) {
|
|
1035
|
+
res.status(404).json({ success: false, error: 'Repository not found' });
|
|
1036
|
+
return;
|
|
1037
|
+
}
|
|
1038
|
+
const { repo } = resolved;
|
|
1039
|
+
const workingDir = getWorkingDir(session, repo);
|
|
1040
|
+
// Get git branch
|
|
1041
|
+
let branch = 'unknown';
|
|
1042
|
+
try {
|
|
1043
|
+
branch = execSync('git branch --show-current', {
|
|
1044
|
+
cwd: workingDir,
|
|
1045
|
+
encoding: 'utf-8',
|
|
1046
|
+
timeout: 5000,
|
|
1047
|
+
}).trim();
|
|
1048
|
+
}
|
|
1049
|
+
catch {
|
|
1050
|
+
// Ignore errors
|
|
1051
|
+
}
|
|
1052
|
+
// Get git status counts and file details
|
|
1053
|
+
let modified = 0;
|
|
1054
|
+
let staged = 0;
|
|
1055
|
+
let untracked = 0;
|
|
1056
|
+
const files = [];
|
|
1057
|
+
try {
|
|
1058
|
+
const status = execSync('git status --porcelain', {
|
|
1059
|
+
cwd: workingDir,
|
|
1060
|
+
encoding: 'utf-8',
|
|
1061
|
+
timeout: 5000,
|
|
1062
|
+
});
|
|
1063
|
+
const lines = status.split('\n').filter(line => line.trim());
|
|
1064
|
+
for (const line of lines) {
|
|
1065
|
+
const indexStatus = line[0];
|
|
1066
|
+
const workTreeStatus = line[1];
|
|
1067
|
+
// File path starts at position 3 (after "XY ")
|
|
1068
|
+
const filePath = line.substring(3).trim();
|
|
1069
|
+
if (indexStatus !== ' ' && indexStatus !== '?') {
|
|
1070
|
+
staged++;
|
|
1071
|
+
}
|
|
1072
|
+
if (workTreeStatus === 'M' || workTreeStatus === 'D') {
|
|
1073
|
+
modified++;
|
|
1074
|
+
}
|
|
1075
|
+
if (indexStatus === '?') {
|
|
1076
|
+
untracked++;
|
|
1077
|
+
}
|
|
1078
|
+
// Determine file status for display
|
|
1079
|
+
let fileStatus = 'modified';
|
|
1080
|
+
if (indexStatus === '?' && workTreeStatus === '?') {
|
|
1081
|
+
fileStatus = 'untracked';
|
|
1082
|
+
}
|
|
1083
|
+
else if (indexStatus === 'A') {
|
|
1084
|
+
fileStatus = 'added';
|
|
1085
|
+
}
|
|
1086
|
+
else if (indexStatus === 'D' || workTreeStatus === 'D') {
|
|
1087
|
+
fileStatus = 'deleted';
|
|
1088
|
+
}
|
|
1089
|
+
else if (indexStatus === 'R') {
|
|
1090
|
+
fileStatus = 'renamed';
|
|
1091
|
+
}
|
|
1092
|
+
else if (indexStatus === 'M' || workTreeStatus === 'M') {
|
|
1093
|
+
fileStatus = 'modified';
|
|
1094
|
+
}
|
|
1095
|
+
if (filePath) {
|
|
1096
|
+
files.push({ path: filePath, status: fileStatus });
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
catch {
|
|
1101
|
+
// Ignore errors
|
|
1102
|
+
}
|
|
1103
|
+
res.json({
|
|
1104
|
+
success: true,
|
|
1105
|
+
data: { branch, modified, staged, untracked, files, worktreeMode: session.worktreeMode },
|
|
1106
|
+
});
|
|
1107
|
+
}
|
|
1108
|
+
catch (error) {
|
|
1109
|
+
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
1110
|
+
res.status(500).json({ success: false, error: errorMsg });
|
|
1111
|
+
}
|
|
1112
|
+
});
|
|
1113
|
+
// Read a file from a session's repository
|
|
1114
|
+
terminalRouter.post('/sessions/:id/read-file', (req, res) => {
|
|
1115
|
+
try {
|
|
1116
|
+
const { id } = req.params;
|
|
1117
|
+
const { filePath, repoId: requestedRepoId } = req.body;
|
|
1118
|
+
if (!filePath || typeof filePath !== 'string') {
|
|
1119
|
+
res.status(400).json({ success: false, error: 'filePath is required' });
|
|
1120
|
+
return;
|
|
1121
|
+
}
|
|
1122
|
+
const session = terminalSessionManager.getSession(id);
|
|
1123
|
+
if (!session) {
|
|
1124
|
+
res.status(404).json({ success: false, error: 'Session not found' });
|
|
1125
|
+
return;
|
|
1126
|
+
}
|
|
1127
|
+
const resolved = resolveSessionRepo(session, requestedRepoId);
|
|
1128
|
+
if (!resolved) {
|
|
1129
|
+
res.status(404).json({ success: false, error: 'Repository not found' });
|
|
1130
|
+
return;
|
|
1131
|
+
}
|
|
1132
|
+
const { repo } = resolved;
|
|
1133
|
+
const workingDir = getWorkingDir(session, repo);
|
|
1134
|
+
// Ensure path is within working dir (security check)
|
|
1135
|
+
const fullPath = join(workingDir, filePath);
|
|
1136
|
+
if (!fullPath.startsWith(workingDir)) {
|
|
1137
|
+
res.status(400).json({ success: false, error: 'Invalid file path' });
|
|
1138
|
+
return;
|
|
1139
|
+
}
|
|
1140
|
+
if (!existsSync(fullPath)) {
|
|
1141
|
+
res.status(404).json({ success: false, error: 'File not found' });
|
|
1142
|
+
return;
|
|
1143
|
+
}
|
|
1144
|
+
const content = readFileSync(fullPath, 'utf-8');
|
|
1145
|
+
const extension = extname(filePath).slice(1) || 'txt';
|
|
1146
|
+
// Map extensions to language names
|
|
1147
|
+
const languageMap = {
|
|
1148
|
+
ts: 'typescript',
|
|
1149
|
+
tsx: 'tsx',
|
|
1150
|
+
js: 'javascript',
|
|
1151
|
+
jsx: 'jsx',
|
|
1152
|
+
py: 'python',
|
|
1153
|
+
rb: 'ruby',
|
|
1154
|
+
go: 'go',
|
|
1155
|
+
rs: 'rust',
|
|
1156
|
+
java: 'java',
|
|
1157
|
+
cpp: 'cpp',
|
|
1158
|
+
c: 'c',
|
|
1159
|
+
cs: 'csharp',
|
|
1160
|
+
php: 'php',
|
|
1161
|
+
swift: 'swift',
|
|
1162
|
+
kt: 'kotlin',
|
|
1163
|
+
sql: 'sql',
|
|
1164
|
+
json: 'json',
|
|
1165
|
+
yaml: 'yaml',
|
|
1166
|
+
yml: 'yaml',
|
|
1167
|
+
xml: 'xml',
|
|
1168
|
+
html: 'html',
|
|
1169
|
+
css: 'css',
|
|
1170
|
+
scss: 'scss',
|
|
1171
|
+
md: 'markdown',
|
|
1172
|
+
sh: 'bash',
|
|
1173
|
+
bash: 'bash',
|
|
1174
|
+
zsh: 'bash',
|
|
1175
|
+
};
|
|
1176
|
+
res.json({
|
|
1177
|
+
success: true,
|
|
1178
|
+
data: {
|
|
1179
|
+
content,
|
|
1180
|
+
language: languageMap[extension] || extension,
|
|
1181
|
+
path: filePath,
|
|
1182
|
+
},
|
|
1183
|
+
});
|
|
1184
|
+
}
|
|
1185
|
+
catch (error) {
|
|
1186
|
+
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
1187
|
+
res.status(500).json({ success: false, error: errorMsg });
|
|
1188
|
+
}
|
|
1189
|
+
});
|
|
1190
|
+
// Get git diff for a file
|
|
1191
|
+
terminalRouter.post('/sessions/:id/file-diff', (req, res) => {
|
|
1192
|
+
try {
|
|
1193
|
+
const { id } = req.params;
|
|
1194
|
+
const { filePath, staged, repoId: requestedRepoId } = req.body;
|
|
1195
|
+
const session = terminalSessionManager.getSession(id);
|
|
1196
|
+
if (!session) {
|
|
1197
|
+
res.status(404).json({ success: false, error: 'Session not found' });
|
|
1198
|
+
return;
|
|
1199
|
+
}
|
|
1200
|
+
const resolved = resolveSessionRepo(session, requestedRepoId);
|
|
1201
|
+
if (!resolved) {
|
|
1202
|
+
res.status(404).json({ success: false, error: 'Repository not found' });
|
|
1203
|
+
return;
|
|
1204
|
+
}
|
|
1205
|
+
const { repo } = resolved;
|
|
1206
|
+
const workingDir = getWorkingDir(session, repo);
|
|
1207
|
+
let diff = '';
|
|
1208
|
+
try {
|
|
1209
|
+
// Use --cached for staged files, regular diff for unstaged
|
|
1210
|
+
const cachedFlag = staged ? '--cached' : '';
|
|
1211
|
+
if (filePath) {
|
|
1212
|
+
// Diff for specific file (use -M to detect renames)
|
|
1213
|
+
diff = execSync(`git diff ${cachedFlag} -M -- "${filePath}"`, {
|
|
1214
|
+
cwd: workingDir,
|
|
1215
|
+
encoding: 'utf-8',
|
|
1216
|
+
timeout: 10000,
|
|
1217
|
+
});
|
|
1218
|
+
}
|
|
1219
|
+
else {
|
|
1220
|
+
// Diff for all changed files
|
|
1221
|
+
diff = execSync(`git diff ${cachedFlag} -M`, {
|
|
1222
|
+
cwd: workingDir,
|
|
1223
|
+
encoding: 'utf-8',
|
|
1224
|
+
timeout: 10000,
|
|
1225
|
+
});
|
|
1226
|
+
}
|
|
1227
|
+
}
|
|
1228
|
+
catch {
|
|
1229
|
+
// No changes or git error
|
|
1230
|
+
diff = '';
|
|
1231
|
+
}
|
|
1232
|
+
res.json({
|
|
1233
|
+
success: true,
|
|
1234
|
+
data: { diff },
|
|
1235
|
+
});
|
|
1236
|
+
}
|
|
1237
|
+
catch (error) {
|
|
1238
|
+
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
1239
|
+
res.status(500).json({ success: false, error: errorMsg });
|
|
1240
|
+
}
|
|
1241
|
+
});
|
|
1242
|
+
// Get list of changed files
|
|
1243
|
+
terminalRouter.get('/sessions/:id/changed-files', (req, res) => {
|
|
1244
|
+
try {
|
|
1245
|
+
const { id } = req.params;
|
|
1246
|
+
const requestedRepoId = req.query.repoId;
|
|
1247
|
+
const session = terminalSessionManager.getSession(id);
|
|
1248
|
+
if (!session) {
|
|
1249
|
+
res.status(404).json({ success: false, error: 'Session not found' });
|
|
1250
|
+
return;
|
|
1251
|
+
}
|
|
1252
|
+
const resolved = resolveSessionRepo(session, requestedRepoId);
|
|
1253
|
+
if (!resolved) {
|
|
1254
|
+
res.status(404).json({ success: false, error: 'Repository not found' });
|
|
1255
|
+
return;
|
|
1256
|
+
}
|
|
1257
|
+
const { repo } = resolved;
|
|
1258
|
+
const workingDir = getWorkingDir(session, repo);
|
|
1259
|
+
const files = [];
|
|
1260
|
+
try {
|
|
1261
|
+
const status = execSync('git status --porcelain', {
|
|
1262
|
+
cwd: workingDir,
|
|
1263
|
+
encoding: 'utf-8',
|
|
1264
|
+
timeout: 5000,
|
|
1265
|
+
});
|
|
1266
|
+
const lines = status.split('\n').filter(line => line.trim());
|
|
1267
|
+
for (const line of lines) {
|
|
1268
|
+
const statusCode = line.slice(0, 2);
|
|
1269
|
+
const filePath = line.slice(3);
|
|
1270
|
+
let fileStatus = 'modified';
|
|
1271
|
+
if (statusCode.includes('A'))
|
|
1272
|
+
fileStatus = 'added';
|
|
1273
|
+
else if (statusCode.includes('D'))
|
|
1274
|
+
fileStatus = 'deleted';
|
|
1275
|
+
else if (statusCode.includes('?'))
|
|
1276
|
+
fileStatus = 'untracked';
|
|
1277
|
+
else if (statusCode.includes('M'))
|
|
1278
|
+
fileStatus = 'modified';
|
|
1279
|
+
files.push({ path: filePath, status: fileStatus });
|
|
1280
|
+
}
|
|
1281
|
+
}
|
|
1282
|
+
catch {
|
|
1283
|
+
// Ignore errors
|
|
1284
|
+
}
|
|
1285
|
+
res.json({
|
|
1286
|
+
success: true,
|
|
1287
|
+
data: { files },
|
|
1288
|
+
});
|
|
1289
|
+
}
|
|
1290
|
+
catch (error) {
|
|
1291
|
+
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
1292
|
+
res.status(500).json({ success: false, error: errorMsg });
|
|
1293
|
+
}
|
|
1294
|
+
});
|
|
1295
|
+
// Get branch info (current branch and base branch)
|
|
1296
|
+
terminalRouter.get('/sessions/:id/branch-info', (req, res) => {
|
|
1297
|
+
try {
|
|
1298
|
+
const { id } = req.params;
|
|
1299
|
+
const requestedRepoId = req.query.repoId;
|
|
1300
|
+
const session = terminalSessionManager.getSession(id);
|
|
1301
|
+
if (!session) {
|
|
1302
|
+
res.status(404).json({ success: false, error: 'Session not found' });
|
|
1303
|
+
return;
|
|
1304
|
+
}
|
|
1305
|
+
const resolved = resolveSessionRepo(session, requestedRepoId);
|
|
1306
|
+
if (!resolved) {
|
|
1307
|
+
res.status(404).json({ success: false, error: 'Repository not found' });
|
|
1308
|
+
return;
|
|
1309
|
+
}
|
|
1310
|
+
const { repo } = resolved;
|
|
1311
|
+
const workingDir = getWorkingDir(session, repo);
|
|
1312
|
+
let currentBranch = '';
|
|
1313
|
+
let baseBranch = 'main'; // Default to main
|
|
1314
|
+
try {
|
|
1315
|
+
// Get current branch
|
|
1316
|
+
currentBranch = execSync('git branch --show-current', {
|
|
1317
|
+
cwd: workingDir,
|
|
1318
|
+
encoding: 'utf-8',
|
|
1319
|
+
timeout: 5000,
|
|
1320
|
+
}).trim();
|
|
1321
|
+
// Try to find base branch (main or master)
|
|
1322
|
+
const branches = execSync('git branch -a', {
|
|
1323
|
+
cwd: workingDir,
|
|
1324
|
+
encoding: 'utf-8',
|
|
1325
|
+
timeout: 5000,
|
|
1326
|
+
});
|
|
1327
|
+
if (branches.includes('main')) {
|
|
1328
|
+
baseBranch = 'main';
|
|
1329
|
+
}
|
|
1330
|
+
else if (branches.includes('master')) {
|
|
1331
|
+
baseBranch = 'master';
|
|
1332
|
+
}
|
|
1333
|
+
}
|
|
1334
|
+
catch {
|
|
1335
|
+
// Ignore errors
|
|
1336
|
+
}
|
|
1337
|
+
// Get commit count ahead of base branch
|
|
1338
|
+
let commitsAhead = 0;
|
|
1339
|
+
try {
|
|
1340
|
+
const count = execSync(`git rev-list --count ${baseBranch}..HEAD`, {
|
|
1341
|
+
cwd: workingDir,
|
|
1342
|
+
encoding: 'utf-8',
|
|
1343
|
+
timeout: 5000,
|
|
1344
|
+
}).trim();
|
|
1345
|
+
commitsAhead = parseInt(count, 10) || 0;
|
|
1346
|
+
}
|
|
1347
|
+
catch {
|
|
1348
|
+
// Branch may not have diverged yet
|
|
1349
|
+
}
|
|
1350
|
+
res.json({
|
|
1351
|
+
success: true,
|
|
1352
|
+
data: { currentBranch, baseBranch, commitsAhead, worktreeMode: session.worktreeMode },
|
|
1353
|
+
});
|
|
1354
|
+
}
|
|
1355
|
+
catch (error) {
|
|
1356
|
+
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
1357
|
+
res.status(500).json({ success: false, error: errorMsg });
|
|
1358
|
+
}
|
|
1359
|
+
});
|
|
1360
|
+
// Get files changed in current branch vs base branch (main/master)
|
|
1361
|
+
terminalRouter.get('/sessions/:id/branch-changed-files', (req, res) => {
|
|
1362
|
+
try {
|
|
1363
|
+
const { id } = req.params;
|
|
1364
|
+
const baseBranch = req.query.base || 'main';
|
|
1365
|
+
const requestedRepoId = req.query.repoId;
|
|
1366
|
+
const session = terminalSessionManager.getSession(id);
|
|
1367
|
+
if (!session) {
|
|
1368
|
+
res.status(404).json({ success: false, error: 'Session not found' });
|
|
1369
|
+
return;
|
|
1370
|
+
}
|
|
1371
|
+
const resolved = resolveSessionRepo(session, requestedRepoId);
|
|
1372
|
+
if (!resolved) {
|
|
1373
|
+
res.status(404).json({ success: false, error: 'Repository not found' });
|
|
1374
|
+
return;
|
|
1375
|
+
}
|
|
1376
|
+
const { repo } = resolved;
|
|
1377
|
+
const workingDir = getWorkingDir(session, repo);
|
|
1378
|
+
const files = [];
|
|
1379
|
+
try {
|
|
1380
|
+
// Get files changed between base branch and HEAD
|
|
1381
|
+
// Using merge-base to find common ancestor
|
|
1382
|
+
const mergeBase = execSync(`git merge-base ${baseBranch} HEAD`, {
|
|
1383
|
+
cwd: workingDir,
|
|
1384
|
+
encoding: 'utf-8',
|
|
1385
|
+
timeout: 5000,
|
|
1386
|
+
}).trim();
|
|
1387
|
+
// Use -M to detect renames
|
|
1388
|
+
const diffOutput = execSync(`git diff -M --name-status ${mergeBase} HEAD`, {
|
|
1389
|
+
cwd: workingDir,
|
|
1390
|
+
encoding: 'utf-8',
|
|
1391
|
+
timeout: 10000,
|
|
1392
|
+
});
|
|
1393
|
+
const lines = diffOutput.split('\n').filter(line => line.trim());
|
|
1394
|
+
for (const line of lines) {
|
|
1395
|
+
const parts = line.split('\t');
|
|
1396
|
+
const statusCode = parts[0];
|
|
1397
|
+
let fileStatus = 'modified';
|
|
1398
|
+
if (statusCode === 'A')
|
|
1399
|
+
fileStatus = 'added';
|
|
1400
|
+
else if (statusCode === 'D')
|
|
1401
|
+
fileStatus = 'deleted';
|
|
1402
|
+
else if (statusCode === 'M')
|
|
1403
|
+
fileStatus = 'modified';
|
|
1404
|
+
else if (statusCode.startsWith('R'))
|
|
1405
|
+
fileStatus = 'renamed';
|
|
1406
|
+
// For renames (R###), format is: R###\toldPath\tnewPath
|
|
1407
|
+
if (statusCode.startsWith('R') && parts.length >= 3) {
|
|
1408
|
+
const oldPath = parts[1];
|
|
1409
|
+
const newPath = parts[2];
|
|
1410
|
+
if (newPath) {
|
|
1411
|
+
files.push({ path: newPath, status: fileStatus, oldPath });
|
|
1412
|
+
}
|
|
1413
|
+
}
|
|
1414
|
+
else if (parts[1]) {
|
|
1415
|
+
files.push({ path: parts[1], status: fileStatus });
|
|
1416
|
+
}
|
|
1417
|
+
}
|
|
1418
|
+
}
|
|
1419
|
+
catch {
|
|
1420
|
+
// Ignore errors (e.g., no common ancestor)
|
|
1421
|
+
}
|
|
1422
|
+
res.json({
|
|
1423
|
+
success: true,
|
|
1424
|
+
data: { files, baseBranch },
|
|
1425
|
+
});
|
|
1426
|
+
}
|
|
1427
|
+
catch (error) {
|
|
1428
|
+
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
1429
|
+
res.status(500).json({ success: false, error: errorMsg });
|
|
1430
|
+
}
|
|
1431
|
+
});
|
|
1432
|
+
// Get diff for a file vs base branch
|
|
1433
|
+
terminalRouter.post('/sessions/:id/branch-file-diff', (req, res) => {
|
|
1434
|
+
try {
|
|
1435
|
+
const { id } = req.params;
|
|
1436
|
+
const { filePath, baseBranch = 'main', repoId: requestedRepoId } = req.body;
|
|
1437
|
+
const session = terminalSessionManager.getSession(id);
|
|
1438
|
+
if (!session) {
|
|
1439
|
+
res.status(404).json({ success: false, error: 'Session not found' });
|
|
1440
|
+
return;
|
|
1441
|
+
}
|
|
1442
|
+
const resolved = resolveSessionRepo(session, requestedRepoId);
|
|
1443
|
+
if (!resolved) {
|
|
1444
|
+
res.status(404).json({ success: false, error: 'Repository not found' });
|
|
1445
|
+
return;
|
|
1446
|
+
}
|
|
1447
|
+
const { repo } = resolved;
|
|
1448
|
+
const workingDir = getWorkingDir(session, repo);
|
|
1449
|
+
let diff = '';
|
|
1450
|
+
try {
|
|
1451
|
+
// Get merge base for accurate diff
|
|
1452
|
+
const mergeBase = execSync(`git merge-base ${baseBranch} HEAD`, {
|
|
1453
|
+
cwd: workingDir,
|
|
1454
|
+
encoding: 'utf-8',
|
|
1455
|
+
timeout: 5000,
|
|
1456
|
+
}).trim();
|
|
1457
|
+
if (filePath) {
|
|
1458
|
+
// Diff for specific file
|
|
1459
|
+
diff = execSync(`git diff ${mergeBase} HEAD -- "${filePath}"`, {
|
|
1460
|
+
cwd: workingDir,
|
|
1461
|
+
encoding: 'utf-8',
|
|
1462
|
+
timeout: 10000,
|
|
1463
|
+
});
|
|
1464
|
+
}
|
|
1465
|
+
else {
|
|
1466
|
+
// Diff for all changed files
|
|
1467
|
+
diff = execSync(`git diff ${mergeBase} HEAD`, {
|
|
1468
|
+
cwd: workingDir,
|
|
1469
|
+
encoding: 'utf-8',
|
|
1470
|
+
timeout: 30000,
|
|
1471
|
+
});
|
|
1472
|
+
}
|
|
1473
|
+
}
|
|
1474
|
+
catch {
|
|
1475
|
+
// No changes or git error
|
|
1476
|
+
diff = '';
|
|
1477
|
+
}
|
|
1478
|
+
res.json({
|
|
1479
|
+
success: true,
|
|
1480
|
+
data: { diff },
|
|
1481
|
+
});
|
|
1482
|
+
}
|
|
1483
|
+
catch (error) {
|
|
1484
|
+
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
1485
|
+
res.status(500).json({ success: false, error: errorMsg });
|
|
1486
|
+
}
|
|
1487
|
+
});
|
|
1488
|
+
// Get detailed working tree status (staged vs unstaged)
|
|
1489
|
+
terminalRouter.get('/sessions/:id/working-status', (req, res) => {
|
|
1490
|
+
try {
|
|
1491
|
+
const { id } = req.params;
|
|
1492
|
+
const requestedRepoId = req.query.repoId;
|
|
1493
|
+
const session = terminalSessionManager.getSession(id);
|
|
1494
|
+
if (!session) {
|
|
1495
|
+
res.status(404).json({ success: false, error: 'Session not found' });
|
|
1496
|
+
return;
|
|
1497
|
+
}
|
|
1498
|
+
const resolved = resolveSessionRepo(session, requestedRepoId);
|
|
1499
|
+
if (!resolved) {
|
|
1500
|
+
res.status(404).json({ success: false, error: 'Repository not found' });
|
|
1501
|
+
return;
|
|
1502
|
+
}
|
|
1503
|
+
const { repo } = resolved;
|
|
1504
|
+
const workingDir = getWorkingDir(session, repo);
|
|
1505
|
+
const staged = [];
|
|
1506
|
+
const unstaged = [];
|
|
1507
|
+
try {
|
|
1508
|
+
// Use -M to detect renames, -unormal to show untracked files/dirs
|
|
1509
|
+
const status = execSync('git status --porcelain -M -unormal', {
|
|
1510
|
+
cwd: workingDir,
|
|
1511
|
+
encoding: 'utf-8',
|
|
1512
|
+
timeout: 10000,
|
|
1513
|
+
});
|
|
1514
|
+
const lines = status.split('\n').filter(line => line.trim());
|
|
1515
|
+
for (const line of lines) {
|
|
1516
|
+
const indexStatus = line[0]; // Staged status
|
|
1517
|
+
const workTreeStatus = line[1]; // Unstaged status
|
|
1518
|
+
let filePath = line.slice(3);
|
|
1519
|
+
// Handle renamed files: "old-path -> new-path"
|
|
1520
|
+
let oldPath = null;
|
|
1521
|
+
if (filePath.includes(' -> ')) {
|
|
1522
|
+
const parts = filePath.split(' -> ');
|
|
1523
|
+
oldPath = parts[0];
|
|
1524
|
+
filePath = parts[1]; // Use the new path as the main path
|
|
1525
|
+
}
|
|
1526
|
+
// Staged changes (index)
|
|
1527
|
+
if (indexStatus !== ' ' && indexStatus !== '?') {
|
|
1528
|
+
let fileStatus = 'modified';
|
|
1529
|
+
if (indexStatus === 'A')
|
|
1530
|
+
fileStatus = 'added';
|
|
1531
|
+
else if (indexStatus === 'D')
|
|
1532
|
+
fileStatus = 'deleted';
|
|
1533
|
+
else if (indexStatus === 'M')
|
|
1534
|
+
fileStatus = 'modified';
|
|
1535
|
+
else if (indexStatus === 'R')
|
|
1536
|
+
fileStatus = 'renamed';
|
|
1537
|
+
if (indexStatus === 'R' && oldPath) {
|
|
1538
|
+
// For renames, store oldPath separately
|
|
1539
|
+
staged.push({ path: filePath, status: fileStatus, oldPath });
|
|
1540
|
+
}
|
|
1541
|
+
else {
|
|
1542
|
+
staged.push({ path: filePath, status: fileStatus });
|
|
1543
|
+
}
|
|
1544
|
+
}
|
|
1545
|
+
// Unstaged changes (work tree)
|
|
1546
|
+
if (workTreeStatus === 'M' || workTreeStatus === 'D') {
|
|
1547
|
+
const fileStatus = workTreeStatus === 'D' ? 'deleted' : 'modified';
|
|
1548
|
+
unstaged.push({ path: filePath, status: fileStatus });
|
|
1549
|
+
}
|
|
1550
|
+
// Skip untracked from git status - we'll get them from ls-files for individual files
|
|
1551
|
+
}
|
|
1552
|
+
// Get untracked files using ls-files (shows individual files, not just directories)
|
|
1553
|
+
try {
|
|
1554
|
+
const untrackedOutput = execSync('git ls-files --others --exclude-standard', {
|
|
1555
|
+
cwd: workingDir,
|
|
1556
|
+
encoding: 'utf-8',
|
|
1557
|
+
timeout: 10000,
|
|
1558
|
+
});
|
|
1559
|
+
const untrackedFiles = untrackedOutput.split('\n').filter(f => f.trim());
|
|
1560
|
+
for (const file of untrackedFiles) {
|
|
1561
|
+
unstaged.push({ path: file, status: 'untracked' });
|
|
1562
|
+
}
|
|
1563
|
+
}
|
|
1564
|
+
catch {
|
|
1565
|
+
// Ignore ls-files errors
|
|
1566
|
+
}
|
|
1567
|
+
}
|
|
1568
|
+
catch {
|
|
1569
|
+
// Ignore errors
|
|
1570
|
+
}
|
|
1571
|
+
res.json({
|
|
1572
|
+
success: true,
|
|
1573
|
+
data: { staged, unstaged },
|
|
1574
|
+
});
|
|
1575
|
+
}
|
|
1576
|
+
catch (error) {
|
|
1577
|
+
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
1578
|
+
res.status(500).json({ success: false, error: errorMsg });
|
|
1579
|
+
}
|
|
1580
|
+
});
|
|
1581
|
+
// Stage files (git add)
|
|
1582
|
+
terminalRouter.post('/sessions/:id/git-stage', (req, res) => {
|
|
1583
|
+
try {
|
|
1584
|
+
const { id } = req.params;
|
|
1585
|
+
const { files, repoId: requestedRepoId } = req.body; // Array of file paths, or empty for all; repoId for multi-repo
|
|
1586
|
+
const session = terminalSessionManager.getSession(id);
|
|
1587
|
+
if (!session) {
|
|
1588
|
+
res.status(404).json({ success: false, error: 'Session not found' });
|
|
1589
|
+
return;
|
|
1590
|
+
}
|
|
1591
|
+
const resolved = resolveSessionRepo(session, requestedRepoId);
|
|
1592
|
+
if (!resolved) {
|
|
1593
|
+
res.status(404).json({ success: false, error: 'Repository not found' });
|
|
1594
|
+
return;
|
|
1595
|
+
}
|
|
1596
|
+
const { repo } = resolved;
|
|
1597
|
+
const workingDir = getWorkingDir(session, repo);
|
|
1598
|
+
try {
|
|
1599
|
+
if (files && files.length > 0) {
|
|
1600
|
+
// Stage specific files - try to stage all at once first (more efficient)
|
|
1601
|
+
// Escape special characters and handle paths properly
|
|
1602
|
+
const escapedFiles = files.map((f) => `"${f.replace(/"/g, '\\"')}"`).join(' ');
|
|
1603
|
+
try {
|
|
1604
|
+
execSync(`git add -- ${escapedFiles}`, {
|
|
1605
|
+
cwd: workingDir,
|
|
1606
|
+
encoding: 'utf-8',
|
|
1607
|
+
timeout: 10000,
|
|
1608
|
+
});
|
|
1609
|
+
}
|
|
1610
|
+
catch {
|
|
1611
|
+
// If batch add fails, try adding files individually and collect errors
|
|
1612
|
+
const errors = [];
|
|
1613
|
+
const succeeded = [];
|
|
1614
|
+
for (const file of files) {
|
|
1615
|
+
try {
|
|
1616
|
+
execSync(`git add -- "${file.replace(/"/g, '\\"')}"`, {
|
|
1617
|
+
cwd: workingDir,
|
|
1618
|
+
encoding: 'utf-8',
|
|
1619
|
+
timeout: 5000,
|
|
1620
|
+
});
|
|
1621
|
+
succeeded.push(file);
|
|
1622
|
+
}
|
|
1623
|
+
catch (fileErr) {
|
|
1624
|
+
const errMsg = fileErr instanceof Error ? fileErr.message : String(fileErr);
|
|
1625
|
+
errors.push(`${file}: ${errMsg}`);
|
|
1626
|
+
}
|
|
1627
|
+
}
|
|
1628
|
+
if (errors.length > 0 && succeeded.length === 0) {
|
|
1629
|
+
// All files failed
|
|
1630
|
+
res.status(400).json({
|
|
1631
|
+
success: false,
|
|
1632
|
+
error: `Failed to stage files:\n${errors.join('\n')}`
|
|
1633
|
+
});
|
|
1634
|
+
return;
|
|
1635
|
+
}
|
|
1636
|
+
else if (errors.length > 0) {
|
|
1637
|
+
// Some files failed, some succeeded
|
|
1638
|
+
res.json({
|
|
1639
|
+
success: true,
|
|
1640
|
+
data: {
|
|
1641
|
+
message: `Staged ${succeeded.length} files, ${errors.length} failed`,
|
|
1642
|
+
warnings: errors
|
|
1643
|
+
}
|
|
1644
|
+
});
|
|
1645
|
+
return;
|
|
1646
|
+
}
|
|
1647
|
+
}
|
|
1648
|
+
}
|
|
1649
|
+
else {
|
|
1650
|
+
// Stage all
|
|
1651
|
+
execSync('git add -A', {
|
|
1652
|
+
cwd: workingDir,
|
|
1653
|
+
encoding: 'utf-8',
|
|
1654
|
+
timeout: 5000,
|
|
1655
|
+
});
|
|
1656
|
+
}
|
|
1657
|
+
}
|
|
1658
|
+
catch (err) {
|
|
1659
|
+
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
1660
|
+
res.status(400).json({ success: false, error: `Failed to stage: ${errorMsg}` });
|
|
1661
|
+
return;
|
|
1662
|
+
}
|
|
1663
|
+
res.json({ success: true, data: { message: 'Files staged' } });
|
|
1664
|
+
}
|
|
1665
|
+
catch (error) {
|
|
1666
|
+
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
1667
|
+
res.status(500).json({ success: false, error: errorMsg });
|
|
1668
|
+
}
|
|
1669
|
+
});
|
|
1670
|
+
// Unstage files (git reset)
|
|
1671
|
+
terminalRouter.post('/sessions/:id/git-unstage', (req, res) => {
|
|
1672
|
+
try {
|
|
1673
|
+
const { id } = req.params;
|
|
1674
|
+
const { files, repoId: requestedRepoId } = req.body; // Array of file paths, or empty for all; repoId for multi-repo
|
|
1675
|
+
const session = terminalSessionManager.getSession(id);
|
|
1676
|
+
if (!session) {
|
|
1677
|
+
res.status(404).json({ success: false, error: 'Session not found' });
|
|
1678
|
+
return;
|
|
1679
|
+
}
|
|
1680
|
+
const resolved = resolveSessionRepo(session, requestedRepoId);
|
|
1681
|
+
if (!resolved) {
|
|
1682
|
+
res.status(404).json({ success: false, error: 'Repository not found' });
|
|
1683
|
+
return;
|
|
1684
|
+
}
|
|
1685
|
+
const { repo } = resolved;
|
|
1686
|
+
const workingDir = getWorkingDir(session, repo);
|
|
1687
|
+
try {
|
|
1688
|
+
if (files && files.length > 0) {
|
|
1689
|
+
// Unstage specific files - try to unstage all at once first
|
|
1690
|
+
const escapedFiles = files.map((f) => `"${f.replace(/"/g, '\\"')}"`).join(' ');
|
|
1691
|
+
try {
|
|
1692
|
+
execSync(`git reset HEAD -- ${escapedFiles}`, {
|
|
1693
|
+
cwd: workingDir,
|
|
1694
|
+
encoding: 'utf-8',
|
|
1695
|
+
timeout: 10000,
|
|
1696
|
+
});
|
|
1697
|
+
}
|
|
1698
|
+
catch {
|
|
1699
|
+
// If batch reset fails, try individually and collect errors
|
|
1700
|
+
const errors = [];
|
|
1701
|
+
const succeeded = [];
|
|
1702
|
+
for (const file of files) {
|
|
1703
|
+
try {
|
|
1704
|
+
execSync(`git reset HEAD -- "${file.replace(/"/g, '\\"')}"`, {
|
|
1705
|
+
cwd: workingDir,
|
|
1706
|
+
encoding: 'utf-8',
|
|
1707
|
+
timeout: 5000,
|
|
1708
|
+
});
|
|
1709
|
+
succeeded.push(file);
|
|
1710
|
+
}
|
|
1711
|
+
catch (fileErr) {
|
|
1712
|
+
const errMsg = fileErr instanceof Error ? fileErr.message : String(fileErr);
|
|
1713
|
+
errors.push(`${file}: ${errMsg}`);
|
|
1714
|
+
}
|
|
1715
|
+
}
|
|
1716
|
+
if (errors.length > 0 && succeeded.length === 0) {
|
|
1717
|
+
res.status(400).json({
|
|
1718
|
+
success: false,
|
|
1719
|
+
error: `Failed to unstage files:\n${errors.join('\n')}`
|
|
1720
|
+
});
|
|
1721
|
+
return;
|
|
1722
|
+
}
|
|
1723
|
+
else if (errors.length > 0) {
|
|
1724
|
+
res.json({
|
|
1725
|
+
success: true,
|
|
1726
|
+
data: {
|
|
1727
|
+
message: `Unstaged ${succeeded.length} files, ${errors.length} failed`,
|
|
1728
|
+
warnings: errors
|
|
1729
|
+
}
|
|
1730
|
+
});
|
|
1731
|
+
return;
|
|
1732
|
+
}
|
|
1733
|
+
}
|
|
1734
|
+
}
|
|
1735
|
+
else {
|
|
1736
|
+
// Unstage all
|
|
1737
|
+
execSync('git reset HEAD', {
|
|
1738
|
+
cwd: workingDir,
|
|
1739
|
+
encoding: 'utf-8',
|
|
1740
|
+
timeout: 5000,
|
|
1741
|
+
});
|
|
1742
|
+
}
|
|
1743
|
+
}
|
|
1744
|
+
catch (err) {
|
|
1745
|
+
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
1746
|
+
res.status(400).json({ success: false, error: `Failed to unstage: ${errorMsg}` });
|
|
1747
|
+
return;
|
|
1748
|
+
}
|
|
1749
|
+
res.json({ success: true, data: { message: 'Files unstaged' } });
|
|
1750
|
+
}
|
|
1751
|
+
catch (error) {
|
|
1752
|
+
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
1753
|
+
res.status(500).json({ success: false, error: errorMsg });
|
|
1754
|
+
}
|
|
1755
|
+
});
|
|
1756
|
+
// Discard changes (git restore / git checkout)
|
|
1757
|
+
terminalRouter.post('/sessions/:id/git-discard', (req, res) => {
|
|
1758
|
+
try {
|
|
1759
|
+
const { id } = req.params;
|
|
1760
|
+
const { files, repoId: requestedRepoId } = req.body; // Array of file paths (required); repoId for multi-repo
|
|
1761
|
+
if (!files || files.length === 0) {
|
|
1762
|
+
res.status(400).json({ success: false, error: 'Files are required' });
|
|
1763
|
+
return;
|
|
1764
|
+
}
|
|
1765
|
+
const session = terminalSessionManager.getSession(id);
|
|
1766
|
+
if (!session) {
|
|
1767
|
+
res.status(404).json({ success: false, error: 'Session not found' });
|
|
1768
|
+
return;
|
|
1769
|
+
}
|
|
1770
|
+
const resolved = resolveSessionRepo(session, requestedRepoId);
|
|
1771
|
+
if (!resolved) {
|
|
1772
|
+
res.status(404).json({ success: false, error: 'Repository not found' });
|
|
1773
|
+
return;
|
|
1774
|
+
}
|
|
1775
|
+
const { repo } = resolved;
|
|
1776
|
+
const workingDir = getWorkingDir(session, repo);
|
|
1777
|
+
try {
|
|
1778
|
+
for (const file of files) {
|
|
1779
|
+
// Try git restore first (newer), fall back to checkout
|
|
1780
|
+
try {
|
|
1781
|
+
execSync(`git restore "${file}"`, {
|
|
1782
|
+
cwd: workingDir,
|
|
1783
|
+
encoding: 'utf-8',
|
|
1784
|
+
timeout: 5000,
|
|
1785
|
+
});
|
|
1786
|
+
}
|
|
1787
|
+
catch {
|
|
1788
|
+
execSync(`git checkout -- "${file}"`, {
|
|
1789
|
+
cwd: workingDir,
|
|
1790
|
+
encoding: 'utf-8',
|
|
1791
|
+
timeout: 5000,
|
|
1792
|
+
});
|
|
1793
|
+
}
|
|
1794
|
+
}
|
|
1795
|
+
}
|
|
1796
|
+
catch (err) {
|
|
1797
|
+
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
1798
|
+
res.status(400).json({ success: false, error: `Failed to discard: ${errorMsg}` });
|
|
1799
|
+
return;
|
|
1800
|
+
}
|
|
1801
|
+
res.json({ success: true, data: { message: 'Changes discarded' } });
|
|
1802
|
+
}
|
|
1803
|
+
catch (error) {
|
|
1804
|
+
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
1805
|
+
res.status(500).json({ success: false, error: errorMsg });
|
|
1806
|
+
}
|
|
1807
|
+
});
|
|
1808
|
+
// Delete untracked file
|
|
1809
|
+
terminalRouter.post('/sessions/:id/git-delete-untracked', (req, res) => {
|
|
1810
|
+
try {
|
|
1811
|
+
const { id } = req.params;
|
|
1812
|
+
const { file, repoId: requestedRepoId } = req.body;
|
|
1813
|
+
if (!file) {
|
|
1814
|
+
res.status(400).json({ success: false, error: 'File path is required' });
|
|
1815
|
+
return;
|
|
1816
|
+
}
|
|
1817
|
+
const session = terminalSessionManager.getSession(id);
|
|
1818
|
+
if (!session) {
|
|
1819
|
+
res.status(404).json({ success: false, error: 'Session not found' });
|
|
1820
|
+
return;
|
|
1821
|
+
}
|
|
1822
|
+
const resolved = resolveSessionRepo(session, requestedRepoId);
|
|
1823
|
+
if (!resolved) {
|
|
1824
|
+
res.status(404).json({ success: false, error: 'Repository not found' });
|
|
1825
|
+
return;
|
|
1826
|
+
}
|
|
1827
|
+
const { repo } = resolved;
|
|
1828
|
+
const workingDir = getWorkingDir(session, repo);
|
|
1829
|
+
const fullPath = join(workingDir, file);
|
|
1830
|
+
// Security check - ensure path is within working dir
|
|
1831
|
+
if (!fullPath.startsWith(workingDir)) {
|
|
1832
|
+
res.status(400).json({ success: false, error: 'Invalid file path' });
|
|
1833
|
+
return;
|
|
1834
|
+
}
|
|
1835
|
+
try {
|
|
1836
|
+
// Use rmSync for Windows compatibility (handles both files and directories)
|
|
1837
|
+
rmSync(fullPath, { recursive: true, force: true });
|
|
1838
|
+
}
|
|
1839
|
+
catch (err) {
|
|
1840
|
+
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
1841
|
+
res.status(400).json({ success: false, error: `Failed to delete: ${errorMsg}` });
|
|
1842
|
+
return;
|
|
1843
|
+
}
|
|
1844
|
+
res.json({ success: true, data: { message: 'File deleted' } });
|
|
1845
|
+
}
|
|
1846
|
+
catch (error) {
|
|
1847
|
+
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
1848
|
+
res.status(500).json({ success: false, error: errorMsg });
|
|
1849
|
+
}
|
|
1850
|
+
});
|
|
1851
|
+
// Commit staged changes
|
|
1852
|
+
terminalRouter.post('/sessions/:id/git-commit', (req, res) => {
|
|
1853
|
+
try {
|
|
1854
|
+
const { id } = req.params;
|
|
1855
|
+
const { message, repoId: requestedRepoId } = req.body;
|
|
1856
|
+
if (!message || typeof message !== 'string' || !message.trim()) {
|
|
1857
|
+
res.status(400).json({ success: false, error: 'Commit message is required' });
|
|
1858
|
+
return;
|
|
1859
|
+
}
|
|
1860
|
+
const session = terminalSessionManager.getSession(id);
|
|
1861
|
+
if (!session) {
|
|
1862
|
+
res.status(404).json({ success: false, error: 'Session not found' });
|
|
1863
|
+
return;
|
|
1864
|
+
}
|
|
1865
|
+
const resolved = resolveSessionRepo(session, requestedRepoId);
|
|
1866
|
+
if (!resolved) {
|
|
1867
|
+
res.status(404).json({ success: false, error: 'Repository not found' });
|
|
1868
|
+
return;
|
|
1869
|
+
}
|
|
1870
|
+
const { repo } = resolved;
|
|
1871
|
+
const workingDir = getWorkingDir(session, repo);
|
|
1872
|
+
let commitHash = '';
|
|
1873
|
+
try {
|
|
1874
|
+
// Escape message for shell
|
|
1875
|
+
const escapedMessage = message.replace(/"/g, '\\"');
|
|
1876
|
+
execSync(`git commit -m "${escapedMessage}"`, {
|
|
1877
|
+
cwd: workingDir,
|
|
1878
|
+
encoding: 'utf-8',
|
|
1879
|
+
timeout: 30000,
|
|
1880
|
+
});
|
|
1881
|
+
// Get the commit hash
|
|
1882
|
+
commitHash = execSync('git rev-parse --short HEAD', {
|
|
1883
|
+
cwd: workingDir,
|
|
1884
|
+
encoding: 'utf-8',
|
|
1885
|
+
timeout: 5000,
|
|
1886
|
+
}).trim();
|
|
1887
|
+
}
|
|
1888
|
+
catch (err) {
|
|
1889
|
+
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
1890
|
+
res.status(400).json({ success: false, error: `Failed to commit: ${errorMsg}` });
|
|
1891
|
+
return;
|
|
1892
|
+
}
|
|
1893
|
+
res.json({ success: true, data: { message: 'Committed', commitHash } });
|
|
1894
|
+
}
|
|
1895
|
+
catch (error) {
|
|
1896
|
+
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
1897
|
+
res.status(500).json({ success: false, error: errorMsg });
|
|
1898
|
+
}
|
|
1899
|
+
});
|
|
1900
|
+
// Push to remote
|
|
1901
|
+
terminalRouter.post('/sessions/:id/git-push', (req, res) => {
|
|
1902
|
+
let cleanup = null;
|
|
1903
|
+
try {
|
|
1904
|
+
const { id } = req.params;
|
|
1905
|
+
const { repoId: requestedRepoId } = req.body || {};
|
|
1906
|
+
const session = terminalSessionManager.getSession(id);
|
|
1907
|
+
if (!session) {
|
|
1908
|
+
res.status(404).json({ success: false, error: 'Session not found' });
|
|
1909
|
+
return;
|
|
1910
|
+
}
|
|
1911
|
+
const resolved = resolveSessionRepo(session, requestedRepoId);
|
|
1912
|
+
if (!resolved) {
|
|
1913
|
+
res.status(404).json({ success: false, error: 'Repository not found' });
|
|
1914
|
+
return;
|
|
1915
|
+
}
|
|
1916
|
+
const { repo } = resolved;
|
|
1917
|
+
const workingDir = getWorkingDir(session, repo);
|
|
1918
|
+
// Get exec options with OAuth credentials if available
|
|
1919
|
+
const { options: execOptions, cleanup: cleanupFn } = getGitExecOptions(workingDir, 120000);
|
|
1920
|
+
cleanup = cleanupFn;
|
|
1921
|
+
try {
|
|
1922
|
+
// Get current branch
|
|
1923
|
+
const branch = execSync('git branch --show-current', {
|
|
1924
|
+
cwd: workingDir,
|
|
1925
|
+
encoding: 'utf-8',
|
|
1926
|
+
timeout: 5000,
|
|
1927
|
+
}).trim();
|
|
1928
|
+
// Push with upstream tracking using OAuth credentials
|
|
1929
|
+
execSync(`git push -u origin ${branch}`, execOptions);
|
|
1930
|
+
}
|
|
1931
|
+
catch (err) {
|
|
1932
|
+
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
1933
|
+
res.status(400).json({ success: false, error: `Failed to push: ${errorMsg}` });
|
|
1934
|
+
return;
|
|
1935
|
+
}
|
|
1936
|
+
finally {
|
|
1937
|
+
if (cleanup)
|
|
1938
|
+
cleanup();
|
|
1939
|
+
}
|
|
1940
|
+
res.json({ success: true, data: { message: 'Pushed to remote' } });
|
|
1941
|
+
}
|
|
1942
|
+
catch (error) {
|
|
1943
|
+
if (cleanup)
|
|
1944
|
+
cleanup();
|
|
1945
|
+
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
1946
|
+
res.status(500).json({ success: false, error: errorMsg });
|
|
1947
|
+
}
|
|
1948
|
+
});
|
|
1949
|
+
// Checkout/switch to a different branch
|
|
1950
|
+
terminalRouter.post('/sessions/:id/git-checkout', (req, res) => {
|
|
1951
|
+
try {
|
|
1952
|
+
const { id } = req.params;
|
|
1953
|
+
const { branch, repoId: requestedRepoId } = req.body;
|
|
1954
|
+
if (!branch || typeof branch !== 'string') {
|
|
1955
|
+
res.status(400).json({ success: false, error: 'Branch name is required' });
|
|
1956
|
+
return;
|
|
1957
|
+
}
|
|
1958
|
+
const session = terminalSessionManager.getSession(id);
|
|
1959
|
+
if (!session) {
|
|
1960
|
+
res.status(404).json({ success: false, error: 'Session not found' });
|
|
1961
|
+
return;
|
|
1962
|
+
}
|
|
1963
|
+
const resolved = resolveSessionRepo(session, requestedRepoId);
|
|
1964
|
+
if (!resolved) {
|
|
1965
|
+
res.status(404).json({ success: false, error: 'Repository not found' });
|
|
1966
|
+
return;
|
|
1967
|
+
}
|
|
1968
|
+
const { repo } = resolved;
|
|
1969
|
+
const workingDir = getWorkingDir(session, repo);
|
|
1970
|
+
try {
|
|
1971
|
+
// Check for uncommitted changes first
|
|
1972
|
+
const status = execSync('git status --porcelain', {
|
|
1973
|
+
cwd: workingDir,
|
|
1974
|
+
encoding: 'utf-8',
|
|
1975
|
+
timeout: 5000,
|
|
1976
|
+
});
|
|
1977
|
+
if (status.trim()) {
|
|
1978
|
+
res.status(400).json({
|
|
1979
|
+
success: false,
|
|
1980
|
+
error: 'Cannot switch branches with uncommitted changes. Please commit or stash your changes first.',
|
|
1981
|
+
});
|
|
1982
|
+
return;
|
|
1983
|
+
}
|
|
1984
|
+
// Perform the checkout
|
|
1985
|
+
execSync(`git checkout "${branch}"`, {
|
|
1986
|
+
cwd: workingDir,
|
|
1987
|
+
encoding: 'utf-8',
|
|
1988
|
+
timeout: 30000,
|
|
1989
|
+
});
|
|
1990
|
+
}
|
|
1991
|
+
catch (err) {
|
|
1992
|
+
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
1993
|
+
res.status(400).json({ success: false, error: `Failed to checkout: ${errorMsg}` });
|
|
1994
|
+
return;
|
|
1995
|
+
}
|
|
1996
|
+
res.json({ success: true, data: { message: `Switched to branch ${branch}`, branch } });
|
|
1997
|
+
}
|
|
1998
|
+
catch (error) {
|
|
1999
|
+
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
2000
|
+
res.status(500).json({ success: false, error: errorMsg });
|
|
2001
|
+
}
|
|
2002
|
+
});
|
|
2003
|
+
// Create a pull request (GitHub) or merge request (GitLab)
|
|
2004
|
+
terminalRouter.post('/sessions/:id/create-pr', async (req, res) => {
|
|
2005
|
+
try {
|
|
2006
|
+
const { id } = req.params;
|
|
2007
|
+
const { title, body, repoId: requestedRepoId, targetBranch } = req.body;
|
|
2008
|
+
if (!title || typeof title !== 'string' || !title.trim()) {
|
|
2009
|
+
res.status(400).json({ success: false, error: 'PR title is required' });
|
|
2010
|
+
return;
|
|
2011
|
+
}
|
|
2012
|
+
const session = terminalSessionManager.getSession(id);
|
|
2013
|
+
if (!session) {
|
|
2014
|
+
res.status(404).json({ success: false, error: 'Session not found' });
|
|
2015
|
+
return;
|
|
2016
|
+
}
|
|
2017
|
+
const resolved = resolveSessionRepo(session, requestedRepoId);
|
|
2018
|
+
if (!resolved) {
|
|
2019
|
+
res.status(404).json({ success: false, error: 'Repository not found' });
|
|
2020
|
+
return;
|
|
2021
|
+
}
|
|
2022
|
+
const { repo } = resolved;
|
|
2023
|
+
const workingDir = getWorkingDir(session, repo);
|
|
2024
|
+
// Debug logging for PR creation
|
|
2025
|
+
console.log(`[create-pr] repo.path: ${repo.path}`);
|
|
2026
|
+
console.log(`[create-pr] workingDir: ${workingDir}`);
|
|
2027
|
+
console.log(`[create-pr] session.worktreeMode: ${session.worktreeMode}`);
|
|
2028
|
+
// Get current branch
|
|
2029
|
+
let currentBranch;
|
|
2030
|
+
try {
|
|
2031
|
+
currentBranch = execSync('git branch --show-current', {
|
|
2032
|
+
cwd: workingDir,
|
|
2033
|
+
encoding: 'utf-8',
|
|
2034
|
+
timeout: 5000,
|
|
2035
|
+
}).trim();
|
|
2036
|
+
}
|
|
2037
|
+
catch (err) {
|
|
2038
|
+
res.status(400).json({ success: false, error: 'Failed to get current branch' });
|
|
2039
|
+
return;
|
|
2040
|
+
}
|
|
2041
|
+
// Get remote URL to detect platform (GitHub vs GitLab)
|
|
2042
|
+
let remoteUrl;
|
|
2043
|
+
try {
|
|
2044
|
+
remoteUrl = execSync('git remote get-url origin', {
|
|
2045
|
+
cwd: workingDir,
|
|
2046
|
+
encoding: 'utf-8',
|
|
2047
|
+
timeout: 5000,
|
|
2048
|
+
}).trim();
|
|
2049
|
+
}
|
|
2050
|
+
catch (err) {
|
|
2051
|
+
res.status(400).json({ success: false, error: 'No remote origin configured' });
|
|
2052
|
+
return;
|
|
2053
|
+
}
|
|
2054
|
+
// Detect platform and create PR/MR
|
|
2055
|
+
const isGitLab = remoteUrl.includes('gitlab.com') || remoteUrl.includes('gitlab.');
|
|
2056
|
+
const isGitHub = remoteUrl.includes('github.com') || remoteUrl.includes('github.');
|
|
2057
|
+
// Pass repo.path for workspace lookup (needed for worktree mode where workingDir is a temp path)
|
|
2058
|
+
if (isGitLab) {
|
|
2059
|
+
const result = await gitlabIntegration.createMR(workingDir, currentBranch, title.trim(), body || '', repo.path, targetBranch);
|
|
2060
|
+
if (result.success) {
|
|
2061
|
+
res.json({
|
|
2062
|
+
success: true,
|
|
2063
|
+
data: {
|
|
2064
|
+
url: result.mrUrl,
|
|
2065
|
+
type: 'merge_request',
|
|
2066
|
+
platform: 'gitlab',
|
|
2067
|
+
},
|
|
2068
|
+
});
|
|
2069
|
+
}
|
|
2070
|
+
else {
|
|
2071
|
+
res.status(400).json({ success: false, error: result.error });
|
|
2072
|
+
}
|
|
2073
|
+
}
|
|
2074
|
+
else if (isGitHub) {
|
|
2075
|
+
const result = await githubIntegration.createPR(workingDir, currentBranch, title.trim(), body || '', repo.path, targetBranch);
|
|
2076
|
+
if (result.success) {
|
|
2077
|
+
res.json({
|
|
2078
|
+
success: true,
|
|
2079
|
+
data: {
|
|
2080
|
+
url: result.prUrl,
|
|
2081
|
+
type: 'pull_request',
|
|
2082
|
+
platform: 'github',
|
|
2083
|
+
},
|
|
2084
|
+
});
|
|
2085
|
+
}
|
|
2086
|
+
else {
|
|
2087
|
+
res.status(400).json({ success: false, error: result.error });
|
|
2088
|
+
}
|
|
2089
|
+
}
|
|
2090
|
+
else {
|
|
2091
|
+
res.status(400).json({
|
|
2092
|
+
success: false,
|
|
2093
|
+
error: 'Could not detect platform from remote URL. Only GitHub and GitLab are supported.',
|
|
2094
|
+
});
|
|
2095
|
+
}
|
|
2096
|
+
}
|
|
2097
|
+
catch (error) {
|
|
2098
|
+
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
2099
|
+
res.status(500).json({ success: false, error: errorMsg });
|
|
2100
|
+
}
|
|
2101
|
+
});
|
|
2102
|
+
// Generate PR title and description using AI
|
|
2103
|
+
terminalRouter.post('/sessions/:id/generate-pr-content', async (req, res) => {
|
|
2104
|
+
try {
|
|
2105
|
+
const { id } = req.params;
|
|
2106
|
+
const { repoId: requestedRepoId, targetBranch: requestedTargetBranch } = req.body || {};
|
|
2107
|
+
const session = terminalSessionManager.getSession(id);
|
|
2108
|
+
if (!session) {
|
|
2109
|
+
res.status(404).json({ success: false, error: 'Session not found' });
|
|
2110
|
+
return;
|
|
2111
|
+
}
|
|
2112
|
+
const resolved = resolveSessionRepo(session, requestedRepoId);
|
|
2113
|
+
if (!resolved) {
|
|
2114
|
+
res.status(404).json({ success: false, error: 'Repository not found' });
|
|
2115
|
+
return;
|
|
2116
|
+
}
|
|
2117
|
+
const { repo } = resolved;
|
|
2118
|
+
const workingDir = getWorkingDir(session, repo);
|
|
2119
|
+
// Get current branch and base branch
|
|
2120
|
+
let currentBranch;
|
|
2121
|
+
let baseBranch = requestedTargetBranch || 'main';
|
|
2122
|
+
try {
|
|
2123
|
+
currentBranch = execSync('git branch --show-current', {
|
|
2124
|
+
cwd: workingDir,
|
|
2125
|
+
encoding: 'utf-8',
|
|
2126
|
+
timeout: 5000,
|
|
2127
|
+
}).trim();
|
|
2128
|
+
// Only auto-detect base branch if not provided
|
|
2129
|
+
if (!requestedTargetBranch) {
|
|
2130
|
+
const branches = execSync('git branch -a', {
|
|
2131
|
+
cwd: workingDir,
|
|
2132
|
+
encoding: 'utf-8',
|
|
2133
|
+
timeout: 5000,
|
|
2134
|
+
});
|
|
2135
|
+
baseBranch = branches.includes('remotes/origin/main') ? 'main' : 'master';
|
|
2136
|
+
}
|
|
2137
|
+
}
|
|
2138
|
+
catch {
|
|
2139
|
+
res.status(400).json({ success: false, error: 'Failed to get branch info' });
|
|
2140
|
+
return;
|
|
2141
|
+
}
|
|
2142
|
+
// Get commit messages in this branch
|
|
2143
|
+
let commitMessages = '';
|
|
2144
|
+
try {
|
|
2145
|
+
commitMessages = execSync(`git log --format="%s" ${baseBranch}..HEAD`, {
|
|
2146
|
+
cwd: workingDir,
|
|
2147
|
+
encoding: 'utf-8',
|
|
2148
|
+
timeout: 10000,
|
|
2149
|
+
}).trim();
|
|
2150
|
+
}
|
|
2151
|
+
catch {
|
|
2152
|
+
commitMessages = '';
|
|
2153
|
+
}
|
|
2154
|
+
if (!commitMessages) {
|
|
2155
|
+
res.status(400).json({ success: false, error: 'No commits to summarize' });
|
|
2156
|
+
return;
|
|
2157
|
+
}
|
|
2158
|
+
// Get file changes summary
|
|
2159
|
+
let fileChanges = '';
|
|
2160
|
+
try {
|
|
2161
|
+
const mergeBase = execSync(`git merge-base ${baseBranch} HEAD`, {
|
|
2162
|
+
cwd: workingDir,
|
|
2163
|
+
encoding: 'utf-8',
|
|
2164
|
+
timeout: 5000,
|
|
2165
|
+
}).trim();
|
|
2166
|
+
fileChanges = execSync(`git diff --stat ${mergeBase} HEAD`, {
|
|
2167
|
+
cwd: workingDir,
|
|
2168
|
+
encoding: 'utf-8',
|
|
2169
|
+
timeout: 10000,
|
|
2170
|
+
}).trim();
|
|
2171
|
+
}
|
|
2172
|
+
catch {
|
|
2173
|
+
fileChanges = '';
|
|
2174
|
+
}
|
|
2175
|
+
// Try to read CLAUDE.md for project conventions
|
|
2176
|
+
let claudeMd = '';
|
|
2177
|
+
const claudeMdPath = join(workingDir, 'CLAUDE.md');
|
|
2178
|
+
if (existsSync(claudeMdPath)) {
|
|
2179
|
+
try {
|
|
2180
|
+
claudeMd = readFileSync(claudeMdPath, 'utf-8');
|
|
2181
|
+
// Limit to first 2000 chars to avoid token limits
|
|
2182
|
+
if (claudeMd.length > 2000) {
|
|
2183
|
+
claudeMd = claudeMd.substring(0, 2000) + '\n...(truncated)';
|
|
2184
|
+
}
|
|
2185
|
+
}
|
|
2186
|
+
catch {
|
|
2187
|
+
claudeMd = '';
|
|
2188
|
+
}
|
|
2189
|
+
}
|
|
2190
|
+
// Build prompt
|
|
2191
|
+
const prompt = `Generate a Pull Request title and description for the following changes.
|
|
2192
|
+
|
|
2193
|
+
Branch: ${currentBranch} → ${baseBranch}
|
|
2194
|
+
|
|
2195
|
+
Commits in this branch:
|
|
2196
|
+
${commitMessages}
|
|
2197
|
+
|
|
2198
|
+
Files changed:
|
|
2199
|
+
${fileChanges}
|
|
2200
|
+
${claudeMd ? `
|
|
2201
|
+
Project conventions (from CLAUDE.md):
|
|
2202
|
+
${claudeMd}
|
|
2203
|
+
` : ''}
|
|
2204
|
+
Rules:
|
|
2205
|
+
- Title: Max 72 characters, imperative mood (e.g., "Add", "Fix", "Update", "Refactor")
|
|
2206
|
+
- Description: Markdown format with sections: ## Summary (2-3 bullet points), ## Changes (list key changes), ## Test Plan (if applicable)
|
|
2207
|
+
- Follow any PR conventions mentioned in CLAUDE.md
|
|
2208
|
+
- Output format must be exactly:
|
|
2209
|
+
TITLE: <title here>
|
|
2210
|
+
DESCRIPTION:
|
|
2211
|
+
<description here>`;
|
|
2212
|
+
// Call Claude Code CLI
|
|
2213
|
+
const { spawn } = await import('child_process');
|
|
2214
|
+
const claudeProcess = spawn('claude', ['--dangerously-skip-permissions', '-p', '-'], {
|
|
2215
|
+
cwd: workingDir,
|
|
2216
|
+
shell: true,
|
|
2217
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
2218
|
+
});
|
|
2219
|
+
let output = '';
|
|
2220
|
+
let errorOutput = '';
|
|
2221
|
+
claudeProcess.stdout?.on('data', (data) => {
|
|
2222
|
+
output += data.toString();
|
|
2223
|
+
});
|
|
2224
|
+
claudeProcess.stderr?.on('data', (data) => {
|
|
2225
|
+
errorOutput += data.toString();
|
|
2226
|
+
});
|
|
2227
|
+
// Write prompt to stdin
|
|
2228
|
+
claudeProcess.stdin?.write(prompt);
|
|
2229
|
+
claudeProcess.stdin?.end();
|
|
2230
|
+
// Wait for process to complete with timeout
|
|
2231
|
+
const timeoutMs = 60000;
|
|
2232
|
+
const result = await Promise.race([
|
|
2233
|
+
new Promise((resolve) => {
|
|
2234
|
+
claudeProcess.on('close', (code) => {
|
|
2235
|
+
if (code === 0 && output.trim()) {
|
|
2236
|
+
// Parse output - look for TITLE: and DESCRIPTION:
|
|
2237
|
+
const titleMatch = output.match(/TITLE:\s*(.+?)(?:\n|DESCRIPTION:)/s);
|
|
2238
|
+
const descMatch = output.match(/DESCRIPTION:\s*([\s\S]+)$/);
|
|
2239
|
+
const title = titleMatch ? titleMatch[1].trim() : '';
|
|
2240
|
+
const description = descMatch ? descMatch[1].trim() : '';
|
|
2241
|
+
if (title) {
|
|
2242
|
+
resolve({ success: true, title, description });
|
|
2243
|
+
}
|
|
2244
|
+
else {
|
|
2245
|
+
// Fallback: use first line as title, rest as description
|
|
2246
|
+
const lines = output.trim().split('\n');
|
|
2247
|
+
resolve({
|
|
2248
|
+
success: true,
|
|
2249
|
+
title: lines[0].replace(/^(TITLE:|Title:)\s*/i, '').trim(),
|
|
2250
|
+
description: lines.slice(1).join('\n').replace(/^(DESCRIPTION:|Description:)\s*/i, '').trim(),
|
|
2251
|
+
});
|
|
2252
|
+
}
|
|
2253
|
+
}
|
|
2254
|
+
else {
|
|
2255
|
+
resolve({ success: false, error: errorOutput || 'Failed to generate PR content' });
|
|
2256
|
+
}
|
|
2257
|
+
});
|
|
2258
|
+
}),
|
|
2259
|
+
new Promise((resolve) => {
|
|
2260
|
+
setTimeout(() => {
|
|
2261
|
+
claudeProcess.kill();
|
|
2262
|
+
resolve({ success: false, error: 'Timeout generating PR content' });
|
|
2263
|
+
}, timeoutMs);
|
|
2264
|
+
}),
|
|
2265
|
+
]);
|
|
2266
|
+
if (result.success && 'title' in result) {
|
|
2267
|
+
// Clean up Claude session
|
|
2268
|
+
try {
|
|
2269
|
+
const { homedir } = await import('os');
|
|
2270
|
+
const projectName = workingDir.replace(/:/g, '-').replace(/\\/g, '-').replace(/\//g, '-');
|
|
2271
|
+
const sessionsDir = join(homedir(), '.claude', 'projects', projectName);
|
|
2272
|
+
const now = Date.now();
|
|
2273
|
+
const files = readdirSync(sessionsDir).filter(f => f.endsWith('.jsonl'));
|
|
2274
|
+
for (const file of files) {
|
|
2275
|
+
const filePath = join(sessionsDir, file);
|
|
2276
|
+
const stat = statSync(filePath);
|
|
2277
|
+
if (now - stat.mtimeMs < 120000) {
|
|
2278
|
+
unlinkSync(filePath);
|
|
2279
|
+
}
|
|
2280
|
+
}
|
|
2281
|
+
}
|
|
2282
|
+
catch {
|
|
2283
|
+
// Ignore cleanup errors
|
|
2284
|
+
}
|
|
2285
|
+
res.json({
|
|
2286
|
+
success: true,
|
|
2287
|
+
data: {
|
|
2288
|
+
title: result.title,
|
|
2289
|
+
description: result.description,
|
|
2290
|
+
},
|
|
2291
|
+
});
|
|
2292
|
+
}
|
|
2293
|
+
else {
|
|
2294
|
+
res.status(500).json({ success: false, error: result.error || 'Failed to generate content' });
|
|
2295
|
+
}
|
|
2296
|
+
}
|
|
2297
|
+
catch (error) {
|
|
2298
|
+
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
2299
|
+
res.status(500).json({ success: false, error: errorMsg });
|
|
2300
|
+
}
|
|
2301
|
+
});
|
|
2302
|
+
// Delete file added in branch (git rm)
|
|
2303
|
+
terminalRouter.post('/sessions/:id/git-rm-branch-file', (req, res) => {
|
|
2304
|
+
try {
|
|
2305
|
+
const { id } = req.params;
|
|
2306
|
+
const { file, baseBranch = 'main', repoId: requestedRepoId } = req.body;
|
|
2307
|
+
if (!file) {
|
|
2308
|
+
res.status(400).json({ success: false, error: 'File path is required' });
|
|
2309
|
+
return;
|
|
2310
|
+
}
|
|
2311
|
+
const session = terminalSessionManager.getSession(id);
|
|
2312
|
+
if (!session) {
|
|
2313
|
+
res.status(404).json({ success: false, error: 'Session not found' });
|
|
2314
|
+
return;
|
|
2315
|
+
}
|
|
2316
|
+
const resolved = resolveSessionRepo(session, requestedRepoId);
|
|
2317
|
+
if (!resolved) {
|
|
2318
|
+
res.status(404).json({ success: false, error: 'Repository not found' });
|
|
2319
|
+
return;
|
|
2320
|
+
}
|
|
2321
|
+
const { repo } = resolved;
|
|
2322
|
+
const workingDir = getWorkingDir(session, repo);
|
|
2323
|
+
try {
|
|
2324
|
+
// Verify file was added in this branch (not in base branch)
|
|
2325
|
+
const mergeBase = execSync(`git merge-base ${baseBranch} HEAD`, {
|
|
2326
|
+
cwd: workingDir,
|
|
2327
|
+
encoding: 'utf-8',
|
|
2328
|
+
timeout: 5000,
|
|
2329
|
+
}).trim();
|
|
2330
|
+
const diffOutput = execSync(`git diff --name-status ${mergeBase} HEAD -- "${file}"`, {
|
|
2331
|
+
cwd: workingDir,
|
|
2332
|
+
encoding: 'utf-8',
|
|
2333
|
+
timeout: 5000,
|
|
2334
|
+
});
|
|
2335
|
+
// Check if file was added (status 'A')
|
|
2336
|
+
if (!diffOutput.trim().startsWith('A')) {
|
|
2337
|
+
res.status(400).json({
|
|
2338
|
+
success: false,
|
|
2339
|
+
error: 'Can only delete files that were added in this branch'
|
|
2340
|
+
});
|
|
2341
|
+
return;
|
|
2342
|
+
}
|
|
2343
|
+
// Remove the file using git rm -f (force needed if file has local modifications)
|
|
2344
|
+
execSync(`git rm -f "${file}"`, {
|
|
2345
|
+
cwd: workingDir,
|
|
2346
|
+
encoding: 'utf-8',
|
|
2347
|
+
timeout: 5000,
|
|
2348
|
+
});
|
|
2349
|
+
}
|
|
2350
|
+
catch (err) {
|
|
2351
|
+
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
2352
|
+
res.status(400).json({ success: false, error: `Failed to delete: ${errorMsg}` });
|
|
2353
|
+
return;
|
|
2354
|
+
}
|
|
2355
|
+
res.json({ success: true, data: { message: 'File removed (staged for deletion)' } });
|
|
2356
|
+
}
|
|
2357
|
+
catch (error) {
|
|
2358
|
+
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
2359
|
+
res.status(500).json({ success: false, error: errorMsg });
|
|
2360
|
+
}
|
|
2361
|
+
});
|
|
2362
|
+
// Generate commit message using Claude
|
|
2363
|
+
terminalRouter.post('/sessions/:id/generate-commit-message', async (req, res) => {
|
|
2364
|
+
try {
|
|
2365
|
+
const { id } = req.params;
|
|
2366
|
+
const { repoId: requestedRepoId } = req.body || {};
|
|
2367
|
+
const session = terminalSessionManager.getSession(id);
|
|
2368
|
+
if (!session) {
|
|
2369
|
+
res.status(404).json({ success: false, error: 'Session not found' });
|
|
2370
|
+
return;
|
|
2371
|
+
}
|
|
2372
|
+
const resolved = resolveSessionRepo(session, requestedRepoId);
|
|
2373
|
+
if (!resolved) {
|
|
2374
|
+
res.status(404).json({ success: false, error: 'Repository not found' });
|
|
2375
|
+
return;
|
|
2376
|
+
}
|
|
2377
|
+
const { repo } = resolved;
|
|
2378
|
+
const workingDir = getWorkingDir(session, repo);
|
|
2379
|
+
// Get staged file statuses (A=added, M=modified, D=deleted, R=renamed)
|
|
2380
|
+
let stagedStatus;
|
|
2381
|
+
try {
|
|
2382
|
+
stagedStatus = execSync('git diff --cached --name-status', {
|
|
2383
|
+
cwd: workingDir,
|
|
2384
|
+
encoding: 'utf-8',
|
|
2385
|
+
timeout: 5000,
|
|
2386
|
+
});
|
|
2387
|
+
}
|
|
2388
|
+
catch {
|
|
2389
|
+
stagedStatus = '';
|
|
2390
|
+
}
|
|
2391
|
+
if (!stagedStatus.trim()) {
|
|
2392
|
+
res.status(400).json({ success: false, error: 'No staged changes to summarize' });
|
|
2393
|
+
return;
|
|
2394
|
+
}
|
|
2395
|
+
// Parse file statuses for summary
|
|
2396
|
+
const lines = stagedStatus.trim().split('\n');
|
|
2397
|
+
const added = lines.filter(l => l.startsWith('A')).length;
|
|
2398
|
+
const modified = lines.filter(l => l.startsWith('M')).length;
|
|
2399
|
+
const deleted = lines.filter(l => l.startsWith('D')).length;
|
|
2400
|
+
const renamed = lines.filter(l => l.startsWith('R')).length;
|
|
2401
|
+
// Build summary
|
|
2402
|
+
const summaryParts = [];
|
|
2403
|
+
if (deleted > 0)
|
|
2404
|
+
summaryParts.push(`${deleted} deleted`);
|
|
2405
|
+
if (added > 0)
|
|
2406
|
+
summaryParts.push(`${added} added`);
|
|
2407
|
+
if (modified > 0)
|
|
2408
|
+
summaryParts.push(`${modified} modified`);
|
|
2409
|
+
if (renamed > 0)
|
|
2410
|
+
summaryParts.push(`${renamed} renamed`);
|
|
2411
|
+
// Pass file list with statuses - simpler and clearer than full diff
|
|
2412
|
+
const prompt = `Generate a commit message for these staged changes:
|
|
2413
|
+
|
|
2414
|
+
Files (${summaryParts.join(', ')}):
|
|
2415
|
+
${stagedStatus}
|
|
2416
|
+
|
|
2417
|
+
Rules:
|
|
2418
|
+
- Max 72 characters
|
|
2419
|
+
- Imperative mood (e.g., "Remove", "Add", "Update", "Refactor")
|
|
2420
|
+
- If mostly deletions, use "Remove" or "Delete"
|
|
2421
|
+
- Output ONLY the commit message text
|
|
2422
|
+
- No quotes, no explanation`;
|
|
2423
|
+
// Call Claude Code CLI
|
|
2424
|
+
const { spawn } = await import('child_process');
|
|
2425
|
+
// Use --dangerously-skip-permissions to skip prompts, -p for print mode, - for stdin
|
|
2426
|
+
const claudeProcess = spawn('claude', ['--dangerously-skip-permissions', '-p', '-'], {
|
|
2427
|
+
cwd: workingDir,
|
|
2428
|
+
shell: true,
|
|
2429
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
2430
|
+
});
|
|
2431
|
+
let output = '';
|
|
2432
|
+
let errorOutput = '';
|
|
2433
|
+
claudeProcess.stdout?.on('data', (data) => {
|
|
2434
|
+
output += data.toString();
|
|
2435
|
+
});
|
|
2436
|
+
claudeProcess.stderr?.on('data', (data) => {
|
|
2437
|
+
errorOutput += data.toString();
|
|
2438
|
+
});
|
|
2439
|
+
// Write prompt to stdin
|
|
2440
|
+
claudeProcess.stdin?.write(prompt);
|
|
2441
|
+
claudeProcess.stdin?.end();
|
|
2442
|
+
// Wait for process to complete with timeout
|
|
2443
|
+
const timeoutMs = 30000;
|
|
2444
|
+
const result = await Promise.race([
|
|
2445
|
+
new Promise((resolve) => {
|
|
2446
|
+
claudeProcess.on('close', (code) => {
|
|
2447
|
+
if (code === 0 && output.trim()) {
|
|
2448
|
+
// Clean up the output - remove any markdown formatting
|
|
2449
|
+
let message = output.trim();
|
|
2450
|
+
// Remove quotes if wrapped
|
|
2451
|
+
if ((message.startsWith('"') && message.endsWith('"')) ||
|
|
2452
|
+
(message.startsWith("'") && message.endsWith("'"))) {
|
|
2453
|
+
message = message.slice(1, -1);
|
|
2454
|
+
}
|
|
2455
|
+
// Take only the first line if multiple lines
|
|
2456
|
+
message = message.split('\n')[0].trim();
|
|
2457
|
+
resolve({ success: true, message });
|
|
2458
|
+
}
|
|
2459
|
+
else {
|
|
2460
|
+
resolve({ success: false, error: errorOutput || 'Failed to generate commit message' });
|
|
2461
|
+
}
|
|
2462
|
+
});
|
|
2463
|
+
}),
|
|
2464
|
+
new Promise((resolve) => {
|
|
2465
|
+
setTimeout(() => {
|
|
2466
|
+
claudeProcess.kill();
|
|
2467
|
+
resolve({ success: false, error: 'Timeout generating commit message' });
|
|
2468
|
+
}, timeoutMs);
|
|
2469
|
+
}),
|
|
2470
|
+
]);
|
|
2471
|
+
if (result.success && 'message' in result && result.message) {
|
|
2472
|
+
const generatedMessage = result.message;
|
|
2473
|
+
// Clean up Claude session to avoid polluting /resume history
|
|
2474
|
+
// The session is created in ~/.claude/projects/<project-folder>/
|
|
2475
|
+
try {
|
|
2476
|
+
const { homedir } = await import('os');
|
|
2477
|
+
const { join } = await import('path');
|
|
2478
|
+
const { readdirSync, unlinkSync, statSync } = await import('fs');
|
|
2479
|
+
const projectName = workingDir.replace(/:/g, '-').replace(/\\/g, '-').replace(/\//g, '-');
|
|
2480
|
+
const sessionsDir = join(homedir(), '.claude', 'projects', projectName);
|
|
2481
|
+
// Find and delete the most recent session file (created in last 60 seconds)
|
|
2482
|
+
const now = Date.now();
|
|
2483
|
+
const files = readdirSync(sessionsDir).filter(f => f.endsWith('.jsonl'));
|
|
2484
|
+
for (const file of files) {
|
|
2485
|
+
const filePath = join(sessionsDir, file);
|
|
2486
|
+
const stat = statSync(filePath);
|
|
2487
|
+
// Delete if created in last 60 seconds (our temp session)
|
|
2488
|
+
if (now - stat.mtimeMs < 60000) {
|
|
2489
|
+
unlinkSync(filePath);
|
|
2490
|
+
}
|
|
2491
|
+
}
|
|
2492
|
+
}
|
|
2493
|
+
catch {
|
|
2494
|
+
// Ignore cleanup errors - session dir might not exist
|
|
2495
|
+
}
|
|
2496
|
+
res.json({ success: true, data: { message: generatedMessage } });
|
|
2497
|
+
}
|
|
2498
|
+
else {
|
|
2499
|
+
res.status(500).json({ success: false, error: result.error || 'Failed to generate message' });
|
|
2500
|
+
}
|
|
2501
|
+
}
|
|
2502
|
+
catch (error) {
|
|
2503
|
+
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
2504
|
+
res.status(500).json({ success: false, error: errorMsg });
|
|
2505
|
+
}
|
|
2506
|
+
});
|
|
2507
|
+
// Get commit log for branch (commits ahead of base branch)
|
|
2508
|
+
terminalRouter.get('/sessions/:id/commit-log', (req, res) => {
|
|
2509
|
+
try {
|
|
2510
|
+
const { id } = req.params;
|
|
2511
|
+
const baseBranch = req.query.base || 'main';
|
|
2512
|
+
const limit = parseInt(req.query.limit) || 20;
|
|
2513
|
+
const before = req.query.before;
|
|
2514
|
+
const requestedRepoId = req.query.repoId;
|
|
2515
|
+
const session = terminalSessionManager.getSession(id);
|
|
2516
|
+
if (!session) {
|
|
2517
|
+
res.status(404).json({ success: false, error: 'Session not found' });
|
|
2518
|
+
return;
|
|
2519
|
+
}
|
|
2520
|
+
const resolved = resolveSessionRepo(session, requestedRepoId);
|
|
2521
|
+
if (!resolved) {
|
|
2522
|
+
res.status(404).json({ success: false, error: 'Repository not found' });
|
|
2523
|
+
return;
|
|
2524
|
+
}
|
|
2525
|
+
const { repo } = resolved;
|
|
2526
|
+
const workingDir = getWorkingDir(session, repo);
|
|
2527
|
+
const commits = [];
|
|
2528
|
+
try {
|
|
2529
|
+
// Get commit log between base branch and HEAD
|
|
2530
|
+
// Format: fullHash|shortHash|subject|authorName|authorEmail|isoDate
|
|
2531
|
+
let logCommand = `git log --format="%H|%h|%s|%an|%ae|%aI" ${baseBranch}..HEAD`;
|
|
2532
|
+
if (before) {
|
|
2533
|
+
logCommand = `git log --format="%H|%h|%s|%an|%ae|%aI" ${baseBranch}..${before}^`;
|
|
2534
|
+
}
|
|
2535
|
+
const logOutput = execSync(logCommand, {
|
|
2536
|
+
cwd: workingDir,
|
|
2537
|
+
encoding: 'utf-8',
|
|
2538
|
+
timeout: 15000,
|
|
2539
|
+
});
|
|
2540
|
+
const lines = logOutput.trim().split('\n').filter(line => line.trim());
|
|
2541
|
+
// Limit the results
|
|
2542
|
+
const limitedLines = lines.slice(0, limit + 1); // Get one extra to check hasMore
|
|
2543
|
+
const hasMore = lines.length > limit;
|
|
2544
|
+
const processLines = limitedLines.slice(0, limit);
|
|
2545
|
+
for (const line of processLines) {
|
|
2546
|
+
const parts = line.split('|');
|
|
2547
|
+
if (parts.length >= 6) {
|
|
2548
|
+
const fullHash = parts[0];
|
|
2549
|
+
const hash = parts[1];
|
|
2550
|
+
const message = parts[2];
|
|
2551
|
+
const author = parts[3];
|
|
2552
|
+
const authorEmail = parts[4];
|
|
2553
|
+
const date = parts[5];
|
|
2554
|
+
// Get file count for this commit
|
|
2555
|
+
let filesCount = 0;
|
|
2556
|
+
try {
|
|
2557
|
+
const countOutput = execSync(`git diff-tree --no-commit-id --name-only -r ${fullHash}`, {
|
|
2558
|
+
cwd: workingDir,
|
|
2559
|
+
encoding: 'utf-8',
|
|
2560
|
+
timeout: 5000,
|
|
2561
|
+
});
|
|
2562
|
+
filesCount = countOutput.trim().split('\n').filter(f => f.trim()).length;
|
|
2563
|
+
}
|
|
2564
|
+
catch {
|
|
2565
|
+
// Ignore errors
|
|
2566
|
+
}
|
|
2567
|
+
commits.push({
|
|
2568
|
+
hash,
|
|
2569
|
+
fullHash,
|
|
2570
|
+
message,
|
|
2571
|
+
author,
|
|
2572
|
+
authorEmail,
|
|
2573
|
+
date,
|
|
2574
|
+
filesCount,
|
|
2575
|
+
});
|
|
2576
|
+
}
|
|
2577
|
+
}
|
|
2578
|
+
res.json({
|
|
2579
|
+
success: true,
|
|
2580
|
+
data: { commits, hasMore },
|
|
2581
|
+
});
|
|
2582
|
+
}
|
|
2583
|
+
catch {
|
|
2584
|
+
// No commits or error - return empty
|
|
2585
|
+
res.json({
|
|
2586
|
+
success: true,
|
|
2587
|
+
data: { commits: [], hasMore: false },
|
|
2588
|
+
});
|
|
2589
|
+
}
|
|
2590
|
+
}
|
|
2591
|
+
catch (error) {
|
|
2592
|
+
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
2593
|
+
res.status(500).json({ success: false, error: errorMsg });
|
|
2594
|
+
}
|
|
2595
|
+
});
|
|
2596
|
+
// Get files changed in a specific commit
|
|
2597
|
+
terminalRouter.get('/sessions/:id/commit-files', (req, res) => {
|
|
2598
|
+
try {
|
|
2599
|
+
const { id } = req.params;
|
|
2600
|
+
const commitHash = req.query.hash;
|
|
2601
|
+
const requestedRepoId = req.query.repoId;
|
|
2602
|
+
if (!commitHash) {
|
|
2603
|
+
res.status(400).json({ success: false, error: 'Commit hash is required' });
|
|
2604
|
+
return;
|
|
2605
|
+
}
|
|
2606
|
+
const session = terminalSessionManager.getSession(id);
|
|
2607
|
+
if (!session) {
|
|
2608
|
+
res.status(404).json({ success: false, error: 'Session not found' });
|
|
2609
|
+
return;
|
|
2610
|
+
}
|
|
2611
|
+
const resolved = resolveSessionRepo(session, requestedRepoId);
|
|
2612
|
+
if (!resolved) {
|
|
2613
|
+
res.status(404).json({ success: false, error: 'Repository not found' });
|
|
2614
|
+
return;
|
|
2615
|
+
}
|
|
2616
|
+
const { repo } = resolved;
|
|
2617
|
+
const workingDir = getWorkingDir(session, repo);
|
|
2618
|
+
const files = [];
|
|
2619
|
+
try {
|
|
2620
|
+
// Get files changed in this commit with rename detection
|
|
2621
|
+
const diffOutput = execSync(`git diff-tree --no-commit-id --name-status -r -M ${commitHash}`, {
|
|
2622
|
+
cwd: workingDir,
|
|
2623
|
+
encoding: 'utf-8',
|
|
2624
|
+
timeout: 10000,
|
|
2625
|
+
});
|
|
2626
|
+
const lines = diffOutput.split('\n').filter(line => line.trim());
|
|
2627
|
+
for (const line of lines) {
|
|
2628
|
+
const parts = line.split('\t');
|
|
2629
|
+
const statusCode = parts[0];
|
|
2630
|
+
let fileStatus = 'modified';
|
|
2631
|
+
if (statusCode === 'A')
|
|
2632
|
+
fileStatus = 'added';
|
|
2633
|
+
else if (statusCode === 'D')
|
|
2634
|
+
fileStatus = 'deleted';
|
|
2635
|
+
else if (statusCode === 'M')
|
|
2636
|
+
fileStatus = 'modified';
|
|
2637
|
+
else if (statusCode.startsWith('R'))
|
|
2638
|
+
fileStatus = 'renamed';
|
|
2639
|
+
// For renames (R###), format is: R###\toldPath\tnewPath
|
|
2640
|
+
if (statusCode.startsWith('R') && parts.length >= 3) {
|
|
2641
|
+
const oldPath = parts[1];
|
|
2642
|
+
const newPath = parts[2];
|
|
2643
|
+
if (newPath) {
|
|
2644
|
+
files.push({ path: newPath, status: fileStatus, oldPath });
|
|
2645
|
+
}
|
|
2646
|
+
}
|
|
2647
|
+
else if (parts[1]) {
|
|
2648
|
+
files.push({ path: parts[1], status: fileStatus });
|
|
2649
|
+
}
|
|
2650
|
+
}
|
|
2651
|
+
}
|
|
2652
|
+
catch {
|
|
2653
|
+
// Ignore errors
|
|
2654
|
+
}
|
|
2655
|
+
res.json({
|
|
2656
|
+
success: true,
|
|
2657
|
+
data: { files },
|
|
2658
|
+
});
|
|
2659
|
+
}
|
|
2660
|
+
catch (error) {
|
|
2661
|
+
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
2662
|
+
res.status(500).json({ success: false, error: errorMsg });
|
|
2663
|
+
}
|
|
2664
|
+
});
|
|
2665
|
+
// Get diff for a specific file in a specific commit
|
|
2666
|
+
terminalRouter.post('/sessions/:id/commit-file-diff', (req, res) => {
|
|
2667
|
+
try {
|
|
2668
|
+
const { id } = req.params;
|
|
2669
|
+
const { commitHash, filePath, repoId: requestedRepoId } = req.body;
|
|
2670
|
+
if (!commitHash || !filePath) {
|
|
2671
|
+
res.status(400).json({ success: false, error: 'commitHash and filePath are required' });
|
|
2672
|
+
return;
|
|
2673
|
+
}
|
|
2674
|
+
const session = terminalSessionManager.getSession(id);
|
|
2675
|
+
if (!session) {
|
|
2676
|
+
res.status(404).json({ success: false, error: 'Session not found' });
|
|
2677
|
+
return;
|
|
2678
|
+
}
|
|
2679
|
+
const resolved = resolveSessionRepo(session, requestedRepoId);
|
|
2680
|
+
if (!resolved) {
|
|
2681
|
+
res.status(404).json({ success: false, error: 'Repository not found' });
|
|
2682
|
+
return;
|
|
2683
|
+
}
|
|
2684
|
+
const { repo } = resolved;
|
|
2685
|
+
const workingDir = getWorkingDir(session, repo);
|
|
2686
|
+
let diff = '';
|
|
2687
|
+
try {
|
|
2688
|
+
// Get the diff for this specific file in this commit
|
|
2689
|
+
// git show shows the diff introduced by a commit
|
|
2690
|
+
diff = execSync(`git show ${commitHash} -- "${filePath}"`, {
|
|
2691
|
+
cwd: workingDir,
|
|
2692
|
+
encoding: 'utf-8',
|
|
2693
|
+
timeout: 10000,
|
|
2694
|
+
});
|
|
2695
|
+
}
|
|
2696
|
+
catch {
|
|
2697
|
+
// No changes or git error
|
|
2698
|
+
diff = '';
|
|
2699
|
+
}
|
|
2700
|
+
res.json({
|
|
2701
|
+
success: true,
|
|
2702
|
+
data: { diff },
|
|
2703
|
+
});
|
|
2704
|
+
}
|
|
2705
|
+
catch (error) {
|
|
2706
|
+
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
2707
|
+
res.status(500).json({ success: false, error: errorMsg });
|
|
2708
|
+
}
|
|
2709
|
+
});
|
|
2710
|
+
// ============================================================================
|
|
2711
|
+
// Ship Changes Endpoints
|
|
2712
|
+
// ============================================================================
|
|
2713
|
+
// Get ship summary (all info needed for the Ship modal)
|
|
2714
|
+
terminalRouter.get('/sessions/:id/ship-summary', (req, res) => {
|
|
2715
|
+
try {
|
|
2716
|
+
const { id } = req.params;
|
|
2717
|
+
const requestedRepoId = req.query.repoId;
|
|
2718
|
+
const session = terminalSessionManager.getSession(id);
|
|
2719
|
+
if (!session) {
|
|
2720
|
+
res.status(404).json({ success: false, error: 'Session not found' });
|
|
2721
|
+
return;
|
|
2722
|
+
}
|
|
2723
|
+
const resolved = resolveSessionRepo(session, requestedRepoId);
|
|
2724
|
+
if (!resolved) {
|
|
2725
|
+
res.status(404).json({ success: false, error: 'Repository not found' });
|
|
2726
|
+
return;
|
|
2727
|
+
}
|
|
2728
|
+
const { repo } = resolved;
|
|
2729
|
+
const workingDir = getWorkingDir(session, repo);
|
|
2730
|
+
// Get current branch
|
|
2731
|
+
let currentBranch = '';
|
|
2732
|
+
let baseBranch = 'main';
|
|
2733
|
+
try {
|
|
2734
|
+
currentBranch = execSync('git branch --show-current', {
|
|
2735
|
+
cwd: workingDir,
|
|
2736
|
+
encoding: 'utf-8',
|
|
2737
|
+
timeout: 5000,
|
|
2738
|
+
}).trim();
|
|
2739
|
+
// Try to find base branch (main or master)
|
|
2740
|
+
const branches = execSync('git branch -a', {
|
|
2741
|
+
cwd: workingDir,
|
|
2742
|
+
encoding: 'utf-8',
|
|
2743
|
+
timeout: 5000,
|
|
2744
|
+
});
|
|
2745
|
+
if (branches.includes('main')) {
|
|
2746
|
+
baseBranch = 'main';
|
|
2747
|
+
}
|
|
2748
|
+
else if (branches.includes('master')) {
|
|
2749
|
+
baseBranch = 'master';
|
|
2750
|
+
}
|
|
2751
|
+
}
|
|
2752
|
+
catch {
|
|
2753
|
+
// Ignore errors
|
|
2754
|
+
}
|
|
2755
|
+
// Check for existing PR/MR for this branch
|
|
2756
|
+
let existingPR = null;
|
|
2757
|
+
if (currentBranch && currentBranch !== baseBranch) {
|
|
2758
|
+
const workspace = workspaceManager.getWorkspaceForRepo(workingDir);
|
|
2759
|
+
const creds = workspaceManager.getGitCredentialsForRepo(workingDir);
|
|
2760
|
+
console.log(`[ship-summary] Checking for existing PR/MR. Branch: ${currentBranch}, baseBranch: ${baseBranch}, platform: ${creds.platform}, hasToken: ${!!creds.token}, workspaceId: ${workspace?.id}`);
|
|
2761
|
+
if (creds.platform === 'github') {
|
|
2762
|
+
// Try gh CLI first
|
|
2763
|
+
try {
|
|
2764
|
+
const prJson = execSync(`gh pr view --json url,number,title,state`, {
|
|
2765
|
+
cwd: workingDir,
|
|
2766
|
+
encoding: 'utf-8',
|
|
2767
|
+
timeout: 10000,
|
|
2768
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
2769
|
+
});
|
|
2770
|
+
const pr = JSON.parse(prJson);
|
|
2771
|
+
if (pr.url) {
|
|
2772
|
+
existingPR = {
|
|
2773
|
+
url: pr.url,
|
|
2774
|
+
number: pr.number,
|
|
2775
|
+
title: pr.title,
|
|
2776
|
+
state: pr.state?.toLowerCase() || 'open',
|
|
2777
|
+
};
|
|
2778
|
+
}
|
|
2779
|
+
}
|
|
2780
|
+
catch {
|
|
2781
|
+
// No PR exists or gh not available
|
|
2782
|
+
}
|
|
2783
|
+
}
|
|
2784
|
+
else if (creds.platform === 'gitlab') {
|
|
2785
|
+
console.log(`[ship-summary] GitLab detected, checking for existing MR on branch: ${currentBranch}`);
|
|
2786
|
+
// Try glab CLI first with correct -F flag
|
|
2787
|
+
let glabWorked = false;
|
|
2788
|
+
try {
|
|
2789
|
+
const mrJson = execSync(`glab mr view -F json`, {
|
|
2790
|
+
cwd: workingDir,
|
|
2791
|
+
encoding: 'utf-8',
|
|
2792
|
+
timeout: 10000,
|
|
2793
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
2794
|
+
});
|
|
2795
|
+
console.log(`[ship-summary] glab mr view output:`, mrJson);
|
|
2796
|
+
const mr = JSON.parse(mrJson);
|
|
2797
|
+
if (mr.web_url) {
|
|
2798
|
+
existingPR = {
|
|
2799
|
+
url: mr.web_url,
|
|
2800
|
+
number: mr.iid,
|
|
2801
|
+
title: mr.title,
|
|
2802
|
+
state: mr.state?.toLowerCase() || 'opened',
|
|
2803
|
+
};
|
|
2804
|
+
glabWorked = true;
|
|
2805
|
+
console.log(`[ship-summary] Found existing MR via glab:`, existingPR);
|
|
2806
|
+
}
|
|
2807
|
+
}
|
|
2808
|
+
catch (glabErr) {
|
|
2809
|
+
// glab not available or no MR - try API fallback
|
|
2810
|
+
const err = glabErr;
|
|
2811
|
+
console.log(`[ship-summary] glab mr view failed:`, err.message || err.stderr || 'unknown error');
|
|
2812
|
+
}
|
|
2813
|
+
// If glab didn't work, try GitLab API with OAuth token
|
|
2814
|
+
if (!glabWorked && creds.token) {
|
|
2815
|
+
console.log(`[ship-summary] Trying GitLab API fallback with token`);
|
|
2816
|
+
// Helper function to make the API call with a given token
|
|
2817
|
+
const checkMRWithToken = (token) => {
|
|
2818
|
+
try {
|
|
2819
|
+
// Get remote URL to determine project path
|
|
2820
|
+
const remoteUrl = execSync('git remote get-url origin', {
|
|
2821
|
+
cwd: workingDir,
|
|
2822
|
+
encoding: 'utf-8',
|
|
2823
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
2824
|
+
}).trim();
|
|
2825
|
+
// Parse GitLab project path from remote URL
|
|
2826
|
+
const match = remoteUrl.match(/gitlab\.com[:/](.+?)(?:\.git)?$/);
|
|
2827
|
+
if (!match) {
|
|
2828
|
+
return { found: false, error: 'Could not parse GitLab remote URL' };
|
|
2829
|
+
}
|
|
2830
|
+
const projectPath = match[1].replace(/\.git$/, '');
|
|
2831
|
+
const projectPathEncoded = encodeURIComponent(projectPath);
|
|
2832
|
+
const apiUrl = `https://gitlab.com/api/v4/projects/${projectPathEncoded}/merge_requests?source_branch=${encodeURIComponent(currentBranch)}`;
|
|
2833
|
+
console.log(`[ship-summary] API URL: ${apiUrl}`);
|
|
2834
|
+
const tempScriptPath = join(tmpdir(), `gitlab-mr-check-${randomUUID()}.mjs`);
|
|
2835
|
+
const tempResultPath = join(tmpdir(), `gitlab-mr-result-${randomUUID()}.json`);
|
|
2836
|
+
const scriptContent = `
|
|
2837
|
+
import { writeFileSync } from 'fs';
|
|
2838
|
+
try {
|
|
2839
|
+
const response = await fetch(${JSON.stringify(apiUrl)}, {
|
|
2840
|
+
headers: {
|
|
2841
|
+
'Accept': 'application/json',
|
|
2842
|
+
'Authorization': 'Bearer ' + ${JSON.stringify(token)},
|
|
2843
|
+
},
|
|
2844
|
+
});
|
|
2845
|
+
const text = await response.text();
|
|
2846
|
+
const status = response.status;
|
|
2847
|
+
|
|
2848
|
+
if (!response.ok) {
|
|
2849
|
+
writeFileSync(${JSON.stringify(tempResultPath)}, JSON.stringify({
|
|
2850
|
+
found: false,
|
|
2851
|
+
error: 'HTTP ' + status + ': ' + text.substring(0, 500),
|
|
2852
|
+
status: status
|
|
2853
|
+
}));
|
|
2854
|
+
} else {
|
|
2855
|
+
let mrs;
|
|
2856
|
+
try {
|
|
2857
|
+
mrs = JSON.parse(text);
|
|
2858
|
+
} catch (parseErr) {
|
|
2859
|
+
writeFileSync(${JSON.stringify(tempResultPath)}, JSON.stringify({
|
|
2860
|
+
found: false,
|
|
2861
|
+
error: 'Invalid JSON: ' + text.substring(0, 200)
|
|
2862
|
+
}));
|
|
2863
|
+
process.exit(0);
|
|
2864
|
+
}
|
|
2865
|
+
|
|
2866
|
+
if (Array.isArray(mrs) && mrs.length > 0) {
|
|
2867
|
+
const mr = mrs[0];
|
|
2868
|
+
writeFileSync(${JSON.stringify(tempResultPath)}, JSON.stringify({
|
|
2869
|
+
found: true,
|
|
2870
|
+
url: mr.web_url,
|
|
2871
|
+
number: mr.iid,
|
|
2872
|
+
title: mr.title,
|
|
2873
|
+
state: mr.state
|
|
2874
|
+
}));
|
|
2875
|
+
} else {
|
|
2876
|
+
writeFileSync(${JSON.stringify(tempResultPath)}, JSON.stringify({
|
|
2877
|
+
found: false,
|
|
2878
|
+
debug: 'API returned ' + (Array.isArray(mrs) ? mrs.length + ' results' : typeof mrs),
|
|
2879
|
+
status: status
|
|
2880
|
+
}));
|
|
2881
|
+
}
|
|
2882
|
+
}
|
|
2883
|
+
} catch (e) {
|
|
2884
|
+
writeFileSync(${JSON.stringify(tempResultPath)}, JSON.stringify({ found: false, error: e.message }));
|
|
2885
|
+
}
|
|
2886
|
+
`;
|
|
2887
|
+
writeFileSync(tempScriptPath, scriptContent);
|
|
2888
|
+
try {
|
|
2889
|
+
execSync(`node "${tempScriptPath}"`, {
|
|
2890
|
+
encoding: 'utf-8',
|
|
2891
|
+
timeout: 15000,
|
|
2892
|
+
});
|
|
2893
|
+
const resultJson = readFileSync(tempResultPath, 'utf-8');
|
|
2894
|
+
console.log(`[ship-summary] GitLab API result:`, resultJson);
|
|
2895
|
+
return JSON.parse(resultJson);
|
|
2896
|
+
}
|
|
2897
|
+
finally {
|
|
2898
|
+
try {
|
|
2899
|
+
unlinkSync(tempScriptPath);
|
|
2900
|
+
}
|
|
2901
|
+
catch { }
|
|
2902
|
+
try {
|
|
2903
|
+
unlinkSync(tempResultPath);
|
|
2904
|
+
}
|
|
2905
|
+
catch { }
|
|
2906
|
+
}
|
|
2907
|
+
}
|
|
2908
|
+
catch (e) {
|
|
2909
|
+
console.log(`[ship-summary] GitLab API MR check error:`, e);
|
|
2910
|
+
return { found: false, error: String(e) };
|
|
2911
|
+
}
|
|
2912
|
+
};
|
|
2913
|
+
// First attempt with current token
|
|
2914
|
+
let result = checkMRWithToken(creds.token);
|
|
2915
|
+
// If we got a 401, try refreshing the token
|
|
2916
|
+
if (result.status === 401 && workspace) {
|
|
2917
|
+
console.log(`[ship-summary] Got 401, attempting token refresh for workspace ${workspace.id}`);
|
|
2918
|
+
const newToken = tryRefreshGitLabToken(workspace.id);
|
|
2919
|
+
if (newToken) {
|
|
2920
|
+
console.log(`[ship-summary] Token refreshed, retrying API call`);
|
|
2921
|
+
result = checkMRWithToken(newToken);
|
|
2922
|
+
}
|
|
2923
|
+
}
|
|
2924
|
+
if (result.found) {
|
|
2925
|
+
existingPR = {
|
|
2926
|
+
url: result.url,
|
|
2927
|
+
number: result.number,
|
|
2928
|
+
title: result.title,
|
|
2929
|
+
state: result.state?.toLowerCase() || 'opened',
|
|
2930
|
+
};
|
|
2931
|
+
console.log(`[ship-summary] Found existing MR via API:`, existingPR);
|
|
2932
|
+
}
|
|
2933
|
+
else {
|
|
2934
|
+
console.log(`[ship-summary] No MR found via API, error:`, result.error);
|
|
2935
|
+
}
|
|
2936
|
+
}
|
|
2937
|
+
}
|
|
2938
|
+
}
|
|
2939
|
+
// Get unpushed commits count
|
|
2940
|
+
let unpushedCommits = 0;
|
|
2941
|
+
try {
|
|
2942
|
+
const count = execSync(`git rev-list --count origin/${currentBranch}..HEAD`, {
|
|
2943
|
+
cwd: workingDir,
|
|
2944
|
+
encoding: 'utf-8',
|
|
2945
|
+
timeout: 5000,
|
|
2946
|
+
}).trim();
|
|
2947
|
+
unpushedCommits = parseInt(count, 10) || 0;
|
|
2948
|
+
}
|
|
2949
|
+
catch {
|
|
2950
|
+
// Branch may not have upstream or no commits yet
|
|
2951
|
+
try {
|
|
2952
|
+
// Count all commits on current branch
|
|
2953
|
+
unpushedCommits = parseInt(execSync('git rev-list --count HEAD', {
|
|
2954
|
+
cwd: workingDir,
|
|
2955
|
+
encoding: 'utf-8',
|
|
2956
|
+
timeout: 5000,
|
|
2957
|
+
}).trim(), 10) || 0;
|
|
2958
|
+
}
|
|
2959
|
+
catch {
|
|
2960
|
+
unpushedCommits = 0;
|
|
2961
|
+
}
|
|
2962
|
+
}
|
|
2963
|
+
// Get staged and unstaged files
|
|
2964
|
+
let hasStagedChanges = false;
|
|
2965
|
+
let hasUnstagedChanges = false;
|
|
2966
|
+
try {
|
|
2967
|
+
const status = execSync('git status --porcelain', {
|
|
2968
|
+
cwd: workingDir,
|
|
2969
|
+
encoding: 'utf-8',
|
|
2970
|
+
timeout: 5000,
|
|
2971
|
+
});
|
|
2972
|
+
const lines = status.split('\n').filter(line => line.trim());
|
|
2973
|
+
for (const line of lines) {
|
|
2974
|
+
const indexStatus = line[0];
|
|
2975
|
+
const workTreeStatus = line[1];
|
|
2976
|
+
if (indexStatus !== ' ' && indexStatus !== '?') {
|
|
2977
|
+
hasStagedChanges = true;
|
|
2978
|
+
}
|
|
2979
|
+
if (workTreeStatus === 'M' || workTreeStatus === 'D' || indexStatus === '?') {
|
|
2980
|
+
hasUnstagedChanges = true;
|
|
2981
|
+
}
|
|
2982
|
+
}
|
|
2983
|
+
}
|
|
2984
|
+
catch {
|
|
2985
|
+
// Ignore errors
|
|
2986
|
+
}
|
|
2987
|
+
const files = [];
|
|
2988
|
+
let totalInsertions = 0;
|
|
2989
|
+
let totalDeletions = 0;
|
|
2990
|
+
// Show all committed changes on this branch compared to base branch
|
|
2991
|
+
// This includes both pushed and unpushed commits
|
|
2992
|
+
if (currentBranch && currentBranch !== baseBranch) {
|
|
2993
|
+
try {
|
|
2994
|
+
// Get files changed on this branch compared to base branch (main/master)
|
|
2995
|
+
// This shows ALL committed changes, both pushed and unpushed
|
|
2996
|
+
let diffBase = `origin/${baseBranch}`;
|
|
2997
|
+
// Check if origin base branch exists
|
|
2998
|
+
try {
|
|
2999
|
+
execSync(`git rev-parse ${diffBase}`, {
|
|
3000
|
+
cwd: workingDir,
|
|
3001
|
+
encoding: 'utf-8',
|
|
3002
|
+
timeout: 5000,
|
|
3003
|
+
});
|
|
3004
|
+
}
|
|
3005
|
+
catch {
|
|
3006
|
+
// Try local base branch
|
|
3007
|
+
diffBase = baseBranch;
|
|
3008
|
+
try {
|
|
3009
|
+
execSync(`git rev-parse ${diffBase}`, {
|
|
3010
|
+
cwd: workingDir,
|
|
3011
|
+
encoding: 'utf-8',
|
|
3012
|
+
timeout: 5000,
|
|
3013
|
+
});
|
|
3014
|
+
}
|
|
3015
|
+
catch {
|
|
3016
|
+
// No base branch found, skip
|
|
3017
|
+
diffBase = '';
|
|
3018
|
+
}
|
|
3019
|
+
}
|
|
3020
|
+
if (diffBase) {
|
|
3021
|
+
// Get numstat for all commits on this branch vs base
|
|
3022
|
+
const branchNumstat = execSync(`git diff ${diffBase}..HEAD --numstat`, {
|
|
3023
|
+
cwd: workingDir,
|
|
3024
|
+
encoding: 'utf-8',
|
|
3025
|
+
timeout: 10000,
|
|
3026
|
+
});
|
|
3027
|
+
// Get name-status for branch commits
|
|
3028
|
+
const branchStatus = execSync(`git diff ${diffBase}..HEAD --name-status -M`, {
|
|
3029
|
+
cwd: workingDir,
|
|
3030
|
+
encoding: 'utf-8',
|
|
3031
|
+
timeout: 10000,
|
|
3032
|
+
});
|
|
3033
|
+
// Parse numstat
|
|
3034
|
+
const branchNumstatMap = new Map();
|
|
3035
|
+
branchNumstat.split('\n').filter(l => l.trim()).forEach(line => {
|
|
3036
|
+
const parts = line.split('\t');
|
|
3037
|
+
if (parts.length >= 3) {
|
|
3038
|
+
const ins = parts[0] === '-' ? 0 : parseInt(parts[0], 10) || 0;
|
|
3039
|
+
const del = parts[1] === '-' ? 0 : parseInt(parts[1], 10) || 0;
|
|
3040
|
+
const path = parts[2];
|
|
3041
|
+
branchNumstatMap.set(path, { insertions: ins, deletions: del });
|
|
3042
|
+
totalInsertions += ins;
|
|
3043
|
+
totalDeletions += del;
|
|
3044
|
+
}
|
|
3045
|
+
});
|
|
3046
|
+
// Parse name-status
|
|
3047
|
+
branchStatus.split('\n').filter(l => l.trim()).forEach(line => {
|
|
3048
|
+
const parts = line.split('\t');
|
|
3049
|
+
const statusCode = parts[0];
|
|
3050
|
+
let status = 'modified';
|
|
3051
|
+
if (statusCode === 'A')
|
|
3052
|
+
status = 'added';
|
|
3053
|
+
else if (statusCode === 'D')
|
|
3054
|
+
status = 'deleted';
|
|
3055
|
+
else if (statusCode.startsWith('R'))
|
|
3056
|
+
status = 'renamed';
|
|
3057
|
+
if (status === 'renamed' && parts.length >= 3) {
|
|
3058
|
+
const oldPath = parts[1];
|
|
3059
|
+
const newPath = parts[2];
|
|
3060
|
+
const stats = branchNumstatMap.get(newPath) || { insertions: 0, deletions: 0 };
|
|
3061
|
+
files.push({
|
|
3062
|
+
path: newPath,
|
|
3063
|
+
insertions: stats.insertions,
|
|
3064
|
+
deletions: stats.deletions,
|
|
3065
|
+
status,
|
|
3066
|
+
oldPath,
|
|
3067
|
+
});
|
|
3068
|
+
}
|
|
3069
|
+
else if (parts[1]) {
|
|
3070
|
+
const stats = branchNumstatMap.get(parts[1]) || { insertions: 0, deletions: 0 };
|
|
3071
|
+
files.push({
|
|
3072
|
+
path: parts[1],
|
|
3073
|
+
insertions: stats.insertions,
|
|
3074
|
+
deletions: stats.deletions,
|
|
3075
|
+
status,
|
|
3076
|
+
});
|
|
3077
|
+
}
|
|
3078
|
+
});
|
|
3079
|
+
}
|
|
3080
|
+
}
|
|
3081
|
+
catch (err) {
|
|
3082
|
+
console.log('[ship-summary] Failed to get branch commits diff:', err);
|
|
3083
|
+
}
|
|
3084
|
+
}
|
|
3085
|
+
else {
|
|
3086
|
+
// No unpushed commits - show uncommitted changes (staged, unstaged, untracked)
|
|
3087
|
+
try {
|
|
3088
|
+
// Get numstat for line counts (staged changes)
|
|
3089
|
+
const stagedNumstat = execSync('git diff --cached --numstat', {
|
|
3090
|
+
cwd: workingDir,
|
|
3091
|
+
encoding: 'utf-8',
|
|
3092
|
+
timeout: 10000,
|
|
3093
|
+
});
|
|
3094
|
+
// Get name-status for status info (staged changes)
|
|
3095
|
+
const stagedStatus = execSync('git diff --cached --name-status -M', {
|
|
3096
|
+
cwd: workingDir,
|
|
3097
|
+
encoding: 'utf-8',
|
|
3098
|
+
timeout: 10000,
|
|
3099
|
+
});
|
|
3100
|
+
// Parse numstat
|
|
3101
|
+
const numstatMap = new Map();
|
|
3102
|
+
stagedNumstat.split('\n').filter(l => l.trim()).forEach(line => {
|
|
3103
|
+
const parts = line.split('\t');
|
|
3104
|
+
if (parts.length >= 3) {
|
|
3105
|
+
const ins = parts[0] === '-' ? 0 : parseInt(parts[0], 10) || 0;
|
|
3106
|
+
const del = parts[1] === '-' ? 0 : parseInt(parts[1], 10) || 0;
|
|
3107
|
+
const path = parts[2];
|
|
3108
|
+
numstatMap.set(path, { insertions: ins, deletions: del });
|
|
3109
|
+
totalInsertions += ins;
|
|
3110
|
+
totalDeletions += del;
|
|
3111
|
+
}
|
|
3112
|
+
});
|
|
3113
|
+
// Parse name-status
|
|
3114
|
+
stagedStatus.split('\n').filter(l => l.trim()).forEach(line => {
|
|
3115
|
+
const parts = line.split('\t');
|
|
3116
|
+
const statusCode = parts[0];
|
|
3117
|
+
let status = 'modified';
|
|
3118
|
+
if (statusCode === 'A')
|
|
3119
|
+
status = 'added';
|
|
3120
|
+
else if (statusCode === 'D')
|
|
3121
|
+
status = 'deleted';
|
|
3122
|
+
else if (statusCode.startsWith('R'))
|
|
3123
|
+
status = 'renamed';
|
|
3124
|
+
// For renames, format is: R###\toldPath\tnewPath
|
|
3125
|
+
if (status === 'renamed' && parts.length >= 3) {
|
|
3126
|
+
const oldPath = parts[1];
|
|
3127
|
+
const newPath = parts[2];
|
|
3128
|
+
const stats = numstatMap.get(newPath) || { insertions: 0, deletions: 0 };
|
|
3129
|
+
files.push({
|
|
3130
|
+
path: newPath,
|
|
3131
|
+
insertions: stats.insertions,
|
|
3132
|
+
deletions: stats.deletions,
|
|
3133
|
+
status,
|
|
3134
|
+
oldPath,
|
|
3135
|
+
});
|
|
3136
|
+
}
|
|
3137
|
+
else if (parts[1]) {
|
|
3138
|
+
const stats = numstatMap.get(parts[1]) || { insertions: 0, deletions: 0 };
|
|
3139
|
+
files.push({
|
|
3140
|
+
path: parts[1],
|
|
3141
|
+
insertions: stats.insertions,
|
|
3142
|
+
deletions: stats.deletions,
|
|
3143
|
+
status,
|
|
3144
|
+
});
|
|
3145
|
+
}
|
|
3146
|
+
});
|
|
3147
|
+
// Also add unstaged changes for complete picture
|
|
3148
|
+
const unstagedNumstat = execSync('git diff --numstat', {
|
|
3149
|
+
cwd: workingDir,
|
|
3150
|
+
encoding: 'utf-8',
|
|
3151
|
+
timeout: 10000,
|
|
3152
|
+
});
|
|
3153
|
+
const unstagedStatus = execSync('git diff --name-status -M', {
|
|
3154
|
+
cwd: workingDir,
|
|
3155
|
+
encoding: 'utf-8',
|
|
3156
|
+
timeout: 10000,
|
|
3157
|
+
});
|
|
3158
|
+
// Parse unstaged numstat
|
|
3159
|
+
const unstagedNumstatMap = new Map();
|
|
3160
|
+
unstagedNumstat.split('\n').filter(l => l.trim()).forEach(line => {
|
|
3161
|
+
const parts = line.split('\t');
|
|
3162
|
+
if (parts.length >= 3) {
|
|
3163
|
+
const ins = parts[0] === '-' ? 0 : parseInt(parts[0], 10) || 0;
|
|
3164
|
+
const del = parts[1] === '-' ? 0 : parseInt(parts[1], 10) || 0;
|
|
3165
|
+
const path = parts[2];
|
|
3166
|
+
unstagedNumstatMap.set(path, { insertions: ins, deletions: del });
|
|
3167
|
+
totalInsertions += ins;
|
|
3168
|
+
totalDeletions += del;
|
|
3169
|
+
}
|
|
3170
|
+
});
|
|
3171
|
+
// Parse unstaged name-status (only add if not already in staged)
|
|
3172
|
+
const existingPaths = new Set(files.map(f => f.path));
|
|
3173
|
+
unstagedStatus.split('\n').filter(l => l.trim()).forEach(line => {
|
|
3174
|
+
const parts = line.split('\t');
|
|
3175
|
+
const statusCode = parts[0];
|
|
3176
|
+
const path = statusCode.startsWith('R') && parts.length >= 3 ? parts[2] : parts[1];
|
|
3177
|
+
if (path && !existingPaths.has(path)) {
|
|
3178
|
+
let status = 'modified';
|
|
3179
|
+
if (statusCode === 'A')
|
|
3180
|
+
status = 'added';
|
|
3181
|
+
else if (statusCode === 'D')
|
|
3182
|
+
status = 'deleted';
|
|
3183
|
+
else if (statusCode.startsWith('R'))
|
|
3184
|
+
status = 'renamed';
|
|
3185
|
+
const stats = unstagedNumstatMap.get(path) || { insertions: 0, deletions: 0 };
|
|
3186
|
+
if (status === 'renamed' && parts.length >= 3) {
|
|
3187
|
+
files.push({
|
|
3188
|
+
path,
|
|
3189
|
+
insertions: stats.insertions,
|
|
3190
|
+
deletions: stats.deletions,
|
|
3191
|
+
status,
|
|
3192
|
+
oldPath: parts[1],
|
|
3193
|
+
});
|
|
3194
|
+
}
|
|
3195
|
+
else {
|
|
3196
|
+
files.push({
|
|
3197
|
+
path,
|
|
3198
|
+
insertions: stats.insertions,
|
|
3199
|
+
deletions: stats.deletions,
|
|
3200
|
+
status,
|
|
3201
|
+
});
|
|
3202
|
+
}
|
|
3203
|
+
}
|
|
3204
|
+
});
|
|
3205
|
+
// Add untracked files
|
|
3206
|
+
const untracked = execSync('git ls-files --others --exclude-standard', {
|
|
3207
|
+
cwd: workingDir,
|
|
3208
|
+
encoding: 'utf-8',
|
|
3209
|
+
timeout: 5000,
|
|
3210
|
+
});
|
|
3211
|
+
const existingPathsWithUntracked = new Set(files.map(f => f.path));
|
|
3212
|
+
untracked.split('\n').filter(l => l.trim()).forEach(path => {
|
|
3213
|
+
if (!existingPathsWithUntracked.has(path)) {
|
|
3214
|
+
files.push({
|
|
3215
|
+
path,
|
|
3216
|
+
insertions: 0,
|
|
3217
|
+
deletions: 0,
|
|
3218
|
+
status: 'added',
|
|
3219
|
+
});
|
|
3220
|
+
}
|
|
3221
|
+
});
|
|
3222
|
+
}
|
|
3223
|
+
catch {
|
|
3224
|
+
// Ignore errors
|
|
3225
|
+
}
|
|
3226
|
+
}
|
|
3227
|
+
const hasUncommittedChanges = hasStagedChanges || hasUnstagedChanges;
|
|
3228
|
+
const hasChangesToShip = files.length > 0 || unpushedCommits > 0;
|
|
3229
|
+
res.json({
|
|
3230
|
+
success: true,
|
|
3231
|
+
data: {
|
|
3232
|
+
files,
|
|
3233
|
+
totalInsertions,
|
|
3234
|
+
totalDeletions,
|
|
3235
|
+
currentBranch,
|
|
3236
|
+
baseBranch,
|
|
3237
|
+
hasUncommittedChanges,
|
|
3238
|
+
hasChangesToShip,
|
|
3239
|
+
unpushedCommits,
|
|
3240
|
+
hasStagedChanges,
|
|
3241
|
+
hasUnstagedChanges,
|
|
3242
|
+
existingPR,
|
|
3243
|
+
},
|
|
3244
|
+
});
|
|
3245
|
+
}
|
|
3246
|
+
catch (error) {
|
|
3247
|
+
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
3248
|
+
res.status(500).json({ success: false, error: errorMsg });
|
|
3249
|
+
}
|
|
3250
|
+
});
|
|
3251
|
+
// Ship changes (stage, commit, push, create PR in one flow)
|
|
3252
|
+
terminalRouter.post('/sessions/:id/ship', async (req, res) => {
|
|
3253
|
+
let cleanup = null;
|
|
3254
|
+
try {
|
|
3255
|
+
const { id } = req.params;
|
|
3256
|
+
const { commitMessage, push = true, createPR = true, prTitle, prBody, targetBranch, repoId: requestedRepoId, } = req.body;
|
|
3257
|
+
// Commit message is optional - only required if there are uncommitted changes
|
|
3258
|
+
const hasCommitMessage = commitMessage && typeof commitMessage === 'string' && commitMessage.trim();
|
|
3259
|
+
const session = terminalSessionManager.getSession(id);
|
|
3260
|
+
if (!session) {
|
|
3261
|
+
res.status(404).json({ success: false, error: 'Session not found' });
|
|
3262
|
+
return;
|
|
3263
|
+
}
|
|
3264
|
+
const resolved = resolveSessionRepo(session, requestedRepoId);
|
|
3265
|
+
if (!resolved) {
|
|
3266
|
+
res.status(404).json({ success: false, error: 'Repository not found' });
|
|
3267
|
+
return;
|
|
3268
|
+
}
|
|
3269
|
+
const { repo } = resolved;
|
|
3270
|
+
const workingDir = getWorkingDir(session, repo);
|
|
3271
|
+
let committed = false;
|
|
3272
|
+
let pushed = false;
|
|
3273
|
+
let prUrl;
|
|
3274
|
+
let commitHash;
|
|
3275
|
+
// Step 1: Stage all changes (including untracked)
|
|
3276
|
+
try {
|
|
3277
|
+
execSync('git add -A', {
|
|
3278
|
+
cwd: workingDir,
|
|
3279
|
+
encoding: 'utf-8',
|
|
3280
|
+
timeout: 30000,
|
|
3281
|
+
});
|
|
3282
|
+
}
|
|
3283
|
+
catch (err) {
|
|
3284
|
+
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
3285
|
+
res.status(400).json({ success: false, error: `Failed to stage changes: ${errorMsg}` });
|
|
3286
|
+
return;
|
|
3287
|
+
}
|
|
3288
|
+
// Check if there's anything to commit
|
|
3289
|
+
let hasChangesToCommit = false;
|
|
3290
|
+
try {
|
|
3291
|
+
const status = execSync('git status --porcelain', {
|
|
3292
|
+
cwd: workingDir,
|
|
3293
|
+
encoding: 'utf-8',
|
|
3294
|
+
timeout: 5000,
|
|
3295
|
+
});
|
|
3296
|
+
hasChangesToCommit = status.trim().length > 0;
|
|
3297
|
+
}
|
|
3298
|
+
catch {
|
|
3299
|
+
// Ignore
|
|
3300
|
+
}
|
|
3301
|
+
// Step 2: Commit if there are staged changes AND commit message provided
|
|
3302
|
+
if (hasChangesToCommit) {
|
|
3303
|
+
if (!hasCommitMessage) {
|
|
3304
|
+
res.status(400).json({ success: false, error: 'Commit message is required for uncommitted changes' });
|
|
3305
|
+
return;
|
|
3306
|
+
}
|
|
3307
|
+
try {
|
|
3308
|
+
const escapedMessage = commitMessage.replace(/"/g, '\\"');
|
|
3309
|
+
execSync(`git commit -m "${escapedMessage}"`, {
|
|
3310
|
+
cwd: workingDir,
|
|
3311
|
+
encoding: 'utf-8',
|
|
3312
|
+
timeout: 30000,
|
|
3313
|
+
});
|
|
3314
|
+
commitHash = execSync('git rev-parse --short HEAD', {
|
|
3315
|
+
cwd: workingDir,
|
|
3316
|
+
encoding: 'utf-8',
|
|
3317
|
+
timeout: 5000,
|
|
3318
|
+
}).trim();
|
|
3319
|
+
committed = true;
|
|
3320
|
+
}
|
|
3321
|
+
catch (err) {
|
|
3322
|
+
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
3323
|
+
// Check if it's "nothing to commit"
|
|
3324
|
+
if (!errorMsg.includes('nothing to commit')) {
|
|
3325
|
+
res.status(400).json({ success: false, error: `Failed to commit: ${errorMsg}` });
|
|
3326
|
+
return;
|
|
3327
|
+
}
|
|
3328
|
+
}
|
|
3329
|
+
}
|
|
3330
|
+
// Step 3: Push if requested
|
|
3331
|
+
if (push) {
|
|
3332
|
+
const { options: execOptions, cleanup: cleanupFn } = getGitExecOptions(workingDir, 120000);
|
|
3333
|
+
cleanup = cleanupFn;
|
|
3334
|
+
try {
|
|
3335
|
+
const branch = execSync('git branch --show-current', {
|
|
3336
|
+
cwd: workingDir,
|
|
3337
|
+
encoding: 'utf-8',
|
|
3338
|
+
timeout: 5000,
|
|
3339
|
+
}).trim();
|
|
3340
|
+
execSync(`git push -u origin ${branch}`, execOptions);
|
|
3341
|
+
pushed = true;
|
|
3342
|
+
}
|
|
3343
|
+
catch (err) {
|
|
3344
|
+
if (cleanup)
|
|
3345
|
+
cleanup();
|
|
3346
|
+
cleanup = null;
|
|
3347
|
+
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
3348
|
+
res.status(400).json({
|
|
3349
|
+
success: false,
|
|
3350
|
+
error: `Failed to push: ${errorMsg}`,
|
|
3351
|
+
committed,
|
|
3352
|
+
commitHash,
|
|
3353
|
+
});
|
|
3354
|
+
return;
|
|
3355
|
+
}
|
|
3356
|
+
finally {
|
|
3357
|
+
if (cleanup) {
|
|
3358
|
+
cleanup();
|
|
3359
|
+
cleanup = null;
|
|
3360
|
+
}
|
|
3361
|
+
}
|
|
3362
|
+
// Step 4: Create PR if requested and pushed successfully
|
|
3363
|
+
if (createPR && pushed) {
|
|
3364
|
+
try {
|
|
3365
|
+
const currentBranch = execSync('git branch --show-current', {
|
|
3366
|
+
cwd: workingDir,
|
|
3367
|
+
encoding: 'utf-8',
|
|
3368
|
+
timeout: 5000,
|
|
3369
|
+
}).trim();
|
|
3370
|
+
// Determine target branch
|
|
3371
|
+
let target = targetBranch || 'main';
|
|
3372
|
+
if (!targetBranch) {
|
|
3373
|
+
const branches = execSync('git branch -a', {
|
|
3374
|
+
cwd: workingDir,
|
|
3375
|
+
encoding: 'utf-8',
|
|
3376
|
+
timeout: 5000,
|
|
3377
|
+
});
|
|
3378
|
+
if (branches.includes('main')) {
|
|
3379
|
+
target = 'main';
|
|
3380
|
+
}
|
|
3381
|
+
else if (branches.includes('master')) {
|
|
3382
|
+
target = 'master';
|
|
3383
|
+
}
|
|
3384
|
+
}
|
|
3385
|
+
// Detect platform and create PR/MR
|
|
3386
|
+
const creds = workspaceManager.getGitCredentialsForRepo(workingDir);
|
|
3387
|
+
const title = prTitle?.trim() || commitMessage.trim();
|
|
3388
|
+
const body = prBody || '';
|
|
3389
|
+
if (creds.platform === 'github' && creds.token) {
|
|
3390
|
+
const result = await githubIntegration.createPR(workingDir, currentBranch, title, body, undefined, // workspaceLookupPath
|
|
3391
|
+
target);
|
|
3392
|
+
if (result.success && result.prUrl) {
|
|
3393
|
+
prUrl = result.prUrl;
|
|
3394
|
+
}
|
|
3395
|
+
}
|
|
3396
|
+
else if (creds.platform === 'gitlab' && creds.token) {
|
|
3397
|
+
const result = await gitlabIntegration.createMR(workingDir, currentBranch, title, body, undefined, // workspaceLookupPath
|
|
3398
|
+
target);
|
|
3399
|
+
if (result.success && result.mrUrl) {
|
|
3400
|
+
prUrl = result.mrUrl;
|
|
3401
|
+
}
|
|
3402
|
+
}
|
|
3403
|
+
else {
|
|
3404
|
+
// Try using gh CLI as fallback
|
|
3405
|
+
try {
|
|
3406
|
+
const escapedTitle = title.replace(/"/g, '\\"');
|
|
3407
|
+
const escapedBody = body.replace(/"/g, '\\"');
|
|
3408
|
+
const result = execSync(`gh pr create --title "${escapedTitle}" --body "${escapedBody}" --base ${target}`, {
|
|
3409
|
+
cwd: workingDir,
|
|
3410
|
+
encoding: 'utf-8',
|
|
3411
|
+
timeout: 60000,
|
|
3412
|
+
});
|
|
3413
|
+
// Extract URL from result
|
|
3414
|
+
const urlMatch = result.match(/https:\/\/github\.com\/[^\s]+/);
|
|
3415
|
+
if (urlMatch) {
|
|
3416
|
+
prUrl = urlMatch[0];
|
|
3417
|
+
}
|
|
3418
|
+
}
|
|
3419
|
+
catch (ghErr) {
|
|
3420
|
+
// PR creation failed but commit and push succeeded
|
|
3421
|
+
console.error('[ship] Failed to create PR:', ghErr);
|
|
3422
|
+
}
|
|
3423
|
+
}
|
|
3424
|
+
}
|
|
3425
|
+
catch (prErr) {
|
|
3426
|
+
// PR creation failed but commit and push succeeded
|
|
3427
|
+
console.error('[ship] Failed to create PR:', prErr);
|
|
3428
|
+
}
|
|
3429
|
+
}
|
|
3430
|
+
}
|
|
3431
|
+
res.json({
|
|
3432
|
+
success: true,
|
|
3433
|
+
data: {
|
|
3434
|
+
success: true,
|
|
3435
|
+
committed,
|
|
3436
|
+
pushed,
|
|
3437
|
+
prUrl,
|
|
3438
|
+
commitHash,
|
|
3439
|
+
},
|
|
3440
|
+
});
|
|
3441
|
+
}
|
|
3442
|
+
catch (error) {
|
|
3443
|
+
if (cleanup)
|
|
3444
|
+
cleanup();
|
|
3445
|
+
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
3446
|
+
res.status(500).json({ success: false, error: errorMsg });
|
|
3447
|
+
}
|
|
3448
|
+
});
|
|
3449
|
+
// ============================================================================
|
|
3450
|
+
// Usage Tracking Endpoints
|
|
3451
|
+
// ============================================================================
|
|
3452
|
+
// Get usage stats for a specific session
|
|
3453
|
+
terminalRouter.get('/sessions/:sessionId/usage', (req, res) => {
|
|
3454
|
+
try {
|
|
3455
|
+
const { sessionId } = req.params;
|
|
3456
|
+
const session = terminalSessionManager.getSession(sessionId);
|
|
3457
|
+
if (!session) {
|
|
3458
|
+
res.status(404).json({ success: false, error: 'Session not found' });
|
|
3459
|
+
return;
|
|
3460
|
+
}
|
|
3461
|
+
const stats = usageManager.getSessionUsage(sessionId);
|
|
3462
|
+
res.json({ success: true, data: stats });
|
|
3463
|
+
}
|
|
3464
|
+
catch (error) {
|
|
3465
|
+
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
3466
|
+
res.status(500).json({ success: false, error: errorMsg });
|
|
3467
|
+
}
|
|
3468
|
+
});
|
|
3469
|
+
// Get global usage stats for today
|
|
3470
|
+
terminalRouter.get('/usage', (req, res) => {
|
|
3471
|
+
try {
|
|
3472
|
+
const stats = usageManager.getGlobalUsage();
|
|
3473
|
+
res.json({ success: true, data: stats });
|
|
3474
|
+
}
|
|
3475
|
+
catch (error) {
|
|
3476
|
+
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
3477
|
+
res.status(500).json({ success: false, error: errorMsg });
|
|
3478
|
+
}
|
|
3479
|
+
});
|
|
3480
|
+
// Get weekly usage stats (resets on Sunday)
|
|
3481
|
+
terminalRouter.get('/usage/weekly', (_req, res) => {
|
|
3482
|
+
try {
|
|
3483
|
+
const stats = usageManager.getWeeklyUsage();
|
|
3484
|
+
res.json({ success: true, data: stats });
|
|
3485
|
+
}
|
|
3486
|
+
catch (error) {
|
|
3487
|
+
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
3488
|
+
res.status(500).json({ success: false, error: errorMsg });
|
|
3489
|
+
}
|
|
3490
|
+
});
|
|
3491
|
+
// Get Claude subscription quota (from Anthropic OAuth API)
|
|
3492
|
+
terminalRouter.get('/usage/quota', async (req, res) => {
|
|
3493
|
+
try {
|
|
3494
|
+
const forceRefresh = req.query.refresh === 'true';
|
|
3495
|
+
const quota = await queryClaudeQuota(forceRefresh);
|
|
3496
|
+
if (quota) {
|
|
3497
|
+
res.json({ success: true, data: quota });
|
|
3498
|
+
}
|
|
3499
|
+
else {
|
|
3500
|
+
res.json({
|
|
3501
|
+
success: true,
|
|
3502
|
+
data: null,
|
|
3503
|
+
message: 'Claude quota data not available. OAuth token may not be accessible.',
|
|
3504
|
+
});
|
|
3505
|
+
}
|
|
3506
|
+
}
|
|
3507
|
+
catch (error) {
|
|
3508
|
+
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
3509
|
+
res.status(500).json({ success: false, error: errorMsg });
|
|
3510
|
+
}
|
|
3511
|
+
});
|
|
3512
|
+
// Refresh Claude subscription quota
|
|
3513
|
+
terminalRouter.post('/usage/quota/refresh', async (_req, res) => {
|
|
3514
|
+
try {
|
|
3515
|
+
clearQuotaCache();
|
|
3516
|
+
const quota = await queryClaudeQuota(true);
|
|
3517
|
+
res.json({ success: true, data: quota });
|
|
3518
|
+
}
|
|
3519
|
+
catch (error) {
|
|
3520
|
+
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
3521
|
+
res.status(500).json({ success: false, error: errorMsg });
|
|
3522
|
+
}
|
|
3523
|
+
});
|
|
3524
|
+
//# sourceMappingURL=terminal-routes.js.map
|