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:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user