diff --git a/README.md b/README.md index adc9ee5..501b8d1 100644 --- a/README.md +++ b/README.md @@ -59,3 +59,17 @@ Config is stored in `~/.config/sandlot/config.json`. | Key | Default | Description | |-----|---------|-------------| | `memory` | `16G` | Container memory limit (e.g. 4G, 16G, 32G, 64G) | +| `hosts` | `[]` | Hostnames to resolve on the host and inject into the container | + +#### Local network hosts + +The container can't resolve `.local` (mDNS) hostnames natively. To make local network hosts reachable from inside the container, add them to the `hosts` config: + +```bash +sandlot config hosts add claude.toes.local +sandlot config hosts add myserver.local +sandlot config hosts rm myserver.local +sandlot config hosts # list configured hosts +``` + +Hostnames are resolved on the Mac via mDNS and written to the container's `/etc/hosts` every time the VM starts. diff --git a/src/commands/config.ts b/src/commands/config.ts index 6ef89c5..f5189e0 100644 --- a/src/commands/config.ts +++ b/src/commands/config.ts @@ -2,14 +2,19 @@ import { die } from "../fmt.ts" import * as config from "../config.ts" const VALID_KEYS = Object.keys(config.DEFAULTS) as config.Key[] +const ARRAY_KEYS = Object.entries(config.DEFAULTS).filter(([, v]) => Array.isArray(v)).map(([k]) => k) export async function action(args: string[]) { if (args.length === 0) { const cfg = await config.load() for (const key of VALID_KEYS) { const val = cfg[key] - const display = val ?? `${config.DEFAULTS[key]} (default)` - console.log(`${key} = ${display}`) + if (Array.isArray(config.DEFAULTS[key])) { + const arr = (val as string[] | undefined) ?? [] + console.log(`${key} = ${arr.length ? arr.join(", ") : "(empty)"}`) + } else { + console.log(`${key} = ${val ?? `${config.DEFAULTS[key]} (default)`}`) + } } return } @@ -17,6 +22,32 @@ export async function action(args: string[]) { const [key, ...rest] = args if (!VALID_KEYS.includes(key as config.Key)) die(`Unknown config key: ${key}\nAvailable keys: ${VALID_KEYS.join(", ")}`) + // Array keys: `config hosts add foo.local`, `config hosts rm foo.local` + if (ARRAY_KEYS.includes(key)) { + const current = ((await config.get(key as config.Key)) ?? []) as string[] + if (rest.length === 0) { + if (current.length === 0) console.log("(empty)") + else current.forEach(v => console.log(v)) + return + } + const [op, ...values] = rest + if (op === "add") { + if (values.length === 0) die("Usage: sandlot config hosts add ") + const updated = [...new Set([...current, ...values])] + await config.set(key as config.Key, updated as any) + updated.forEach(v => console.log(v)) + } else if (op === "rm" || op === "remove") { + if (values.length === 0) die("Usage: sandlot config hosts rm ") + const updated = current.filter(v => !values.includes(v)) + await config.set(key as config.Key, updated as any) + if (updated.length === 0) console.log("(empty)") + else updated.forEach(v => console.log(v)) + } else { + die(`Unknown operation: ${op}\nUsage: sandlot config ${key} add|rm `) + } + return + } + if (rest.length === 0) { const val = await config.get(key as config.Key) console.log(val ?? `${config.DEFAULTS[key as config.Key]} (default)`) diff --git a/src/config.ts b/src/config.ts index d1ce0fa..585a8ac 100644 --- a/src/config.ts +++ b/src/config.ts @@ -5,14 +5,16 @@ import { join } from "path" const CONFIG_DIR = join(homedir(), ".config", "sandlot") const CONFIG_PATH = join(CONFIG_DIR, "config.json") -export const DEFAULTS = { +export const DEFAULTS: Record = { memory: "16G", -} as const + hosts: [], +} export type Key = keyof typeof DEFAULTS export interface Config { memory?: string + hosts?: string[] } const MIN_MEMORY_MB = 512 diff --git a/src/vm.ts b/src/vm.ts index f158ed5..8f18160 100644 --- a/src/vm.ts +++ b/src/vm.ts @@ -246,6 +246,22 @@ echo '${claudeJson}' > ~/.claude.json `}`.quiet() } +/** Resolve hostnames on the host (via mDNS) and add them to the container's /etc/hosts. + * Reads the "hosts" array from config (e.g. ["claude.toes.local"]). */ +async function syncLocalHosts(): Promise { + const hostnames = (await getConfig("hosts")) as string[] | undefined + if (!hostnames?.length) return + const entries: string[] = [] + for (const name of hostnames) { + const out = (await $`dscacheutil -q host -a name ${name}`.nothrow().quiet().text()).trim() + const match = out.match(/ip_address:\s+(\S+)/) + if (match) entries.push(`${match[1]} ${name}`) + } + if (!entries.length) return + const block = entries.join("\\n") + await $`container exec ${CONTAINER_NAME} bash -c ${`grep -v '# sandlot-hosts' /etc/hosts > /tmp/hosts.clean; echo -e '${block}' | sed 's/$/ # sandlot-hosts/' >> /tmp/hosts.clean; cp /tmp/hosts.clean /etc/hosts`}`.nothrow().quiet() +} + // ── create() ──────────────────────────────────────────────────────── /** Create and provision the container from scratch. Fails if it already exists. */ @@ -273,6 +289,7 @@ export async function create(log?: (msg: string) => void): Promise { log?.("Configuring environment") await configureEnvironment(home, apiKey) + await syncLocalHosts() } /** Start a stopped container. */ @@ -282,6 +299,7 @@ export async function start(): Promise { if (s === "running") return if (s === "missing") throw new Error("Container does not exist. Use 'sandlot vm create' first.") await run($`container start ${CONTAINER_NAME}`, "Container start") + await syncLocalHosts() } /** Ensure the sandlot container exists and is running. Creates and provisions on first use. */ @@ -294,7 +312,10 @@ export async function ensure(log?: (msg: string) => void): Promise { else await $`container system start --enable-kernel-install`.nothrow().quiet() const s = await status() - if (s === "running") return + if (s === "running") { + await syncLocalHosts() + return + } if (s === "stopped") { await start()