Files
nanoclaw/setup/container.ts
gavrielc 56ef5b4461 feat(setup): clarify setup flow from user-feedback session
- Container step: duration hint + 3-line rolling output window with
  60s stall detector that offers "keep waiting" vs "ask Claude"
- First chat: reframed as a try-out with sandbox-model explainer
  (wakes on message, sleeps when idle, context persists)
- Timezone: auto-detected non-UTC zones now get an explicit
  confirm from the user instead of silent persist
- Outro: added always-on warning + prominent "check your DM" banner
  when a channel was configured; directive last line
- Discord: always show token-location reminder even when user says
  they have one; new "do you have a server?" branch walks through
  server creation if not
- All select prompts: custom brightSelect renderer keeps inactive
  option labels at full brightness (was dim gray); adds @clack/core
  as a direct dep

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 10:35:12 +03:00

233 lines
7.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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 { 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<DockerStatus> {
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<void> {
const projectRoot = process.cwd();
const { runtime } = parseArgs(args);
const image = 'nanoclaw-agent:latest';
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` (via install-docker.sh
// or a prior install) doesn't affect us until next login. Re-exec this
// step 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')) {
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
// 310 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);
}