mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-07 07:59:42 +02:00
feat(docker): one-click "Run in Docker" case creation + Export button
- New POST /api/cases/docker-quickcreate: creates a normal case (folder in CASES_DIR, scaffolded CLAUDE.md + hooks) AND links it to a hardened container with default settings, auto-provisioning a shared `default` docker host — the user never touches host/image/network fields. - Create New tab gains a "Run in isolated Docker container" checkbox; on submit it calls docker-quickcreate then auto-starts a claude session inside the container. - Case Manage list gains an Export (full-image) button per docker case. - SSE listeners for docker:exportComplete/exportFailed toast + refresh the exports list. Verified end-to-end on the live instance: one-click create put the case in ~/codeman-cases/<name>, auto-created the default host, launched claude in the container; export button produces a bundle; checkbox + button render (Playwright). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1433,6 +1433,25 @@ class CodemanApp {
|
|||||||
for (const event of [SSE_EVENTS.SESSION_CREATED, SSE_EVENTS.SESSION_DELETED]) {
|
for (const event of [SSE_EVENTS.SESSION_CREATED, SSE_EVENTS.SESSION_DELETED]) {
|
||||||
addListener(event, () => this._onSessionListMaybeChanged());
|
addListener(event, () => this._onSessionListMaybeChanged());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Docker export/import: toast + refresh the Manage-tab exports list on completion.
|
||||||
|
addListener(SSE_EVENTS.DOCKER_EXPORT_COMPLETE, (e) => {
|
||||||
|
try {
|
||||||
|
const d = e.data ? JSON.parse(e.data) : {};
|
||||||
|
this.showToast(`Docker export ready: ${d.bundle} (${Math.round((d.sizeBytes || 0) / 1e6)} MB)`, 'success');
|
||||||
|
this.refreshDockerExports?.();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[SSE] docker export complete:', err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
addListener(SSE_EVENTS.DOCKER_EXPORT_FAILED, (e) => {
|
||||||
|
try {
|
||||||
|
const d = e.data ? JSON.parse(e.data) : {};
|
||||||
|
this.showToast(`Docker export failed: ${d.error || 'unknown error'}`, 'error');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[SSE] docker export failed:', err);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|||||||
@@ -1864,6 +1864,10 @@
|
|||||||
<label>Description (optional)</label>
|
<label>Description (optional)</label>
|
||||||
<input type="text" id="newCaseDescription" placeholder="A brief description..." autocomplete="off">
|
<input type="text" id="newCaseDescription" placeholder="A brief description..." autocomplete="off">
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<label class="checkbox-row"><input type="checkbox" id="newCaseDocker"> 🐳 Run in an isolated Docker container</label>
|
||||||
|
<span class="form-hint">One click: creates the case folder AND a hardened container with default settings, then starts the session inside it. Requires the base image (<code>node scripts/build-agent-image.mjs</code>).</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Link Existing Tab -->
|
<!-- Link Existing Tab -->
|
||||||
<div class="modal-tab-content hidden" id="case-link">
|
<div class="modal-tab-content hidden" id="case-link">
|
||||||
|
|||||||
@@ -1642,8 +1642,13 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// One-click "Run in Docker": create the case folder AND a container (default
|
||||||
|
// settings), then start a session inside it.
|
||||||
|
const inDocker = document.getElementById('newCaseDocker')?.checked;
|
||||||
|
const endpoint = inDocker ? '/api/cases/docker-quickcreate' : '/api/cases';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/cases', {
|
const res = await fetch(endpoint, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ name, description })
|
body: JSON.stringify({ name, description })
|
||||||
@@ -1652,11 +1657,18 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
this.closeCreateCaseModal();
|
this.closeCreateCaseModal();
|
||||||
this.showToast(`Case "${name}" created`, 'success');
|
|
||||||
// Reload cases and select the new one
|
// Reload cases and select the new one
|
||||||
await this.loadQuickStartCases(name);
|
await this.loadQuickStartCases(name);
|
||||||
// Save as last used case
|
// Save as last used case
|
||||||
await this.saveLastUsedCase(name);
|
await this.saveLastUsedCase(name);
|
||||||
|
if (inDocker) {
|
||||||
|
const caps = data.data?.capsEnforced === false ? ' (resource caps advisory on this engine)' : '';
|
||||||
|
this.showToast(`Docker case "${name}" created${caps} — starting session…`, 'success');
|
||||||
|
// Start a session INSIDE the container (routes through quick-start).
|
||||||
|
await this.runClaude();
|
||||||
|
} else {
|
||||||
|
this.showToast(`Case "${name}" created`, 'success');
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
this.showToast(data.error || 'Failed to create case', 'error');
|
this.showToast(data.error || 'Failed to create case', 'error');
|
||||||
}
|
}
|
||||||
@@ -1985,6 +1997,12 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
<span class="case-manage-path">${escapeHtml(pathDisplay)}</span>
|
<span class="case-manage-path">${escapeHtml(pathDisplay)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="case-manage-actions">
|
<div class="case-manage-actions">
|
||||||
|
${
|
||||||
|
c.location === 'docker'
|
||||||
|
? `<button class="case-manage-btn" onclick="app.exportDockerCaseBundle(${escapeHtml(JSON.stringify(c.name))}, 'full')"
|
||||||
|
title="Export container (full image + workspace) to move to another machine">📦</button>`
|
||||||
|
: ''
|
||||||
|
}
|
||||||
<button class="case-manage-btn" onclick="app.moveCaseUp(${escapeHtml(JSON.stringify(c.name))})"
|
<button class="case-manage-btn" onclick="app.moveCaseUp(${escapeHtml(JSON.stringify(c.name))})"
|
||||||
title="Move up" ${isFirst ? 'disabled' : ''}>▲</button>
|
title="Move up" ${isFirst ? 'disabled' : ''}>▲</button>
|
||||||
<button class="case-manage-btn" onclick="app.moveCaseDown(${escapeHtml(JSON.stringify(c.name))})"
|
<button class="case-manage-btn" onclick="app.moveCaseDown(${escapeHtml(JSON.stringify(c.name))})"
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import { dataPath, getDataDir } from '../../config/instance.js';
|
|||||||
import {
|
import {
|
||||||
checkDockerAvailable,
|
checkDockerAvailable,
|
||||||
checkDockerTmuxAvailable,
|
checkDockerTmuxAvailable,
|
||||||
|
DEFAULT_AGENT_IMAGE,
|
||||||
dockerContainerName,
|
dockerContainerName,
|
||||||
dockerDisplayPath,
|
dockerDisplayPath,
|
||||||
readDockerCases,
|
readDockerCases,
|
||||||
@@ -56,6 +57,8 @@ const LINKED_CASES_FILE = dataPath('linked-cases.json');
|
|||||||
const CODEMAN_CONFIG_DIR = getDataDir();
|
const CODEMAN_CONFIG_DIR = getDataDir();
|
||||||
const SAFE_CASE_NAME = /^[a-zA-Z0-9_-]+$/;
|
const SAFE_CASE_NAME = /^[a-zA-Z0-9_-]+$/;
|
||||||
const DOCKER_EXPORTS_DIR = dataPath('docker-exports');
|
const DOCKER_EXPORTS_DIR = dataPath('docker-exports');
|
||||||
|
/** Auto-created host profile for the one-click "Run in Docker" case flow. */
|
||||||
|
const DEFAULT_DOCKER_HOST_ID = 'default';
|
||||||
|
|
||||||
/** App version for export manifests (best-effort read of package.json). */
|
/** App version for export manifests (best-effort read of package.json). */
|
||||||
const APP_VERSION = (() => {
|
const APP_VERSION = (() => {
|
||||||
@@ -391,6 +394,84 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// One-click "Run in Docker": create a NORMAL case (folder in CASES_DIR, scaffolded)
|
||||||
|
// AND link it to a hardened container with default settings, auto-provisioning a
|
||||||
|
// shared `default` docker host so the user never touches host/image/network fields.
|
||||||
|
app.post(
|
||||||
|
'/api/cases/docker-quickcreate',
|
||||||
|
async (req): Promise<ApiResponse<{ case: unknown; capsEnforced?: boolean; isDesktop?: boolean }>> => {
|
||||||
|
const { name, description } = parseBody(CreateCaseSchema, req.body);
|
||||||
|
const casePath = validatePathWithinBase(name, CASES_DIR);
|
||||||
|
if (!casePath) return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case path');
|
||||||
|
|
||||||
|
// Collision across every case kind.
|
||||||
|
const linkedCases = await readLinkedCases();
|
||||||
|
const dockerCases = await readDockerCases(CODEMAN_CONFIG_DIR);
|
||||||
|
const remoteCases = await readRemoteCases(CODEMAN_CONFIG_DIR);
|
||||||
|
if (
|
||||||
|
existsSync(casePath) ||
|
||||||
|
dockerCases.some((item) => item.name === name) ||
|
||||||
|
remoteCases.some((item) => item.name === name) ||
|
||||||
|
linkedCases[name]
|
||||||
|
) {
|
||||||
|
return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, 'Case already exists');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-provision the shared `default` docker host (convenient, hardened defaults).
|
||||||
|
const hosts = await readDockerHosts(CODEMAN_CONFIG_DIR);
|
||||||
|
let host = hosts.find((item) => item.id === DEFAULT_DOCKER_HOST_ID);
|
||||||
|
if (!host) {
|
||||||
|
host = {
|
||||||
|
id: DEFAULT_DOCKER_HOST_ID,
|
||||||
|
label: 'Default',
|
||||||
|
image: DEFAULT_AGENT_IMAGE,
|
||||||
|
network: 'bridge',
|
||||||
|
mountCredentials: true,
|
||||||
|
resumeOnStart: true,
|
||||||
|
hooksEnabled: true,
|
||||||
|
};
|
||||||
|
await writeDockerHosts(CODEMAN_CONFIG_DIR, [...hosts, host]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Probe the daemon + base image BEFORE scaffolding, so a missing docker/image
|
||||||
|
// surfaces a clear error instead of leaving an orphaned case folder.
|
||||||
|
const availability = await checkDockerAvailable(host.engine);
|
||||||
|
if (!availability.ok) {
|
||||||
|
return createErrorResponse(
|
||||||
|
ApiErrorCode.OPERATION_FAILED,
|
||||||
|
availability.error || 'docker daemon is not available'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const dockerCase = { name, type: 'docker' as const, hostId: host.id, hostWorkspacePath: casePath };
|
||||||
|
const tmuxCheck = await checkDockerTmuxAvailable(toSessionDocker(host, dockerCase));
|
||||||
|
if (!tmuxCheck.ok) {
|
||||||
|
return createErrorResponse(
|
||||||
|
ApiErrorCode.OPERATION_FAILED,
|
||||||
|
tmuxCheck.error || 'base image is missing tmux (build it: node scripts/build-agent-image.mjs)'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scaffold the case folder exactly like a normal case.
|
||||||
|
try {
|
||||||
|
mkdirSync(casePath, { recursive: true });
|
||||||
|
mkdirSync(join(casePath, 'src'), { recursive: true });
|
||||||
|
const templatePath = await ctx.getDefaultClaudeMdPath();
|
||||||
|
writeFileSync(join(casePath, 'CLAUDE.md'), generateClaudeMd(name, description || '', templatePath));
|
||||||
|
await writeHooksConfig(casePath);
|
||||||
|
} catch (err) {
|
||||||
|
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to create case: ${getErrorMessage(err)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await writeDockerCases(CODEMAN_CONFIG_DIR, [...dockerCases, dockerCase]);
|
||||||
|
ctx.broadcast(SseEvent.CaseCreated, { name, path: casePath });
|
||||||
|
ctx.broadcast(SseEvent.CaseLinked, { name, path: casePath, type: 'docker' });
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: { case: dockerCase, capsEnforced: availability.capsEnforced, isDesktop: availability.isDesktop },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
// ========== Docker export / import ==========
|
// ========== Docker export / import ==========
|
||||||
|
|
||||||
// Export a docker case to a portable bundle. Runs in the BACKGROUND (a full image
|
// Export a docker case to a portable bundle. Runs in the BACKGROUND (a full image
|
||||||
|
|||||||
Reference in New Issue
Block a user