aiwg
Version:
Deployment tool and support utility for AI context. Copies agents, skills, commands, rules, and behaviors into the paths each AI platform reads (Claude Code, Codex, Copilot, Cursor, Warp, OpenClaw, and 6 more) so one source of truth works across 10 platfo
284 lines (240 loc) • 7.73 kB
JavaScript
/**
* ContainerManager — Docker lifecycle for daemon containerization.
*
* Manages building, starting, stopping, and monitoring the daemon
* Docker container. The entire daemon runs inside the container with
* the project directory mounted as a volume.
*
* @implements Plan: Daemon Starter — Docker Containerization
*/
import { execSync, spawn } from 'child_process';
import { createHash } from 'crypto';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { homedir } from 'os';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const CONTAINER_ID_FILE = '.aiwg/daemon/container-id';
/**
* @typedef {Object} ContainerManagerOptions
* @property {string} [projectDir] - Project directory to mount
* @property {string} [imageName] - Docker image name
* @property {string} [imageTag] - Docker image tag
* @property {number} [webPort] - Web UI port
* @property {Object} [dockerConfig] - Docker config from daemon.yaml
*/
export class ContainerManager {
/** @type {string} */
#projectDir;
/** @type {string} */
#imageName;
/** @type {string} */
#imageTag;
/** @type {string} */
#containerName;
/** @type {number} */
#webPort;
/** @type {Object} */
#dockerConfig;
/**
* @param {ContainerManagerOptions} [options]
*/
constructor(options = {}) {
this.#projectDir = options.projectDir || process.cwd();
this.#imageName = options.imageName || options.dockerConfig?.image?.split(':')[0] || 'aiwg-daemon';
this.#imageTag = options.imageTag || options.dockerConfig?.image?.split(':')[1] || 'latest';
this.#webPort = options.webPort || 7474;
this.#dockerConfig = options.dockerConfig || {};
const hash = createHash('md5').update(this.#projectDir).digest('hex').slice(0, 8);
this.#containerName = `aiwg-daemon-${hash}`;
}
/**
* Check if Docker is available.
*
* @returns {boolean}
*/
isDockerAvailable() {
try {
execSync('docker info', { stdio: 'ignore' });
return true;
} catch {
return false;
}
}
/**
* Build the Docker image if not present or if Dockerfile changed.
*/
ensureImage() {
const dockerfile = path.join(__dirname, 'Dockerfile.daemon');
if (!fs.existsSync(dockerfile)) {
throw new Error(`Dockerfile not found: ${dockerfile}`);
}
const fullImage = `${this.#imageName}:${this.#imageTag}`;
// Check if image exists
try {
execSync(`docker image inspect ${fullImage}`, { stdio: 'ignore' });
console.log(`[docker] Image ${fullImage} exists`);
return;
} catch {
// Image doesn't exist, build it
}
console.log(`[docker] Building image ${fullImage}...`);
const buildContext = this.#dockerConfig.buildContext || this.#projectDir;
execSync(
`docker build -f ${dockerfile} -t ${fullImage} ${buildContext}`,
{ stdio: 'inherit' }
);
console.log(`[docker] Image ${fullImage} built successfully`);
}
/**
* Start the daemon container.
*
* @returns {{ containerId: string, containerName: string }}
*/
start() {
if (!this.isDockerAvailable()) {
throw new Error('Docker is not available. Install Docker or run without --docker.');
}
// Stop existing container if running
this.#stopExisting();
this.ensureImage();
const fullImage = `${this.#imageName}:${this.#imageTag}`;
const home = homedir();
const args = [
'run', '-d',
'--name', this.#containerName,
// Mount project directory
'-v', `${this.#projectDir}:/workspace`,
// Mount credentials read-only
'-v', `${home}/.config:/root/.config:ro`,
// Web UI port
'-p', `${this.#webPort}:7474`,
// Restart policy
'--restart', this.#dockerConfig.restart_policy || 'unless-stopped',
];
// Resource limits
if (this.#dockerConfig.resources?.memory) {
args.push('--memory', this.#dockerConfig.resources.memory);
}
if (this.#dockerConfig.resources?.cpus) {
args.push('--cpus', this.#dockerConfig.resources.cpus);
}
// Extra environment from .env file
if (this.#dockerConfig.env_file) {
const envFile = path.resolve(this.#projectDir, this.#dockerConfig.env_file);
if (fs.existsSync(envFile)) {
args.push('--env-file', envFile);
}
}
// Pass through API key env vars
for (const envVar of ['ANTHROPIC_API_KEY', 'OPENAI_API_KEY', 'AIWG_TELEGRAM_TOKEN', 'AIWG_DISCORD_TOKEN']) {
if (process.env[envVar]) {
args.push('-e', envVar);
}
}
// Extra volumes
if (Array.isArray(this.#dockerConfig.volumes)) {
for (const vol of this.#dockerConfig.volumes) {
if (vol.source && vol.target) {
const src = path.resolve(this.#projectDir, vol.source);
args.push('-v', `${src}:${vol.target}:${vol.mode || 'rw'}`);
}
}
}
args.push(fullImage);
const result = execSync(`docker ${args.join(' ')}`, { encoding: 'utf8' }).trim();
const containerId = result.slice(0, 12);
// Store container ID
const idDir = path.dirname(CONTAINER_ID_FILE);
if (!fs.existsSync(idDir)) {
fs.mkdirSync(idDir, { recursive: true });
}
fs.writeFileSync(CONTAINER_ID_FILE, containerId);
console.log(`[docker] Container started: ${this.#containerName} (${containerId})`);
return { containerId, containerName: this.#containerName };
}
/**
* Stop and remove the container.
*/
stop() {
try {
execSync(`docker stop ${this.#containerName}`, { stdio: 'ignore', timeout: 15000 });
} catch {
// Container may not be running
}
try {
execSync(`docker rm ${this.#containerName}`, { stdio: 'ignore' });
} catch {
// Container may not exist
}
// Clean up container ID file
if (fs.existsSync(CONTAINER_ID_FILE)) {
fs.unlinkSync(CONTAINER_ID_FILE);
}
console.log(`[docker] Container stopped: ${this.#containerName}`);
}
/**
* Get container status.
*
* @returns {{ running: boolean, containerId: string|null, health: string|null }}
*/
status() {
try {
const output = execSync(
`docker inspect --format '{{.State.Running}} {{.State.Health.Status}}' ${this.#containerName}`,
{ encoding: 'utf8', stdio: ['pipe', 'pipe', 'ignore'] }
).trim();
const [running, health] = output.split(' ');
return {
running: running === 'true',
containerId: this.#readContainerId(),
health: health || null,
};
} catch {
return { running: false, containerId: null, health: null };
}
}
/**
* Stream container logs.
*
* @param {boolean} [follow=false]
* @param {number} [lines=50]
* @returns {import('child_process').ChildProcess}
*/
logs(follow = false, lines = 50) {
const args = ['logs', '--tail', String(lines)];
if (follow) args.push('-f');
args.push(this.#containerName);
return spawn('docker', args, { stdio: 'inherit' });
}
/**
* Stop existing container if running.
*/
#stopExisting() {
try {
const output = execSync(
`docker ps -q -f name=${this.#containerName}`,
{ encoding: 'utf8', stdio: ['pipe', 'pipe', 'ignore'] }
).trim();
if (output) {
console.log(`[docker] Stopping existing container: ${this.#containerName}`);
this.stop();
}
} catch {
// No existing container
}
}
/**
* Read stored container ID.
*
* @returns {string|null}
*/
#readContainerId() {
try {
return fs.readFileSync(CONTAINER_ID_FILE, 'utf8').trim();
} catch {
return null;
}
}
}
export default ContainerManager;