Add per-user config system with sandlot config command

Allow users to configure container memory limit (default 32G) via
`sandlot config memory <value>` instead of hardcoding it.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Chris Wanstrath 2026-03-20 10:12:50 -07:00
parent 0f568e69de
commit f7d876776b
5 changed files with 84 additions and 2 deletions

View File

@ -36,7 +36,9 @@ src/
fmt.ts # ANSI escape codes, die/success/info helpers, pager fmt.ts # ANSI escape codes, die/success/info helpers, pager
markdown.ts # Terminal markdown renderer (headings, bold, links, code blocks) markdown.ts # Terminal markdown renderer (headings, bold, links, code blocks)
spinner.ts # CLI progress spinner (braille frames) spinner.ts # CLI progress spinner (braille frames)
config.ts # Per-user config (~/.config/sandlot/config.json): memory, etc.
commands/ commands/
config.ts # Get/set config values (e.g. `sandlot config memory 16G`)
new.ts # Create session, derive branch name from prompt new.ts # Create session, derive branch name from prompt
open.ts # Re-enter existing session (always uses --continue) open.ts # Re-enter existing session (always uses --continue)
close.ts # Remove worktree and clean up session close.ts # Remove worktree and clean up session
@ -79,7 +81,7 @@ Each module has a single responsibility. No classes — only exported async func
- Image: `ubuntu:24.04` - Image: `ubuntu:24.04`
- User: `ubuntu` - User: `ubuntu`
- Memory limit: 4G - Memory limit: configurable via `sandlot config memory` (default 32G)
- Mounts: `~/dev` **read-only** at `/host`, `~/.sandlot` read-write at `/sandlot` - Mounts: `~/dev` **read-only** at `/host`, `~/.sandlot` read-write at `/sandlot`
- Host symlinks: creates `~/dev``/host` and `~/.sandlot``/sandlot` inside the container so host-absolute worktree paths resolve correctly - Host symlinks: creates `~/dev``/host` and `~/.sandlot``/sandlot` inside the container so host-absolute worktree paths resolve correctly
- `containerPath()` in `vm.ts` translates host paths to container paths (`~/.sandlot/…` → `/sandlot/…`, `~/dev/…``/host/…`) - `containerPath()` in `vm.ts` translates host paths to container paths (`~/.sandlot/…` → `/sandlot/…`, `~/dev/…``/host/…`)

View File

@ -26,6 +26,7 @@ import { register as registerVmCommands } from "./commands/vm.ts"
import { action as completionsAction } from "./commands/completions.ts" import { action as completionsAction } from "./commands/completions.ts"
import { action as initAction } from "./commands/init.ts" import { action as initAction } from "./commands/init.ts"
import { action as cdAction } from "./commands/cd.ts" import { action as cdAction } from "./commands/cd.ts"
import { action as configAction } from "./commands/config.ts"
const pkg = await Bun.file(new URL("../package.json", import.meta.url)).json() const pkg = await Bun.file(new URL("../package.json", import.meta.url)).json()
@ -185,6 +186,12 @@ program
program.commandsGroup("Admin Commands:") program.commandsGroup("Admin Commands:")
program
.command("config")
.argument("[args...]", "key [value]")
.description("Get or set configuration (e.g. sandlot config memory 16G)")
.action(configAction)
program program
.command("cleanup") .command("cleanup")
.description("Remove stale sessions whose worktrees no longer exist") .description("Remove stale sessions whose worktrees no longer exist")

38
src/commands/config.ts Normal file
View File

@ -0,0 +1,38 @@
import { die } from "../fmt.ts"
import * as config from "../config.ts"
const KEYS: Record<string, { description: string; validate: (v: string) => string | null }> = {
memory: {
description: "Container memory limit (e.g. 4G, 16G, 32G, 64G)",
validate: (v) => /^\d+[GMgm]$/.test(v) ? null : "Must be a number followed by G or M (e.g. 16G)",
},
}
export async function action(args: string[]) {
if (args.length === 0) {
const cfg = await config.load()
const defaults: Record<string, string> = { memory: "32G" }
for (const [key, meta] of Object.entries(KEYS)) {
const val = (cfg as any)[key]
const display = val ?? `${defaults[key]} (default)`
console.log(`${key} = ${display}`)
}
return
}
if (args.length === 1) {
const key = args[0]
if (!(key in KEYS)) die(`Unknown config key: ${key}\nAvailable keys: ${Object.keys(KEYS).join(", ")}`)
const val = await config.get(key as keyof config.Config)
const defaults: Record<string, string> = { memory: "32G" }
console.log(val ?? `${defaults[key]} (default)`)
return
}
const [key, value] = args
if (!(key in KEYS)) die(`Unknown config key: ${key}\nAvailable keys: ${Object.keys(KEYS).join(", ")}`)
const error = KEYS[key].validate(value)
if (error) die(error)
await config.set(key as keyof config.Config, value.toUpperCase())
console.log(`${key} = ${value.toUpperCase()}`)
}

33
src/config.ts Normal file
View File

@ -0,0 +1,33 @@
import { homedir } from "os"
import { dirname, join } from "path"
const CONFIG_PATH = join(homedir(), ".config", "sandlot", "config.json")
export interface Config {
memory?: string
}
export async function load(): Promise<Config> {
try {
return await Bun.file(CONFIG_PATH).json()
} catch {
return {}
}
}
export async function save(config: Config): Promise<void> {
const { mkdir } = await import("fs/promises")
await mkdir(dirname(CONFIG_PATH), { recursive: true })
await Bun.write(CONFIG_PATH, JSON.stringify(config, null, 2) + "\n")
}
export async function get<K extends keyof Config>(key: K): Promise<Config[K]> {
const config = await load()
return config[key]
}
export async function set<K extends keyof Config>(key: K, value: Config[K]): Promise<void> {
const config = await load()
config[key] = value
await save(config)
}

View File

@ -4,6 +4,7 @@ import { homedir } from "os"
import { dirname, join } from "path" import { dirname, join } from "path"
import { requireApiKey } from "./env.ts" import { requireApiKey } from "./env.ts"
import { info } from "./fmt.ts" import { info } from "./fmt.ts"
import { get as getConfig } from "./config.ts"
const DEBUG = !!process.env.DEBUG const DEBUG = !!process.env.DEBUG
const CONTAINER_NAME = "sandlot" const CONTAINER_NAME = "sandlot"
@ -66,7 +67,8 @@ function hostMounts(home: string): { dev: boolean; code: boolean } {
/** Pull the image and start the container in detached mode. */ /** Pull the image and start the container in detached mode. */
async function createContainer(home: string): Promise<void> { async function createContainer(home: string): Promise<void> {
const mounts = hostMounts(home) const mounts = hostMounts(home)
const args = ["container", "run", "-d", "--name", CONTAINER_NAME, "-m", "32G"] const memory = (await getConfig("memory")) ?? "32G"
const args = ["container", "run", "-d", "--name", CONTAINER_NAME, "-m", memory]
if (mounts.dev) args.push("--mount", `type=bind,source=${home}/dev,target=/host/dev,readonly`) if (mounts.dev) args.push("--mount", `type=bind,source=${home}/dev,target=/host/dev,readonly`)
if (mounts.code) args.push("--mount", `type=bind,source=${home}/code,target=/host/code,readonly`) if (mounts.code) args.push("--mount", `type=bind,source=${home}/code,target=/host/code,readonly`)
args.push("-v", `${home}/.sandlot:/sandlot`, "ubuntu:24.04", "sleep", "infinity") args.push("-v", `${home}/.sandlot:/sandlot`, "ubuntu:24.04", "sleep", "infinity")