diff --git a/CHANGELOG.md b/CHANGELOG.md index ce3f7b2..a35f82f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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). --- diff --git a/entrypoint-user.sh b/entrypoint-user.sh index d62ba10..5ad47c2 100644 --- a/entrypoint-user.sh +++ b/entrypoint-user.sh @@ -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 ── diff --git a/rootfs/usr/local/lib/opencode-devbox/generate-config.py b/rootfs/usr/local/lib/opencode-devbox/generate-config.py index 4674be6..4f264e6 100755 --- a/rootfs/usr/local/lib/opencode-devbox/generate-config.py +++ b/rootfs/usr/local/lib/opencode-devbox/generate-config.py @@ -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 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( diff --git a/scripts/smoke-test.sh b/scripts/smoke-test.sh index a9cd93a..b7b9a69 100755 --- a/scripts/smoke-test.sh +++ b/scripts/smoke-test.sh @@ -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