/** * Step: container — Build container image and verify with test run. * Replaces 03-setup-container.sh */ import { execSync, spawnSync } from 'child_process'; import path from 'path'; import { setTimeout as sleep } from 'timers/promises'; import { log } from '../src/log.js'; import { getDefaultContainerImage } from '../src/install-slug.js'; import { commandExists, getPlatform } from './platform.js'; import { emitStatus } from './status.js'; type DockerStatus = 'ok' | 'no-permission' | 'no-daemon' | 'other'; function dockerStatus(): DockerStatus { const res = spawnSync('docker', ['info'], { encoding: 'utf-8' }); if (res.status === 0) return 'ok'; const err = `${res.stderr ?? ''}\n${res.stdout ?? ''}`; if (/permission denied/i.test(err)) return 'no-permission'; if (/cannot connect|is the docker daemon running|no such file/i.test(err)) return 'no-daemon'; return 'other'; } function dockerRunning(): boolean { return dockerStatus() === 'ok'; } /** * Try to start Docker if it's installed but idle. Poll up to 60s for the * daemon to come up — but bail immediately if the socket is reachable and * only blocked by a group-permission error, since that won't resolve by * waiting (the caller handles the sg re-exec for that case). */ async function tryStartDocker(): Promise { const platform = getPlatform(); log.info('Docker not running — attempting to start', { platform }); try { if (platform === 'macos') { execSync('open -a Docker', { stdio: 'ignore' }); } else if (platform === 'linux') { // Inherit stdio so sudo can prompt for a password if needed. execSync('sudo systemctl start docker', { stdio: 'inherit' }); } else { return 'other'; } } catch (err) { log.warn('Start command failed', { err }); return 'other'; } for (let i = 0; i < 30; i++) { await sleep(2000); const s = dockerStatus(); if (s === 'ok') { log.info('Docker is up'); return 'ok'; } if (s === 'no-permission') { log.info('Docker daemon is up but socket is not accessible (group membership)'); return 'no-permission'; } } log.warn('Docker did not become ready within 60s'); return 'no-daemon'; } function parseArgs(args: string[]): { runtime: string } { // `--runtime` is still accepted for backwards compatibility with the /setup // skill, but `docker` is the only supported value. let runtime = 'docker'; for (let i = 0; i < args.length; i++) { if (args[i] === '--runtime' && args[i + 1]) { runtime = args[i + 1]; i++; } } return { runtime }; } export async function run(args: string[]): Promise { const projectRoot = process.cwd(); const { runtime } = parseArgs(args); const image = getDefaultContainerImage(projectRoot); const logFile = path.join(projectRoot, 'logs', 'setup.log'); if (runtime !== 'docker') { emitStatus('SETUP_CONTAINER', { RUNTIME: runtime, IMAGE: image, BUILD_OK: false, TEST_OK: false, STATUS: 'failed', ERROR: 'unknown_runtime', LOG: 'logs/setup.log', }); process.exit(4); } if (!commandExists('docker')) { log.info('Docker not found — running setup/install-docker.sh'); try { execSync('bash setup/install-docker.sh', { cwd: projectRoot, stdio: 'inherit' }); } catch (err) { log.warn('install-docker.sh failed', { err }); } } if (!commandExists('docker')) { emitStatus('SETUP_CONTAINER', { RUNTIME: runtime, IMAGE: image, BUILD_OK: false, TEST_OK: false, STATUS: 'failed', ERROR: 'runtime_not_available', LOG: 'logs/setup.log', }); process.exit(2); } { let status = dockerStatus(); if (status !== 'ok') { status = await tryStartDocker(); } // Socket is unreachable due to group perms — current shell's supplementary // groups are fixed at login, so `usermod -aG docker` doesn't affect us // until next login. Ensure the user is in the docker group (install-docker.sh // does this on fresh installs, but skips when Docker is already present), // then re-exec under `sg docker` so the child picks up docker as its // primary group and can talk to /var/run/docker.sock without a logout. if (status === 'no-permission' && getPlatform() === 'linux' && commandExists('sg')) { // Ensure the current user is in the docker group — without this, // sg will ask for the (typically unset) group password and fail. const inGroup = spawnSync('id', ['-nG'], { encoding: 'utf-8' }); if (!(inGroup.stdout ?? '').split(/\s+/).includes('docker')) { log.info('Adding current user to docker group'); spawnSync('sudo', ['usermod', '-aG', 'docker', process.env.USER ?? ''], { stdio: 'inherit', }); } log.info('Re-executing container step under `sg docker`'); const res = spawnSync( 'sg', ['docker', '-c', 'pnpm exec tsx setup/index.ts --step container'], { cwd: projectRoot, stdio: 'inherit' }, ); process.exit(res.status ?? 1); } if (status !== 'ok') { const error = status === 'no-permission' ? 'docker_group_not_active' : 'runtime_not_available'; emitStatus('SETUP_CONTAINER', { RUNTIME: runtime, IMAGE: image, BUILD_OK: false, TEST_OK: false, STATUS: 'failed', ERROR: error, LOG: 'logs/setup.log', }); process.exit(2); } } const buildCmd = 'docker build'; const runCmd = 'docker'; // Build-args from .env. Only INSTALL_CJK_FONTS is passed through today. // Keeps /setup and ./container/build.sh in sync — both read the same source. const buildArgs: string[] = []; try { const fs = await import('fs'); const envPath = path.join(projectRoot, '.env'); if (fs.existsSync(envPath)) { const match = fs.readFileSync(envPath, 'utf-8').match(/^INSTALL_CJK_FONTS=(.+)$/m); const val = match?.[1].trim().replace(/^["']|["']$/g, '').toLowerCase(); if (val === 'true') buildArgs.push('--build-arg INSTALL_CJK_FONTS=true'); } } catch { // .env is optional; absence is normal on a fresh checkout } // Build — stdio inherit so the parent setup runner can tail docker's // per-step output and render it in a rolling window. Previously we used // execSync which buffered everything; users couldn't tell whether a // 3–10 minute build was making progress or hung. let buildOk = false; log.info('Building container', { runtime, buildArgs }); const buildRes = spawnSync( buildCmd.split(' ')[0], [ ...buildCmd.split(' ').slice(1), ...buildArgs.flatMap((a) => a.split(' ')), '-t', image, '.', ], { cwd: path.join(projectRoot, 'container'), stdio: 'inherit', }, ); if (buildRes.status === 0) { buildOk = true; log.info('Container build succeeded'); } else { log.error('Container build failed', { exitCode: buildRes.status }); } // Test let testOk = false; if (buildOk) { log.info('Testing container'); try { const output = execSync( `echo '{}' | ${runCmd} run -i --rm --entrypoint /bin/echo ${image} "Container OK"`, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }, ); testOk = output.includes('Container OK'); log.info('Container test result', { testOk }); } catch { log.error('Container test failed'); } } const status = buildOk && testOk ? 'success' : 'failed'; emitStatus('SETUP_CONTAINER', { RUNTIME: runtime, IMAGE: image, BUILD_OK: buildOk, TEST_OK: testOk, STATUS: status, LOG: 'logs/setup.log', }); if (status === 'failed') process.exit(1); }