mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-02 13:39:41 +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]) {
|
||||
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>
|
||||
<input type="text" id="newCaseDescription" placeholder="A brief description..." autocomplete="off">
|
||||
</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>
|
||||
<!-- Link Existing Tab -->
|
||||
<div class="modal-tab-content hidden" id="case-link">
|
||||
|
||||
@@ -1642,8 +1642,13 @@ Object.assign(CodemanApp.prototype, {
|
||||
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 {
|
||||
const res = await fetch('/api/cases', {
|
||||
const res = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, description })
|
||||
@@ -1652,11 +1657,18 @@ Object.assign(CodemanApp.prototype, {
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
this.closeCreateCaseModal();
|
||||
this.showToast(`Case "${name}" created`, 'success');
|
||||
// Reload cases and select the new one
|
||||
await this.loadQuickStartCases(name);
|
||||
// Save as last used case
|
||||
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 {
|
||||
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>
|
||||
</div>
|
||||
<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))})"
|
||||
title="Move up" ${isFirst ? 'disabled' : ''}>▲</button>
|
||||
<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 {
|
||||
checkDockerAvailable,
|
||||
checkDockerTmuxAvailable,
|
||||
DEFAULT_AGENT_IMAGE,
|
||||
dockerContainerName,
|
||||
dockerDisplayPath,
|
||||
readDockerCases,
|
||||
@@ -56,6 +57,8 @@ const LINKED_CASES_FILE = dataPath('linked-cases.json');
|
||||
const CODEMAN_CONFIG_DIR = getDataDir();
|
||||
const SAFE_CASE_NAME = /^[a-zA-Z0-9_-]+$/;
|
||||
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). */
|
||||
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 ==========
|
||||
|
||||
// Export a docker case to a portable bundle. Runs in the BACKGROUND (a full image
|
||||
|
||||
Reference in New Issue
Block a user