| 267 | return writeYAML(filepath.Join(c.Dir, "config.yaml"), c) |
| 268 | } |
| 269 | |
| 270 | func writeYAML(path string, v any) error { |
| 271 | b, err := yaml.Marshal(v) |
| 272 | if err != nil { |
| 273 | return err |
| 274 | } |
| 275 | // Re-prepended every Save since yaml.Marshal drops free-form comments, the |
| 276 | // only place a hint survives. The sandbox line catches the top first-run |
| 277 | // footgun: in a devcontainer/WSL2 with Ollama on the host, `localhost` |
| 278 | // doesn't reach the host and yields a baffling "connection refused". Native |
| 279 | // users aren't affected, hence sandbox-vs-host framing over an OS-specific one. |
| 280 | header := []byte(`# codehamr configuration |
| 281 | # |
| 282 | # Running codehamr in a devcontainer / WSL2 with Ollama on the host: |
| 283 | # swap 'http://localhost:11434' with 'http://host.docker.internal:11434' below. |
| 284 | # |
| 285 | # Keys: ` + "`key: ${MY_KEY}`" + ` expands the env var at runtime, so the reference (not |
| 286 | # the secret) round-trips on Save. Literal keys still work for backward compat. |
| 287 | # |
| 288 | # context_size is what codehamr packs to - set it to your server's ACTUAL window, |
| 289 | # not the model's theoretical max. For Ollama that's OLLAMA_CONTEXT_LENGTH (or a |
| 290 | # Modelfile 'PARAMETER num_ctx'); too high and the server silently drops the |
| 291 | # oldest messages. More VRAM lets you raise both together. |
| 292 | # |
| 293 | # The seeded 262144 is qwen3.8:27b's full 256k window; your server only delivers |
| 294 | # it if told to (start Ollama with OLLAMA_CONTEXT_LENGTH=262144). Serving less? |
| 295 | # Lower 'context_size' here to match. |
| 296 | |
| 297 | `) |
| 298 | // Write to a sibling temp then rename over config.yaml. Rename is atomic |
| 299 | // within the directory, so a crash, signal, or full disk mid-write can never |
| 300 | // leave a truncated config.yaml, which Bootstrap's strict decode would fatal |
| 301 | // on, bricking the next launch until the file is hand-deleted. Mirrors |
| 302 | // internal/update's promote-by-rename. os.CreateTemp makes the temp 0o600 and |
| 303 | // rename installs that fresh inode in place, so this also closes the |
| 304 | // upgrade-path leak the old in-place write needed a trailing Chmod for: |
| 305 | // config.yaml carries the hamrpass key, and only the project owner should |
| 306 | // read it. |
| 307 | tmp, err := os.CreateTemp(filepath.Dir(path), ".config-*.yaml") |
| 308 | if err != nil { |
| 309 | return err |
| 310 | } |
| 311 | tmpPath := tmp.Name() |
| 312 | defer os.Remove(tmpPath) // no-op after a successful rename; cleans up early returns |
| 313 | if _, err := tmp.Write(append(header, b...)); err != nil { |
| 314 | tmp.Close() |
| 315 | return err |
| 316 | } |
| 317 | // Sync before the rename: rename is metadata-only, so a power loss right |
| 318 | // after Save could otherwise journal the rename ahead of the data and |
| 319 | // leave the truncated config.yaml the crash-safety above promises away. |
| 320 | if err := tmp.Sync(); err != nil { |
| 321 | tmp.Close() |
| 322 | return err |
| 323 | } |
| 324 | if err := tmp.Close(); err != nil { |
| 325 | return err |
| 326 | } |