Add hosts config for local network resolution

This commit is contained in:
Chris Wanstrath 2026-04-11 15:12:22 -07:00
parent a7e3a6333b
commit 894a0455a7
4 changed files with 73 additions and 5 deletions

View File

@ -59,3 +59,17 @@ Config is stored in `~/.config/sandlot/config.json`.
| Key | Default | Description | | Key | Default | Description |
|-----|---------|-------------| |-----|---------|-------------|
| `memory` | `16G` | Container memory limit (e.g. 4G, 16G, 32G, 64G) | | `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.

View File

@ -2,14 +2,19 @@ import { die } from "../fmt.ts"
import * as config from "../config.ts" import * as config from "../config.ts"
const VALID_KEYS = Object.keys(config.DEFAULTS) as config.Key[] 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[]) { export async function action(args: string[]) {
if (args.length === 0) { if (args.length === 0) {
const cfg = await config.load() const cfg = await config.load()
for (const key of VALID_KEYS) { for (const key of VALID_KEYS) {
const val = cfg[key] const val = cfg[key]
const display = val ?? `${config.DEFAULTS[key]} (default)` if (Array.isArray(config.DEFAULTS[key])) {
console.log(`${key} = ${display}`) const arr = (val as string[] | undefined) ?? []
console.log(`${key} = ${arr.length ? arr.join(", ") : "(empty)"}`)
} else {
console.log(`${key} = ${val ?? `${config.DEFAULTS[key]} (default)`}`)
}
} }
return return
} }
@ -17,6 +22,32 @@ export async function action(args: string[]) {
const [key, ...rest] = args const [key, ...rest] = args
if (!VALID_KEYS.includes(key as config.Key)) die(`Unknown config key: ${key}\nAvailable keys: ${VALID_KEYS.join(", ")}`) 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 <hostname>")
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 <hostname>")
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 <value>`)
}
return
}
if (rest.length === 0) { if (rest.length === 0) {
const val = await config.get(key as config.Key) const val = await config.get(key as config.Key)
console.log(val ?? `${config.DEFAULTS[key as config.Key]} (default)`) console.log(val ?? `${config.DEFAULTS[key as config.Key]} (default)`)

View File

@ -5,14 +5,16 @@ import { join } from "path"
const CONFIG_DIR = join(homedir(), ".config", "sandlot") const CONFIG_DIR = join(homedir(), ".config", "sandlot")
const CONFIG_PATH = join(CONFIG_DIR, "config.json") const CONFIG_PATH = join(CONFIG_DIR, "config.json")
export const DEFAULTS = { export const DEFAULTS: Record<string, string | string[]> = {
memory: "16G", memory: "16G",
} as const hosts: [],
}
export type Key = keyof typeof DEFAULTS export type Key = keyof typeof DEFAULTS
export interface Config { export interface Config {
memory?: string memory?: string
hosts?: string[]
} }
const MIN_MEMORY_MB = 512 const MIN_MEMORY_MB = 512

View File

@ -246,6 +246,22 @@ echo '${claudeJson}' > ~/.claude.json
`}`.quiet() `}`.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<void> {
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() ────────────────────────────────────────────────────────
/** Create and provision the container from scratch. Fails if it already exists. */ /** 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<void> {
log?.("Configuring environment") log?.("Configuring environment")
await configureEnvironment(home, apiKey) await configureEnvironment(home, apiKey)
await syncLocalHosts()
} }
/** Start a stopped container. */ /** Start a stopped container. */
@ -282,6 +299,7 @@ export async function start(): Promise<void> {
if (s === "running") return if (s === "running") return
if (s === "missing") throw new Error("Container does not exist. Use 'sandlot vm create' first.") if (s === "missing") throw new Error("Container does not exist. Use 'sandlot vm create' first.")
await run($`container start ${CONTAINER_NAME}`, "Container start") await run($`container start ${CONTAINER_NAME}`, "Container start")
await syncLocalHosts()
} }
/** Ensure the sandlot container exists and is running. Creates and provisions on first use. */ /** 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<void> {
else await $`container system start --enable-kernel-install`.nothrow().quiet() else await $`container system start --enable-kernel-install`.nothrow().quiet()
const s = await status() const s = await status()
if (s === "running") return if (s === "running") {
await syncLocalHosts()
return
}
if (s === "stopped") { if (s === "stopped") {
await start() await start()