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.
Files changed (51) hide show
  1. package/README.md +559 -0
  2. package/README.zh-Hans.md +559 -0
  3. package/dist/cli.d.ts +3 -0
  4. package/dist/cli.d.ts.map +1 -0
  5. package/dist/cli.js +377 -0
  6. package/dist/cli.js.map +1 -0
  7. package/dist/config.d.ts +4 -0
  8. package/dist/config.d.ts.map +1 -0
  9. package/dist/config.js +50 -0
  10. package/dist/config.js.map +1 -0
  11. package/dist/container.d.ts +23 -0
  12. package/dist/container.d.ts.map +1 -0
  13. package/dist/container.js +971 -0
  14. package/dist/container.js.map +1 -0
  15. package/dist/credentials.d.ts +8 -0
  16. package/dist/credentials.d.ts.map +1 -0
  17. package/dist/credentials.js +145 -0
  18. package/dist/credentials.js.map +1 -0
  19. package/dist/docker-config.d.ts +19 -0
  20. package/dist/docker-config.d.ts.map +1 -0
  21. package/dist/docker-config.js +101 -0
  22. package/dist/docker-config.js.map +1 -0
  23. package/dist/git/shadow-repository.d.ts +30 -0
  24. package/dist/git/shadow-repository.d.ts.map +1 -0
  25. package/dist/git/shadow-repository.js +645 -0
  26. package/dist/git/shadow-repository.js.map +1 -0
  27. package/dist/git-monitor.d.ts +15 -0
  28. package/dist/git-monitor.d.ts.map +1 -0
  29. package/dist/git-monitor.js +94 -0
  30. package/dist/git-monitor.js.map +1 -0
  31. package/dist/index.d.ts +22 -0
  32. package/dist/index.d.ts.map +1 -0
  33. package/dist/index.js +221 -0
  34. package/dist/index.js.map +1 -0
  35. package/dist/types.d.ts +49 -0
  36. package/dist/types.d.ts.map +1 -0
  37. package/dist/types.js +3 -0
  38. package/dist/types.js.map +1 -0
  39. package/dist/ui.d.ts +12 -0
  40. package/dist/ui.d.ts.map +1 -0
  41. package/dist/ui.js +82 -0
  42. package/dist/ui.js.map +1 -0
  43. package/dist/web-server-attach.d.ts +16 -0
  44. package/dist/web-server-attach.d.ts.map +1 -0
  45. package/dist/web-server-attach.js +249 -0
  46. package/dist/web-server-attach.js.map +1 -0
  47. package/dist/web-server.d.ts +27 -0
  48. package/dist/web-server.d.ts.map +1 -0
  49. package/dist/web-server.js +812 -0
  50. package/dist/web-server.js.map +1 -0
  51. 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