diff --git a/src/cli/src/commands/setup.ts b/src/cli/src/commands/setup.ts index de57bbe..e8a5180 100644 --- a/src/cli/src/commands/setup.ts +++ b/src/cli/src/commands/setup.ts @@ -51,6 +51,7 @@ import { buildProviderKeyName, SecretsManager } from '@tinyclaw/secrets'; import type { StreamCallback } from '@tinyclaw/types'; import { createWebUI } from '@tinyclaw/web'; import QRCode from 'qrcode'; +import { isRunningInContainer } from '../detect-container.js'; import { showBanner } from '../ui/banner.js'; import { theme } from '../ui/theme.js'; @@ -210,6 +211,35 @@ export async function setupCommand(): Promise { p.intro(theme.brand("Let's set up Tiny Claw")); + // --- Container environment warning ---------------------------------- + + if (isRunningInContainer()) { + p.note( + theme.warn('Container Environment Detected') + + '\n\n' + + 'Interactive CLI setup may not work properly in Docker/containers.\n' + + 'If prompts freeze or fail, cancel and run:\n\n' + + ' ' + + theme.cmd('tinyclaw setup --docker') + + '\n\n' + + 'Or use ' + + theme.cmd('--web') + + ' for browser-based setup.', + 'Docker/Container', + ); + + const continueAnyway = await p.confirm({ + message: 'Continue with interactive setup anyway?', + initialValue: false, + }); + + if (p.isCancel(continueAnyway) || !continueAnyway) { + p.outro(theme.dim('Setup cancelled. Use --docker or --web flag for container environments.')); + await cleanup(secretsManager, configManager); + process.exit(0); + } + } + // --- Security warning ----------------------------------------------- p.note( diff --git a/src/cli/src/detect-container.ts b/src/cli/src/detect-container.ts new file mode 100644 index 0000000..a5a0a19 --- /dev/null +++ b/src/cli/src/detect-container.ts @@ -0,0 +1,28 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { logger } from '@tinyclaw/logger'; + +/** + * Detect if running inside a Docker container or CI environment. + * Checks for common indicators: .dockerenv, cgroup, CI env vars, container-specific env vars. + */ +export function isRunningInContainer(): boolean { + if (process.env.CI || process.env.CONTAINER || process.env.DOCKER_CONTAINER) { + return true; + } + try { + if (existsSync('/.dockerenv')) { + return true; + } + } catch (err) { + logger.debug('Container detection: failed to check /.dockerenv', err); + } + try { + const cgroup = readFileSync('/proc/1/cgroup', 'utf8'); + if (/docker|containerd|kubepods|lxc|podman/i.test(cgroup)) { + return true; + } + } catch (err) { + logger.debug('Container detection: failed to read /proc/1/cgroup', err); + } + return false; +} diff --git a/src/cli/src/index.ts b/src/cli/src/index.ts index e73e32c..6e5745a 100644 --- a/src/cli/src/index.ts +++ b/src/cli/src/index.ts @@ -6,16 +6,18 @@ * Lightweight argument router. No framework — just process.argv. * * Usage: - * tinyclaw Show banner + help - * tinyclaw setup Interactive first-time setup wizard - * tinyclaw setup --web Start web onboarding at /setup - * tinyclaw start Boot the agent (requires setup first) - * tinyclaw purge Wipe all data for a fresh install - * tinyclaw --version Print version - * tinyclaw --help Show help + * tinyclaw Show banner + help + * tinyclaw setup Interactive first-time setup wizard + * tinyclaw setup --web Start web onboarding at /setup + * tinyclaw setup --docker Force web mode for Docker/container environments + * tinyclaw start Boot the agent (requires setup first) + * tinyclaw purge Wipe all data for a fresh install + * tinyclaw --version Print version + * tinyclaw --help Show help */ import { logger } from '@tinyclaw/logger'; +import { isRunningInContainer } from './detect-container.js'; import { getVersion, showBanner } from './ui/banner.js'; import { theme } from './ui/theme.js'; @@ -27,8 +29,10 @@ function showHelp(): void { console.log(` ${theme.cmd('tinyclaw')} ${theme.dim('')}`); console.log(); console.log(` ${theme.label('Commands')}`); + console.log(` ${theme.cmd('setup')} Interactive setup wizard`); + console.log(` ${theme.dim('Use --web for browser onboarding')}`); console.log( - ` ${theme.cmd('setup')} Interactive setup wizard (use --web for browser onboarding)`, + ` ${theme.dim('Use --docker for Docker/container environments (auto-detected)')}`, ); console.log(` ${theme.cmd('start')} Start the Tiny Claw agent`); console.log(` ${theme.cmd('config')} Manage models, providers, and settings`); @@ -40,6 +44,10 @@ function showHelp(): void { ); console.log(); console.log(` ${theme.label('Options')}`); + console.log( + ` ${theme.dim('--docker')} Force web-based setup for Docker/container environments`, + ); + console.log(` ${theme.dim('--web')} Use web-based setup instead of CLI wizard`); console.log(` ${theme.dim('--verbose')} Show debug-level logs during start`); console.log(` ${theme.dim('--version, -v')} Show version number`); console.log(` ${theme.dim('--help, -h')} Show this help message`); @@ -54,7 +62,11 @@ async function main(): Promise { switch (command) { case 'setup': { - if (args.includes('--web')) { + // Detect Docker/container environment and auto-route to web mode + const isDocker = args.includes('--docker') || isRunningInContainer(); + const isWeb = args.includes('--web') || isDocker; + + if (isWeb) { // Web setup goes through supervisor so the restart mechanism works const { supervisedStart } = await import('./supervisor.js'); await supervisedStart(); diff --git a/src/cli/tests/detect-container.test.ts b/src/cli/tests/detect-container.test.ts new file mode 100644 index 0000000..7d79f46 --- /dev/null +++ b/src/cli/tests/detect-container.test.ts @@ -0,0 +1,144 @@ +/** + * Tests for container environment detection (detect-container.ts). + * + * Uses module mocking to simulate Docker, CI, and non-container environments. + */ + +import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'; + +// Store original env so we can restore after each test +const originalEnv = { ...process.env }; + +// Mock node:fs so we can control existsSync and readFileSync +const mockExistsSync = mock(() => false); +const mockReadFileSync = mock(() => ''); + +mock.module('node:fs', () => ({ + existsSync: mockExistsSync, + readFileSync: mockReadFileSync, +})); + +// Mock logger to avoid side effects +mock.module('@tinyclaw/logger', () => ({ + logger: { + debug: mock(() => {}), + info: mock(() => {}), + warn: mock(() => {}), + error: mock(() => {}), + }, +})); + +// Import after mocks are set up +const { isRunningInContainer } = await import('../src/detect-container.js'); + +beforeEach(() => { + // Reset env vars + delete process.env.CI; + delete process.env.CONTAINER; + delete process.env.DOCKER_CONTAINER; + + // Reset mocks + mockExistsSync.mockReset(); + mockExistsSync.mockReturnValue(false); + mockReadFileSync.mockReset(); + mockReadFileSync.mockReturnValue(''); +}); + +afterEach(() => { + // Restore original environment + process.env = { ...originalEnv }; +}); + +// ----------------------------------------------------------------------- +// Environment variable detection +// ----------------------------------------------------------------------- + +describe('environment variable detection', () => { + test('detects CI environment', () => { + process.env.CI = 'true'; + expect(isRunningInContainer()).toBe(true); + }); + + test('detects CONTAINER environment', () => { + process.env.CONTAINER = 'true'; + expect(isRunningInContainer()).toBe(true); + }); + + test('detects DOCKER_CONTAINER environment', () => { + process.env.DOCKER_CONTAINER = '1'; + expect(isRunningInContainer()).toBe(true); + }); +}); + +// ----------------------------------------------------------------------- +// .dockerenv file detection +// ----------------------------------------------------------------------- + +describe('.dockerenv detection', () => { + test('detects /.dockerenv file', () => { + mockExistsSync.mockReturnValue(true); + expect(isRunningInContainer()).toBe(true); + }); + + test('handles /.dockerenv check failure gracefully', () => { + mockExistsSync.mockImplementation(() => { + throw new Error('Permission denied'); + }); + // Should not throw, should fall through to next check + expect(isRunningInContainer()).toBe(false); + }); +}); + +// ----------------------------------------------------------------------- +// cgroup detection +// ----------------------------------------------------------------------- + +describe('cgroup detection', () => { + test('detects docker in cgroup', () => { + mockReadFileSync.mockReturnValue('12:memory:/docker/abc123\n'); + expect(isRunningInContainer()).toBe(true); + }); + + test('detects containerd in cgroup', () => { + mockReadFileSync.mockReturnValue('1:name=systemd:/containerd/abc\n'); + expect(isRunningInContainer()).toBe(true); + }); + + test('detects kubepods in cgroup', () => { + mockReadFileSync.mockReturnValue('11:cpuset:/kubepods/pod-xyz\n'); + expect(isRunningInContainer()).toBe(true); + }); + + test('detects lxc in cgroup', () => { + mockReadFileSync.mockReturnValue('10:devices:/lxc/container1\n'); + expect(isRunningInContainer()).toBe(true); + }); + + test('detects podman in cgroup', () => { + mockReadFileSync.mockReturnValue('1:name=systemd:/podman/ctr-abc\n'); + expect(isRunningInContainer()).toBe(true); + }); + + test('handles cgroup read failure gracefully', () => { + mockReadFileSync.mockImplementation(() => { + throw new Error('ENOENT: no such file or directory'); + }); + expect(isRunningInContainer()).toBe(false); + }); +}); + +// ----------------------------------------------------------------------- +// Non-container environment +// ----------------------------------------------------------------------- + +describe('non-container environment', () => { + test('returns false when no container indicators found', () => { + mockReadFileSync.mockReturnValue('1:name=systemd:/init.scope\n'); + expect(isRunningInContainer()).toBe(false); + }); + + test('returns false with empty cgroup', () => { + mockReadFileSync.mockReturnValue(''); + expect(isRunningInContainer()).toBe(false); + }); +});