Checkpoint

This commit is contained in:
Onur Solmaz 2025-05-28 00:35:41 +02:00
parent 37cfa521c9
commit 92f3895f17
6 changed files with 901 additions and 0 deletions

145
package-lock.json generated
View file

@ -31,13 +31,17 @@
},
"devDependencies": {
"@types/dockerode": "^3.3.23",
"@types/fs-extra": "^11.0.4",
"@types/inquirer": "^9.0.8",
"@types/node": "^20.11.5",
"@types/socket.io-client": "^1.4.36",
"@typescript-eslint/eslint-plugin": "^6.19.0",
"@typescript-eslint/parser": "^6.19.0",
"eslint": "^8.56.0",
"fs-extra": "^11.3.0",
"jest": "^29.7.0",
"puppeteer": "^24.9.0",
"socket.io-client": "^4.8.1",
"ts-jest": "^29.1.2",
"typescript": "^5.3.3"
},
@ -1546,6 +1550,17 @@
"@types/send": "*"
}
},
"node_modules/@types/fs-extra": {
"version": "11.0.4",
"resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-11.0.4.tgz",
"integrity": "sha512-yTbItCNreRooED33qjunPthRcSjERP1r4MqCZc7wv0u2sUkzTFp45tgUfS5+r7FrZPdmCCNflLhVSP/o+SemsQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/jsonfile": "*",
"@types/node": "*"
}
},
"node_modules/@types/graceful-fs": {
"version": "4.1.9",
"resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz",
@ -1607,6 +1622,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/jsonfile": {
"version": "6.1.4",
"resolved": "https://registry.npmjs.org/@types/jsonfile/-/jsonfile-6.1.4.tgz",
"integrity": "sha512-D5qGUYwjvnNNextdU59/+fI+spnwtTFmyQP0h+PfIOSkNfpU6AOICUOkm4i0OnSk+NyjdPJrxCDro0sJsWlRpQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/mime": {
"version": "1.3.5",
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz",
@ -1662,6 +1687,13 @@
"@types/send": "*"
}
},
"node_modules/@types/socket.io-client": {
"version": "1.4.36",
"resolved": "https://registry.npmjs.org/@types/socket.io-client/-/socket.io-client-1.4.36.tgz",
"integrity": "sha512-ZJWjtFBeBy1kRSYpVbeGYTElf6BqPQUkXDlHHD4k/42byCN5Rh027f4yARHCink9sKAkbtGZXEAmR0ZCnc2/Ag==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/ssh2": {
"version": "1.15.5",
"resolved": "https://registry.npmjs.org/@types/ssh2/-/ssh2-1.15.5.tgz",
@ -3343,6 +3375,38 @@
"node": ">=10.2.0"
}
},
"node_modules/engine.io-client": {
"version": "6.6.3",
"resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.3.tgz",
"integrity": "sha512-T0iLjnyNWahNyv/lcjS2y4oE358tVS/SYQNxYXGAJ9/GLgH4VCvOQ/mhTjqU88mLZCQgiG8RIegFHYCdVC+j5w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.3.1",
"engine.io-parser": "~5.2.1",
"ws": "~8.17.1",
"xmlhttprequest-ssl": "~2.1.1"
}
},
"node_modules/engine.io-client/node_modules/debug": {
"version": "4.3.7",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
"integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/engine.io-parser": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz",
@ -4092,6 +4156,21 @@
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
"license": "MIT"
},
"node_modules/fs-extra": {
"version": "11.3.0",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.0.tgz",
"integrity": "sha512-Z4XaCL6dUDHfP/jT25jJKMmtxvuwbkrD1vNSMFlo9lNLY2c5FHYSQgHPRZUjAB26TpDEoW9HCOgplrdbaPV/ew==",
"dev": true,
"license": "MIT",
"dependencies": {
"graceful-fs": "^4.2.0",
"jsonfile": "^6.0.1",
"universalify": "^2.0.0"
},
"engines": {
"node": ">=14.14"
}
},
"node_modules/fs.realpath": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
@ -5561,6 +5640,19 @@
"node": ">=6"
}
},
"node_modules/jsonfile": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz",
"integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"universalify": "^2.0.0"
},
"optionalDependencies": {
"graceful-fs": "^4.1.6"
}
},
"node_modules/keyv": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@ -7118,6 +7210,40 @@
}
}
},
"node_modules/socket.io-client": {
"version": "4.8.1",
"resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.1.tgz",
"integrity": "sha512-hJVXfu3E28NmzGk8o1sHhN3om52tRvwYeidbj7xKy2eIIse5IoKX3USlS6Tqt3BHAtflLIkCQBkzVrEEfWUyYQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.3.2",
"engine.io-client": "~6.6.1",
"socket.io-parser": "~4.2.4"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/socket.io-client/node_modules/debug": {
"version": "4.3.7",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
"integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/socket.io-parser": {
"version": "4.2.4",
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.4.tgz",
@ -7746,6 +7872,16 @@
"integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==",
"license": "MIT"
},
"node_modules/universalify": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
"integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 10.0.0"
}
},
"node_modules/unpipe": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
@ -7939,6 +8075,15 @@
}
}
},
"node_modules/xmlhttprequest-ssl": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz",
"integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==",
"dev": true,
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/xterm": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/xterm/-/xterm-5.3.0.tgz",

View file

@ -43,13 +43,17 @@
},
"devDependencies": {
"@types/dockerode": "^3.3.23",
"@types/fs-extra": "^11.0.4",
"@types/inquirer": "^9.0.8",
"@types/node": "^20.11.5",
"@types/socket.io-client": "^1.4.36",
"@typescript-eslint/eslint-plugin": "^6.19.0",
"@typescript-eslint/parser": "^6.19.0",
"eslint": "^8.56.0",
"fs-extra": "^11.3.0",
"jest": "^29.7.0",
"puppeteer": "^24.9.0",
"socket.io-client": "^4.8.1",
"ts-jest": "^29.1.2",
"typescript": "^5.3.3"
},

View file

@ -169,6 +169,12 @@ function onIdleDetected() {
originalPageTitle = document.title;
}
document.title = '⚠️ Input needed - ' + originalPageTitle;
// Trigger file sync
if (socket && containerId) {
console.log('[SYNC] Triggering file sync due to input needed...');
socket.emit('input-needed', { containerId });
}
}
}
}
@ -434,6 +440,21 @@ function initSocket() {
}
});
socket.on('sync-complete', (data) => {
console.log('[SYNC] Sync completed:', data);
if (data.hasChanges) {
updateStatus('connected', `📁 Changes synced: ${data.summary}`);
showGitWorkflow(data);
} else {
updateStatus('connected', '✨ No changes to sync');
}
});
socket.on('sync-error', (error) => {
console.error('[SYNC] Sync error:', error);
updateStatus('error', `Sync failed: ${error.message}`);
});
socket.on('error', (error) => {
console.error('Socket error:', error);
updateStatus('error', 'Error: ' + error.message);
@ -561,6 +582,388 @@ document.addEventListener('DOMContentLoaded', () => {
});
});
// Git workflow functions
function showGitWorkflow(syncData) {
// Remove any existing git workflow modal
const existingModal = document.getElementById('git-workflow-modal');
if (existingModal) {
existingModal.remove();
}
// Create modal
const modal = document.createElement('div');
modal.id = 'git-workflow-modal';
modal.innerHTML = `
<div class="git-modal-overlay" onclick="closeGitWorkflow()">
<div class="git-modal-content" onclick="event.stopPropagation()">
<div class="git-modal-header">
<h2>📁 Git Changes Review</h2>
<button onclick="closeGitWorkflow()" class="close-btn">×</button>
</div>
<div class="git-modal-body">
<div class="changes-summary">
<strong>Changes Summary:</strong> ${syncData.summary}
</div>
<div class="diff-viewer" id="diff-viewer">
${formatDiffForDisplay(syncData.diffData)}
</div>
<div class="commit-section">
<h3>💾 Commit Changes</h3>
<textarea
id="commit-message"
placeholder="Enter commit message..."
rows="3"
>Update files from Claude
${syncData.summary}</textarea>
<div class="commit-actions">
<button onclick="commitChanges('${syncData.containerId}')" class="btn btn-primary">
Commit Changes
</button>
</div>
</div>
<div class="push-section" id="push-section" style="display: none;">
<h3>🚀 Push to Remote</h3>
<div class="branch-input">
<label for="branch-name">Branch name:</label>
<input type="text" id="branch-name" placeholder="claude-changes" value="claude-changes">
</div>
<div class="push-actions">
<button onclick="pushChanges('${syncData.containerId}')" class="btn btn-success">
Push to Remote
</button>
</div>
</div>
</div>
</div>
</div>
`;
document.body.appendChild(modal);
// Add CSS styles if not already present
if (!document.getElementById('git-workflow-styles')) {
const styles = document.createElement('style');
styles.id = 'git-workflow-styles';
styles.textContent = `
.git-modal-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.8);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
.git-modal-content {
background: #1e1e1e;
border: 1px solid #444;
border-radius: 8px;
width: 90%;
max-width: 800px;
max-height: 90vh;
overflow-y: auto;
color: #fff;
}
.git-modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px;
border-bottom: 1px solid #444;
}
.git-modal-header h2 {
margin: 0;
color: #fff;
}
.close-btn {
background: none;
border: none;
color: #fff;
font-size: 24px;
cursor: pointer;
padding: 0;
width: 30px;
height: 30px;
display: flex;
justify-content: center;
align-items: center;
}
.close-btn:hover {
background: #444;
border-radius: 4px;
}
.git-modal-body {
padding: 20px;
}
.changes-summary {
background: #2d2d2d;
padding: 15px;
border-radius: 6px;
margin-bottom: 20px;
border-left: 4px solid #4CAF50;
}
.diff-viewer {
background: #0d1117;
border: 1px solid #30363d;
border-radius: 6px;
padding: 15px;
margin-bottom: 20px;
font-family: 'SF Mono', Consolas, 'Liberation Mono', Menlo, monospace;
font-size: 13px;
line-height: 1.45;
overflow-x: auto;
max-height: 300px;
overflow-y: auto;
}
.diff-line {
padding: 2px 0;
white-space: pre;
}
.diff-line.added {
background: rgba(46, 160, 67, 0.15);
color: #3fb950;
}
.diff-line.removed {
background: rgba(248, 81, 73, 0.15);
color: #f85149;
}
.diff-line.context {
color: #e6edf3;
}
.diff-line.header {
color: #7d8590;
font-weight: bold;
}
.commit-section, .push-section {
background: #2d2d2d;
padding: 20px;
border-radius: 6px;
margin-bottom: 15px;
}
.commit-section h3, .push-section h3 {
margin: 0 0 15px 0;
color: #fff;
}
#commit-message {
width: 100%;
background: #0d1117;
border: 1px solid #30363d;
border-radius: 6px;
padding: 12px;
color: #e6edf3;
font-family: 'SF Mono', Consolas, 'Liberation Mono', Menlo, monospace;
font-size: 14px;
resize: vertical;
margin-bottom: 15px;
}
.branch-input {
margin-bottom: 15px;
}
.branch-input label {
display: block;
margin-bottom: 5px;
color: #e6edf3;
}
#branch-name {
width: 200px;
background: #0d1117;
border: 1px solid #30363d;
border-radius: 6px;
padding: 8px 12px;
color: #e6edf3;
font-family: 'SF Mono', Consolas, 'Liberation Mono', Menlo, monospace;
}
.btn {
background: #238636;
color: white;
border: none;
padding: 8px 16px;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
font-weight: 500;
}
.btn:hover {
background: #2ea043;
}
.btn:disabled {
background: #484f58;
cursor: not-allowed;
}
.btn-primary {
background: #1f6feb;
}
.btn-primary:hover {
background: #388bfd;
}
.btn-success {
background: #238636;
}
.btn-success:hover {
background: #2ea043;
}
`;
document.head.appendChild(styles);
}
}
function formatDiffForDisplay(diffData) {
if (!diffData) return '<div class="diff-line context">No changes to display</div>';
const lines = [];
// Show file status
if (diffData.status) {
lines.push('<div class="diff-line header">📄 File Status:</div>');
diffData.status.split('\n').forEach(line => {
if (line.trim()) {
const status = line.substring(0, 2);
const filename = line.substring(3);
let statusText = '';
if (status === '??') statusText = 'New file';
else if (status === ' M') statusText = 'Modified';
else if (status === ' D') statusText = 'Deleted';
else if (status === 'A ') statusText = 'Added';
lines.push(`<div class="diff-line context"> ${statusText}: ${filename}</div>`);
}
});
lines.push('<div class="diff-line context"></div>');
}
// Show diff
if (diffData.diff) {
lines.push('<div class="diff-line header">📝 Changes:</div>');
diffData.diff.split('\n').forEach(line => {
let className = 'context';
if (line.startsWith('+')) className = 'added';
else if (line.startsWith('-')) className = 'removed';
else if (line.startsWith('@@')) className = 'header';
lines.push(`<div class="diff-line ${className}">${escapeHtml(line)}</div>`);
});
}
// Show untracked files
if (diffData.untrackedFiles && diffData.untrackedFiles.length > 0) {
lines.push('<div class="diff-line context"></div>');
lines.push('<div class="diff-line header">📁 New Files:</div>');
diffData.untrackedFiles.forEach(filename => {
lines.push(`<div class="diff-line added">+ ${filename}</div>`);
});
}
return lines.join('');
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function closeGitWorkflow() {
const modal = document.getElementById('git-workflow-modal');
if (modal) {
modal.remove();
}
}
function commitChanges(containerId) {
const commitMessage = document.getElementById('commit-message').value.trim();
if (!commitMessage) {
alert('Please enter a commit message');
return;
}
const btn = event.target;
btn.disabled = true;
btn.textContent = 'Committing...';
socket.emit('commit-changes', { containerId, commitMessage });
// Handle commit result
socket.once('commit-success', () => {
btn.textContent = '✓ Committed';
btn.style.background = '#238636';
// Show push section
document.getElementById('push-section').style.display = 'block';
updateStatus('connected', '✓ Changes committed successfully');
});
socket.once('commit-error', (error) => {
btn.disabled = false;
btn.textContent = 'Commit Changes';
alert('Commit failed: ' + error.message);
updateStatus('error', 'Commit failed: ' + error.message);
});
}
function pushChanges(containerId) {
const branchName = document.getElementById('branch-name').value.trim() || 'claude-changes';
const btn = event.target;
btn.disabled = true;
btn.textContent = 'Pushing...';
socket.emit('push-changes', { containerId, branchName });
// Handle push result
socket.once('push-success', () => {
btn.textContent = '✓ Pushed';
btn.style.background = '#238636';
updateStatus('connected', `✓ Changes pushed to ${branchName}`);
setTimeout(() => {
closeGitWorkflow();
}, 2000);
});
socket.once('push-error', (error) => {
btn.disabled = false;
btn.textContent = 'Push to Remote';
alert('Push failed: ' + error.message);
updateStatus('error', 'Push failed: ' + error.message);
});
}
// Handle keyboard shortcuts
document.addEventListener('keydown', (e) => {
// Ctrl+Shift+C for copy

View file

@ -0,0 +1,194 @@
import * as path from 'path';
import * as fs from 'fs-extra';
import { exec } from 'child_process';
import { promisify } from 'util';
import chalk from 'chalk';
const execAsync = promisify(exec);
export interface ShadowRepoOptions {
originalRepo: string;
claudeBranch: string; // The target Claude branch to create
sessionId: string;
}
export class ShadowRepository {
private shadowPath: string;
private initialized = false;
constructor(
private options: ShadowRepoOptions,
private basePath: string = '/tmp/claude-shadows'
) {
this.shadowPath = path.join(this.basePath, this.options.sessionId);
}
async initialize(): Promise<void> {
if (this.initialized) return;
console.log(chalk.blue('🔨 Creating shadow repository...'));
// Ensure base directory exists
await fs.ensureDir(this.basePath);
// Remove any existing shadow repo
if (await fs.pathExists(this.shadowPath)) {
await fs.remove(this.shadowPath);
}
// Clone with minimal data
try {
// First, determine the current branch in the original repo
const { stdout: currentBranch } = await execAsync('git branch --show-current', {
cwd: this.options.originalRepo
});
const sourceBranch = currentBranch.trim() || 'main';
// Clone from the current branch, not the target Claude branch
const cloneCmd = `git clone --single-branch --branch ${sourceBranch} --depth 1 ${this.options.originalRepo} ${this.shadowPath}`;
await execAsync(cloneCmd);
// Create the Claude branch locally if it's different from source
if (this.options.claudeBranch !== sourceBranch) {
await execAsync(`git checkout -b ${this.options.claudeBranch}`, { cwd: this.shadowPath });
}
console.log(chalk.green('✓ Shadow repository created'));
this.initialized = true;
} catch (error) {
console.error(chalk.red('Failed to create shadow repository:'), error);
throw error;
}
}
async syncFromContainer(containerId: string, containerPath: string = '/workspace'): Promise<void> {
if (!this.initialized) {
await this.initialize();
}
console.log(chalk.blue('🔄 Syncing files from container...'));
// First, ensure files in container are owned by claude user
try {
await execAsync(`docker exec ${containerId} chown -R claude:claude ${containerPath}`);
} catch (error) {
console.log(chalk.gray(' (Could not fix container file ownership, continuing...)'));
}
// Check if rsync is available in container
const hasRsync = await this.checkRsyncInContainer(containerId);
if (hasRsync) {
await this.syncWithRsync(containerId, containerPath);
} else {
await this.syncWithDockerCp(containerId, containerPath);
}
console.log(chalk.green('✓ Files synced successfully'));
}
private async checkRsyncInContainer(containerId: string): Promise<boolean> {
try {
await execAsync(`docker exec ${containerId} which rsync`);
return true;
} catch {
return false;
}
}
private async syncWithRsync(containerId: string, containerPath: string): Promise<void> {
// Create a temporary directory in container for rsync
const tempDir = '/tmp/sync-staging';
await execAsync(`docker exec ${containerId} mkdir -p ${tempDir}`);
// Rsync within container to staging area (excluding .git and node_modules)
const rsyncCmd = `docker exec ${containerId} rsync -av --delete \
--exclude=.git \
--exclude=node_modules \
--exclude=.next \
--exclude=dist \
--exclude=build \
${containerPath}/ ${tempDir}/`;
await execAsync(rsyncCmd);
// Copy from container staging to shadow repo
await execAsync(`docker cp ${containerId}:${tempDir}/. ${this.shadowPath}/`);
// Clean up staging directory
await execAsync(`docker exec ${containerId} rm -rf ${tempDir}`);
}
private async syncWithDockerCp(containerId: string, containerPath: string): Promise<void> {
console.log(chalk.yellow('⚠️ Using docker cp (rsync not available in container)'));
// Save the git directory
const gitBackupPath = path.join(this.basePath, '.git-backup');
const gitPath = path.join(this.shadowPath, '.git');
if (await fs.pathExists(gitPath)) {
await fs.move(gitPath, gitBackupPath, { overwrite: true });
}
// Direct copy - less efficient but works everywhere
await execAsync(`docker cp ${containerId}:${containerPath}/. ${this.shadowPath}/`);
// Fix ownership of copied files to current user
try {
const currentUser = process.env.USER || process.env.USERNAME || 'claude';
await execAsync(`chown -R ${currentUser}:${currentUser} ${this.shadowPath}`);
} catch (error) {
// Ignore chown errors (might not have permission or be on different OS)
console.log(chalk.gray(' (Could not fix file ownership, continuing...)'));
}
// Restore git directory
if (await fs.pathExists(gitBackupPath)) {
await fs.move(gitBackupPath, gitPath, { overwrite: true });
}
// Remove unwanted directories after copy (except .git which we preserved)
const excludeDirs = ['node_modules', '.next', 'dist', 'build'];
for (const dir of excludeDirs) {
const dirPath = path.join(this.shadowPath, dir);
if (await fs.pathExists(dirPath)) {
await fs.remove(dirPath);
}
}
}
async getChanges(): Promise<{ hasChanges: boolean; summary: string }> {
const { stdout: status } = await execAsync('git status --porcelain', {
cwd: this.shadowPath
});
if (!status.trim()) {
return { hasChanges: false, summary: 'No changes detected' };
}
const lines = status.trim().split('\n');
const modified = lines.filter(l => l.startsWith(' M')).length;
const added = lines.filter(l => l.startsWith('??')).length;
const deleted = lines.filter(l => l.startsWith(' D')).length;
const summary = `Modified: ${modified}, Added: ${added}, Deleted: ${deleted}`;
return { hasChanges: true, summary };
}
async showDiff(): Promise<void> {
const { stdout } = await execAsync('git diff', { cwd: this.shadowPath });
console.log(stdout);
}
async cleanup(): Promise<void> {
if (await fs.pathExists(this.shadowPath)) {
await fs.remove(this.shadowPath);
console.log(chalk.gray('🧹 Shadow repository cleaned up'));
}
}
getPath(): string {
return this.shadowPath;
}
}

View file

@ -74,6 +74,10 @@ export class ClaudeSandbox {
// Launch web UI if requested
if (this.config.webUI) {
this.webServer = new WebUIServer(this.docker);
// Pass repo info to web server
this.webServer.setRepoInfo(process.cwd(), branchName);
const webUrl = await this.webServer.start();
// Open browser to the web UI with container ID

View file

@ -4,6 +4,11 @@ import { Server } from 'socket.io';
import path from 'path';
import Docker from 'dockerode';
import chalk from 'chalk';
import { exec } from 'child_process';
import { promisify } from 'util';
import { ShadowRepository } from './git/shadow-repository';
const execAsync = promisify(exec);
interface SessionInfo {
containerId: string;
@ -20,6 +25,9 @@ export class WebUIServer {
private docker: Docker;
private sessions: Map<string, SessionInfo> = new Map(); // container -> session mapping
private port: number = 3456;
private shadowRepos: Map<string, ShadowRepository> = new Map(); // container -> shadow repo
private originalRepo: string = '';
private currentBranch: string = 'main';
constructor(docker: Docker) {
this.docker = docker;
@ -226,6 +234,139 @@ export class WebUIServer {
}
});
socket.on('input-needed', async (data) => {
const { containerId } = data;
console.log(chalk.blue('🔔 Input needed detected, triggering sync...'));
try {
// Initialize shadow repo if not exists
if (!this.shadowRepos.has(containerId)) {
const shadowRepo = new ShadowRepository({
originalRepo: this.originalRepo || process.cwd(),
claudeBranch: this.currentBranch || 'claude-changes',
sessionId: containerId.substring(0, 12)
});
this.shadowRepos.set(containerId, shadowRepo);
}
// Sync files from container
const shadowRepo = this.shadowRepos.get(containerId)!;
await shadowRepo.syncFromContainer(containerId);
// Get changes summary and diff data
const changes = await shadowRepo.getChanges();
let diffData = null;
if (changes.hasChanges) {
// Get detailed file status and diffs
const { stdout: statusOutput } = await execAsync('git status --porcelain', {
cwd: shadowRepo.getPath()
});
const { stdout: diffOutput } = await execAsync('git diff HEAD', {
cwd: shadowRepo.getPath(),
maxBuffer: 1024 * 1024 // 1MB limit
});
// Get list of untracked files with their content
const untrackedFiles: string[] = [];
const statusLines = statusOutput.split('\n').filter(line => line.startsWith('??'));
for (const line of statusLines) {
const filename = line.substring(3);
untrackedFiles.push(filename);
}
diffData = {
status: statusOutput,
diff: diffOutput,
untrackedFiles: untrackedFiles
};
}
// Send summary back to client
socket.emit('sync-complete', {
hasChanges: changes.hasChanges,
summary: changes.summary,
shadowPath: shadowRepo.getPath(),
diffData: diffData,
containerId: containerId
});
} catch (error: any) {
console.error(chalk.red('Sync failed:'), error);
socket.emit('sync-error', { message: error.message });
}
});
// Handle commit operation
socket.on('commit-changes', async (data) => {
const { containerId, commitMessage } = data;
try {
const shadowRepo = this.shadowRepos.get(containerId);
if (!shadowRepo) {
throw new Error('Shadow repository not found');
}
const shadowPath = shadowRepo.getPath();
// Stage all changes
await execAsync('git add .', { cwd: shadowPath });
// Create commit
await execAsync(`git commit -m "${commitMessage.replace(/"/g, '\\"')}"`, {
cwd: shadowPath
});
console.log(chalk.green('✓ Changes committed'));
socket.emit('commit-success', { message: 'Changes committed successfully' });
} catch (error: any) {
console.error(chalk.red('Commit failed:'), error);
socket.emit('commit-error', { message: error.message });
}
});
// Handle push operation
socket.on('push-changes', async (data) => {
const { containerId, branchName } = data;
try {
const shadowRepo = this.shadowRepos.get(containerId);
if (!shadowRepo) {
throw new Error('Shadow repository not found');
}
const shadowPath = shadowRepo.getPath();
// Create and switch to new branch if specified
if (branchName && branchName !== 'main') {
try {
await execAsync(`git checkout -b ${branchName}`, { cwd: shadowPath });
} catch (error) {
// Branch might already exist, try to switch
await execAsync(`git checkout ${branchName}`, { cwd: shadowPath });
}
}
// Push to remote
const { stdout: remoteOutput } = await execAsync('git remote -v', { cwd: shadowPath });
if (remoteOutput.includes('origin')) {
// Get current branch name if not specified
const pushBranch = branchName || await execAsync('git branch --show-current', { cwd: shadowPath }).then(r => r.stdout.trim());
await execAsync(`git push -u origin ${pushBranch}`, { cwd: shadowPath });
console.log(chalk.green('✓ Changes pushed to remote'));
socket.emit('push-success', { message: 'Changes pushed successfully' });
} else {
throw new Error('No remote origin configured');
}
} catch (error: any) {
console.error(chalk.red('Push failed:'), error);
socket.emit('push-error', { message: error.message });
}
});
socket.on('disconnect', () => {
console.log(chalk.yellow('Client disconnected from web UI'));
@ -261,7 +402,17 @@ export class WebUIServer {
});
}
setRepoInfo(originalRepo: string, branch: string): void {
this.originalRepo = originalRepo;
this.currentBranch = branch;
}
async stop(): Promise<void> {
// Clean up shadow repos
for (const [, shadowRepo] of this.shadowRepos) {
await shadowRepo.cleanup();
}
// Clean up all sessions
for (const [, session] of this.sessions) {
if (session.stream) {