mirror of
https://github.com/textcortex/claude-code-sandbox.git
synced 2025-08-04 19:08:21 +00:00
Checkpoint
This commit is contained in:
parent
37cfa521c9
commit
92f3895f17
6 changed files with 901 additions and 0 deletions
145
package-lock.json
generated
145
package-lock.json
generated
|
@ -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",
|
||||
|
|
|
@ -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"
|
||||
},
|
||||
|
|
403
public/app.js
403
public/app.js
|
@ -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
|
||||
|
|
194
src/git/shadow-repository.ts
Normal file
194
src/git/shadow-repository.ts
Normal 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;
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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) {
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue