Compare commits

...

6 Commits

Author SHA1 Message Date
f7a8ff6a3c Fix config command to pass actual key instead of hardcoded "memory"
The config command already parsed the key but ignored it when calling
set() and printing the result. Also clarify sync vs async convention
in CLAUDE.md and log invalid memory config instead of silently falling
back.
2026-03-20 10:59:37 -07:00
3b3820f95f Derive config keys from DEFAULTS and enforce minimum memory of 512M
The config command previously maintained a separate key list and used a
switch statement that would need updating for each new key. Deriving
VALID_KEYS from config.DEFAULTS keeps them in sync automatically.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 10:40:22 -07:00
fa077ff8f5 Move validateMemory to config module and harden against bad values
The validator is now reusable by both the CLI config command and
the VM startup path, which falls back to the default if the stored
value is invalid. Also lowers the default memory limit to 16G and
makes config.load() resilient to malformed JSON.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 10:36:48 -07:00
ea1b09dc8e Move config defaults to a shared constant in config.ts
Eliminates duplicated default values and simplifies the config
command by replacing the generic key metadata registry with
direct validation functions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 10:30:39 -07:00
e3e4419933 Refactor config command and fix default memory limit
Move defaults and normalization into KEYS metadata, validate config
file shape on load, and lower default container memory from 32G to 16G.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 10:24:57 -07:00
f7d876776b 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>
2026-03-20 10:13:20 -07:00
6 changed files with 116 additions and 3 deletions

View File

@ -36,7 +36,9 @@ src/
fmt.ts # ANSI escape codes, die/success/info helpers, pager
markdown.ts # Terminal markdown renderer (headings, bold, links, code blocks)
spinner.ts # CLI progress spinner (braille frames)
config.ts # Per-user config (~/.config/sandlot/config.json): memory, etc.
commands/
config.ts # Get/set config values (e.g. `sandlot config memory 16G`)
new.ts # Create session, derive branch name from prompt
open.ts # Re-enter existing session (always uses --continue)
close.ts # Remove worktree and clean up session
@ -60,7 +62,7 @@ src/
## Architecture
Each module has a single responsibility. No classes — only exported async functions.
Each module has a single responsibility. No classes — only exported functions (async when they do I/O, sync when pure).
**Session flow for `sandlot new <branch>`:**
1. `git.createWorktree()` → creates worktree at `~/.sandlot/<repo>/<branch>`
@ -79,7 +81,7 @@ Each module has a single responsibility. No classes — only exported async func
- Image: `ubuntu:24.04`
- User: `ubuntu`
- Memory limit: 4G
- Memory limit: configurable via `sandlot config memory` (default 16G)
- 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
- `containerPath()` in `vm.ts` translates host paths to container paths (`~/.sandlot/…` → `/sandlot/…`, `~/dev/…``/host/…`)

View File

@ -45,3 +45,17 @@ sandlot rm <branch> # tear down session (container, worktree, local branch)
```
Use git directly for commits, pushes, merges, etc. The worktree is a normal git checkout.
### Configuration
```bash
sandlot config # show all options and current values
sandlot config memory # show current memory setting
sandlot config memory 32G # set container memory limit
```
Config is stored in `~/.config/sandlot/config.json`.
| Key | Default | Description |
|-----|---------|-------------|
| `memory` | `16G` | Container memory limit (e.g. 4G, 16G, 32G, 64G) |

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 initAction } from "./commands/init.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()
@ -185,6 +186,12 @@ program
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
.command("cleanup")
.description("Remove stale sessions whose worktrees no longer exist")

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

@ -0,0 +1,32 @@
import { die } from "../fmt.ts"
import * as config from "../config.ts"
const VALID_KEYS = Object.keys(config.DEFAULTS) as config.Key[]
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}`)
}
return
}
const [key, ...rest] = args
if (!VALID_KEYS.includes(key as config.Key)) die(`Unknown config key: ${key}\nAvailable keys: ${VALID_KEYS.join(", ")}`)
if (rest.length === 0) {
const val = await config.get(key as config.Key)
console.log(val ?? `${config.DEFAULTS[key as config.Key]} (default)`)
return
}
if (rest.length > 1) die(`Too many arguments. Usage: sandlot config ${key} <value>`)
let normalized: string
try { normalized = config.validateMemory(rest[0]) } catch { return die("Must be a number followed by G or M, minimum 512M (e.g. 16G)") }
await config.set(key as config.Key, normalized)
console.log(`${key} = ${normalized}`)
}

55
src/config.ts Normal file
View File

@ -0,0 +1,55 @@
import { mkdir } from "fs/promises"
import { homedir } from "os"
import { join } from "path"
const CONFIG_DIR = join(homedir(), ".config", "sandlot")
const CONFIG_PATH = join(CONFIG_DIR, "config.json")
export const DEFAULTS = {
memory: "16G",
} as const
export type Key = keyof typeof DEFAULTS
export interface Config {
memory?: string
}
const MIN_MEMORY_MB = 512
export function validateMemory(v: string): string {
if (!/^[1-9]\d*[GMgm]$/.test(v)) throw new Error(`Invalid memory value: ${v} (must be a number followed by G or M, e.g. 16G)`)
const num = parseInt(v)
const unit = v.slice(-1).toUpperCase()
const mb = unit === "G" ? num * 1024 : num
if (mb < MIN_MEMORY_MB) throw new Error(`Memory too low: ${v} (minimum ${MIN_MEMORY_MB}M)`)
return v.toUpperCase()
}
export async function load(): Promise<Config> {
const file = Bun.file(CONFIG_PATH)
if (!(await file.exists())) return {}
try {
const raw = await file.json()
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return {}
return raw as Config
} catch {
return {}
}
}
export async function save(config: Config): Promise<void> {
await mkdir(CONFIG_DIR, { 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 { requireApiKey } from "./env.ts"
import { info } from "./fmt.ts"
import { get as getConfig, DEFAULTS, validateMemory } from "./config.ts"
const DEBUG = !!process.env.DEBUG
const CONTAINER_NAME = "sandlot"
@ -66,7 +67,9 @@ function hostMounts(home: string): { dev: boolean; code: boolean } {
/** Pull the image and start the container in detached mode. */
async function createContainer(home: string): Promise<void> {
const mounts = hostMounts(home)
const args = ["container", "run", "-d", "--name", CONTAINER_NAME, "-m", "32G"]
let memory: string
try { memory = validateMemory((await getConfig("memory")) ?? DEFAULTS.memory) } catch (e) { info(`Invalid memory config, using default: ${e instanceof Error ? e.message : e}`); memory = DEFAULTS.memory }
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.code) args.push("--mount", `type=bind,source=${home}/code,target=/host/code,readonly`)
args.push("-v", `${home}/.sandlot:/sandlot`, "ubuntu:24.04", "sleep", "infinity")