claude-code-runner 0.1.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/README.md +559 -0
- package/README.zh-Hans.md +559 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +377 -0
- package/dist/cli.js.map +1 -0
- package/dist/config.d.ts +4 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +50 -0
- package/dist/config.js.map +1 -0
- package/dist/container.d.ts +23 -0
- package/dist/container.d.ts.map +1 -0
- package/dist/container.js +971 -0
- package/dist/container.js.map +1 -0
- package/dist/credentials.d.ts +8 -0
- package/dist/credentials.d.ts.map +1 -0
- package/dist/credentials.js +145 -0
- package/dist/credentials.js.map +1 -0
- package/dist/docker-config.d.ts +19 -0
- package/dist/docker-config.d.ts.map +1 -0
- package/dist/docker-config.js +101 -0
- package/dist/docker-config.js.map +1 -0
- package/dist/git/shadow-repository.d.ts +30 -0
- package/dist/git/shadow-repository.d.ts.map +1 -0
- package/dist/git/shadow-repository.js +645 -0
- package/dist/git/shadow-repository.js.map +1 -0
- package/dist/git-monitor.d.ts +15 -0
- package/dist/git-monitor.d.ts.map +1 -0
- package/dist/git-monitor.js +94 -0
- package/dist/git-monitor.js.map +1 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +221 -0
- package/dist/index.js.map +1 -0
- package/dist/types.d.ts +49 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- package/dist/ui.d.ts +12 -0
- package/dist/ui.d.ts.map +1 -0
- package/dist/ui.js +82 -0
- package/dist/ui.js.map +1 -0
- package/dist/web-server-attach.d.ts +16 -0
- package/dist/web-server-attach.d.ts.map +1 -0
- package/dist/web-server-attach.js +249 -0
- package/dist/web-server-attach.js.map +1 -0
- package/dist/web-server.d.ts +27 -0
- package/dist/web-server.d.ts.map +1 -0
- package/dist/web-server.js +812 -0
- package/dist/web-server.js.map +1 -0
- package/package.json +77 -0
|
@@ -0,0 +1,812 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
36
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
37
|
+
};
|
|
38
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
39
|
+
exports.WebUIServer = void 0;
|
|
40
|
+
const node_buffer_1 = require("node:buffer");
|
|
41
|
+
const node_child_process_1 = require("node:child_process");
|
|
42
|
+
const node_http_1 = require("node:http");
|
|
43
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
44
|
+
const node_process_1 = __importDefault(require("node:process"));
|
|
45
|
+
const node_util_1 = require("node:util");
|
|
46
|
+
const chalk_1 = __importDefault(require("chalk"));
|
|
47
|
+
const express_1 = __importDefault(require("express"));
|
|
48
|
+
const fs = __importStar(require("fs-extra"));
|
|
49
|
+
const socket_io_1 = require("socket.io");
|
|
50
|
+
const docker_config_1 = require("./docker-config");
|
|
51
|
+
const shadow_repository_1 = require("./git/shadow-repository");
|
|
52
|
+
const execAsync = (0, node_util_1.promisify)(node_child_process_1.exec);
|
|
53
|
+
class WebUIServer {
|
|
54
|
+
app;
|
|
55
|
+
httpServer;
|
|
56
|
+
io;
|
|
57
|
+
docker;
|
|
58
|
+
sessions = new Map(); // container -> session mapping
|
|
59
|
+
port = 3456;
|
|
60
|
+
shadowRepos = new Map(); // container -> shadow repo
|
|
61
|
+
syncInProgress = new Set(); // Track containers currently syncing
|
|
62
|
+
originalRepo = '';
|
|
63
|
+
currentBranch = 'main';
|
|
64
|
+
fileWatchers = new Map(); // container -> monitor (inotify stream or interval)
|
|
65
|
+
containerCmd; // 'docker' or 'podman'
|
|
66
|
+
constructor(docker, containerRuntime) {
|
|
67
|
+
this.docker = docker;
|
|
68
|
+
this.containerCmd = containerRuntime || (0, docker_config_1.getContainerRuntimeCmd)();
|
|
69
|
+
this.app = (0, express_1.default)();
|
|
70
|
+
this.httpServer = (0, node_http_1.createServer)(this.app);
|
|
71
|
+
this.io = new socket_io_1.Server(this.httpServer, {
|
|
72
|
+
cors: {
|
|
73
|
+
origin: '*',
|
|
74
|
+
methods: ['GET', 'POST'],
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
this.setupRoutes();
|
|
78
|
+
this.setupSocketHandlers();
|
|
79
|
+
}
|
|
80
|
+
setupRoutes() {
|
|
81
|
+
// Serve static files
|
|
82
|
+
this.app.use(express_1.default.static(node_path_1.default.join(__dirname, '../public')));
|
|
83
|
+
// Health check endpoint
|
|
84
|
+
this.app.get('/api/health', (_req, res) => {
|
|
85
|
+
res.json({ status: 'ok' });
|
|
86
|
+
});
|
|
87
|
+
// Container info endpoint
|
|
88
|
+
this.app.get('/api/containers', async (_req, res) => {
|
|
89
|
+
try {
|
|
90
|
+
const containers = await this.docker.listContainers();
|
|
91
|
+
const claudeContainers = containers.filter(c => c.Names.some(name => name.includes('claude-code-runner')));
|
|
92
|
+
res.json(claudeContainers);
|
|
93
|
+
}
|
|
94
|
+
catch (error) {
|
|
95
|
+
res.status(500).json({ error: 'Failed to list containers' });
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
// Git info endpoint - get current branch and PRs
|
|
99
|
+
this.app.get('/api/git/info', async (req, res) => {
|
|
100
|
+
try {
|
|
101
|
+
const containerId = req.query.containerId;
|
|
102
|
+
let currentBranch = 'loading...';
|
|
103
|
+
const workingDir = this.originalRepo || node_process_1.default.cwd();
|
|
104
|
+
// If containerId is provided, try to get branch from shadow repo
|
|
105
|
+
if (containerId && this.shadowRepos.has(containerId)) {
|
|
106
|
+
const shadowRepo = this.shadowRepos.get(containerId);
|
|
107
|
+
const shadowPath = shadowRepo.getPath();
|
|
108
|
+
if (shadowPath) {
|
|
109
|
+
try {
|
|
110
|
+
const branchResult = await execAsync('git rev-parse --abbrev-ref HEAD', {
|
|
111
|
+
cwd: shadowPath,
|
|
112
|
+
});
|
|
113
|
+
currentBranch = branchResult.stdout.trim();
|
|
114
|
+
// Use original repo for PR lookup (PRs are created against the main repo)
|
|
115
|
+
}
|
|
116
|
+
catch (error) {
|
|
117
|
+
// Shadow repo might not be fully initialized yet, fall back to original repo
|
|
118
|
+
try {
|
|
119
|
+
const branchResult = await execAsync('git rev-parse --abbrev-ref HEAD', {
|
|
120
|
+
cwd: workingDir,
|
|
121
|
+
});
|
|
122
|
+
currentBranch = branchResult.stdout.trim();
|
|
123
|
+
}
|
|
124
|
+
catch (fallbackError) {
|
|
125
|
+
// Keep default "loading..."
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
else {
|
|
131
|
+
// Fallback to original repo
|
|
132
|
+
try {
|
|
133
|
+
const branchResult = await execAsync('git rev-parse --abbrev-ref HEAD', {
|
|
134
|
+
cwd: workingDir,
|
|
135
|
+
});
|
|
136
|
+
currentBranch = branchResult.stdout.trim();
|
|
137
|
+
}
|
|
138
|
+
catch (error) {
|
|
139
|
+
// Keep default "loading..."
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
// Get repository remote URL for branch links
|
|
143
|
+
let repoUrl = '';
|
|
144
|
+
let isGitHub = false;
|
|
145
|
+
try {
|
|
146
|
+
const remoteResult = await execAsync('git remote get-url origin', {
|
|
147
|
+
cwd: this.originalRepo || node_process_1.default.cwd(),
|
|
148
|
+
});
|
|
149
|
+
const remoteUrl = remoteResult.stdout.trim();
|
|
150
|
+
// Convert SSH URLs to HTTPS for web links and detect GitHub
|
|
151
|
+
if (remoteUrl.startsWith('git@github.com:')) {
|
|
152
|
+
repoUrl = remoteUrl
|
|
153
|
+
.replace('git@github.com:', 'https://github.com/')
|
|
154
|
+
.replace('.git', '');
|
|
155
|
+
isGitHub = true;
|
|
156
|
+
}
|
|
157
|
+
else if (remoteUrl.includes('github.com')) {
|
|
158
|
+
repoUrl = remoteUrl.replace('.git', '');
|
|
159
|
+
isGitHub = true;
|
|
160
|
+
}
|
|
161
|
+
else if (remoteUrl.startsWith('https://')) {
|
|
162
|
+
repoUrl = remoteUrl.replace('.git', '');
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
catch (error) {
|
|
166
|
+
console.warn('Could not get repository URL:', error);
|
|
167
|
+
}
|
|
168
|
+
// Get PR info using GitHub CLI (only for GitHub repositories)
|
|
169
|
+
let prs = [];
|
|
170
|
+
if (isGitHub) {
|
|
171
|
+
try {
|
|
172
|
+
const prResult = await execAsync(`gh pr list --head "${currentBranch}" --json number,title,state,url,isDraft,mergeable`, {
|
|
173
|
+
cwd: this.originalRepo || node_process_1.default.cwd(),
|
|
174
|
+
});
|
|
175
|
+
prs = JSON.parse(prResult.stdout || '[]');
|
|
176
|
+
}
|
|
177
|
+
catch (error) {
|
|
178
|
+
// GitHub CLI might not be installed or not authenticated
|
|
179
|
+
// Only log this in debug mode to avoid spam
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
const branchUrl = repoUrl ? `${repoUrl}/tree/${currentBranch}` : '';
|
|
183
|
+
res.json({
|
|
184
|
+
currentBranch,
|
|
185
|
+
branchUrl,
|
|
186
|
+
repoUrl,
|
|
187
|
+
prs,
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
catch (error) {
|
|
191
|
+
console.error('Failed to get git info:', error);
|
|
192
|
+
res.status(500).json({ error: 'Failed to get git info' });
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
setupSocketHandlers() {
|
|
197
|
+
this.io.on('connection', (socket) => {
|
|
198
|
+
console.log(chalk_1.default.blue('✓ Client connected to web UI'));
|
|
199
|
+
socket.on('attach', async (data) => {
|
|
200
|
+
const { containerId } = data;
|
|
201
|
+
try {
|
|
202
|
+
const container = this.docker.getContainer(containerId);
|
|
203
|
+
// Check if we already have a session for this container
|
|
204
|
+
let session = this.sessions.get(containerId);
|
|
205
|
+
if (!session || !session.stream) {
|
|
206
|
+
// No existing session, create a new one
|
|
207
|
+
console.log(chalk_1.default.blue('Creating new Claude session...'));
|
|
208
|
+
const exec = await container.exec({
|
|
209
|
+
AttachStdin: true,
|
|
210
|
+
AttachStdout: true,
|
|
211
|
+
AttachStderr: true,
|
|
212
|
+
Tty: true,
|
|
213
|
+
Cmd: ['/home/claude/start-session.sh'],
|
|
214
|
+
WorkingDir: '/workspace',
|
|
215
|
+
User: 'claude',
|
|
216
|
+
Env: ['TERM=xterm-256color', 'COLORTERM=truecolor'],
|
|
217
|
+
});
|
|
218
|
+
const stream = await exec.start({
|
|
219
|
+
hijack: true,
|
|
220
|
+
stdin: true,
|
|
221
|
+
});
|
|
222
|
+
session = {
|
|
223
|
+
containerId,
|
|
224
|
+
exec,
|
|
225
|
+
stream,
|
|
226
|
+
connectedSockets: new Set([socket.id]),
|
|
227
|
+
outputHistory: [],
|
|
228
|
+
};
|
|
229
|
+
this.sessions.set(containerId, session);
|
|
230
|
+
// Set up stream handlers that broadcast to all connected sockets
|
|
231
|
+
stream.on('data', (chunk) => {
|
|
232
|
+
// Process and broadcast to all connected sockets for this session
|
|
233
|
+
let dataToSend;
|
|
234
|
+
if (chunk.length > 8) {
|
|
235
|
+
const firstByte = chunk[0];
|
|
236
|
+
if (firstByte >= 1 && firstByte <= 3) {
|
|
237
|
+
dataToSend = chunk.slice(8);
|
|
238
|
+
}
|
|
239
|
+
else {
|
|
240
|
+
dataToSend = chunk;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
else {
|
|
244
|
+
dataToSend = chunk;
|
|
245
|
+
}
|
|
246
|
+
if (dataToSend.length > 0) {
|
|
247
|
+
// Store in history (limit to last 100KB)
|
|
248
|
+
if (session.outputHistory) {
|
|
249
|
+
session.outputHistory.push(node_buffer_1.Buffer.from(dataToSend));
|
|
250
|
+
let totalSize = session.outputHistory.reduce((sum, buf) => sum + buf.length, 0);
|
|
251
|
+
while (totalSize > 100000
|
|
252
|
+
&& session.outputHistory.length > 1) {
|
|
253
|
+
const removed = session.outputHistory.shift();
|
|
254
|
+
if (removed) {
|
|
255
|
+
totalSize -= removed.length;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
// Broadcast to all connected sockets for this container
|
|
260
|
+
for (const socketId of session.connectedSockets) {
|
|
261
|
+
const connectedSocket = this.io.sockets.sockets.get(socketId);
|
|
262
|
+
if (connectedSocket) {
|
|
263
|
+
connectedSocket.emit('output', new Uint8Array(dataToSend));
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
});
|
|
268
|
+
stream.on('error', (err) => {
|
|
269
|
+
console.error(chalk_1.default.red('Stream error:'), err);
|
|
270
|
+
// Notify all connected sockets
|
|
271
|
+
for (const socketId of session.connectedSockets) {
|
|
272
|
+
const connectedSocket = this.io.sockets.sockets.get(socketId);
|
|
273
|
+
if (connectedSocket) {
|
|
274
|
+
connectedSocket.emit('error', { message: err.message });
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
});
|
|
278
|
+
stream.on('end', () => {
|
|
279
|
+
// Notify all connected sockets
|
|
280
|
+
for (const socketId of session.connectedSockets) {
|
|
281
|
+
const connectedSocket = this.io.sockets.sockets.get(socketId);
|
|
282
|
+
if (connectedSocket) {
|
|
283
|
+
connectedSocket.emit('container-disconnected');
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
// Stop continuous monitoring
|
|
287
|
+
this.stopContinuousMonitoring(containerId);
|
|
288
|
+
// Clean up session and shadow repo
|
|
289
|
+
this.sessions.delete(containerId);
|
|
290
|
+
if (this.shadowRepos.has(containerId)) {
|
|
291
|
+
this.shadowRepos.get(containerId)?.cleanup();
|
|
292
|
+
this.shadowRepos.delete(containerId);
|
|
293
|
+
}
|
|
294
|
+
});
|
|
295
|
+
console.log(chalk_1.default.green('New Claude session started'));
|
|
296
|
+
// Start continuous monitoring for this container
|
|
297
|
+
this.startContinuousMonitoring(containerId);
|
|
298
|
+
}
|
|
299
|
+
else {
|
|
300
|
+
// Add this socket to the existing session
|
|
301
|
+
console.log(chalk_1.default.blue('Reconnecting to existing Claude session'));
|
|
302
|
+
session.connectedSockets.add(socket.id);
|
|
303
|
+
// Replay output history to the reconnecting client
|
|
304
|
+
if (session.outputHistory && session.outputHistory.length > 0) {
|
|
305
|
+
console.log(chalk_1.default.blue(`Replaying ${session.outputHistory.length} output chunks`));
|
|
306
|
+
// Send a clear screen first
|
|
307
|
+
socket.emit('output', new Uint8Array(node_buffer_1.Buffer.from('\x1B[2J\x1B[H')));
|
|
308
|
+
// Then replay the history
|
|
309
|
+
for (const chunk of session.outputHistory) {
|
|
310
|
+
socket.emit('output', new Uint8Array(chunk));
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
// Confirm attachment
|
|
315
|
+
socket.emit('attached', { containerId });
|
|
316
|
+
// Send initial resize after a small delay
|
|
317
|
+
if (session.exec && data.cols && data.rows) {
|
|
318
|
+
setTimeout(async () => {
|
|
319
|
+
try {
|
|
320
|
+
await session.exec.resize({ w: data.cols, h: data.rows });
|
|
321
|
+
}
|
|
322
|
+
catch (e) {
|
|
323
|
+
// Ignore resize errors
|
|
324
|
+
}
|
|
325
|
+
}, 100);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
catch (error) {
|
|
329
|
+
console.error(chalk_1.default.red('Failed to attach to container:'), error);
|
|
330
|
+
socket.emit('error', { message: error.message });
|
|
331
|
+
}
|
|
332
|
+
});
|
|
333
|
+
socket.on('resize', async (data) => {
|
|
334
|
+
const { cols, rows } = data;
|
|
335
|
+
// Find which session this socket belongs to
|
|
336
|
+
for (const [, session] of this.sessions) {
|
|
337
|
+
if (session.connectedSockets.has(socket.id) && session.exec) {
|
|
338
|
+
try {
|
|
339
|
+
await session.exec.resize({ w: cols, h: rows });
|
|
340
|
+
}
|
|
341
|
+
catch (error) {
|
|
342
|
+
// Ignore HTTP 201 from Podman (it's actually a success response)
|
|
343
|
+
if (error.statusCode === 201) {
|
|
344
|
+
continue;
|
|
345
|
+
}
|
|
346
|
+
console.error(chalk_1.default.yellow('Failed to resize terminal:'), error);
|
|
347
|
+
}
|
|
348
|
+
break;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
});
|
|
352
|
+
socket.on('input', (data) => {
|
|
353
|
+
// Find which session this socket belongs to
|
|
354
|
+
for (const [, session] of this.sessions) {
|
|
355
|
+
if (session.connectedSockets.has(socket.id) && session.stream) {
|
|
356
|
+
session.stream.write(data);
|
|
357
|
+
break;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
});
|
|
361
|
+
// Test handler to verify socket connectivity
|
|
362
|
+
socket.on('test-sync', (data) => {
|
|
363
|
+
console.log(chalk_1.default.yellow(`[TEST] Received test-sync event:`, data));
|
|
364
|
+
});
|
|
365
|
+
// input-needed handler removed - now using continuous monitoring
|
|
366
|
+
// Handle commit operation
|
|
367
|
+
socket.on('commit-changes', async (data) => {
|
|
368
|
+
const { containerId, commitMessage } = data;
|
|
369
|
+
try {
|
|
370
|
+
const shadowRepo = this.shadowRepos.get(containerId);
|
|
371
|
+
if (!shadowRepo) {
|
|
372
|
+
throw new Error('Shadow repository not found');
|
|
373
|
+
}
|
|
374
|
+
// Perform final sync before commit to ensure we have latest changes
|
|
375
|
+
console.log(chalk_1.default.blue('🔄 Final sync before commit...'));
|
|
376
|
+
await shadowRepo.syncFromContainer(containerId);
|
|
377
|
+
const shadowPath = shadowRepo.getPath();
|
|
378
|
+
// Stage all changes
|
|
379
|
+
await execAsync('git add .', { cwd: shadowPath });
|
|
380
|
+
// Create commit
|
|
381
|
+
await execAsync(`git commit -m "${commitMessage.replace(/"/g, '\\"')}"`, {
|
|
382
|
+
cwd: shadowPath,
|
|
383
|
+
});
|
|
384
|
+
console.log(chalk_1.default.green('✓ Changes committed'));
|
|
385
|
+
socket.emit('commit-success', {
|
|
386
|
+
message: 'Changes committed successfully',
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
catch (error) {
|
|
390
|
+
console.error(chalk_1.default.red('Commit failed:'), error);
|
|
391
|
+
socket.emit('commit-error', { message: error.message });
|
|
392
|
+
}
|
|
393
|
+
});
|
|
394
|
+
// Handle push operation
|
|
395
|
+
socket.on('push-changes', async (data) => {
|
|
396
|
+
const { containerId, branchName } = data;
|
|
397
|
+
try {
|
|
398
|
+
const shadowRepo = this.shadowRepos.get(containerId);
|
|
399
|
+
if (!shadowRepo) {
|
|
400
|
+
throw new Error('Shadow repository not found');
|
|
401
|
+
}
|
|
402
|
+
// Perform final sync before push to ensure we have latest changes
|
|
403
|
+
console.log(chalk_1.default.blue('🔄 Final sync before push...'));
|
|
404
|
+
await shadowRepo.syncFromContainer(containerId);
|
|
405
|
+
const shadowPath = shadowRepo.getPath();
|
|
406
|
+
// Create and switch to new branch if specified
|
|
407
|
+
if (branchName && branchName !== 'main') {
|
|
408
|
+
try {
|
|
409
|
+
await execAsync(`git checkout -b ${branchName}`, {
|
|
410
|
+
cwd: shadowPath,
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
catch (error) {
|
|
414
|
+
// Branch might already exist, try to switch
|
|
415
|
+
await execAsync(`git checkout ${branchName}`, {
|
|
416
|
+
cwd: shadowPath,
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
// Push to remote
|
|
421
|
+
const { stdout: remoteOutput } = await execAsync('git remote -v', {
|
|
422
|
+
cwd: shadowPath,
|
|
423
|
+
});
|
|
424
|
+
if (remoteOutput.includes('origin')) {
|
|
425
|
+
// Get current branch name if not specified
|
|
426
|
+
const pushBranch = branchName
|
|
427
|
+
|| (await execAsync('git branch --show-current', {
|
|
428
|
+
cwd: shadowPath,
|
|
429
|
+
}).then(r => r.stdout.trim()));
|
|
430
|
+
await execAsync(`git push -u origin ${pushBranch}`, {
|
|
431
|
+
cwd: shadowPath,
|
|
432
|
+
});
|
|
433
|
+
console.log(chalk_1.default.green('✓ Changes pushed to remote'));
|
|
434
|
+
socket.emit('push-success', {
|
|
435
|
+
message: 'Changes pushed successfully',
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
else {
|
|
439
|
+
throw new Error('No remote origin configured');
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
catch (error) {
|
|
443
|
+
console.error(chalk_1.default.red('Push failed:'), error);
|
|
444
|
+
socket.emit('push-error', { message: error.message });
|
|
445
|
+
}
|
|
446
|
+
});
|
|
447
|
+
socket.on('disconnect', () => {
|
|
448
|
+
console.log(chalk_1.default.yellow('Client disconnected from web UI'));
|
|
449
|
+
// Remove socket from all sessions
|
|
450
|
+
for (const [, session] of this.sessions) {
|
|
451
|
+
session.connectedSockets.delete(socket.id);
|
|
452
|
+
}
|
|
453
|
+
});
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
async performSync(containerId) {
|
|
457
|
+
if (this.syncInProgress.has(containerId)) {
|
|
458
|
+
return; // Skip if sync already in progress
|
|
459
|
+
}
|
|
460
|
+
this.syncInProgress.add(containerId);
|
|
461
|
+
try {
|
|
462
|
+
// Initialize shadow repo if not exists
|
|
463
|
+
let isNewShadowRepo = false;
|
|
464
|
+
if (!this.shadowRepos.has(containerId)) {
|
|
465
|
+
const shadowRepo = new shadow_repository_1.ShadowRepository({
|
|
466
|
+
originalRepo: this.originalRepo || node_process_1.default.cwd(),
|
|
467
|
+
claudeBranch: this.currentBranch || 'claude-changes',
|
|
468
|
+
sessionId: containerId.substring(0, 12),
|
|
469
|
+
containerRuntime: this.containerCmd,
|
|
470
|
+
});
|
|
471
|
+
this.shadowRepos.set(containerId, shadowRepo);
|
|
472
|
+
isNewShadowRepo = true;
|
|
473
|
+
// Reset shadow repo to match container's branch (important for PR/remote branch scenarios)
|
|
474
|
+
await shadowRepo.resetToContainerBranch(containerId);
|
|
475
|
+
}
|
|
476
|
+
// Sync files from container (inotify already told us there are changes)
|
|
477
|
+
const shadowRepo = this.shadowRepos.get(containerId);
|
|
478
|
+
await shadowRepo.syncFromContainer(containerId);
|
|
479
|
+
// If this is a new shadow repo, establish a clean baseline after the first sync
|
|
480
|
+
if (isNewShadowRepo) {
|
|
481
|
+
console.log(chalk_1.default.blue('🔄 Establishing clean baseline for new shadow repo...'));
|
|
482
|
+
const shadowPath = shadowRepo.getPath();
|
|
483
|
+
try {
|
|
484
|
+
// Stage all synced files and create a baseline commit
|
|
485
|
+
await execAsync('git add -A', { cwd: shadowPath });
|
|
486
|
+
await execAsync('git commit -m "Establish baseline from container content" --allow-empty', { cwd: shadowPath });
|
|
487
|
+
console.log(chalk_1.default.green('✓ Clean baseline established'));
|
|
488
|
+
// Now do one more sync to see if there are any actual changes
|
|
489
|
+
await shadowRepo.syncFromContainer(containerId);
|
|
490
|
+
}
|
|
491
|
+
catch (baselineError) {
|
|
492
|
+
console.warn(chalk_1.default.yellow('Warning: Could not establish baseline'), baselineError);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
// Check if shadow repo actually has git initialized
|
|
496
|
+
const shadowPath = shadowRepo.getPath();
|
|
497
|
+
const gitPath = node_path_1.default.join(shadowPath, '.git');
|
|
498
|
+
if (!(await fs.pathExists(gitPath))) {
|
|
499
|
+
console.log(chalk_1.default.yellow('Shadow repository .git directory missing - skipping sync'));
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
// Get changes summary and diff data
|
|
503
|
+
const changes = await shadowRepo.getChanges();
|
|
504
|
+
console.log(chalk_1.default.gray(`[MONITOR] Shadow repo changes: ${changes.summary}`));
|
|
505
|
+
let diffData = null;
|
|
506
|
+
if (changes.hasChanges) {
|
|
507
|
+
// Get detailed file status and diffs
|
|
508
|
+
const { stdout: statusOutput } = await execAsync('git status --porcelain', {
|
|
509
|
+
cwd: shadowPath,
|
|
510
|
+
});
|
|
511
|
+
// Try git diff HEAD first, fallback to git diff if no HEAD
|
|
512
|
+
let diffOutput = '';
|
|
513
|
+
try {
|
|
514
|
+
const { stdout } = await execAsync('git diff HEAD', {
|
|
515
|
+
cwd: shadowPath,
|
|
516
|
+
maxBuffer: 10 * 1024 * 1024, // 10MB limit
|
|
517
|
+
});
|
|
518
|
+
diffOutput = stdout;
|
|
519
|
+
}
|
|
520
|
+
catch (headError) {
|
|
521
|
+
try {
|
|
522
|
+
// Fallback to git diff (shows unstaged changes)
|
|
523
|
+
const { stdout } = await execAsync('git diff', {
|
|
524
|
+
cwd: shadowPath,
|
|
525
|
+
maxBuffer: 10 * 1024 * 1024, // 10MB limit
|
|
526
|
+
});
|
|
527
|
+
diffOutput = stdout;
|
|
528
|
+
}
|
|
529
|
+
catch (diffError) {
|
|
530
|
+
console.log(chalk_1.default.gray(' Could not generate diff, skipping...'));
|
|
531
|
+
diffOutput = 'Could not generate diff';
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
// Get list of untracked files with their content
|
|
535
|
+
const untrackedFiles = [];
|
|
536
|
+
const statusLines = statusOutput
|
|
537
|
+
.split('\n')
|
|
538
|
+
.filter(line => line.startsWith('??'));
|
|
539
|
+
for (const line of statusLines) {
|
|
540
|
+
const filename = line.substring(3);
|
|
541
|
+
untrackedFiles.push(filename);
|
|
542
|
+
}
|
|
543
|
+
// Calculate diff statistics
|
|
544
|
+
const diffStats = this.calculateDiffStats(diffOutput);
|
|
545
|
+
diffData = {
|
|
546
|
+
status: statusOutput,
|
|
547
|
+
diff: diffOutput,
|
|
548
|
+
untrackedFiles,
|
|
549
|
+
stats: diffStats,
|
|
550
|
+
};
|
|
551
|
+
console.log(chalk_1.default.cyan(`[MONITOR] Changes detected: ${changes.summary}`));
|
|
552
|
+
console.log(chalk_1.default.cyan(`[MONITOR] Diff stats:`, diffStats));
|
|
553
|
+
}
|
|
554
|
+
const syncCompleteData = {
|
|
555
|
+
hasChanges: changes.hasChanges,
|
|
556
|
+
summary: changes.summary,
|
|
557
|
+
shadowPath,
|
|
558
|
+
diffData,
|
|
559
|
+
containerId,
|
|
560
|
+
};
|
|
561
|
+
// Send to all connected sockets for this container
|
|
562
|
+
const session = this.sessions.get(containerId);
|
|
563
|
+
if (session) {
|
|
564
|
+
for (const socketId of session.connectedSockets) {
|
|
565
|
+
const connectedSocket = this.io.sockets.sockets.get(socketId);
|
|
566
|
+
if (connectedSocket) {
|
|
567
|
+
connectedSocket.emit('sync-complete', syncCompleteData);
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
catch (error) {
|
|
573
|
+
console.error(chalk_1.default.red('[MONITOR] Sync failed:'), error);
|
|
574
|
+
const session = this.sessions.get(containerId);
|
|
575
|
+
if (session) {
|
|
576
|
+
for (const socketId of session.connectedSockets) {
|
|
577
|
+
const connectedSocket = this.io.sockets.sockets.get(socketId);
|
|
578
|
+
if (connectedSocket) {
|
|
579
|
+
connectedSocket.emit('sync-error', { message: error.message });
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
finally {
|
|
585
|
+
this.syncInProgress.delete(containerId);
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
async startContinuousMonitoring(containerId) {
|
|
589
|
+
// Clear existing monitoring if any
|
|
590
|
+
this.stopContinuousMonitoring(containerId);
|
|
591
|
+
console.log(chalk_1.default.blue(`[MONITOR] Starting inotify-based monitoring for container ${containerId.substring(0, 12)}`));
|
|
592
|
+
// Do initial sync
|
|
593
|
+
await this.performSync(containerId);
|
|
594
|
+
// Install inotify-tools if not present
|
|
595
|
+
try {
|
|
596
|
+
await execAsync(`${this.containerCmd} exec ${containerId} which inotifywait`);
|
|
597
|
+
}
|
|
598
|
+
catch {
|
|
599
|
+
console.log(chalk_1.default.yellow(' Installing inotify-tools in container...'));
|
|
600
|
+
try {
|
|
601
|
+
// Try different package managers
|
|
602
|
+
const installCommands = [
|
|
603
|
+
'dnf install -y inotify-tools',
|
|
604
|
+
'yum install -y inotify-tools',
|
|
605
|
+
'apt-get update && apt-get install -y inotify-tools',
|
|
606
|
+
'apk add --no-cache inotify-tools',
|
|
607
|
+
];
|
|
608
|
+
let installed = false;
|
|
609
|
+
for (const cmd of installCommands) {
|
|
610
|
+
try {
|
|
611
|
+
// Try as root user first, then fallback to regular exec
|
|
612
|
+
try {
|
|
613
|
+
await execAsync(`${this.containerCmd} exec --user root ${containerId} sh -c "${cmd}"`);
|
|
614
|
+
installed = true;
|
|
615
|
+
break;
|
|
616
|
+
}
|
|
617
|
+
catch (rootError) {
|
|
618
|
+
// If --user root fails, try without it (container might already be running as root)
|
|
619
|
+
await execAsync(`${this.containerCmd} exec ${containerId} sh -c "${cmd}"`);
|
|
620
|
+
installed = true;
|
|
621
|
+
break;
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
catch {
|
|
625
|
+
continue;
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
if (!installed) {
|
|
629
|
+
console.log(chalk_1.default.red(' Could not install inotify-tools, falling back to basic monitoring'));
|
|
630
|
+
return;
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
catch (error) {
|
|
634
|
+
console.log(chalk_1.default.red(' Could not install inotify-tools:', error));
|
|
635
|
+
return;
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
// Start inotifywait process in container
|
|
639
|
+
const inotifyExec = await this.docker.getContainer(containerId).exec({
|
|
640
|
+
Cmd: [
|
|
641
|
+
'sh',
|
|
642
|
+
'-c',
|
|
643
|
+
`inotifywait -m -r -e modify,create,delete,move --format '%w%f %e' /workspace --exclude '(\.git|node_modules|\.next|__pycache__|\.venv)'`,
|
|
644
|
+
],
|
|
645
|
+
AttachStdout: true,
|
|
646
|
+
AttachStderr: true,
|
|
647
|
+
Tty: false,
|
|
648
|
+
});
|
|
649
|
+
const stream = await inotifyExec.start({ hijack: true, stdin: false });
|
|
650
|
+
// Debounce sync to avoid too many rapid syncs
|
|
651
|
+
let syncTimeout = null;
|
|
652
|
+
const debouncedSync = () => {
|
|
653
|
+
if (syncTimeout)
|
|
654
|
+
clearTimeout(syncTimeout);
|
|
655
|
+
syncTimeout = setTimeout(async () => {
|
|
656
|
+
console.log(chalk_1.default.gray('[INOTIFY] Changes detected, syncing...'));
|
|
657
|
+
await this.performSync(containerId);
|
|
658
|
+
}, 500); // Wait 500ms after last change before syncing
|
|
659
|
+
};
|
|
660
|
+
// Process inotify events
|
|
661
|
+
stream.on('data', (chunk) => {
|
|
662
|
+
// Handle docker exec stream format (may have header bytes)
|
|
663
|
+
let data;
|
|
664
|
+
if (chunk.length > 8) {
|
|
665
|
+
const firstByte = chunk[0];
|
|
666
|
+
if (firstByte >= 1 && firstByte <= 3) {
|
|
667
|
+
data = chunk.slice(8);
|
|
668
|
+
}
|
|
669
|
+
else {
|
|
670
|
+
data = chunk;
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
else {
|
|
674
|
+
data = chunk;
|
|
675
|
+
}
|
|
676
|
+
const events = data.toString().trim().split('\n');
|
|
677
|
+
for (const event of events) {
|
|
678
|
+
if (event.trim()) {
|
|
679
|
+
console.log(chalk_1.default.gray(`[INOTIFY] ${event}`));
|
|
680
|
+
debouncedSync();
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
});
|
|
684
|
+
stream.on('error', (err) => {
|
|
685
|
+
console.error(chalk_1.default.red('[INOTIFY] Stream error:'), err);
|
|
686
|
+
});
|
|
687
|
+
stream.on('end', () => {
|
|
688
|
+
console.log(chalk_1.default.yellow('[INOTIFY] Monitoring stopped'));
|
|
689
|
+
});
|
|
690
|
+
// Store the stream for cleanup
|
|
691
|
+
this.fileWatchers.set(containerId, { stream, exec: inotifyExec });
|
|
692
|
+
}
|
|
693
|
+
stopContinuousMonitoring(containerId) {
|
|
694
|
+
const monitor = this.fileWatchers.get(containerId);
|
|
695
|
+
if (monitor) {
|
|
696
|
+
// If it's an inotify monitor, close the stream
|
|
697
|
+
if (monitor.stream) {
|
|
698
|
+
monitor.stream.destroy();
|
|
699
|
+
}
|
|
700
|
+
else {
|
|
701
|
+
// Old interval-based monitoring
|
|
702
|
+
clearInterval(monitor);
|
|
703
|
+
}
|
|
704
|
+
this.fileWatchers.delete(containerId);
|
|
705
|
+
console.log(chalk_1.default.blue(`[MONITOR] Stopped monitoring for container ${containerId.substring(0, 12)}`));
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
calculateDiffStats(diffOutput) {
|
|
709
|
+
if (!diffOutput)
|
|
710
|
+
return { additions: 0, deletions: 0, files: 0 };
|
|
711
|
+
let additions = 0;
|
|
712
|
+
let deletions = 0;
|
|
713
|
+
const files = new Set();
|
|
714
|
+
const lines = diffOutput.split('\n');
|
|
715
|
+
for (const line of lines) {
|
|
716
|
+
if (line.startsWith('+') && !line.startsWith('+++')) {
|
|
717
|
+
additions++;
|
|
718
|
+
}
|
|
719
|
+
else if (line.startsWith('-') && !line.startsWith('---')) {
|
|
720
|
+
deletions++;
|
|
721
|
+
}
|
|
722
|
+
else if (line.startsWith('diff --git')) {
|
|
723
|
+
// Extract filename from diff header
|
|
724
|
+
const match = line.match(/diff --git a\/(.*?) b\//);
|
|
725
|
+
if (match) {
|
|
726
|
+
files.add(match[1]);
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
return { additions, deletions, files: files.size };
|
|
731
|
+
}
|
|
732
|
+
async start() {
|
|
733
|
+
return new Promise((resolve, reject) => {
|
|
734
|
+
this.httpServer.listen(this.port, () => {
|
|
735
|
+
const url = `http://localhost:${this.port}`;
|
|
736
|
+
console.log(chalk_1.default.green(`✓ Web UI server started at ${url}`));
|
|
737
|
+
resolve(url);
|
|
738
|
+
});
|
|
739
|
+
this.httpServer.on('error', (err) => {
|
|
740
|
+
if (err.code === 'EADDRINUSE') {
|
|
741
|
+
// Try next port
|
|
742
|
+
this.port++;
|
|
743
|
+
this.httpServer.listen(this.port, () => {
|
|
744
|
+
const url = `http://localhost:${this.port}`;
|
|
745
|
+
console.log(chalk_1.default.green(`✓ Web UI server started at ${url}`));
|
|
746
|
+
resolve(url);
|
|
747
|
+
});
|
|
748
|
+
}
|
|
749
|
+
else {
|
|
750
|
+
reject(err);
|
|
751
|
+
}
|
|
752
|
+
});
|
|
753
|
+
});
|
|
754
|
+
}
|
|
755
|
+
setRepoInfo(originalRepo, branch) {
|
|
756
|
+
this.originalRepo = originalRepo;
|
|
757
|
+
this.currentBranch = branch;
|
|
758
|
+
}
|
|
759
|
+
async stop() {
|
|
760
|
+
// Clean up shadow repos
|
|
761
|
+
for (const [, shadowRepo] of this.shadowRepos) {
|
|
762
|
+
await shadowRepo.cleanup();
|
|
763
|
+
}
|
|
764
|
+
// Clean up all sessions
|
|
765
|
+
for (const [, session] of this.sessions) {
|
|
766
|
+
if (session.stream) {
|
|
767
|
+
session.stream.end();
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
this.sessions.clear();
|
|
771
|
+
// Close socket.io connections
|
|
772
|
+
this.io.close();
|
|
773
|
+
// Close HTTP server
|
|
774
|
+
return new Promise((resolve) => {
|
|
775
|
+
this.httpServer.close(() => {
|
|
776
|
+
console.log(chalk_1.default.yellow('Web UI server stopped'));
|
|
777
|
+
resolve();
|
|
778
|
+
});
|
|
779
|
+
});
|
|
780
|
+
}
|
|
781
|
+
async openInBrowser(url) {
|
|
782
|
+
try {
|
|
783
|
+
// Try the open module first
|
|
784
|
+
const open = (await Promise.resolve().then(() => __importStar(require('open')))).default;
|
|
785
|
+
await open(url);
|
|
786
|
+
console.log(chalk_1.default.blue('✓ Opened browser'));
|
|
787
|
+
}
|
|
788
|
+
catch (error) {
|
|
789
|
+
// Fallback to platform-specific commands
|
|
790
|
+
try {
|
|
791
|
+
const platform = node_process_1.default.platform;
|
|
792
|
+
if (platform === 'darwin') {
|
|
793
|
+
(0, node_child_process_1.execSync)(`open "${url}"`, { stdio: 'ignore' });
|
|
794
|
+
}
|
|
795
|
+
else if (platform === 'win32') {
|
|
796
|
+
(0, node_child_process_1.execSync)(`start "" "${url}"`, { stdio: 'ignore' });
|
|
797
|
+
}
|
|
798
|
+
else {
|
|
799
|
+
// Linux/Unix
|
|
800
|
+
(0, node_child_process_1.execSync)(`xdg-open "${url}" || firefox "${url}" || google-chrome "${url}"`, { stdio: 'ignore' });
|
|
801
|
+
}
|
|
802
|
+
console.log(chalk_1.default.blue('✓ Opened browser'));
|
|
803
|
+
}
|
|
804
|
+
catch (fallbackError) {
|
|
805
|
+
console.log(chalk_1.default.yellow('Could not open browser automatically'));
|
|
806
|
+
console.log(chalk_1.default.yellow(`Please open ${url} in your browser`));
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
exports.WebUIServer = WebUIServer;
|
|
812
|
+
//# sourceMappingURL=web-server.js.map
|