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
|
`OPENCODE_VERSION` ARG in `Dockerfile.variant`. `1.17.8` is the current npm
|
||||||
`latest` stable. Only the variant layer rebuilds; the base is unaffected.
|
`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
|
The pi-devbox v1.1.4 deep-merge into a preserved `settings.json` does not port
|
||||||
preserved-volume `settings.json`. The direct analogue does **not** port cleanly
|
cleanly here: opencode's config is *generated from env vars* and written as
|
||||||
here: opencode's config is *generated from env vars* and written as **JSONC
|
JSONC with comments (not a static image-owned template), and overwriting or
|
||||||
with comments** (not a static image-owned template), and `generate-config.py`
|
`jq`-merging a possibly-bind-mounted host config is destructive. Instead,
|
||||||
deliberately never touches an existing config (host bind-mount or persisted
|
`generate-config.py` keeps its "never touch an existing config" guarantee and
|
||||||
volume). A `jq`-style merge would strip the JSONC comments and risks clobbering
|
adds a non-destructive side-channel: when a live config exists, it writes
|
||||||
or re-adding entries a user removed. Left for a separate, deliberate change —
|
`opencode.jsonc.proposed` — the config it *would* generate for the current
|
||||||
see discussion.
|
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 ──────
|
# ── Generate opencode config from env vars if no config mounted ──────
|
||||||
# Delegated to a standalone Python script for clarity and testability.
|
# Delegated to a standalone Python script for clarity and testability.
|
||||||
# The script is idempotent: it never overwrites an existing opencode.json
|
# The script never overwrites an existing opencode.json/.jsonc (bind-mounted
|
||||||
# (bind-mounted from host, persisted in named volume, or previously
|
# from host, persisted in named volume, or previously generated) and no-ops if
|
||||||
# generated) and no-ops if OPENCODE_PROVIDER is unset.
|
# 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
|
python3 /usr/local/lib/opencode-devbox/generate-config.py
|
||||||
|
|
||||||
# ── Skillset: deploy skills/instructions from mounted skillset repo ──
|
# ── Skillset: deploy skills/instructions from mounted skillset repo ──
|
||||||
|
|||||||
@@ -3,9 +3,12 @@
|
|||||||
Generate opencode.json from environment variables on first container start.
|
Generate opencode.json from environment variables on first container start.
|
||||||
|
|
||||||
Safety guarantees:
|
Safety guarantees:
|
||||||
- NEVER overwrites an existing opencode.json. If the file is present
|
- NEVER overwrites an existing config (opencode.json / opencode.jsonc),
|
||||||
(whether bind-mounted from the host, persisted in a named volume, or
|
whether bind-mounted from the host, persisted in a named volume, or
|
||||||
previously generated), this script exits immediately without writing.
|
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.
|
- Requires OPENCODE_PROVIDER to be set. Without it, no file is written.
|
||||||
|
|
||||||
Environment variables:
|
Environment variables:
|
||||||
@@ -18,13 +21,16 @@ MCP servers are auto-registered for tools detected on PATH:
|
|||||||
- mempalace (if installed) — enabled
|
- mempalace (if installed) — enabled
|
||||||
- gitea-mcp (if installed) — registered but disabled by default
|
- 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
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -110,6 +116,113 @@ def register_mcp_servers(config: dict) -> list[str]:
|
|||||||
return list(servers.keys())
|
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:
|
def main() -> int:
|
||||||
provider = os.environ.get("OPENCODE_PROVIDER", "").strip()
|
provider = os.environ.get("OPENCODE_PROVIDER", "").strip()
|
||||||
if not provider:
|
if not provider:
|
||||||
@@ -120,19 +233,7 @@ def main() -> int:
|
|||||||
config_dir = home / ".config" / "opencode"
|
config_dir = home / ".config" / "opencode"
|
||||||
config_file = config_dir / "opencode.jsonc"
|
config_file = config_dir / "opencode.jsonc"
|
||||||
config_file_legacy = config_dir / "opencode.json"
|
config_file_legacy = config_dir / "opencode.json"
|
||||||
|
proposed_file = config_dir / "opencode.jsonc.proposed"
|
||||||
# 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
|
|
||||||
|
|
||||||
if provider not in DEFAULT_MODELS:
|
if provider not in DEFAULT_MODELS:
|
||||||
print(
|
print(
|
||||||
@@ -145,30 +246,37 @@ def main() -> int:
|
|||||||
provider, FALLBACK_MODEL
|
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)
|
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)
|
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:
|
with config_file.open("w") as f:
|
||||||
f.write(content)
|
f.write(content)
|
||||||
f.write("\n")
|
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:
|
if added:
|
||||||
print(
|
print(
|
||||||
|
|||||||
@@ -306,6 +306,37 @@ if docker run --rm \
|
|||||||
else
|
else
|
||||||
fail "$label: existing config was modified!"
|
fail "$label: existing config was modified!"
|
||||||
fi
|
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"
|
rm -rf "$tmp"
|
||||||
|
|
||||||
echo
|
echo
|
||||||
|
|||||||
Reference in New Issue
Block a user