diff --git a/README.md b/README.md index a26e3fa..d420bb9 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,8 @@ provisioning, and — optionally — credential setup and OpenClaw onboarding. | Command | Description | | ------------------------------------------ | ------------------------------------------------- | | `clawctl create` | Interactive wizard | -| `clawctl create --config ` | Headless mode (config-file-driven) | +| `clawctl create --config ` | Config-driven with TUI progress | +| `clawctl create --config --plain` | Plain log output (CI/automation) | | `clawctl list` | List all instances with live status | | `clawctl status [name]` | Detailed info for one instance | | `clawctl start [name]` | Start a stopped instance | diff --git a/docs/headless-mode.md b/docs/headless-mode.md index 0752505..7063d7d 100644 --- a/docs/headless-mode.md +++ b/docs/headless-mode.md @@ -1,33 +1,44 @@ -# Headless Mode +# Config-Driven Mode -`clawctl create --config ` runs the full provisioning pipeline without -interactive prompts. Use it for CI/CD, scripted setups, or reproducible team -onboarding. +`clawctl create --config ` runs the provisioning pipeline from a JSON +config file, skipping the interactive wizard. By default it shows the same +fullscreen TUI as the wizard — with stage progress, step history, and a live +log viewer. -## When to use it +For CI/CD or piped environments, add `--plain` for a simple streaming log. -- **CI/CD pipelines** — spin up OpenClaw gateways as part of automated workflows -- **Scripted setups** — provision multiple instances from a shell script -- **Team onboarding** — share a config file so teammates get identical environments -- **Reproducible rebuilds** — recreate a VM from a checked-in config +## Modes + +| Command | Output | +| ---------------------------------------- | ---------------------------------- | +| `clawctl create` | Full interactive wizard | +| `clawctl create --config ` | TUI progress (stages, steps, logs) | +| `clawctl create --config --plain` | Plain `[prefix] message` log lines | + +The TUI mode uses the alternate screen buffer with Ctrl-C cleanup — same +behavior as the interactive wizard. The `--plain` mode writes directly to +stdout and is safe for non-TTY environments. + +## When to use each mode + +- **Interactive wizard** — first-time setup, exploring options +- **Config + TUI** — re-provisioning, team onboarding, watching progress +- **Config + `--plain`** — CI/CD pipelines, scripted setups, log files ## Pipeline -The headless pipeline runs these stages in order: +All three modes run the same provisioning stages: -1. **Load config** — read and validate the JSON config file -2. **Check prerequisites** — macOS, Apple Silicon, Homebrew -3. **Install Lima** — via Homebrew, if not already present -4. **Create and provision VM** — generate lima.yaml, boot Ubuntu 24.04, run provisioning scripts -5. **Verify installed tools** — Node.js 22, Tailscale, Homebrew, 1Password CLI -6. **Set up 1Password** — if `services.onePassword` is configured +1. **Check prerequisites** — macOS, Apple Silicon, Homebrew +2. **Install Lima** — via Homebrew, if not already present +3. **Create and provision VM** — generate lima.yaml, boot Ubuntu 24.04, run provisioning +4. **Verify installed tools** — Node.js 22, Tailscale, Homebrew, 1Password CLI +5. **Set up 1Password** — if `services.onePassword` is configured +6. **Resolve secrets** — if `op://` references are present in the config 7. **Connect Tailscale** — if `network.tailscale` is configured 8. **Bootstrap gateway** — if `provider` is configured (runs `openclaw onboard --non-interactive`) 9. **Register instance** — write `clawctl.json` and update the instance registry -Progress is printed as `[prefix] message` lines — suitable for terminals and -CI logs. - Without a `provider` section, onboarding is skipped. Run `openclaw onboard` in the VM when ready. @@ -60,6 +71,15 @@ For the full schema and all available fields, see the ## Tips for CI +### Use `--plain` for automation + +CI environments typically don't have a TTY, so the TUI can't render. Use +`--plain` explicitly to get clean, parseable log output: + +```bash +clawctl create --config ./vm-bootstrap.json --plain +``` + ### Use `env://` for secrets Keep secrets out of config files by using `env://` references that resolve diff --git a/packages/cli/bin/cli.tsx b/packages/cli/bin/cli.tsx index b7d2e90..8a4fdab 100755 --- a/packages/cli/bin/cli.tsx +++ b/packages/cli/bin/cli.tsx @@ -4,7 +4,8 @@ import { Command } from "commander"; import pkg from "../../../package.json"; import { LimaDriver, BIN_NAME } from "@clawctl/host-core"; import { - runCreateHeadless, + runCreateFromConfig, + runCreatePlain, runCreateWizard, runList, runStatus, @@ -41,11 +42,14 @@ const program = new Command() program .command("create") .description("Create a new OpenClaw instance") - .option("--config ", "Config file for headless mode") - .action(async (opts: { config?: string }) => { + .option("--config ", "Config file (skips wizard, shows TUI progress)") + .option("--plain", "Plain log output instead of TUI (for CI/automation)") + .action(async (opts: { config?: string; plain?: boolean }) => { try { - if (opts.config) { - await runCreateHeadless(driver, opts.config); + if (opts.config && opts.plain) { + await runCreatePlain(driver, opts.config); + } else if (opts.config) { + await runCreateFromConfig(driver, opts.config); } else { await runCreateWizard(driver); } diff --git a/packages/cli/src/app.tsx b/packages/cli/src/app.tsx index 54f63e1..e3ce5ba 100644 --- a/packages/cli/src/app.tsx +++ b/packages/cli/src/app.tsx @@ -1,152 +1,154 @@ -import React, { useState } from "react"; -import { Box, Text } from "ink"; -import { Welcome } from "./steps/welcome.js"; -import { Configure } from "./steps/configure.js"; -import { Credentials } from "./steps/credentials.js"; -import { HostSetup } from "./steps/host-setup.js"; -import { CreateVM } from "./steps/create-vm.js"; -import { ProvisionStatus } from "./steps/provision-status.js"; -import { CredentialSetup } from "./steps/credential-setup.js"; -import { Finish } from "./steps/finish.js"; -import { Onboard } from "./steps/onboard.js"; +import React, { useState, useEffect, useRef } from "react"; +import { Box, Text, useApp } from "ink"; +import { PrereqCheck } from "./steps/prereq-check.js"; +import { ConfigBuilder } from "./steps/config-builder.js"; +import { ProvisionMonitor } from "./components/provision-monitor.js"; +import { CompletionScreen } from "./components/completion-screen.js"; import { useVerboseMode } from "./hooks/use-verbose-mode.js"; import { VerboseContext } from "./hooks/verbose-context.js"; -import type { VMConfig } from "@clawctl/types"; -import type { VMDriver, CleanupTarget } from "@clawctl/host-core"; -import type { WizardStep, PrereqStatus, CredentialConfig } from "./types.js"; +import { useTerminalSize } from "./hooks/use-terminal-size.js"; +import type { InstanceConfig } from "@clawctl/types"; +import type { VMDriver, HeadlessResult } from "@clawctl/host-core"; -const PROCESS_STEPS: WizardStep[] = ["host-setup", "create-vm", "provision", "credential-setup"]; +type AppPhase = "prereqs" | "config" | "provision" | "done" | "error"; -interface AppProps { +export interface AppResult { + action: "created"; + result: HeadlessResult; +} + +// -- ProvisionApp: config-driven TUI (skips wizard, goes straight to provision) -- + +interface ProvisionAppProps { driver: VMDriver; - /** Mutable ref set when VM creation starts, so the caller can clean up on interrupt. */ - creationTarget?: CleanupTarget; + config: InstanceConfig; } -export function App({ driver, creationTarget }: AppProps) { - const [step, setStep] = useState("welcome"); +export function ProvisionApp({ driver, config }: ProvisionAppProps) { + const { exit } = useApp(); const { verbose } = useVerboseMode(); - const [prereqs, setPrereqs] = useState({ - isMacOS: false, - isArm64: false, - hasHomebrew: false, - hasVMBackend: false, - }); - const [config, setConfig] = useState({ - projectDir: "", - vmName: "", - cpus: 4, - memory: "8GiB", - disk: "50GiB", - }); - const [credentialConfig, setCredentialConfig] = useState({}); - const [onboardSkipped, setOnboardSkipped] = useState(false); - - const showHint = PROCESS_STEPS.includes(step); + const [phase, setPhase] = useState<"provision" | "done" | "error">("provision"); + const [result, setResult] = useState(null); + const [error, setError] = useState(null); + const exited = useRef(false); + const { rows } = useTerminalSize(); + + useEffect(() => { + if (phase === "done" && result && !exited.current) { + exited.current = true; + setTimeout(() => { + exit({ action: "created", result } as AppResult); + }, 1000); + } + }, [phase, result]); return ( - - {step === "welcome" && ( - + {phase === "provision" && ( + { - setPrereqs(p); - if (!p.isMacOS || !p.hasHomebrew) { - return; - } - setStep(p.hasVMBackend ? "configure" : "host-setup"); + config={config} + onComplete={(res) => { + setResult(res); + setPhase("done"); }} - /> - )} - - {step === "host-setup" && ( - { - setPrereqs(updated); - setStep("configure"); + onError={(err) => { + setError(err.message); + setPhase("error"); }} /> )} - {step === "configure" && ( - { - setConfig(c); - if (creationTarget) { - creationTarget.vmName = c.vmName; - creationTarget.projectDir = c.projectDir; - } - setStep("credentials"); - }} - /> - )} + {phase === "done" && result && } - {step === "credentials" && ( - { - setCredentialConfig(creds); - setStep("create-vm"); - }} - /> + {phase === "error" && ( + + + {"\u2717"} Provisioning failed + + + {error} + + + )} + + + ); +} - {step === "create-vm" && ( - setStep("provision")} - /> - )} +// -- App: full interactive wizard (prereqs → config → provision → done) -- - {step === "provision" && ( - setStep("credential-setup")} - /> +interface AppProps { + driver: VMDriver; + onProvisionStart?: (config: InstanceConfig) => void; +} + +export function App({ driver, onProvisionStart }: AppProps) { + const { exit } = useApp(); + const [phase, setPhase] = useState("prereqs"); + const { verbose } = useVerboseMode(); + const [config, setConfig] = useState(null); + const [result, setResult] = useState(null); + const [error, setError] = useState(null); + const exited = useRef(false); + const { rows } = useTerminalSize(); + + // Exit Ink on completion or error so waitUntilExit() resolves + useEffect(() => { + if (phase === "done" && result && !exited.current) { + exited.current = true; + // Brief delay so the completion screen renders + setTimeout(() => { + exit({ action: "created", result } as AppResult); + }, 1000); + } + }, [phase, result]); + + return ( + + + {phase === "prereqs" && ( + setPhase("config")} /> )} - {step === "credential-setup" && ( - { - setCredentialConfig(creds); - setStep("onboard"); + {phase === "config" && ( + { + setConfig(cfg); + onProvisionStart?.(cfg); + setPhase("provision"); }} /> )} - {step === "onboard" && ( - { - setOnboardSkipped(skipped); - setStep("finish"); + onComplete={(res) => { + setResult(res); + setPhase("done"); + }} + onError={(err) => { + setError(err.message); + setPhase("error"); }} /> )} - {step === "finish" && ( - - )} + {phase === "done" && result && } - {showHint && ( - - Press [v] to {verbose ? "hide" : "show"} process logs + {phase === "error" && ( + + + {"\u2717"} Provisioning failed + + + {error} + + )} diff --git a/packages/cli/src/commands/create.ts b/packages/cli/src/commands/create.ts index fe70b80..75e1434 100644 --- a/packages/cli/src/commands/create.ts +++ b/packages/cli/src/commands/create.ts @@ -1,271 +1,193 @@ -import { writeFile } from "fs/promises"; import { openSync } from "node:fs"; import { ReadStream } from "node:tty"; -import { join } from "path"; import type { VMDriver } from "@clawctl/host-core"; import { addInstance, - getTailscaleHostname, - cleanupVM, + loadConfig, runHeadless, - extractGatewayToken, - loadRegistry, - saveRegistry, + configToVMConfig, + cleanupVM, BIN_NAME, } from "@clawctl/host-core"; -import type { RegistryEntry, CleanupTarget } from "@clawctl/host-core"; -import { GATEWAY_PORT } from "@clawctl/types"; +import type { RegistryEntry, HeadlessResult } from "@clawctl/host-core"; +import type { InstanceConfig } from "@clawctl/types"; /** - * Run the headless create path: load config, provision, register. + * Run the plain (CI-friendly) create path: load config, provision with + * streaming log output, register. No TUI. */ -export async function runCreateHeadless(driver: VMDriver, configPath: string): Promise { +export async function runCreatePlain(driver: VMDriver, configPath: string): Promise { const result = await runHeadless(driver, configPath); - - const entry: RegistryEntry = { - name: result.name, - projectDir: result.projectDir, - vmName: result.vmName, - driver: result.driver, - createdAt: new Date().toISOString(), - providerType: result.providerType, - gatewayPort: result.gatewayPort, - tailscaleUrl: result.tailscaleUrl, - }; - await addInstance(entry); + await registerInstance(result, driver.name); } /** - * Run the interactive wizard create path. - * Returns when the wizard exits (either via onboard or finish). + * Run config-driven create with the TUI provision monitor. + * Loads the config from disk, then renders ProvisionApp for the visual + * progress view (stages, steps, logs). */ -export async function runCreateWizard(driver: VMDriver): Promise { +export async function runCreateFromConfig(driver: VMDriver, configPath: string): Promise { + const config = await loadConfig(configPath); + const React = (await import("react")).default; const { render } = await import("ink"); - const { App } = await import("../app.js"); + const { ProvisionApp } = await import("../app.js"); - type OnboardResult = { - action: "onboard"; - vmName: string; - projectDir: string; - tailscaleMode?: "off" | "serve" | "funnel"; - }; - type FinishResult = { - action: "finish"; - vmName: string; - projectDir: string; - tailscaleMode?: "off" | "serve" | "funnel"; - }; - - // Track VM creation so we can clean up on interrupt. - // App sets vmName/projectDir when entering the create-vm step. - const creationTarget: CleanupTarget = { vmName: "", projectDir: "" }; - - // SIGTERM handler (SIGINT is caught by Ink in raw mode, not delivered as a signal) - const onTerm = async () => { - if (creationTarget.vmName) { - console.error("\nCaught SIGTERM, cleaning up..."); - await cleanupVM(driver, creationTarget.vmName, creationTarget.projectDir); - } - process.exit(143); - }; - process.on("SIGTERM", onTerm); - - // Give Ink its own stdin stream via /dev/tty so it never touches - // process.stdin. After Ink exits, the subprocess can inherit - // process.stdin cleanly without competing for bytes on fd 0. let inkStdin: ReadStream | undefined; try { inkStdin = new ReadStream(openSync("/dev/tty", "r")); } catch { - // /dev/tty unavailable (CI, piped, etc.) — Ink will use process.stdin + // /dev/tty unavailable — Ink will use process.stdin } - const renderOpts = inkStdin ? { stdin: inkStdin } : undefined; - const instance = render(React.createElement(App, { driver, creationTarget }), renderOpts); - const result = await instance.waitUntilExit(); + const useAltScreen = process.stdout.isTTY; + if (useAltScreen) { + process.stdout.write("\x1b[?1049h"); + } - // Destroy Ink's private stdin — doesn't affect process.stdin - inkStdin?.destroy(); + const renderOpts = inkStdin ? { stdin: inkStdin } : undefined; + const instance = render(React.createElement(ProvisionApp, { driver, config }), renderOpts); - // If the wizard was interrupted (Ctrl+C → Ink exits without an action result), - // clean up any partially-created VM and exit. - const isNormalExit = result && typeof result === "object" && "action" in result; - if (!isNormalExit) { - process.off("SIGTERM", onTerm); - if (creationTarget.vmName) { - console.error("\nInterrupted, cleaning up..."); - await cleanupVM(driver, creationTarget.vmName, creationTarget.projectDir); + let exitResult: unknown; + try { + exitResult = await instance.waitUntilExit(); + } finally { + if (useAltScreen) { + process.stdout.write("\x1b[?1049l"); } - return; + inkStdin?.destroy(); } - // Wizard completed normally — stop cleaning up on SIGTERM. - // From here the VM is intentional; post-wizard work (onboarding etc.) - // is retryable and shouldn't trigger VM deletion. - process.off("SIGTERM", onTerm); - - // Write minimal clawctl.json for wizard-created instances - const writeMinimalConfig = async (vmName: string, projectDir: string) => { - const minimal = { name: vmName, project: projectDir }; - await writeFile(join(projectDir, "clawctl.json"), JSON.stringify(minimal, null, 2) + "\n"); - }; - - // Register the instance - const registerWizardInstance = async ( - vmName: string, - projectDir: string, - tailscaleUrl?: string, - ) => { - const entry: RegistryEntry = { - name: vmName, - projectDir, - vmName, - driver: driver.name, - createdAt: new Date().toISOString(), - gatewayPort: GATEWAY_PORT, - tailscaleUrl, - }; - await addInstance(entry); - }; - if ( - result && - typeof result === "object" && - "action" in result && - (result as OnboardResult).action === "onboard" + exitResult && + typeof exitResult === "object" && + "action" in exitResult && + (exitResult as { action: string }).action === "created" ) { - const { vmName, projectDir, tailscaleMode } = result as OnboardResult; - - await registerWizardInstance(vmName, projectDir); - await writeMinimalConfig(vmName, projectDir); - - console.log(""); - console.log("--- OpenClaw Onboarding (running inside VM) ---"); - console.log(""); - - try { - const onboardResult = await driver.execInteractive(vmName, "openclaw onboard --skip-daemon"); - console.log(""); - - if (onboardResult.exitCode !== 0) { - console.log(`Warning: Onboarding exited with code ${onboardResult.exitCode}`); - console.log(` You can retry: ${BIN_NAME} oc onboard`); - } else { - console.log("Installing gateway service..."); - const installResult = await driver.exec( - vmName, - "openclaw daemon install --runtime node --force", - ); - if (installResult.exitCode !== 0) { - console.log("Warning: Gateway service install failed. You can retry:"); - console.log(` ${BIN_NAME} oc daemon install --runtime node --force`); - } else { - console.log("Starting gateway..."); - await driver.exec(vmName, "openclaw daemon start"); - await driver.exec(vmName, "openclaw config set tools.profile full"); - await driver.exec( - vmName, - "openclaw config set agents.defaults.workspace /mnt/project/data/workspace", - ); - // Configure Tailscale gateway mode + allowedOrigins before restart - if (tailscaleMode && tailscaleMode !== "off") { - await driver.exec( - vmName, - `openclaw config set gateway.tailscale.mode ${tailscaleMode}`, - ); - const tsHostname = await getTailscaleHostname(driver, vmName); - if (tsHostname) { - const tsUrl = `https://${tsHostname}`; - await driver.exec( - vmName, - `openclaw config set gateway.controlUi.allowedOrigins '["${tsUrl}"]'`, - ); - } - } - - await driver.exec(vmName, "openclaw daemon restart"); - - const envResult = await driver.exec( - vmName, - "systemctl --user show openclaw-gateway.service -p Environment", - ); - const token = extractGatewayToken(envResult.stdout); + const { result } = exitResult as { action: string; result: HeadlessResult }; + await registerInstance(result, driver.name); + printSummary(result); + // The headless pipeline's subprocesses can keep the event loop alive. + process.exit(0); + } else { + // Ctrl-C or error — clean up VM and project dir + const vmConfig = configToVMConfig(config); + await cleanupVM(driver, vmConfig.vmName, vmConfig.projectDir); + process.exit(130); + } +} - const doctorResult = await driver.exec(vmName, "openclaw doctor"); - if (doctorResult.exitCode === 0) { - console.log("OpenClaw setup complete — openclaw doctor passed"); - } else { - console.log("Warning: Setup finished but openclaw doctor reported issues"); - } +/** + * Run the interactive TUI create path. + * + * Uses the alternate screen buffer so each phase renders on a clean canvas + * without ghost elements. On exit, the original terminal content is restored + * and a summary is printed to the main buffer. + */ +export async function runCreateWizard(driver: VMDriver): Promise { + const React = (await import("react")).default; + const { render } = await import("ink"); + const { App } = await import("../app.js"); - console.log(""); - if (token) { - console.log(`Dashboard: http://localhost:${GATEWAY_PORT}/#token=${token}`); - } else { - console.log(`Dashboard: http://localhost:${GATEWAY_PORT}`); - } - console.log(`Enter VM: ${BIN_NAME} shell`); + // Capture config when provisioning starts so we can clean up on Ctrl-C. + // Ink intercepts Ctrl-C in raw mode (as a character, not SIGINT), so the + // process-level signal handlers registered by runHeadlessFromConfig never + // fire. We handle cleanup here instead. + let provisionConfig: InstanceConfig | null = null; - // Update registry with Tailscale URL if serve/funnel mode - if (tailscaleMode && tailscaleMode !== "off") { - const tsHostnameForRegistry = await getTailscaleHostname(driver, vmName); - if (tsHostnameForRegistry) { - const tsBaseUrl = `https://${tsHostnameForRegistry}`; - console.log(`Tailscale: ${tsBaseUrl}`); - console.log(" First tailnet connection requires device approval:"); - console.log(` ${BIN_NAME} oc devices list`); - console.log(` ${BIN_NAME} oc devices approve `); - const registry = await loadRegistry(); - if (registry.instances[vmName]) { - registry.instances[vmName].tailscaleUrl = tsBaseUrl; - await saveRegistry(registry); - } - } - } + // Give Ink its own stdin stream via /dev/tty so it never touches + // process.stdin. After Ink exits, the subprocess can inherit + // process.stdin cleanly without competing for bytes on fd 0. + let inkStdin: ReadStream | undefined; + try { + inkStdin = new ReadStream(openSync("/dev/tty", "r")); + } catch { + // /dev/tty unavailable (CI, piped, etc.) — Ink will use process.stdin + } - const bootstrapCheck = await driver.exec( - vmName, - "test -f ~/.openclaw/workspace/BOOTSTRAP.md && echo yes || echo no", - ); - if (bootstrapCheck.stdout.trim() === "yes") { - console.log(""); - console.log("--- First conversation (the agent wants to meet you) ---"); - console.log(""); - try { - await driver.execInteractive( - vmName, - "openclaw tui --message 'You just woke up. Time to figure out who you are.'", - ); - } catch { - // User Ctrl-C'd out of the first conversation — that's fine - } - } + // Enter alternate screen buffer for a clean canvas. This prevents ghost + // elements from Ink's differential rendering when the output height + // shrinks between phases. On exit, the original terminal is restored. + const useAltScreen = process.stdout.isTTY; + if (useAltScreen) { + process.stdout.write("\x1b[?1049h"); + } - // AGENTS.md managed section is now written VM-side during provision-workspace. - // See @clawctl/capabilities runner.ts. - } - } - } catch (err) { - console.error("Failed to run onboarding:", err); - console.log(` You can retry: ${BIN_NAME} oc onboard`); + const renderOpts = inkStdin ? { stdin: inkStdin } : undefined; + const instance = render( + React.createElement(App, { + driver, + onProvisionStart: (cfg: InstanceConfig) => { + provisionConfig = cfg; + }, + }), + renderOpts, + ); + + let exitResult: unknown; + try { + exitResult = await instance.waitUntilExit(); + } finally { + // Leave alternate screen buffer — restores original terminal content + if (useAltScreen) { + process.stdout.write("\x1b[?1049l"); } - } else if ( - result && - typeof result === "object" && - "action" in result && - (result as FinishResult).action === "finish" + inkStdin?.destroy(); + } + + // Register instance if provisioning completed successfully + if ( + exitResult && + typeof exitResult === "object" && + "action" in exitResult && + (exitResult as { action: string }).action === "created" ) { - const { vmName, projectDir, tailscaleMode } = result as FinishResult; + const { result } = exitResult as { action: string; result: HeadlessResult }; + await registerInstance(result, driver.name); + printSummary(result); + } else if (provisionConfig) { + // Ctrl-C or error during provisioning — clean up the VM and project dir. + // The abandoned headless pipeline's subprocesses keep the event loop + // alive, so we must force-exit after cleanup. + const vmConfig = configToVMConfig(provisionConfig); + await cleanupVM(driver, vmConfig.vmName, vmConfig.projectDir); + process.exit(130); + } +} - // Query Tailscale URL if serve/funnel mode was selected - let tailscaleUrl: string | undefined; - if (tailscaleMode && tailscaleMode !== "off") { - const tsHostname = await getTailscaleHostname(driver, vmName); - if (tsHostname) tailscaleUrl = `https://${tsHostname}`; - } +/** Print a summary to the main terminal after leaving the alternate screen. */ +function printSummary(result: HeadlessResult): void { + const dashboardUrl = result.gatewayToken + ? `http://localhost:${result.gatewayPort}/#token=${result.gatewayToken}` + : `http://localhost:${result.gatewayPort}`; - await registerWizardInstance(vmName, projectDir, tailscaleUrl); - await writeMinimalConfig(vmName, projectDir); + console.log(`\n\x1b[32m\u2713\x1b[0m \x1b[1m${result.name}\x1b[0m is ready\n`); + console.log(` Dashboard ${dashboardUrl}`); + if (result.tailscaleUrl) { + console.log(` Tailscale ${result.tailscaleUrl}`); + } + console.log(` Config ${result.projectDir}/clawctl.json`); + console.log(); + const n = result.name; + console.log(` ${BIN_NAME} shell ${n} Enter the VM`); + console.log(` ${BIN_NAME} oc -i ${n} tui Chat with your agent`); + console.log(` ${BIN_NAME} status ${n} Check instance health`); + if (!result.providerType) { + console.log(` ${BIN_NAME} oc -i ${n} onboard Configure a provider`); } + console.log(); +} + +async function registerInstance(result: HeadlessResult, driverName: string): Promise { + const entry: RegistryEntry = { + name: result.name, + projectDir: result.projectDir, + vmName: result.vmName, + driver: driverName, + createdAt: new Date().toISOString(), + providerType: result.providerType, + gatewayPort: result.gatewayPort, + tailscaleUrl: result.tailscaleUrl, + }; + await addInstance(entry); } diff --git a/packages/cli/src/commands/index.ts b/packages/cli/src/commands/index.ts index 9f9eb00..353ab73 100644 --- a/packages/cli/src/commands/index.ts +++ b/packages/cli/src/commands/index.ts @@ -1,4 +1,4 @@ -export { runCreateHeadless, runCreateWizard } from "./create.js"; +export { runCreateFromConfig, runCreatePlain, runCreateWizard } from "./create.js"; export { runList } from "./list.js"; export { runStatus } from "./status.js"; export { runStart } from "./start.js"; diff --git a/packages/cli/src/components/completion-screen.tsx b/packages/cli/src/components/completion-screen.tsx new file mode 100644 index 0000000..7439e15 --- /dev/null +++ b/packages/cli/src/components/completion-screen.tsx @@ -0,0 +1,83 @@ +import React from "react"; +import { Text, Box } from "ink"; +import type { HeadlessResult } from "@clawctl/host-core"; +import { BIN_NAME } from "@clawctl/host-core"; + +interface CompletionScreenProps { + result: HeadlessResult; +} + +export function CompletionScreen({ result }: CompletionScreenProps) { + const dashboardUrl = result.gatewayToken + ? `http://localhost:${result.gatewayPort}/#token=${result.gatewayToken}` + : `http://localhost:${result.gatewayPort}`; + + return ( + + + + {"\u2713"} {result.name} is ready + + + + + + + Dashboard + + {dashboardUrl} + + {result.tailscaleUrl && ( + + + Tailscale + + {result.tailscaleUrl} + + )} + + + Config + + {result.projectDir}/clawctl.json + + + + + Next steps: + + {" "} + + {BIN_NAME} shell {result.name} + {" "} + Enter the VM + + + {" "} + + {BIN_NAME} oc -i {result.name} tui + {" "} + Chat with your agent + + + {" "} + + {BIN_NAME} status {result.name} + {" "} + Check instance health + + {!result.providerType && ( + + {" "} + + {BIN_NAME} oc -i {result.name} onboard + {" "} + Configure a provider + + )} + + + + + ); +} diff --git a/packages/cli/src/components/config-review.tsx b/packages/cli/src/components/config-review.tsx new file mode 100644 index 0000000..7a00e0c --- /dev/null +++ b/packages/cli/src/components/config-review.tsx @@ -0,0 +1,180 @@ +import React from "react"; +import { Text, Box } from "ink"; +import type { InstanceConfig } from "@clawctl/types"; + +interface ConfigReviewProps { + config: InstanceConfig; + validationErrors: string[]; + validationWarnings: string[]; + focused?: boolean; +} + +function MaskedValue({ value, label }: { value?: string; label?: string }) { + if (!value) + return ( + + {"\u2500\u2500"} not configured {"\u2500\u2500"} + + ); + const masked = value.slice(0, 6) + "\u2022".repeat(Math.max(0, Math.min(value.length - 6, 18))); + return ( + + {label ? `${label} ` : ""} + {masked} + + ); +} + +function Row({ label, children }: { label: string; children: React.ReactNode }) { + return ( + + + {label} + + {children} + + ); +} + +export function ConfigReview({ config, validationErrors, validationWarnings }: ConfigReviewProps) { + const resources = config.resources ?? {}; + const cpus = resources.cpus ?? 4; + const memory = resources.memory ?? "8GiB"; + const disk = resources.disk ?? "50GiB"; + + const bootstrapSummary = config.bootstrap + ? typeof config.bootstrap === "string" + ? "(custom prompt)" + : `${config.bootstrap.agent.name}${config.bootstrap.agent.context ? ` \u2014 "${config.bootstrap.agent.context.slice(0, 40)}${config.bootstrap.agent.context.length > 40 ? "..." : ""}"` : ""}` + : null; + + return ( + + + Review Configuration + + + + + {config.name} + + + {config.project} + + + + {cpus} cpu {"\u00b7"} {memory} {"\u00b7"} {disk} + + + + + + + {config.provider ? ( + {config.provider.type} + ) : ( + + {"\u2500\u2500"} not configured {"\u2500\u2500"} + + )} + + {config.provider?.apiKey && ( + + + + )} + + + + + {config.services?.onePassword ? ( + {"\u2713"} configured + ) : ( + + {"\u2500\u2500"} not configured {"\u2500\u2500"} + + )} + + + {config.network?.tailscale ? ( + + {"\u2713"} {config.network.tailscale.mode ?? "serve"} (auth key provided) + + ) : ( + + {"\u2500\u2500"} not configured {"\u2500\u2500"} + + )} + + + + + + {bootstrapSummary ? ( + {bootstrapSummary} + ) : ( + + {"\u2500\u2500"} not configured {"\u2500\u2500"} + + )} + + + {config.telegram ? ( + {"\u2713"} configured + ) : ( + + {"\u2500\u2500"} not configured {"\u2500\u2500"} + + )} + + + + 0 ? "red" : "green"} + marginTop={1} + paddingX={1} + > + Validation + {validationErrors.length === 0 && validationWarnings.length === 0 && ( + {"\u2713"} All required fields are set + )} + {validationErrors.map((err, i) => ( + + {" "} + {"\u2717"} {err} + + ))} + {validationWarnings.map((warn, i) => ( + + {" "} + {"\u2139"} {warn} + + ))} + {config.services?.onePassword && ( + {"\u2139"} 1Password token will be validated during provisioning + )} + {config.network?.tailscale && ( + {"\u2139"} Tailscale auth key will be used (no interactive login) + )} + + {" "} + {"\u2139"} Config will be saved to {config.project}/clawctl.json + + + + + + + {validationErrors.length > 0 ? ( + [Esc] Back to editor (fix errors first) + ) : ( + + [Enter] Create instance {"\u00b7"} [Esc] Back to editor {"\u00b7"} [S] Save config only + + )} + + + ); +} diff --git a/packages/cli/src/components/form-field.tsx b/packages/cli/src/components/form-field.tsx new file mode 100644 index 0000000..6673ff9 --- /dev/null +++ b/packages/cli/src/components/form-field.tsx @@ -0,0 +1,50 @@ +import React from "react"; +import { Text, Box } from "ink"; + +export type FieldStatus = "idle" | "focused" | "editing" | "valid" | "error"; + +interface FormFieldProps { + label: string; + value: string; + status?: FieldStatus; + error?: string; + masked?: boolean; + placeholder?: string; + dimValue?: boolean; +} + +export function FormField({ + label, + value, + status = "idle", + error, + masked = false, + placeholder, + dimValue = false, +}: FormFieldProps) { + const isFocused = status === "focused" || status === "editing"; + const displayValue = masked && value ? "\u2022".repeat(Math.min(value.length, 24)) : value; + + return ( + + + + {isFocused ? "\u25b8 " : " "} + + + {label} + + {displayValue ? ( + {displayValue} + ) : placeholder ? ( + {placeholder} + ) : null} + + {error && status === "error" && ( + + {error} + + )} + + ); +} diff --git a/packages/cli/src/components/form-section.tsx b/packages/cli/src/components/form-section.tsx new file mode 100644 index 0000000..c89575f --- /dev/null +++ b/packages/cli/src/components/form-section.tsx @@ -0,0 +1,66 @@ +import React from "react"; +import { Text, Box } from "ink"; + +export type SectionStatus = "unconfigured" | "configured" | "error"; + +interface FormSectionProps { + label: string; + status: SectionStatus; + summary?: string; + error?: string; + focused?: boolean; + expanded?: boolean; + children?: React.ReactNode; +} + +export function FormSection({ + label, + status, + summary, + error, + focused = false, + expanded = false, + children, +}: FormSectionProps) { + const icon = expanded ? "\u25be" : "\u25b8"; + const statusIcon = + status === "configured" ? ( + {"\u2713"} + ) : status === "error" ? ( + {"\u2717"} + ) : null; + + const statusText = + status === "unconfigured" ? ( + + {"\u2500\u2500"} none {"\u2500\u2500"} + + ) : ( + {summary} + ); + + return ( + + + + {focused ? icon : " " + icon.replace("\u25b8", "\u25b8").replace("\u25be", "\u25be")}{" "} + + {statusIcon} + + {label} + + {!expanded && statusText} + + {error && status === "error" && !expanded && ( + + {error} + + )} + {expanded && ( + + {children} + + )} + + ); +} diff --git a/packages/cli/src/components/log-output.tsx b/packages/cli/src/components/log-output.tsx index 5adce14..cafd3e4 100644 --- a/packages/cli/src/components/log-output.tsx +++ b/packages/cli/src/components/log-output.tsx @@ -10,7 +10,7 @@ export function LogOutput({ lines, maxLines = 10 }: LogOutputProps) { const visible = lines.slice(-maxLines); return ( - + {visible.map((line, i) => ( {line} diff --git a/packages/cli/src/components/process-output.tsx b/packages/cli/src/components/process-output.tsx index d8e1eb7..8791e4d 100644 --- a/packages/cli/src/components/process-output.tsx +++ b/packages/cli/src/components/process-output.tsx @@ -14,7 +14,7 @@ export function ProcessOutput({ label, logs, verbose, maxLines = 20 }: ProcessOu const lastLine = logs.length > 0 ? logs[logs.length - 1] : undefined; return ( - + {verbose && logs.length > 0 ? ( diff --git a/packages/cli/src/components/provision-monitor.tsx b/packages/cli/src/components/provision-monitor.tsx new file mode 100644 index 0000000..6023e6a --- /dev/null +++ b/packages/cli/src/components/provision-monitor.tsx @@ -0,0 +1,182 @@ +import React, { useState, useEffect, useCallback } from "react"; +import { Text, Box, useInput } from "ink"; +import { Spinner } from "./spinner.js"; +import { LogOutput } from "./log-output.js"; +import { useTerminalSize } from "../hooks/use-terminal-size.js"; +import type { VMDriver } from "@clawctl/host-core"; +import { runHeadlessFromConfig } from "@clawctl/host-core"; +import type { + HeadlessResult, + HeadlessCallbacks, + HeadlessStage, + StageStatus, +} from "@clawctl/host-core"; +import type { InstanceConfig } from "@clawctl/types"; + +interface StageInfo { + label: string; + status: StageStatus; + detail?: string; +} + +const STAGE_LABELS: Record = { + prereqs: "Prerequisites", + provision: "Provisioning VM", + verify: "Verifying installation", + onepassword: "Setting up 1Password", + secrets: "Resolving secrets", + tailscale: "Connecting Tailscale", + bootstrap: "Bootstrapping OpenClaw", + done: "Complete", +}; + +interface ProvisionMonitorProps { + driver: VMDriver; + config: InstanceConfig; + onComplete: (result: HeadlessResult) => void; + onError: (error: Error) => void; +} + +export function ProvisionMonitor({ driver, config, onComplete, onError }: ProvisionMonitorProps) { + const { rows } = useTerminalSize(); + const [stages, setStages] = useState>(() => new Map()); + const [steps, setSteps] = useState([]); + const [logs, setLogs] = useState([]); + const [showLogs, setShowLogs] = useState(true); + + useInput((input) => { + if (input === "v") setShowLogs((s) => !s); + }); + + const onStage = useCallback((stage: HeadlessStage, status: StageStatus, detail?: string) => { + setStages((prev) => { + const next = new Map(prev); + next.set(stage, { label: STAGE_LABELS[stage], status, detail }); + return next; + }); + }, []); + + const onStep = useCallback((label: string) => { + setSteps((prev) => [...prev, label]); + }, []); + + const onLine = useCallback((_prefix: string, message: string) => { + setLogs((prev) => [...prev, message]); + }, []); + + const onCbError = useCallback((_stage: HeadlessStage, error: string) => { + setLogs((prev) => [...prev, `ERROR: ${error}`]); + }, []); + + useEffect(() => { + const callbacks: HeadlessCallbacks = { + onStage, + onStep, + onLine, + onError: onCbError, + }; + + runHeadlessFromConfig(driver, config, callbacks).then(onComplete).catch(onError); + }, []); + + // Determine which stages are relevant for this config + const activeStages: HeadlessStage[] = [ + "prereqs", + "provision", + "verify", + ...(config.services?.onePassword ? ["onepassword" as HeadlessStage] : []), + ...(config.network?.tailscale ? ["tailscale" as HeadlessStage] : []), + ...(config.provider ? ["bootstrap" as HeadlessStage] : []), + ]; + + // The status panel: header(1) + stages + 2 extra rows for steps overflow. + const statusHeight = 1 + activeStages.length + 2; + + // Compute dynamic maxLines for the log viewer: + // header border(3) + margin(1) + statusHeight + margin(1) + log border(3) + help(1) + const fixed = 3 + 1 + statusHeight + 1 + 3 + 1; + const maxLines = Math.max(3, rows - fixed); + + // Show the most recent steps that fit (statusHeight - 1 for header) + const maxSteps = Math.max(1, statusHeight - 1); + const visibleSteps = steps.slice(-maxSteps); + + return ( + + + Provisioning: {config.name} + + + {/* Stages (left) + Steps (right) side by side */} + + {/* Stages column — 50/50 split */} + + + Stages + + {activeStages.map((stageId) => { + const info = stages.get(stageId); + const status = info?.status ?? "pending"; + const label = STAGE_LABELS[stageId]; + const detail = info?.detail; + + return ( + + {status === "done" ? ( + {"\u2713"} + ) : status === "running" ? ( + + ) : status === "error" ? ( + {"\u2717"} + ) : ( + {"\u25cb"} + )} + + {label} + + {detail && status === "done" && {detail}} + + ); + })} + + + {/* Steps column — 50/50 split, scrolls within fixed height */} + + + Steps + + + {visibleSteps.map((step, i) => ( + + {"\u2500"} {step} + + ))} + + + + + {showLogs && logs.length > 0 ? ( + + + {" "} + Log + + + + ) : ( + + )} + + + [v] {showLogs ? "hide" : "show"} logs + + + ); +} diff --git a/packages/cli/src/components/sidebar.tsx b/packages/cli/src/components/sidebar.tsx new file mode 100644 index 0000000..ac8d1d5 --- /dev/null +++ b/packages/cli/src/components/sidebar.tsx @@ -0,0 +1,207 @@ +import React from "react"; +import { Text, Box } from "ink"; + +interface SidebarProps { + title: string; + lines: string[]; + width?: number; +} + +export function Sidebar({ title, lines, width = 36 }: SidebarProps) { + return ( + + + {"\u2139"} {title} + + + {lines.map((line, i) => ( + + {line} + + ))} + + ); +} + +export type SidebarContent = { title: string; lines: string[] }; + +/** Contextual help content keyed by focus ID. */ +export const SIDEBAR_HELP: Record = { + name: { + title: "Instance Name", + lines: [ + "Used as the VM name and the", + "directory under project/ for", + "gateway state.", + "", + "Must be a valid hostname:", + "lowercase, hyphens, no spaces.", + "", + "Examples:", + " home-assistant", + " work-agent", + " dev-sandbox", + ], + }, + project: { + title: "Project Directory", + lines: [ + "Host directory for project files,", + "config, and gateway state.", + "", + "Contains data/ (mounted into VM),", + "clawctl.json, and .git.", + "", + "Supports ~ for home directory.", + ], + }, + resources: { + title: "VM Resources", + lines: [ + "CPU, memory, and disk allocation", + "for the Lima VM.", + "", + "Defaults: 4 CPU, 8 GiB, 50 GiB", + "", + "For heavy workloads, consider:", + " 8 CPU, 16 GiB, 100 GiB", + ], + }, + provider: { + title: "Model Provider", + lines: [ + "Required for full bootstrap.", + "Skip to configure later with", + " clawctl oc onboard", + "", + "Provides the LLM backend for", + "your agent. Choose from 14+", + "supported providers.", + ], + }, + "provider.type": { + title: "Provider Type", + lines: [ + "Select your LLM provider.", + "", + "First-class support for:", + " anthropic, openai, gemini,", + " mistral, zai, moonshot,", + " huggingface, and more.", + "", + "Use 'custom' for self-hosted", + "or unsupported providers.", + ], + }, + "provider.apiKey": { + title: "API Key", + lines: [ + "Your provider's API key.", + "Required for all providers", + "except 'custom'.", + "", + "Supports op:// references if", + "1Password is configured.", + "", + "Never stored in clawctl.json", + "(secrets are stripped).", + ], + }, + services: { + title: "Services", + lines: [ + "External service integrations.", + "", + "1Password: inject secrets via", + "op:// references in your config.", + "Requires a service account token.", + ], + }, + "services.opToken": { + title: "1Password Token", + lines: [ + "Service account token for", + "1Password secret injection.", + "", + "Get one at:", + " my.1password.com/developer", + "", + "Enables op:// references in", + "all config fields.", + ], + }, + network: { + title: "Network", + lines: [ + "Gateway port forwarding and", + "Tailscale connectivity.", + "", + "Defaults:", + " Port 18789 forwarded", + " No Tailscale", + ], + }, + "network.tailscale": { + title: "Tailscale Auth Key", + lines: [ + "Pre-authenticated key for", + "Tailscale network join.", + "", + "Generate at:", + " login.tailscale.com/admin", + " /settings/keys", + "", + "Enables remote access to", + "your agent's dashboard.", + ], + }, + bootstrap: { + title: "Agent Identity", + lines: [ + "Give your agent a personality.", + "This becomes the bootstrap", + "prompt sent after setup.", + "", + "Agent context is freeform \u2014", + "describe the creature, vibe,", + "emoji, backstory, whatever", + "makes your agent unique.", + "", + "User info helps the agent", + "personalize interactions.", + ], + }, + telegram: { + title: "Telegram", + lines: [ + "Connect a Telegram bot for", + "chat-based agent control.", + "", + "Requires a bot token from", + "@BotFather on Telegram.", + "", + "allowFrom: Telegram user IDs", + "groups: Group IDs + settings", + ], + }, + review: { + title: "Review", + lines: [ + "Review your configuration", + "before creating the instance.", + "", + "Secrets are validated during", + "provisioning (not here).", + "", + "Config will be saved to", + " /clawctl.json", + ], + }, +}; diff --git a/packages/cli/src/hooks/use-terminal-size.ts b/packages/cli/src/hooks/use-terminal-size.ts new file mode 100644 index 0000000..d9e9ad9 --- /dev/null +++ b/packages/cli/src/hooks/use-terminal-size.ts @@ -0,0 +1,31 @@ +import { useState, useEffect } from "react"; +import { useStdout } from "ink"; + +interface TerminalSize { + rows: number; + columns: number; +} + +export function useTerminalSize(): TerminalSize { + const { stdout } = useStdout(); + + const [size, setSize] = useState(() => ({ + rows: stdout?.rows ?? 24, + columns: stdout?.columns ?? 80, + })); + + useEffect(() => { + if (!stdout) return; + + const onResize = () => { + setSize({ rows: stdout.rows, columns: stdout.columns }); + }; + + stdout.on("resize", onResize); + return () => { + stdout.off("resize", onResize); + }; + }, [stdout]); + + return size; +} diff --git a/packages/cli/src/steps/config-builder.tsx b/packages/cli/src/steps/config-builder.tsx new file mode 100644 index 0000000..3f097d1 --- /dev/null +++ b/packages/cli/src/steps/config-builder.tsx @@ -0,0 +1,872 @@ +import React, { useState, useMemo } from "react"; +import { Text, Box, useInput } from "ink"; +import TextInput from "ink-text-input"; +import SelectInput from "ink-select-input"; +import { FormField } from "../components/form-field.js"; +import { FormSection } from "../components/form-section.js"; +import { Sidebar, SIDEBAR_HELP } from "../components/sidebar.js"; +import { ConfigReview } from "../components/config-review.js"; +import { + instanceConfigSchema, + providerSchema, + ALL_PROVIDER_TYPES, + DEFAULT_PROJECT_BASE, +} from "@clawctl/types"; +import type { InstanceConfig } from "@clawctl/types"; + +type Phase = "form" | "review"; + +/** + * Flat list of all navigable items. Sections are headers; fields within + * expanded sections are sub-items. + */ +type FocusId = + | "name" + | "project" + | "resources" + | "resources.cpus" + | "resources.memory" + | "resources.disk" + | "provider" + | "provider.type" + | "provider.apiKey" + | "provider.model" + | "provider.baseUrl" + | "provider.modelId" + | "services" + | "services.opToken" + | "network" + | "network.port" + | "network.tailscaleKey" + | "network.tailscaleMode" + | "bootstrap" + | "bootstrap.agentName" + | "bootstrap.agentContext" + | "bootstrap.userName" + | "bootstrap.userContext" + | "telegram" + | "telegram.botToken" + | "telegram.allowFrom" + | "action"; // "Review & Create" button + +type SectionId = "resources" | "provider" | "services" | "network" | "bootstrap" | "telegram"; + +const SECTIONS: SectionId[] = [ + "resources", + "provider", + "services", + "network", + "bootstrap", + "telegram", +]; + +const SECTION_CHILDREN: Record = { + resources: ["resources.cpus", "resources.memory", "resources.disk"], + provider: [ + "provider.type", + "provider.apiKey", + "provider.model", + "provider.baseUrl", + "provider.modelId", + ], + services: ["services.opToken"], + network: ["network.port", "network.tailscaleKey", "network.tailscaleMode"], + bootstrap: [ + "bootstrap.agentName", + "bootstrap.agentContext", + "bootstrap.userName", + "bootstrap.userContext", + ], + telegram: ["telegram.botToken", "telegram.allowFrom"], +}; + +function buildFocusList(expanded: Set): FocusId[] { + const list: FocusId[] = ["name", "project"]; + for (const section of SECTIONS) { + list.push(section as FocusId); + if (expanded.has(section)) { + list.push(...SECTION_CHILDREN[section]); + } + } + list.push("action"); + return list; +} + +const MEMORY_OPTIONS = ["4GiB", "8GiB", "16GiB", "32GiB"]; +const DISK_OPTIONS = ["30GiB", "50GiB", "100GiB", "200GiB"]; +const TS_MODE_OPTIONS = [ + { label: "serve", value: "serve" }, + { label: "funnel", value: "funnel" }, + { label: "off", value: "off" }, +]; + +interface ConfigBuilderProps { + onComplete: (config: InstanceConfig) => void; + onSaveOnly?: (config: InstanceConfig) => void; +} + +export function ConfigBuilder({ onComplete, onSaveOnly }: ConfigBuilderProps) { + const [phase, setPhase] = useState("form"); + + // Config state + const [name, setName] = useState(""); + const [project, setProject] = useState(""); + const [cpus, setCpus] = useState("4"); + const [memory, setMemory] = useState("8GiB"); + const [disk, setDisk] = useState("50GiB"); + + // Provider + const [providerType, setProviderType] = useState(""); + const [providerApiKey, setProviderApiKey] = useState(""); + const [providerModel, setProviderModel] = useState(""); + const [providerBaseUrl, setProviderBaseUrl] = useState(""); + const [providerModelId, setProviderModelId] = useState(""); + + // Services + const [opToken, setOpToken] = useState(""); + + // Network + const [gatewayPort, setGatewayPort] = useState("18789"); + const [tailscaleKey, setTailscaleKey] = useState(""); + const [tailscaleMode, setTailscaleMode] = useState("serve"); + + // Bootstrap + const [agentName, setAgentName] = useState(""); + const [agentContext, setAgentContext] = useState(""); + const [userName, setUserName] = useState(""); + const [userContext, setUserContext] = useState(""); + + // Telegram + const [botToken, setBotToken] = useState(""); + const [allowFrom, setAllowFrom] = useState(""); + + // Navigation + const [expanded, setExpanded] = useState>(new Set()); + const [focusIdx, setFocusIdx] = useState(0); + const [editing, setEditing] = useState(false); + const [selectingProviderType, setSelectingProviderType] = useState(false); + const [selectingTsMode, setSelectingTsMode] = useState(false); + const [selectingMemory, setSelectingMemory] = useState(false); + const [selectingDisk, setSelectingDisk] = useState(false); + + const focusList = useMemo(() => buildFocusList(expanded), [expanded]); + const currentFocus = focusList[focusIdx] ?? "name"; + + // Build the InstanceConfig from current state + const buildConfig = (): InstanceConfig => { + const config: InstanceConfig = { + name: name.trim(), + project: project.trim() || `${DEFAULT_PROJECT_BASE}/${name.trim()}`, + }; + + const cpuNum = parseInt(cpus, 10); + if (cpuNum || memory !== "8GiB" || disk !== "50GiB") { + config.resources = {}; + if (cpuNum && cpuNum !== 4) config.resources.cpus = cpuNum; + if (memory !== "8GiB") config.resources.memory = memory; + if (disk !== "50GiB") config.resources.disk = disk; + } + + if (providerType) { + config.provider = { type: providerType }; + if (providerApiKey) config.provider.apiKey = providerApiKey; + if (providerModel) config.provider.model = providerModel; + if (providerType === "custom") { + if (providerBaseUrl) config.provider.baseUrl = providerBaseUrl; + if (providerModelId) config.provider.modelId = providerModelId; + } + } + + if (opToken) { + config.services = { onePassword: { serviceAccountToken: opToken } }; + } + + const port = parseInt(gatewayPort, 10); + if (tailscaleKey || (port && port !== 18789)) { + config.network = {}; + if (port && port !== 18789) config.network.gatewayPort = port; + if (tailscaleKey) { + config.network.tailscale = { + authKey: tailscaleKey, + mode: tailscaleMode as "off" | "serve" | "funnel", + }; + } + } + + if (agentName) { + config.bootstrap = { + agent: { name: agentName, context: agentContext || undefined }, + ...(userName ? { user: { name: userName, context: userContext || undefined } } : {}), + }; + } + + if (botToken) { + config.telegram = { + botToken, + ...(allowFrom + ? { + allowFrom: allowFrom + .split(",") + .map((s) => s.trim()) + .filter(Boolean), + } + : {}), + }; + } + + return config; + }; + + // Validate the assembled config + const validate = (): { errors: string[]; warnings: string[] } => { + const config = buildConfig(); + const errors: string[] = []; + const warnings: string[] = []; + + if (!config.name) errors.push("Instance name is required"); + if (!config.project) errors.push("Project directory is required"); + + // Validate provider section if partially filled + if (config.provider) { + const provResult = providerSchema.safeParse(config.provider); + if (!provResult.success) { + for (const issue of provResult.error.issues) { + errors.push(`Provider: ${issue.message}`); + } + } + } + + // Full schema validation + if (config.name && config.project) { + const result = instanceConfigSchema.safeParse(config); + if (!result.success) { + for (const issue of result.error.issues) { + const path = issue.path.join("."); + const msg = path ? `${path}: ${issue.message}` : issue.message; + if (!errors.some((e) => e.includes(issue.message))) { + errors.push(msg); + } + } + } + } + + if (!config.provider) { + warnings.push("No provider configured \u2014 run clawctl oc onboard later"); + } + + return { errors, warnings }; + }; + + // Sidebar content based on current focus + const sidebarFocusKey = phase === "review" ? "review" : currentFocus.split(".")[0]; + const sidebarContent = + SIDEBAR_HELP[currentFocus] ?? SIDEBAR_HELP[sidebarFocusKey] ?? SIDEBAR_HELP["name"]; + + // Section status helpers + const sectionStatus = (id: SectionId): "unconfigured" | "configured" | "error" => { + switch (id) { + case "resources": + return "configured"; // always has defaults + case "provider": + return providerType ? "configured" : "unconfigured"; + case "services": + return opToken ? "configured" : "unconfigured"; + case "network": + return tailscaleKey ? "configured" : "unconfigured"; + case "bootstrap": + return agentName ? "configured" : "unconfigured"; + case "telegram": + return botToken ? "configured" : "unconfigured"; + } + }; + + const sectionSummary = (id: SectionId): string => { + switch (id) { + case "resources": + return `${cpus} cpu \u00b7 ${memory} \u00b7 ${disk}`; + case "provider": + return providerType || ""; + case "services": + return opToken ? "1Password" : ""; + case "network": + return tailscaleKey ? `Tailscale (${tailscaleMode})` : "defaults"; + case "bootstrap": + return agentName || ""; + case "telegram": + return botToken ? "configured" : ""; + } + }; + + const toggleSection = (id: SectionId) => { + setExpanded((prev) => { + const next = new Set(prev); + if (next.has(id)) { + next.delete(id); + } else { + next.add(id); + } + return next; + }); + }; + + const isSelectMode = selectingProviderType || selectingTsMode || selectingMemory || selectingDisk; + + // Handle input + useInput( + (input, key) => { + if (isSelectMode) return; // SelectInput handles its own input + + if (phase === "review") { + if (key.escape) { + setPhase("form"); + return; + } + if (key.return) { + const { errors } = validate(); + if (errors.length === 0) { + onComplete(buildConfig()); + } + return; + } + if (input.toLowerCase() === "s" && onSaveOnly) { + onSaveOnly(buildConfig()); + return; + } + return; + } + + // Form mode + if (editing) { + if (key.return || key.escape) { + setEditing(false); + // Auto-fill project if empty + if (currentFocus === "name" && !project && name) { + setProject(`${DEFAULT_PROJECT_BASE}/${name.trim()}`); + } + } + return; + } + + if (key.upArrow) { + setFocusIdx((i) => Math.max(0, i - 1)); + } else if (key.downArrow || key.tab) { + setFocusIdx((i) => Math.min(focusList.length - 1, i + 1)); + } else if (key.return) { + if (SECTIONS.includes(currentFocus as SectionId)) { + toggleSection(currentFocus as SectionId); + } else if (currentFocus === "action") { + setPhase("review"); + } else if (currentFocus === "provider.type") { + setSelectingProviderType(true); + } else if (currentFocus === "network.tailscaleMode") { + setSelectingTsMode(true); + } else if (currentFocus === "resources.memory") { + setSelectingMemory(true); + } else if (currentFocus === "resources.disk") { + setSelectingDisk(true); + } else { + setEditing(true); + } + } else if (key.escape) { + // Collapse parent section + for (const section of SECTIONS) { + if (SECTION_CHILDREN[section].includes(currentFocus)) { + toggleSection(section); + // Move focus to the section header + const newList = buildFocusList(new Set([...expanded].filter((s) => s !== section))); + const sectionIdx = newList.indexOf(section as FocusId); + if (sectionIdx >= 0) setFocusIdx(sectionIdx); + break; + } + } + } else if (input.toLowerCase() === "r") { + setPhase("review"); + } + }, + { isActive: !isSelectMode }, + ); + + // Render the review screen + if (phase === "review") { + const { errors, warnings } = validate(); + return ( + + + + + + + + + ); + } + + // Determine field status + const fieldStatus = (id: FocusId) => { + if (currentFocus === id && editing) return "editing" as const; + if (currentFocus === id) return "focused" as const; + return "idle" as const; + }; + + const providerTypeItems = ALL_PROVIDER_TYPES.map((t) => ({ label: t, value: t })); + + return ( + + + + clawctl create + Configure a new OpenClaw instance + + + + {/* Instance fields (always visible) */} + + {currentFocus === "name" && editing ? ( + + + {"\u25b8"}{" "} + + + Name + + + + ) : ( + + )} + + {currentFocus === "project" && editing ? ( + + + {"\u25b8"}{" "} + + + Project + + + + ) : ( + + )} + + + + + {/* Sections */} + + {/* Resources */} + + {currentFocus === "resources.cpus" && editing ? ( + + + CPUs + + + + ) : ( + + )} + {selectingMemory ? ( + + Memory + ({ label: m, value: m }))} + initialIndex={MEMORY_OPTIONS.indexOf(memory)} + onSelect={(item) => { + setMemory(item.value); + setSelectingMemory(false); + }} + /> + + ) : ( + + )} + {selectingDisk ? ( + + Disk + ({ label: d, value: d }))} + initialIndex={DISK_OPTIONS.indexOf(disk)} + onSelect={(item) => { + setDisk(item.value); + setSelectingDisk(false); + }} + /> + + ) : ( + + )} + + + {/* Provider */} + + {selectingProviderType ? ( + + Type + { + setProviderType(item.value); + setSelectingProviderType(false); + }} + /> + + ) : ( + + )} + {currentFocus === "provider.apiKey" && editing ? ( + + + API Key + + + + ) : ( + + )} + {currentFocus === "provider.model" && editing ? ( + + + Model + + + + ) : ( + + )} + {providerType === "custom" && ( + <> + {currentFocus === "provider.baseUrl" && editing ? ( + + + Base URL + + + + ) : ( + + )} + {currentFocus === "provider.modelId" && editing ? ( + + + Model ID + + + + ) : ( + + )} + + )} + + + {/* Services */} + + {currentFocus === "services.opToken" && editing ? ( + + + 1P Token + + + + ) : ( + + )} + + + {/* Network */} + + {currentFocus === "network.port" && editing ? ( + + + Port + + + + ) : ( + + )} + {currentFocus === "network.tailscaleKey" && editing ? ( + + + TS Auth Key + + + + ) : ( + + )} + {selectingTsMode ? ( + + TS Mode + o.value === tailscaleMode)} + onSelect={(item) => { + setTailscaleMode(item.value); + setSelectingTsMode(false); + }} + /> + + ) : ( + + )} + + + {/* Bootstrap / Agent Identity */} + + {currentFocus === "bootstrap.agentName" && editing ? ( + + + Agent Name + + + + ) : ( + + )} + {currentFocus === "bootstrap.agentContext" && editing ? ( + + + Agent Vibe + + + + ) : ( + + )} + {currentFocus === "bootstrap.userName" && editing ? ( + + + Your Name + + + + ) : ( + + )} + {currentFocus === "bootstrap.userContext" && editing ? ( + + + Your Context + + + + ) : ( + + )} + + + {/* Telegram */} + + {currentFocus === "telegram.botToken" && editing ? ( + + + Bot Token + + + + ) : ( + + )} + {currentFocus === "telegram.allowFrom" && editing ? ( + + + Allow From + + + + ) : ( + + )} + + + + + + {/* Action button */} + + + {currentFocus === "action" ? "\u25b8 " : " "} + [Enter] Review & Create + + + + + {/* Keybinding hints (pinned bottom) */} + + + [{"\u2191\u2193"}] navigate {"\u00b7"} [Enter] {editing ? "confirm" : "edit/expand"}{" "} + {"\u00b7"} [Esc] {editing ? "cancel" : "collapse"} {"\u00b7"} [R] review + + + + + {/* Sidebar */} + + + + + ); +} diff --git a/packages/cli/src/steps/configure.tsx b/packages/cli/src/steps/configure.tsx deleted file mode 100644 index 38b0266..0000000 --- a/packages/cli/src/steps/configure.tsx +++ /dev/null @@ -1,127 +0,0 @@ -import React, { useState } from "react"; -import { Text, Box, useInput } from "ink"; -import TextInput from "ink-text-input"; -import { StepIndicator } from "../components/step-indicator.js"; -import type { VMConfig, MountSpec } from "@clawctl/types"; -import os from "os"; -import { join } from "path"; - -interface ConfigureProps { - onComplete: (config: VMConfig) => void; -} - -interface Field { - key: keyof VMConfig; - label: string; - defaultValue: string; -} - -const FIELDS: Field[] = [ - { - key: "projectDir", - label: "Project directory", - defaultValue: join(os.homedir(), "openclaw-vms/my-agent"), - }, - { key: "vmName", label: "VM name", defaultValue: "openclaw" }, - { key: "cpus", label: "CPUs", defaultValue: "4" }, - { key: "memory", label: "Memory (GiB)", defaultValue: "8GiB" }, - { key: "disk", label: "Disk (GiB)", defaultValue: "50GiB" }, -]; - -type Phase = "fields" | "mount-home"; - -export function Configure({ onComplete }: ConfigureProps) { - const [phase, setPhase] = useState("fields"); - const [fieldIndex, setFieldIndex] = useState(0); - const [values, setValues] = useState>({}); - const [currentValue, setCurrentValue] = useState(FIELDS[0].defaultValue); - const [mountHome, setMountHome] = useState(undefined); - - const currentField = FIELDS[fieldIndex]; - - function buildConfig(newValues: Record, extraMounts?: MountSpec[]): VMConfig { - const config: VMConfig = { - projectDir: newValues.projectDir!.replace(/^~/, os.homedir()), - vmName: newValues.vmName!, - cpus: parseInt(newValues.cpus!, 10), - memory: newValues.memory!, - disk: newValues.disk!, - }; - if (extraMounts) config.extraMounts = extraMounts; - return config; - } - - function handleSubmit(value: string) { - const finalValue = value || currentField.defaultValue; - const newValues = { ...values, [currentField.key]: finalValue }; - setValues(newValues); - - if (fieldIndex < FIELDS.length - 1) { - const nextIndex = fieldIndex + 1; - setFieldIndex(nextIndex); - setCurrentValue(FIELDS[nextIndex].defaultValue); - } else { - setPhase("mount-home"); - } - } - - useInput((input, key) => { - if (phase !== "mount-home") return; - - if (input.toLowerCase() === "y") { - setMountHome(true); - onComplete(buildConfig(values, [{ location: "~", mountPoint: "/mnt/host" }])); - } else if (input.toLowerCase() === "n" || key.return) { - setMountHome(false); - onComplete(buildConfig(values)); - } - }); - - return ( - - - - - {/* Show already-answered fields */} - {FIELDS.slice(0, fieldIndex).map((field) => ( - - {field.label}: {values[field.key]} - - ))} - - {/* Current field (only during fields phase) */} - {phase === "fields" && ( - - - ? {currentField.label}:{" "} - - - - )} - - {/* Show all fields as completed when in mount-home phase */} - {phase === "mount-home" && ( - <> - - {FIELDS[FIELDS.length - 1].label}:{" "} - {values[FIELDS[FIELDS.length - 1].key]} - - - {mountHome === undefined && ( - - ? Mount home directory in VM? (read-only){" "} - [y/N] - - )} - {mountHome === true && ( - - Home directory will be mounted at /mnt/host - - )} - {mountHome === false && ○ Home directory not mounted} - - )} - - - ); -} diff --git a/packages/cli/src/steps/create-vm.tsx b/packages/cli/src/steps/create-vm.tsx deleted file mode 100644 index a7049e3..0000000 --- a/packages/cli/src/steps/create-vm.tsx +++ /dev/null @@ -1,92 +0,0 @@ -import React, { useState, useEffect } from "react"; -import { Text, Box } from "ink"; -import { StepIndicator } from "../components/step-indicator.js"; -import { ProcessOutput } from "../components/process-output.js"; -import { Spinner } from "../components/spinner.js"; -import type { VMConfig } from "@clawctl/types"; -import type { VMDriver, ProvisionFeatures } from "@clawctl/host-core"; -import { useVerbose } from "../hooks/verbose-context.js"; -import { useProcessLogs } from "../hooks/use-process-logs.js"; -import { provisionVM } from "@clawctl/host-core"; - -interface CreateVMProps { - driver: VMDriver; - config: VMConfig; - provisionFeatures: ProvisionFeatures; - onComplete: () => void; -} - -type Phase = "project-setup" | "generating" | "creating-vm" | "provisioning" | "done" | "error"; - -export function CreateVM({ driver, config, provisionFeatures, onComplete }: CreateVMProps) { - const verbose = useVerbose(); - const { lines: processLogs, addLine: addProcessLog } = useProcessLogs(); - const [phase, setPhase] = useState("project-setup"); - const [error, setError] = useState(); - const [completedSteps, setCompletedSteps] = useState([]); - - useEffect(() => { - async function run() { - try { - await provisionVM( - driver, - config, - { - onPhase: (p) => setPhase(p as Phase), - onStep: (step) => setCompletedSteps((prev) => [...prev, step]), - onLine: addProcessLog, - }, - { extraMounts: config.extraMounts }, - undefined, - provisionFeatures, - ); - - setTimeout(() => onComplete(), 500); - } catch (err) { - setError(err instanceof Error ? err.message : String(err)); - setPhase("error"); - } - } - run(); - }, []); - - return ( - - - - - {completedSteps.map((step, i) => ( - - {step} - - ))} - - {phase === "project-setup" && } - {phase === "generating" && } - {phase === "creating-vm" && ( - - )} - {phase === "provisioning" && ( - - )} - {phase === "error" && ✗ Error: {error}} - - - ); -} diff --git a/packages/cli/src/steps/credential-setup.tsx b/packages/cli/src/steps/credential-setup.tsx deleted file mode 100644 index f694ef8..0000000 --- a/packages/cli/src/steps/credential-setup.tsx +++ /dev/null @@ -1,110 +0,0 @@ -import React, { useState, useEffect } from "react"; -import { Text, Box } from "ink"; -import { StepIndicator } from "../components/step-indicator.js"; -import { ProcessOutput } from "../components/process-output.js"; -import { useVerbose } from "../hooks/verbose-context.js"; -import { useProcessLogs } from "../hooks/use-process-logs.js"; -import type { VMConfig } from "@clawctl/types"; -import type { VMDriver } from "@clawctl/host-core"; -import { setupOnePassword, connectTailscaleInteractive } from "@clawctl/host-core"; -import type { CredentialConfig } from "../types.js"; - -interface CredentialSetupProps { - driver: VMDriver; - config: VMConfig; - credentialConfig: CredentialConfig; - onComplete: (creds: CredentialConfig) => void; -} - -type Phase = "setting-up" | "done"; - -export function CredentialSetup({ - driver, - config, - credentialConfig, - onComplete, -}: CredentialSetupProps) { - const verbose = useVerbose(); - const { lines: processLogs, addLine: addLog } = useProcessLogs(); - const [phase, setPhase] = useState("setting-up"); - const [error, setError] = useState(); - const [completedSteps, setCompletedSteps] = useState([]); - - const hasOp = !!credentialConfig.opToken; - const hasTs = !!credentialConfig.tailscaleAuthKey; - - useEffect(() => { - async function run() { - // Validate 1Password token in VM - if (hasOp) { - try { - const result = await setupOnePassword( - driver, - config.vmName, - credentialConfig.opToken!, - addLog, - ); - if (result.valid) { - setCompletedSteps((prev) => [ - ...prev, - `1Password validated (${result.account || "ok"})`, - ]); - } else { - setError(`1Password: ${result.error || "validation failed"}`); - } - } catch (err) { - setError(`1Password: ${err instanceof Error ? err.message : String(err)}`); - } - } - - // Connect Tailscale in VM - if (hasTs) { - try { - const result = await connectTailscaleInteractive(driver, config.vmName, addLog); - if (result.connected) { - setCompletedSteps((prev) => [ - ...prev, - `Tailscale connected as ${result.hostname || "ok"}`, - ]); - } else { - setError(`Tailscale: connection failed`); - } - } catch (err) { - setError(`Tailscale: ${err instanceof Error ? err.message : String(err)}`); - } - } - - if (!hasOp && !hasTs) { - setCompletedSteps(["No credentials to set up"]); - } - - setPhase("done"); - setTimeout(() => onComplete(credentialConfig), 500); - } - run(); - }, []); - - return ( - - - - - {completedSteps.map((step, i) => ( - - {step} - - ))} - - {phase === "setting-up" && ( - - )} - - {error && {error}} - - - ); -} diff --git a/packages/cli/src/steps/credentials.tsx b/packages/cli/src/steps/credentials.tsx deleted file mode 100644 index 1f901b0..0000000 --- a/packages/cli/src/steps/credentials.tsx +++ /dev/null @@ -1,129 +0,0 @@ -import React, { useState } from "react"; -import { Text, Box, useInput } from "ink"; -import TextInput from "ink-text-input"; -import { StepIndicator } from "../components/step-indicator.js"; -import type { CredentialConfig } from "../types.js"; - -interface CredentialsProps { - onComplete: (creds: CredentialConfig) => void; -} - -type Phase = "ask-op" | "op-token" | "ask-tailscale" | "ask-tailscale-mode" | "done"; - -export function Credentials({ onComplete }: CredentialsProps) { - const [phase, setPhase] = useState("ask-op"); - const [opToken, setOpToken] = useState(""); - const [skipOp, setSkipOp] = useState(false); - const [skipTs, setSkipTs] = useState(false); - const [tsMode, setTsMode] = useState<"serve" | "off">("serve"); - const [wantTs, setWantTs] = useState(false); - - useInput((input, key) => { - if (phase === "ask-op") { - if (input.toLowerCase() === "y" || key.return) { - setPhase("op-token"); - } else if (input.toLowerCase() === "n") { - setSkipOp(true); - setPhase("ask-tailscale"); - } - } - if (phase === "ask-tailscale") { - if (input.toLowerCase() === "y" || key.return) { - setWantTs(true); - setPhase("ask-tailscale-mode"); - } else if (input.toLowerCase() === "n") { - setSkipTs(true); - finishWithCreds(false); - } - } - if (phase === "ask-tailscale-mode") { - if (input === "1" || key.return) { - setTsMode("serve"); - finishWithCreds(true, "serve"); - } else if (input === "2") { - setTsMode("off"); - finishWithCreds(true, "off"); - } - } - }); - - function finishWithCreds(withTs: boolean = false, mode?: "serve" | "off") { - setPhase("done"); - const creds: CredentialConfig = {}; - if (opToken) creds.opToken = opToken; - if (withTs) { - creds.tailscaleAuthKey = "interactive"; - creds.tailscaleMode = mode; - } - setTimeout(() => onComplete(creds), 300); - } - - return ( - - - - - {/* 1Password section */} - {phase === "ask-op" && ( - - ? Set up 1Password? [Y/n] - - )} - {phase === "op-token" && ( - - - ? OP_SERVICE_ACCOUNT_TOKEN:{" "} - - { - setOpToken(val); - setPhase("ask-tailscale"); - }} - mask="*" - /> - - )} - {opToken && phase !== "ask-op" && phase !== "op-token" && ( - - 1Password token collected (will validate after VM creation) - - )} - {skipOp && ○ 1Password setup skipped (can configure later)} - - {/* Tailscale section */} - {phase === "ask-tailscale" && ( - <> - - - ? Set up Tailscale? [Y/n] - - - )} - {phase === "ask-tailscale-mode" && ( - - - ? Tailscale gateway mode: - - - {" "} - [1] Serve — HTTPS on your tailnet, tokenless dashboard{" "} - (recommended) - - - {" "} - [2] Off — raw Tailscale IP only, no HTTPS proxy - - - )} - {phase === "done" && wantTs && ( - - Tailscale: {tsMode} mode (will connect after VM creation) - - )} - {skipTs && ○ Tailscale setup skipped (can configure later)} - - - ); -} diff --git a/packages/cli/src/steps/finish.tsx b/packages/cli/src/steps/finish.tsx deleted file mode 100644 index a7fe9a4..0000000 --- a/packages/cli/src/steps/finish.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import React, { useEffect, useRef } from "react"; -import { Text, Box, useApp } from "ink"; -import { StepIndicator } from "../components/step-indicator.js"; -import type { VMConfig } from "@clawctl/types"; -import { GATEWAY_PORT } from "@clawctl/types"; -import { BIN_NAME } from "@clawctl/host-core"; - -export interface FinishResult { - action: "finish"; - vmName: string; - projectDir: string; - tailscaleMode?: "off" | "serve" | "funnel"; -} - -interface FinishProps { - config: VMConfig; - onboardSkipped?: boolean; - tailscaleMode?: "off" | "serve" | "funnel"; -} - -export function Finish({ config, onboardSkipped, tailscaleMode }: FinishProps) { - const { exit } = useApp(); - const exited = useRef(false); - - useEffect(() => { - if (!exited.current) { - exited.current = true; - exit({ - action: "finish", - vmName: config.vmName, - projectDir: config.projectDir, - tailscaleMode, - } as FinishResult); - } - }, []); - return ( - - - - - - {onboardSkipped - ? "Your OpenClaw gateway is ready. Onboarding was skipped." - : "Your OpenClaw gateway is ready!"} - - - - - {onboardSkipped ? ( - <> - # Run OpenClaw onboarding - {BIN_NAME} oc onboard --install-daemon - - - ) : ( - <> - # Access dashboard - http://localhost:{GATEWAY_PORT} - - - )} - - # Enter the VM - {BIN_NAME} shell - - - # Enable tab completions: {BIN_NAME} completions --help - - - ); -} diff --git a/packages/cli/src/steps/host-setup.tsx b/packages/cli/src/steps/host-setup.tsx deleted file mode 100644 index 3e094e1..0000000 --- a/packages/cli/src/steps/host-setup.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import React, { useState, useEffect } from "react"; -import { Text, Box } from "ink"; -import { StepIndicator } from "../components/step-indicator.js"; -import { ProcessOutput } from "../components/process-output.js"; -import { useVerbose } from "../hooks/verbose-context.js"; -import { useProcessLogs } from "../hooks/use-process-logs.js"; -import type { VMDriver } from "@clawctl/host-core"; -import type { PrereqStatus } from "../types.js"; - -interface HostSetupProps { - driver: VMDriver; - prereqs: PrereqStatus; - onComplete: (updatedPrereqs: PrereqStatus) => void; -} - -export function HostSetup({ driver, prereqs, onComplete }: HostSetupProps) { - const verbose = useVerbose(); - const { lines: processLogs, addLine } = useProcessLogs(); - const [status, setStatus] = useState<"installing" | "done" | "error">( - prereqs.hasVMBackend ? "done" : "installing", - ); - const [backendVersion, setBackendVersion] = useState(prereqs.vmBackendVersion); - const [error, setError] = useState(); - - useEffect(() => { - if (prereqs.hasVMBackend) { - setTimeout(() => onComplete({ ...prereqs }), 300); - return; - } - - async function setup() { - try { - const version = await driver.install(addLine); - setBackendVersion(version); - setStatus("done"); - setTimeout( - () => - onComplete({ - ...prereqs, - hasVMBackend: true, - vmBackendVersion: version, - }), - 500, - ); - } catch (err) { - setError(err instanceof Error ? err.message : String(err)); - setStatus("error"); - } - } - setup(); - }, []); - - return ( - - - - - {status === "installing" && ( - - )} - {status === "done" && ( - - Lima {backendVersion} installed - - )} - {status === "error" && ✗ Failed to install Lima: {error}} - - - ); -} diff --git a/packages/cli/src/steps/onboard.tsx b/packages/cli/src/steps/onboard.tsx deleted file mode 100644 index ce433f8..0000000 --- a/packages/cli/src/steps/onboard.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import React, { useState } from "react"; -import { Text, Box, useApp, useInput } from "ink"; -import { StepIndicator } from "../components/step-indicator.js"; -import type { VMConfig } from "@clawctl/types"; - -interface OnboardProps { - config: VMConfig; - tailscaleMode?: "off" | "serve" | "funnel"; - onComplete: (skipped: boolean) => void; -} - -export interface OnboardResult { - action: "onboard"; - vmName: string; - projectDir: string; - tailscaleMode?: "off" | "serve" | "funnel"; -} - -export function Onboard({ config, tailscaleMode, onComplete }: OnboardProps) { - const { exit } = useApp(); - const [launched, setLaunched] = useState(false); - - useInput((input, key) => { - if (launched) return; - if (key.return) { - setLaunched(true); - exit({ - action: "onboard", - vmName: config.vmName, - projectDir: config.projectDir, - tailscaleMode, - } as OnboardResult); - } else if (input.toLowerCase() === "s") { - onComplete(true); - } - }); - - return ( - - - - - OpenClaw's onboarding wizard will configure your instance. - - It runs interactively inside the VM — you'll answer a few questions about your provider, - API keys, and preferences. - - - - [Enter] Start onboarding{" "} - [s] Skip (do it later) - - - - ); -} diff --git a/packages/cli/src/steps/prereq-check.tsx b/packages/cli/src/steps/prereq-check.tsx new file mode 100644 index 0000000..12276f2 --- /dev/null +++ b/packages/cli/src/steps/prereq-check.tsx @@ -0,0 +1,124 @@ +import React, { useState, useEffect } from "react"; +import { Text, Box } from "ink"; +import { Spinner } from "../components/spinner.js"; +import { ProcessOutput } from "../components/process-output.js"; +import { useVerbose } from "../hooks/verbose-context.js"; +import { useProcessLogs } from "../hooks/use-process-logs.js"; +import { checkPrereqs } from "@clawctl/host-core"; +import type { VMDriver } from "@clawctl/host-core"; + +type Phase = "checking" | "installing" | "done" | "error"; + +interface PrereqCheckProps { + driver: VMDriver; + onComplete: () => void; +} + +export function PrereqCheck({ driver, onComplete }: PrereqCheckProps) { + const verbose = useVerbose(); + const { lines: installLogs, addLine } = useProcessLogs(); + const [phase, setPhase] = useState("checking"); + const [isMacOS, setIsMacOS] = useState(false); + const [isArm64, setIsArm64] = useState(false); + const [hasHomebrew, setHasHomebrew] = useState(false); + const [hasVMBackend, setHasVMBackend] = useState(false); + const [vmVersion, setVmVersion] = useState(); + const [error, setError] = useState(); + + useEffect(() => { + async function run() { + const prereqs = await checkPrereqs(driver); + setIsMacOS(prereqs.isMacOS); + setIsArm64(prereqs.isArm64); + setHasHomebrew(prereqs.hasHomebrew); + setHasVMBackend(prereqs.hasVMBackend); + setVmVersion(prereqs.vmBackendVersion); + + if (!prereqs.isMacOS || !prereqs.hasHomebrew) { + setError(!prereqs.isMacOS ? "macOS is required" : "Homebrew is required (https://brew.sh)"); + setPhase("error"); + return; + } + + if (!prereqs.hasVMBackend) { + setPhase("installing"); + try { + const version = await driver.install(addLine); + setHasVMBackend(true); + setVmVersion(version); + setPhase("done"); + setTimeout(() => onComplete(), 500); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + setPhase("error"); + } + return; + } + + setPhase("done"); + setTimeout(() => onComplete(), 500); + } + run(); + }, []); + + const Check = ({ ok, label }: { ok: boolean; label: string }) => ( + + {ok ? "\u2713" : "\u2717"} {label} + + ); + + return ( + + + + clawctl + Set up an OpenClaw VM in minutes + + + + {phase === "checking" && } + + {phase !== "checking" && ( + + + + + + )} + + {phase === "installing" && ( + + + + )} + + {phase === "error" && ( + + + {"\u2717"} {error} + + + )} + + {/* Spacer pushes help text to bottom */} + {phase !== "installing" && } + + + Press [v] to {verbose ? "hide" : "show"} process logs + + + ); +} diff --git a/packages/cli/src/steps/provision-status.tsx b/packages/cli/src/steps/provision-status.tsx deleted file mode 100644 index 7b5d1b0..0000000 --- a/packages/cli/src/steps/provision-status.tsx +++ /dev/null @@ -1,92 +0,0 @@ -import React, { useState, useEffect } from "react"; -import { Text, Box } from "ink"; -import { StepIndicator } from "../components/step-indicator.js"; -import { LogOutput } from "../components/log-output.js"; -import { useVerbose } from "../hooks/verbose-context.js"; -import { useProcessLogs } from "../hooks/use-process-logs.js"; -import type { VMConfig } from "@clawctl/types"; -import type { VMDriver } from "@clawctl/host-core"; -import type { ProvisioningStep } from "../types.js"; -import { verifyProvisioning } from "@clawctl/host-core"; - -interface ProvisionStatusProps { - driver: VMDriver; - config: VMConfig; - onComplete: () => void; -} - -export function ProvisionStatus({ driver, config, onComplete }: ProvisionStatusProps) { - const verbose = useVerbose(); - const { lines: processLogs, addLine: addLog } = useProcessLogs(); - const [steps, setSteps] = useState([ - { label: "Node.js 22", status: "pending" }, - { label: "Tailscale", status: "pending" }, - { label: "Homebrew", status: "pending" }, - { label: "1Password CLI", status: "pending" }, - { label: "OpenClaw", status: "pending" }, - ]); - const [error, setError] = useState(); - - useEffect(() => { - async function verify() { - try { - // Mark all as running - setSteps((prev) => prev.map((s) => ({ ...s, status: "running" as const }))); - - const results = await verifyProvisioning(driver, config.vmName, addLog); - - const newSteps: ProvisioningStep[] = results.map((r) => ({ - label: r.label, - status: r.passed ? ("done" as const) : ("error" as const), - error: r.error, - })); - setSteps(newSteps); - - const allPassed = results.every((r) => r.passed); - if (allPassed) { - setTimeout(() => onComplete(), 500); - } else { - setError("Some tools failed to install. Re-run provisioning or check VM logs."); - } - } catch (err) { - setError(err instanceof Error ? err.message : String(err)); - } - } - verify(); - }, []); - - const StatusIcon = ({ s }: { s: ProvisioningStep }) => { - switch (s.status) { - case "done": - return ; - case "error": - return ; - case "running": - return ; - default: - return ; - } - }; - - return ( - - - - - {steps.map((step, i) => ( - - - - {" "} - {step.label} - {step.status === "done" && installed} - {step.error && — {step.error}} - - - ))} - {verbose && processLogs.length > 0 && } - {error && Error: {error}} - - - ); -} diff --git a/packages/cli/src/steps/welcome.tsx b/packages/cli/src/steps/welcome.tsx deleted file mode 100644 index 2dba9e6..0000000 --- a/packages/cli/src/steps/welcome.tsx +++ /dev/null @@ -1,79 +0,0 @@ -import React, { useState, useEffect } from "react"; -import { Text, Box } from "ink"; -import { StepIndicator } from "../components/step-indicator.js"; -import { Spinner } from "../components/spinner.js"; -import type { VMDriver } from "@clawctl/host-core"; -import type { PrereqStatus } from "../types.js"; -import { checkPrereqs } from "@clawctl/host-core"; - -interface WelcomeProps { - driver: VMDriver; - onComplete: (prereqs: PrereqStatus) => void; -} - -export function Welcome({ driver, onComplete }: WelcomeProps) { - const [checking, setChecking] = useState(true); - const [prereqs, setPrereqs] = useState({ - isMacOS: false, - isArm64: false, - hasHomebrew: false, - hasVMBackend: false, - }); - - useEffect(() => { - async function check() { - const result = await checkPrereqs(driver); - setPrereqs(result); - setChecking(false); - - // Auto-advance after a brief pause - setTimeout(() => onComplete(result), 500); - } - check(); - }, []); - - const Check = ({ ok, label }: { ok: boolean; label: string }) => ( - - {ok ? "✓" : "✗"} {label} - - ); - - return ( - - - - clawctl - Set up an OpenClaw VM in minutes - - - - - - {checking ? ( - - ) : ( - - - - - {!prereqs.hasVMBackend && ( - - → Will install Lima via Homebrew - - )} - - )} - - ); -} diff --git a/packages/cli/src/types.ts b/packages/cli/src/types.ts index dff7f57..143cc0d 100644 --- a/packages/cli/src/types.ts +++ b/packages/cli/src/types.ts @@ -11,20 +11,3 @@ export interface ProvisioningStep { status: "pending" | "running" | "done" | "error"; error?: string; } - -export interface CredentialConfig { - opToken?: string; - tailscaleAuthKey?: string; - tailscaleMode?: "off" | "serve" | "funnel"; -} - -export type WizardStep = - | "welcome" - | "configure" - | "credentials" - | "host-setup" - | "create-vm" - | "provision" - | "credential-setup" - | "onboard" - | "finish"; diff --git a/packages/host-core/src/claw-binary.ts b/packages/host-core/src/claw-binary.ts index 3286afd..3132613 100644 --- a/packages/host-core/src/claw-binary.ts +++ b/packages/host-core/src/claw-binary.ts @@ -1,3 +1,4 @@ +// @ts-expect-error — Bun `with { type: "file" }` import resolves to a string path at runtime import embeddedClawPath from "../../../dist/claw" with { type: "file" }; import { readFileSync, writeFileSync, mkdtempSync } from "node:fs"; import { join } from "node:path"; diff --git a/packages/host-core/src/headless.ts b/packages/host-core/src/headless.ts index b620e61..2d04ca0 100644 --- a/packages/host-core/src/headless.ts +++ b/packages/host-core/src/headless.ts @@ -11,6 +11,7 @@ import { syncSecretsToVM, writeEnvSecrets } from "./secrets-sync.js"; import { bootstrapOpenclaw } from "./bootstrap.js"; import { cleanupVM, onSignalCleanup } from "./cleanup.js"; import { GATEWAY_PORT } from "@clawctl/types"; +import type { InstanceConfig } from "@clawctl/types"; import { BIN_NAME } from "./bin-name.js"; import type { VMDriver } from "./drivers/types.js"; @@ -26,20 +27,60 @@ export interface HeadlessResult { tailscaleUrl?: string; } -function log(prefix: string, message: string) { - console.log(`[${prefix}] ${message}`); +export type HeadlessStage = + | "prereqs" + | "provision" + | "verify" + | "onepassword" + | "secrets" + | "tailscale" + | "bootstrap" + | "done"; + +export type StageStatus = "pending" | "running" | "done" | "error"; + +export interface HeadlessCallbacks { + onStage?: (stage: HeadlessStage, status: StageStatus, detail?: string) => void; + onStep?: (label: string) => void; + onLine?: (prefix: string, message: string) => void; + onError?: (stage: HeadlessStage, error: string) => void; +} + +function defaultCallbacks(): Required { + return { + onStage: (stage, status, detail) => { + if (status === "running") console.log(`[${stage}] ${detail ?? "Starting..."}`); + else if (status === "done") console.log(`[${stage}] ${detail ?? "Done"}`); + }, + onStep: (label) => console.log(` ✓ ${label}`), + onLine: (prefix, message) => console.log(`[${prefix}] ${message}`), + onError: (stage, error) => console.log(`[${stage}] ✗ ${error}`), + }; } /** Run headless VM creation from a config file. */ export async function runHeadless(driver: VMDriver, configPath: string): Promise { - // 1. Load config - log("config", `Loading ${configPath}`); - let config = await loadConfig(configPath); - const vmConfig = configToVMConfig(config); - log("config", `Instance: ${config.name} → ${config.project}`); + const cb = defaultCallbacks(); + cb.onLine("config", `Loading ${configPath}`); + const config = await loadConfig(configPath); + cb.onLine("config", `Instance: ${config.name} → ${config.project}`); + return runHeadlessFromConfig(driver, config); +} - // 2. Check prerequisites - log("prereqs", "Checking host prerequisites..."); +/** Run headless VM creation from an in-memory InstanceConfig. */ +export async function runHeadlessFromConfig( + driver: VMDriver, + inputConfig: InstanceConfig, + callbacks?: HeadlessCallbacks, +): Promise { + let config = inputConfig; + const cb: Required = { + ...defaultCallbacks(), + ...callbacks, + }; + + // 1. Check prerequisites + cb.onStage("prereqs", "running", "Checking host prerequisites..."); const prereqs = await checkPrereqs(driver); if (!prereqs.isMacOS) throw new Error("macOS is required"); @@ -47,16 +88,18 @@ export async function runHeadless(driver: VMDriver, configPath: string): Promise if (!prereqs.hasHomebrew) throw new Error("Homebrew is required (https://brew.sh)"); if (!prereqs.hasVMBackend) { - log("prereqs", "Lima not found, installing via Homebrew..."); - const version = await driver.install((line: string) => log("brew", line)); - log("prereqs", `Lima ${version} installed`); + cb.onLine("prereqs", "Lima not found, installing via Homebrew..."); + const version = await driver.install((line: string) => cb.onLine("brew", line)); + cb.onLine("prereqs", `Lima ${version} installed`); } else { - log("prereqs", `Lima ${prereqs.vmBackendVersion} found`); + cb.onLine("prereqs", `Lima ${prereqs.vmBackendVersion} found`); } + cb.onStage("prereqs", "done", `Lima ${prereqs.vmBackendVersion ?? "installed"}`); + + const vmConfig = configToVMConfig(config); - // Steps 3–7 create resources that need cleanup on failure. - // Register signal handlers so Ctrl+C also triggers cleanup. - const cleanupLog = (msg: string) => log("cleanup", msg); + // Signal cleanup for steps 2–7 + const cleanupLog = (msg: string) => cb.onLine("cleanup", msg); const removeSignalHandlers = onSignalCleanup( driver, () => ({ vmName: vmConfig.vmName, projectDir: vmConfig.projectDir }), @@ -64,8 +107,8 @@ export async function runHeadless(driver: VMDriver, configPath: string): Promise ); try { - // 3. Provision VM - log("provision", "Starting VM provisioning..."); + // 2. Provision VM + cb.onStage("provision", "running", "Starting VM provisioning..."); const provisionFeatures = { onePassword: !!config.services?.onePassword || !!config.capabilities?.["one-password"], tailscale: !!config.network?.tailscale || !!config.capabilities?.tailscale, @@ -74,9 +117,9 @@ export async function runHeadless(driver: VMDriver, configPath: string): Promise driver, vmConfig, { - onPhase: (phase: string) => log("provision", `Phase: ${phase}`), - onStep: (step: string) => log("provision", `✓ ${step}`), - onLine: (line: string) => log("vm", line), + onPhase: (phase: string) => cb.onLine("provision", `Phase: ${phase}`), + onStep: (step: string) => cb.onStep(step), + onLine: (line: string) => cb.onLine("vm", line), }, { forwardGateway: config.network?.forwardGateway ?? true, @@ -86,9 +129,10 @@ export async function runHeadless(driver: VMDriver, configPath: string): Promise undefined, provisionFeatures, ); + cb.onStage("provision", "done", "VM provisioned"); - // 4. Verify provisioning - log("verify", "Verifying installed tools..."); + // 3. Verify provisioning + cb.onStage("verify", "running", "Verifying installed tools..."); const results = await verifyProvisioning( driver, vmConfig.vmName, @@ -98,42 +142,44 @@ export async function runHeadless(driver: VMDriver, configPath: string): Promise const errors: string[] = []; for (const r of results) { if (r.passed) { - log("verify", `✓ ${r.label}`); + cb.onStep(`${r.label}`); } else if (r.warn) { const reason = r.availableAfter ? `expected after ${r.availableAfter}` : "warning"; - log("verify", `⚠ ${r.label}: ${r.error} (${reason})`); + cb.onLine("verify", `⚠ ${r.label}: ${r.error} (${reason})`); } else { - log("verify", `✗ ${r.label}: ${r.error}`); + cb.onError("verify", `${r.label}: ${r.error}`); errors.push(r.label); } } if (errors.length > 0) { + cb.onStage("verify", "error", "Some tools failed to install"); throw new Error("Some tools failed to install — check logs above"); } + cb.onStage("verify", "done", "All tools verified"); - // 5. Services: 1Password + // 4. Services: 1Password if (config.services?.onePassword) { - log("1password", "Setting up 1Password..."); + cb.onStage("onepassword", "running", "Setting up 1Password..."); const opResult = await setupOnePassword( driver, vmConfig.vmName, config.services.onePassword.serviceAccountToken, - (line: string) => log("1password", line), + (line: string) => cb.onLine("1password", line), ); if (opResult.valid) { - log("1password", `✓ Token validated (${opResult.account})`); + cb.onStage("onepassword", "done", `Token validated (${opResult.account})`); } else { - log("1password", `✗ ${opResult.error}`); + cb.onStage("onepassword", "error", opResult.error); + cb.onError("onepassword", opResult.error ?? "Token validation failed"); throw new Error("1Password setup failed"); } } - // 5.5. Resolve op:// secret references in the VM + // 4.5. Resolve op:// secret references let resolvedMap: ResolvedSecretRef[] | undefined; if (hasOpRefs(config as unknown as Record)) { - log("secrets", "Resolving op:// secret references..."); + cb.onStage("secrets", "running", "Resolving op:// secret references..."); - // Capture original op:// refs before resolution const opRefs = findSecretRefs(config as unknown as Record).filter( (r) => r.scheme === "op", ); @@ -142,78 +188,71 @@ export async function runHeadless(driver: VMDriver, configPath: string): Promise driver, vmConfig.vmName, config as unknown as Record, - (line: string) => log("secrets", line), + (line: string) => cb.onLine("secrets", line), ); - // Build resolved map pairing each ref with its resolved value resolvedMap = opRefs.map((ref) => ({ ...ref, resolvedValue: getNestedValue(resolved, ref.path) as string, })); config = resolved as unknown as typeof config; - log("secrets", "All references resolved"); + cb.onStage("secrets", "done", "All references resolved"); - // Sync infrastructure secrets to VM and host await syncSecretsToVM(driver, vmConfig.vmName, resolvedMap, (line: string) => - log("secrets", line), + cb.onLine("secrets", line), + ); + await writeEnvSecrets(config.project, resolvedMap, (line: string) => + cb.onLine("secrets", line), ); - await writeEnvSecrets(config.project, resolvedMap, (line: string) => log("secrets", line)); } - // 6. Network: Tailscale + // 5. Network: Tailscale if (config.network?.tailscale) { - log("tailscale", "Connecting to Tailscale..."); + cb.onStage("tailscale", "running", "Connecting to Tailscale..."); const tsResult = await setupTailscale( driver, vmConfig.vmName, config.network.tailscale.authKey, - (line: string) => log("tailscale", line), + (line: string) => cb.onLine("tailscale", line), ); if (tsResult.connected) { - log("tailscale", `✓ Connected as ${tsResult.hostname}`); + cb.onStage("tailscale", "done", `Connected as ${tsResult.hostname}`); } else { - log("tailscale", `✗ ${tsResult.error}`); + cb.onStage("tailscale", "error", tsResult.error); + cb.onError("tailscale", tsResult.error ?? "Connection failed"); throw new Error("Tailscale connection failed"); } } - // 7. Bootstrap openclaw (if provider configured) + // 6. Bootstrap openclaw (if provider configured) const hostPort = config.network?.gatewayPort ?? GATEWAY_PORT; let gatewayToken: string | undefined; let dashboardUrl: string | undefined; let tailscaleUrl: string | undefined; if (config.provider) { - log("bootstrap", "Running openclaw onboard..."); + cb.onStage("bootstrap", "running", "Running openclaw onboard..."); const result = await bootstrapOpenclaw( driver, vmConfig.vmName, config, - (line: string) => log("bootstrap", line), + (line: string) => cb.onLine("bootstrap", line), resolvedMap, ); gatewayToken = result.gatewayToken; dashboardUrl = result.dashboardUrl; tailscaleUrl = result.tailscaleUrl; - log("bootstrap", `Gateway token: ${result.gatewayToken}`); if (!result.doctorPassed) { - log("bootstrap", "Warning: openclaw doctor reported issues"); - } - log("done", `Instance "${config.name}" is ready`); - log("done", `Dashboard: ${result.dashboardUrl}/#token=${result.gatewayToken}`); - if (tailscaleUrl) { - log("done", `Tailscale: ${tailscaleUrl}`); - log("done", "First tailnet connection requires device approval:"); - log("done", ` ${BIN_NAME} oc devices list`); - log("done", ` ${BIN_NAME} oc devices approve `); + cb.onLine("bootstrap", "Warning: openclaw doctor reported issues"); } + cb.onStage("bootstrap", "done", "OpenClaw bootstrapped"); } else { - log("done", `Instance "${config.name}" is ready`); - log("done", `Enter VM: ${BIN_NAME} shell`); - log("done", `No provider configured — run '${BIN_NAME} oc onboard' when ready`); + cb.onLine("done", `No provider configured — run '${BIN_NAME} oc onboard' when ready`); } + cb.onStage("done", "done", `Instance "${config.name}" is ready`); + // Write clawctl.json (sanitized config) to project dir const sanitized = sanitizeConfig(config); await writeFile( @@ -233,7 +272,7 @@ export async function runHeadless(driver: VMDriver, configPath: string): Promise tailscaleUrl, }; } catch (err) { - log("error", "Provisioning failed, cleaning up..."); + cb.onLine("error", "Provisioning failed, cleaning up..."); await cleanupVM(driver, vmConfig.vmName, vmConfig.projectDir, cleanupLog); throw err; } finally { diff --git a/packages/host-core/src/index.ts b/packages/host-core/src/index.ts index 6a1865c..f369722 100644 --- a/packages/host-core/src/index.ts +++ b/packages/host-core/src/index.ts @@ -45,7 +45,7 @@ export { checkPrereqs } from "./prereqs.js"; export type { PrereqStatus } from "./prereqs.js"; // Credentials -export { setupOnePassword, setupTailscale, connectTailscaleInteractive } from "./credentials.js"; +export { setupOnePassword, setupTailscale } from "./credentials.js"; export type { OpResult, TailscaleResult } from "./credentials.js"; // Secrets sync @@ -107,5 +107,5 @@ export { cleanupVM, onSignalCleanup } from "./cleanup.js"; export type { CleanupTarget } from "./cleanup.js"; // Headless -export { runHeadless } from "./headless.js"; -export type { HeadlessResult } from "./headless.js"; +export { runHeadless, runHeadlessFromConfig } from "./headless.js"; +export type { HeadlessResult, HeadlessCallbacks, HeadlessStage, StageStatus } from "./headless.js"; diff --git a/packages/templates/src/completions/bash.ts b/packages/templates/src/completions/bash.ts index ba191f3..2278d1d 100644 --- a/packages/templates/src/completions/bash.ts +++ b/packages/templates/src/completions/bash.ts @@ -97,7 +97,7 @@ export function generateBashCompletion(binName: string): string { case "$cmd" in create) - COMPREPLY=( $(compgen -W "--config --help" -- "$cur") ) + COMPREPLY=( $(compgen -W "--config --plain --help" -- "$cur") ) ;; list) COMPREPLY=( $(compgen -W "--help" -- "$cur") ) diff --git a/packages/templates/src/completions/completions.test.ts b/packages/templates/src/completions/completions.test.ts index 42efe5a..c5e28bc 100644 --- a/packages/templates/src/completions/completions.test.ts +++ b/packages/templates/src/completions/completions.test.ts @@ -57,6 +57,7 @@ describe("generateBashCompletion", () => { test("includes per-command options", () => { expect(script).toContain("--config"); + expect(script).toContain("--plain"); expect(script).toContain("--purge"); expect(script).toContain("--global"); expect(script).toContain("--instance"); @@ -142,6 +143,7 @@ describe("generateZshCompletion", () => { test("includes per-command options", () => { expect(script).toContain("--config"); + expect(script).toContain("--plain"); expect(script).toContain("--purge"); expect(script).toContain("--global"); expect(script).toContain("--instance"); diff --git a/packages/templates/src/completions/zsh.ts b/packages/templates/src/completions/zsh.ts index 6ef2347..41a54e5 100644 --- a/packages/templates/src/completions/zsh.ts +++ b/packages/templates/src/completions/zsh.ts @@ -105,7 +105,8 @@ export function generateZshCompletion(binName: string): string { case \${words[1]} in create) _arguments ${BS} - '--config[Config file for headless mode]:config file:_files' ${BS} + '--config[Config file (skips wizard, shows TUI progress)]:config file:_files' ${BS} + '--plain[Plain log output instead of TUI (for CI/automation)]' ${BS} '--help[Show help]' ;; list) diff --git a/packages/types/src/constants.ts b/packages/types/src/constants.ts index 4123a66..a0106b9 100644 --- a/packages/types/src/constants.ts +++ b/packages/types/src/constants.ts @@ -1,4 +1,5 @@ export const PROJECT_MOUNT_POINT = "/mnt/project"; +export const DEFAULT_PROJECT_BASE = "~/openclaw-vms"; export const GATEWAY_PORT = 18789; export const CHECKPOINT_REQUEST_FILE = ".checkpoint-request"; export const PROVISION_CONFIG_FILE = "provision.json"; diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index d07aef6..3aaa056 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -40,6 +40,7 @@ export { CHECKPOINT_REQUEST_FILE, PROVISION_CONFIG_FILE, CLAW_BIN_PATH, + DEFAULT_PROJECT_BASE, LIFECYCLE_PHASES, phaseReached, } from "./constants.js"; diff --git a/packages/vm-cli/src/capabilities/state.test.ts b/packages/vm-cli/src/capabilities/state.test.ts index fd570d8..2aba61e 100644 --- a/packages/vm-cli/src/capabilities/state.test.ts +++ b/packages/vm-cli/src/capabilities/state.test.ts @@ -40,7 +40,7 @@ describe("state", () => { }); describe("findMigrationPath", () => { - const noop = async () => ({ status: "ok" as const, changed: false }); + const noop = async () => ({ name: "test", status: "unchanged" as const }); it("returns empty array when not installed", () => { const cap = makeCap("foo", "2.0.0", [{ from: "1.0.0", to: "2.0.0", run: noop }]); diff --git a/tasks/2026-03-17_0647_wizard-ui-provisioning/TASK.md b/tasks/2026-03-17_0647_wizard-ui-provisioning/TASK.md new file mode 100644 index 0000000..da42bf9 --- /dev/null +++ b/tasks/2026-03-17_0647_wizard-ui-provisioning/TASK.md @@ -0,0 +1,52 @@ +# Wizard UI → Config-First TUI with Headless Provisioning + +## Status: In Progress + +## Scope + +Replace the 9-step interactive wizard with a config-building TUI that produces an `InstanceConfig` and delegates to the existing headless pipeline. This unifies two code paths into one, covers the full schema, and provides a better UX. + +**In scope:** + +- Two-pane TUI (form + contextual sidebar) +- Collapsible sections for all InstanceConfig fields +- Inline Zod validation per field/section +- Review screen with validation summary +- Provisioning progress monitor (Ink-based, wrapping headless pipeline) +- Completion screen with URLs and next steps +- Cleanup of old wizard steps + +**Out of scope:** + +- Changing headless pipeline logic +- New capabilities +- Interactive Tailscale login (require auth key) + +## Plan + +1. Add `HeadlessCallbacks` and `runHeadlessFromConfig()` to headless.ts +2. Build TUI components: FormField, FormSection, Sidebar, ConfigReview, ProvisionMonitor, CompletionScreen +3. Build ConfigBuilder step (main form orchestrator) +4. Rewrite App.tsx (4-phase flow) and create.ts (unified path) +5. Delete old wizard steps and unused code + +## Steps + +- [ ] Create task directory and TASK.md +- [ ] Phase 1: Callbacks on headless pipeline +- [ ] Phase 2: TUI components +- [ ] Phase 3: ConfigBuilder step +- [ ] Phase 4: App.tsx and create.ts rewrite +- [ ] Phase 5: Cleanup old steps +- [ ] Validate: lint, format, tests +- [ ] Commit and push + +## Notes + +- `ink-select-input` v6 already in package.json but unused — will use for provider type, tailscale mode +- Existing Zod schemas in `packages/types/src/schemas/` can be reused directly for inline validation +- The headless pipeline already handles all provisioning stages including bootstrap — the wizard's post-exit block in create.ts is redundant + +## Outcome + +(To be filled on completion) diff --git a/tasks/2026-03-17_0921_tui-rendering-bugs/TASK.md b/tasks/2026-03-17_0921_tui-rendering-bugs/TASK.md new file mode 100644 index 0000000..92f6ef2 --- /dev/null +++ b/tasks/2026-03-17_0921_tui-rendering-bugs/TASK.md @@ -0,0 +1,64 @@ +# Fix TUI rendering bugs + +## Status: Resolved + +## Scope + +Fix three bugs in the interactive TUI create flow: + +1. **Duplicate "[v]" hint** — During the provision phase, both `App` and + `ProvisionMonitor` render a "[v] show/hide logs" hint, resulting in two + lines. +2. **Ctrl-C cleanup missing** — Ink intercepts Ctrl-C in raw mode (as a + character '\x03', not a SIGINT), so the `onSignalCleanup` process-level + handlers registered by `runHeadlessFromConfig` never fire. The VM and + project directory are left behind on interrupt. +3. **Ghost UI elements** — Ink's differential rendering leaves artifacts when + the output height shrinks between phases (e.g., two-pane config builder → + single-column provision monitor). Old content remains visible on screen. + +Does **not** cover: headless mode (already works), abort during prereqs or +config phases (no VM exists yet). + +## Plan + +1. Remove the global "[v]" hint from `app.tsx` during the provision phase + — `ProvisionMonitor` already renders its own. +2. Add an `onProvisionStart` callback prop to `App` so `runCreateWizard` + can capture the `InstanceConfig` when provisioning begins. +3. After `waitUntilExit()` in `runCreateWizard`, if the exit result isn't + a success and config was captured, compute the VM config and run + `cleanupVM`. + +## Steps + +- [x] Fix duplicate "[v]" hint in `app.tsx` +- [x] Add `onProvisionStart` callback to `App` +- [x] Add cleanup logic to `runCreateWizard` after Ink exits +- [x] Clear terminal on phase transitions to prevent ghost elements +- [x] Verify no type errors + +## Outcome + +All three bugs fixed: + +1. **Duplicate hint**: Changed `app.tsx` to only show the `[v]` hint during the + `prereqs` phase. During `provision`, `ProvisionMonitor` renders its own hint. +2. **Ctrl-C cleanup**: Added `onProvisionStart` callback to `App` so + `runCreateWizard` captures the `InstanceConfig`. After `waitUntilExit()`, if + the result isn't a success and provisioning had started, it computes the VM + config and calls `cleanupVM` to delete the VM and project directory. +3. **Ghost UI elements**: Added `useLayoutEffect` in `App` that clears the + terminal (`\x1b[2J\x1b[H`) on phase transitions. Runs before Ink writes the + new frame, so the new phase content is drawn on a clean slate. + +No new type errors introduced. + +## Notes + +- Ink with raw mode stdin converts Ctrl-C to a character, not SIGINT. + `onSignalCleanup` registers on `process.on('SIGINT')`, which never fires. +- The headless pipeline's catch-block cleanup also doesn't trigger because + no error is thrown — the promise is just abandoned when the process exits. +- We import `configToVMConfig` and `cleanupVM` in `create.ts` to compute + the target from the captured config. diff --git a/tasks/2026-03-17_1014_fullscreen-tui-layout/TASK.md b/tasks/2026-03-17_1014_fullscreen-tui-layout/TASK.md new file mode 100644 index 0000000..8eba81d --- /dev/null +++ b/tasks/2026-03-17_1014_fullscreen-tui-layout/TASK.md @@ -0,0 +1,67 @@ +# Fullscreen TUI Layout + +## Status: Resolved + +## Scope + +Make every TUI phase fill the terminal using Ink's flexbox layout. Each phase +owns the entire screen, expanding areas (logs, forms) use all available space, +and help text is pinned to the bottom. + +Covers: PrereqCheck, ConfigBuilder, ProvisionMonitor, CompletionScreen, +ConfigReview, and the App root. Does not cover headless mode. + +## Plan + +1. New `useTerminalSize` hook — wraps `useStdout()`, returns `{ rows, columns }` +2. App root gets `height={rows}` on the outer Box +3. Each phase component gets `flexGrow={1}` to fill the screen +4. LogOutput / ProcessOutput get `flexGrow={1}` to expand into available space +5. Sidebar gets `flexGrow={1}` to stretch vertically in two-column layouts +6. Help text / keybinding hints pinned to bottom via spacers +7. ProvisionMonitor computes `maxLines` dynamically from `rows` + +## Steps + +- [x] Create `hooks/use-terminal-size.ts` +- [x] Update `app.tsx` — root `height={rows}`, remove `[v]` hint (moved to PrereqCheck) +- [x] Update `log-output.tsx` — `flexGrow={1}`, `overflow="hidden"` +- [x] Update `process-output.tsx` — `flexGrow={1}` on outer Box +- [x] Update `sidebar.tsx` — `flexGrow={1}` on outer Box +- [x] Update `prereq-check.tsx` — fullscreen layout, absorb `[v]` hint, spacer +- [x] Update `config-builder.tsx` — fullscreen two-pane layout, pinned keybindings +- [x] Update `provision-monitor.tsx` — fullscreen, dynamic `maxLines` +- [x] Update `completion-screen.tsx` — fullscreen with spacer +- [x] Update `config-review.tsx` — `flexGrow={1}`, spacer before hints +- [x] Verify no type errors in CLI package + +## Outcome + +All 10 files updated. Every phase now fills the terminal: + +- **App root**: `height={rows}` from `useTerminalSize` hook constrains the + entire tree to the terminal height. Removed the `[v]` hint (now in PrereqCheck). +- **PrereqCheck**: `flexGrow={1}` fills height, spacer pushes `[v]` hint to bottom. + When installing, ProcessOutput expands to fill available space. +- **ConfigBuilder**: Outer Box is `flexGrow={1}` (horizontal), left pane's form area + gets `flexGrow={1}` with `overflow="hidden"`, keybinding hints pinned at bottom. + Sidebar stretches to match via `flexGrow={1}`. Same for review sub-phase. +- **ProvisionMonitor**: `flexGrow={1}` column layout. Log viewer expands to fill + remaining space with dynamically computed `maxLines` based on `rows`. When logs + hidden, spacer fills the gap. Help text pinned at bottom. +- **CompletionScreen**: Content at top, `flexGrow={1}` spacer pushes content up. +- **ConfigReview**: `flexGrow={1}` outer Box, spacer before keybinding hints. +- **LogOutput**: `flexGrow={1}` + `overflow="hidden"` so it fills its container. +- **ProcessOutput**: `flexGrow={1}` so it expands when used inside PrereqCheck. + +No new type errors in the CLI package. Pre-existing errors in vm-cli and +host-core packages are unrelated. + +## Notes + +- `useTerminalSize` falls back to 24×80 if stdout dimensions aren't available. + It listens for `resize` events and triggers re-render. +- The `maxLines` calculation in ProvisionMonitor accounts for header border (3), + stage list, visible steps (capped at 5), log border (3), and help text (1). + Minimum is 3 lines to avoid collapsing completely on tiny terminals. +- The error phase in App also gets a spacer to fill the screen.