feat(config): non-destructive opencode.jsonc.proposed sidecar (closes #8 batch item)
Completes the pi-devbox v1.1.4 "merge new defaults into preserved config" idea, adapted to opencode-devbox's env-generated, JSONC-with-comments config where an in-place merge would be destructive. generate-config.py keeps its "never touch an existing config" guarantee and adds a side-channel: when a live config exists, render the config it WOULD generate for the current env + image defaults and write it to a NON-loaded opencode.jsonc.proposed — but only when it differs from the live config; remove it once they match. opencode never loads .proposed files, so it is a pure manual-merge reference (e.g. surfacing a default MCP server added in a newer image). An unparseable live config surfaces the proposal rather than guessing equivalence. A one-line hint is logged on write. - render_config(): shared renderer so first-gen and proposed paths can't drift - _loads_jsonc(): string-aware //-comment strip (same approach as smoke-test; preserves https:// inside strings), raises on invalid JSON - write_proposed(): write-on-diff + stale removal + live untouched - smoke-test.sh: asserts write-on-diff, removal-on-match, live not clobbered - entrypoint-user.sh + module docstring: document the sidecar - CHANGELOG: moved from "Deferred" to "Added" Caveat (documented in the file header): the proposal reflects env + image defaults, so a diff may include the user's own past edits, not only new image defaults.
This commit is contained in:
+17
-9
@@ -89,16 +89,24 @@ editing the Dockerfile. Default unchanged. (Mirrors pi-devbox v1.1.6.)
|
||||
`OPENCODE_VERSION` ARG in `Dockerfile.variant`. `1.17.8` is the current npm
|
||||
`latest` stable. Only the variant layer rebuilds; the base is unaffected.
|
||||
|
||||
### Deferred (needs a decision): opencode.json merge-on-recreate
|
||||
### Added: opencode.json merge-on-recreate — non-destructive `.proposed` sidecar
|
||||
|
||||
pi-devbox v1.1.4 added a non-destructive deep-merge of new template keys into a
|
||||
preserved-volume `settings.json`. The direct analogue does **not** port cleanly
|
||||
here: opencode's config is *generated from env vars* and written as **JSONC
|
||||
with comments** (not a static image-owned template), and `generate-config.py`
|
||||
deliberately never touches an existing config (host bind-mount or persisted
|
||||
volume). A `jq`-style merge would strip the JSONC comments and risks clobbering
|
||||
or re-adding entries a user removed. Left for a separate, deliberate change —
|
||||
see discussion.
|
||||
The pi-devbox v1.1.4 deep-merge into a preserved `settings.json` does not port
|
||||
cleanly here: opencode's config is *generated from env vars* and written as
|
||||
JSONC with comments (not a static image-owned template), and overwriting or
|
||||
`jq`-merging a possibly-bind-mounted host config is destructive. Instead,
|
||||
`generate-config.py` keeps its "never touch an existing config" guarantee and
|
||||
adds a non-destructive side-channel: when a live config exists, it writes
|
||||
`opencode.jsonc.proposed` — the config it *would* generate for the current
|
||||
environment plus this image's defaults — **only when that differs** from the
|
||||
live config, and removes it once they match. opencode never loads a `.proposed`
|
||||
file, so it is purely a manual-merge reference (e.g. surfacing a default MCP
|
||||
server added in a newer image). A one-line hint is logged when one is written;
|
||||
an unparseable live config surfaces the proposal rather than guessing. The
|
||||
proposed config is regenerated from env + image defaults, so a diff may reflect
|
||||
your own past edits as well as new image defaults — the file header says so.
|
||||
Covered by a new `scripts/smoke-test.sh` assertion (write-on-diff, removal on
|
||||
match, live config never clobbered).
|
||||
|
||||
---
|
||||
|
||||
|
||||
+5
-3
@@ -95,9 +95,11 @@ fi
|
||||
|
||||
# ── Generate opencode config from env vars if no config mounted ──────
|
||||
# Delegated to a standalone Python script for clarity and testability.
|
||||
# The script is idempotent: it never overwrites an existing opencode.json
|
||||
# (bind-mounted from host, persisted in named volume, or previously
|
||||
# generated) and no-ops if OPENCODE_PROVIDER is unset.
|
||||
# The script never overwrites an existing opencode.json/.jsonc (bind-mounted
|
||||
# from host, persisted in named volume, or previously generated) and no-ops if
|
||||
# OPENCODE_PROVIDER is unset. When a config already exists it instead writes a
|
||||
# NON-loaded opencode.jsonc.proposed sidecar (only when newer image defaults
|
||||
# differ) for manual review/merge.
|
||||
python3 /usr/local/lib/opencode-devbox/generate-config.py
|
||||
|
||||
# ── Skillset: deploy skills/instructions from mounted skillset repo ──
|
||||
|
||||
@@ -3,9 +3,12 @@
|
||||
Generate opencode.json from environment variables on first container start.
|
||||
|
||||
Safety guarantees:
|
||||
- NEVER overwrites an existing opencode.json. If the file is present
|
||||
(whether bind-mounted from the host, persisted in a named volume, or
|
||||
previously generated), this script exits immediately without writing.
|
||||
- NEVER overwrites an existing config (opencode.json / opencode.jsonc),
|
||||
whether bind-mounted from the host, persisted in a named volume, or
|
||||
previously generated. When a config already exists, this script instead
|
||||
writes a NON-loaded `opencode.jsonc.proposed` sidecar (only when the
|
||||
freshly-generated config would differ) so new image defaults can be
|
||||
reviewed and merged by hand. opencode never loads the .proposed file.
|
||||
- Requires OPENCODE_PROVIDER to be set. Without it, no file is written.
|
||||
|
||||
Environment variables:
|
||||
@@ -18,13 +21,16 @@ MCP servers are auto-registered for tools detected on PATH:
|
||||
- mempalace (if installed) — enabled
|
||||
- gitea-mcp (if installed) — registered but disabled by default
|
||||
|
||||
Output path: $HOME/.config/opencode/opencode.json
|
||||
Output path: $HOME/.config/opencode/opencode.jsonc
|
||||
(existing config preserved; newer defaults surfaced as
|
||||
$HOME/.config/opencode/opencode.jsonc.proposed)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
@@ -110,6 +116,113 @@ def register_mcp_servers(config: dict) -> list[str]:
|
||||
return list(servers.keys())
|
||||
|
||||
|
||||
def render_config(provider: str, model: str) -> tuple[dict, str, list[str]]:
|
||||
"""Build the config dict and its JSONC rendering for a provider/model.
|
||||
|
||||
Shared by first-generation and the proposed-config side-channel so the
|
||||
two can never drift. Returns (config_dict, jsonc_text, mcp_servers_added).
|
||||
"""
|
||||
config = build_config(provider, model)
|
||||
added = register_mcp_servers(config)
|
||||
|
||||
# Write as JSONC so we can include helpful comments.
|
||||
content = json.dumps(config, indent=2)
|
||||
# Insert a comment about the Context7 API key after the context7 url line.
|
||||
context7_comment = (
|
||||
' "url": "https://mcp.context7.com/mcp"\n'
|
||||
" // For higher rate limits, sign up at https://context7.com/dashboard\n"
|
||||
' // and add: "headers": { "CONTEXT7_API_KEY": "{env:CONTEXT7_API_KEY}" }'
|
||||
)
|
||||
content = content.replace(
|
||||
' "url": "https://mcp.context7.com/mcp"',
|
||||
context7_comment,
|
||||
)
|
||||
return config, content, added
|
||||
|
||||
|
||||
def _loads_jsonc(text: str) -> dict:
|
||||
"""Parse JSONC (JSON + // line comments), preserving // inside strings.
|
||||
|
||||
Uses the same string-aware comment stripper as scripts/smoke-test.sh, so a
|
||||
value such as an https:// URL is never corrupted. Raises on invalid JSON
|
||||
(e.g. trailing commas) — callers treat that as 'cannot compare'.
|
||||
"""
|
||||
pattern = r'"(?:\\.|[^"\\])*"|//[^\n]*'
|
||||
stripped = re.sub(
|
||||
pattern,
|
||||
lambda m: m.group(0) if m.group(0).startswith('"') else "",
|
||||
text,
|
||||
)
|
||||
return json.loads(stripped)
|
||||
|
||||
|
||||
PROPOSED_HEADER = """\
|
||||
// ───────────────────────────────────────────────────────────────
|
||||
// PROPOSED opencode config — NOT loaded by opencode.
|
||||
//
|
||||
// This is what opencode-devbox would generate for your CURRENT environment
|
||||
// plus THIS image's defaults. It is written only when it differs from your
|
||||
// live opencode.jsonc, as a manual-merge reference — e.g. a newer image added
|
||||
// a default MCP server you do not have yet. opencode only loads
|
||||
// opencode.json / opencode.jsonc, never this .proposed file.
|
||||
//
|
||||
// NOTE: this reflects env + image defaults, so a difference may be a new image
|
||||
// default OR simply one of your own past edits (changed model, gitea
|
||||
// enabled=true, …). Diff against your live config and merge what you want.
|
||||
// Delete this file any time — it is rewritten on the next start if still
|
||||
// relevant, and removed automatically once your live config matches.
|
||||
// ───────────────────────────────────────────────────────────────
|
||||
"""
|
||||
|
||||
|
||||
def write_proposed(
|
||||
proposed_file: Path, live_file: Path, config: dict, content: str
|
||||
) -> None:
|
||||
"""Non-destructively surface a newer default config beside the live one.
|
||||
|
||||
Writes <proposed_file> ONLY when the freshly-rendered config differs from
|
||||
the live config (or the live config cannot be parsed for comparison).
|
||||
Removes a stale proposed file when the live config already matches. NEVER
|
||||
touches the live config itself.
|
||||
"""
|
||||
try:
|
||||
live = _loads_jsonc(live_file.read_text())
|
||||
differs = live != config
|
||||
comparable = True
|
||||
except (OSError, ValueError):
|
||||
# Can't read or parse the live config — surface the proposal rather
|
||||
# than silently guess they are equivalent.
|
||||
comparable = False
|
||||
differs = True
|
||||
|
||||
if comparable and not differs:
|
||||
if proposed_file.exists():
|
||||
try:
|
||||
proposed_file.unlink()
|
||||
print(
|
||||
f"Live opencode config matches image defaults; removed "
|
||||
f"stale {proposed_file.name}.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
except OSError:
|
||||
pass
|
||||
return
|
||||
|
||||
try:
|
||||
proposed_file.write_text(PROPOSED_HEADER + content + "\n")
|
||||
except OSError as e:
|
||||
print(f"WARN: could not write {proposed_file}: {e}", file=sys.stderr)
|
||||
return
|
||||
|
||||
why = "" if comparable else " (existing config could not be parsed for comparison)"
|
||||
print(
|
||||
f"A newer default opencode config is available at {proposed_file}{why}. "
|
||||
"It is NOT applied automatically — diff/merge it into your live config "
|
||||
"manually, or delete it to dismiss.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
provider = os.environ.get("OPENCODE_PROVIDER", "").strip()
|
||||
if not provider:
|
||||
@@ -120,19 +233,7 @@ def main() -> int:
|
||||
config_dir = home / ".config" / "opencode"
|
||||
config_file = config_dir / "opencode.jsonc"
|
||||
config_file_legacy = config_dir / "opencode.json"
|
||||
|
||||
# CRITICAL: never overwrite an existing config. Users may have
|
||||
# bind-mounted their host config directory, or their config may be
|
||||
# persisted in a named volume from a previous run.
|
||||
# Check both .json and .jsonc variants.
|
||||
if config_file.exists() or config_file_legacy.exists():
|
||||
existing = config_file if config_file.exists() else config_file_legacy
|
||||
print(
|
||||
f"Existing config found at {existing} — "
|
||||
"skipping generation.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 0
|
||||
proposed_file = config_dir / "opencode.jsonc.proposed"
|
||||
|
||||
if provider not in DEFAULT_MODELS:
|
||||
print(
|
||||
@@ -145,30 +246,37 @@ def main() -> int:
|
||||
provider, FALLBACK_MODEL
|
||||
)
|
||||
|
||||
config, content, added = render_config(provider, model)
|
||||
|
||||
# CRITICAL: never overwrite an existing config. Users may have bind-mounted
|
||||
# their host config directory, or their config may be persisted in a named
|
||||
# volume from a previous run. When a config already exists we instead
|
||||
# surface any newer image defaults via a NON-loaded opencode.jsonc.proposed
|
||||
# sidecar for manual merge (see write_proposed) — the live file is untouched.
|
||||
existing = None
|
||||
if config_file.exists():
|
||||
existing = config_file
|
||||
elif config_file_legacy.exists():
|
||||
existing = config_file_legacy
|
||||
if existing is not None:
|
||||
print(
|
||||
f"Existing config found at {existing} — not overwritten.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
write_proposed(proposed_file, existing, config, content)
|
||||
return 0
|
||||
|
||||
print(f"Generating opencode config for provider: {provider}", file=sys.stderr)
|
||||
|
||||
config = build_config(provider, model)
|
||||
added = register_mcp_servers(config)
|
||||
|
||||
config_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Write as JSONC so we can include helpful comments.
|
||||
content = json.dumps(config, indent=2)
|
||||
|
||||
# Insert a comment about Context7 API key after the context7 url line.
|
||||
context7_comment = (
|
||||
' "url": "https://mcp.context7.com/mcp"\n'
|
||||
" // For higher rate limits, sign up at https://context7.com/dashboard\n"
|
||||
' // and add: "headers": { "CONTEXT7_API_KEY": "{env:CONTEXT7_API_KEY}" }'
|
||||
)
|
||||
content = content.replace(
|
||||
' "url": "https://mcp.context7.com/mcp"',
|
||||
context7_comment,
|
||||
)
|
||||
|
||||
with config_file.open("w") as f:
|
||||
f.write(content)
|
||||
f.write("\n")
|
||||
# The fresh config now equals the image defaults — clear any stale proposal.
|
||||
if proposed_file.exists():
|
||||
try:
|
||||
proposed_file.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
if added:
|
||||
print(
|
||||
|
||||
@@ -306,6 +306,37 @@ if docker run --rm \
|
||||
else
|
||||
fail "$label: existing config was modified!"
|
||||
fi
|
||||
|
||||
# Proposed-config side-channel: when a config already exists, a NEWER default
|
||||
# config is surfaced as a NON-loaded opencode.jsonc.proposed (write-on-diff,
|
||||
# removed once the live config matches). The live config is never touched.
|
||||
label="generate-config writes .proposed only when config differs"
|
||||
if docker run --rm \
|
||||
-e OPENCODE_PROVIDER=anthropic \
|
||||
-e HOME=/tmp/home \
|
||||
--entrypoint="" \
|
||||
"$IMAGE" sh -c '
|
||||
set -e
|
||||
d=/tmp/home/.config/opencode
|
||||
mkdir -p "$d"
|
||||
gc=/usr/local/lib/opencode-devbox/generate-config.py
|
||||
# (a) differing existing config → proposed written, live NOT clobbered
|
||||
printf "{\n \"model\": \"old/model\"\n}\n" > "$d/opencode.jsonc"
|
||||
python3 "$gc" 2>/dev/null
|
||||
test -f "$d/opencode.jsonc.proposed"
|
||||
grep -q "old/model" "$d/opencode.jsonc"
|
||||
# (b) live matches defaults + stale proposed present → proposed removed
|
||||
rm -f "$d/opencode.jsonc" "$d/opencode.jsonc.proposed"
|
||||
python3 "$gc" 2>/dev/null
|
||||
cp "$d/opencode.jsonc" "$d/opencode.jsonc.proposed"
|
||||
python3 "$gc" 2>/dev/null
|
||||
test ! -f "$d/opencode.jsonc.proposed"
|
||||
echo ok
|
||||
' 2>/dev/null | grep -q ok; then
|
||||
pass "$label"
|
||||
else
|
||||
fail "$label: proposed-config behaviour incorrect"
|
||||
fi
|
||||
rm -rf "$tmp"
|
||||
|
||||
echo
|
||||
|
||||
Reference in New Issue
Block a user