diff --git a/src/opencode/devcontainer-feature.json b/src/opencode/devcontainer-feature.json index e71927d..fe287ac 100644 --- a/src/opencode/devcontainer-feature.json +++ b/src/opencode/devcontainer-feature.json @@ -1,7 +1,7 @@ { "name": "opencode CLI", "id": "opencode", - "version": "0.3.6", + "version": "0.3.7", "description": "Installs the opencode AI coding agent CLI and ensures volume-mounted data directories are owned by the correct user.", "documentationURL": "https://opencode.ai", "options": { diff --git a/src/opencode/postStartCommand.sh b/src/opencode/postStartCommand.sh index 0a34e95..99dd1a7 100755 --- a/src/opencode/postStartCommand.sh +++ b/src/opencode/postStartCommand.sh @@ -5,6 +5,60 @@ echo_prefix="[opencode-poststart]" autoupdate="${AUTOUPDATE:-true}" +# ensure volume-mounted config directories exist and have correct ownership +# resolve the user and home matching where install.sh placed the config +TARGET_USER="" +TARGET_HOME="${HOME:-}" + +if [ -n "$TARGET_HOME" ]; then + TARGET_USER="$(getent passwd | awk -F: -v h="$TARGET_HOME" '$6 == h {print $1; exit}')" || true +fi + +if [ -z "$TARGET_USER" ]; then + user="${_REMOTE_USER:-${USERNAME:-}}" + valid_user="$(getent passwd | awk -F: '$3 >= 1000 && $1 !~ /^(nobody|nfsnobody|daemon)$/ {print $1; exit 0}')" || true + [ -z "$valid_user" ] && valid_user="root" + if [ -n "$user" ]; then + user_shell="$(getent passwd "$user" 2>/dev/null | cut -d: -f7)" || true + case "$user_shell" in + */nologin|*/false|"") + user="$valid_user" + ;; + esac + fi + [ -z "$user" ] && user="$valid_user" + TARGET_USER="$user" + TARGET_HOME="$(getent passwd "$TARGET_USER" 2>/dev/null | cut -d: -f6)" + [ -z "$TARGET_HOME" ] && TARGET_HOME="/home/$TARGET_USER" +fi + +# recreate directories masked by volume mounts +mkdir -p "${TARGET_HOME}/.config/opencode" +mkdir -p "${TARGET_HOME}/.local/share/opencode" +mkdir -p "${TARGET_HOME}/.local/state/opencode" + +# ensure opencode.json with lsp config exists +opencode_config="${TARGET_HOME}/.config/opencode/opencode.json" +if [ -f "$opencode_config" ]; then + if ! jq -e '.lsp' "$opencode_config" >/dev/null 2>&1; then + jq '. + {"lsp": {}}' "$opencode_config" > "${opencode_config}.tmp" && mv "${opencode_config}.tmp" "$opencode_config" + echo "$echo_prefix added 'lsp' key to existing opencode.json" + fi +else + cat > "$opencode_config" <<'EOF' +{ + "$schema": "https://opencode.ai/config.json", + "lsp": {} +} +EOF + echo "$echo_prefix created opencode.json with LSP enabled" +fi + +chown -R "${TARGET_USER}:${TARGET_USER}" \ + "${TARGET_HOME}/.config/opencode" \ + "${TARGET_HOME}/.local/share/opencode" \ + "${TARGET_HOME}/.local/state/opencode" 2>/dev/null || true + if ! command -v opencode >/dev/null 2>&1; then echo "$echo_prefix opencode not found, installing..." curl -fsSL https://opencode.ai/install | bash diff --git a/test/opencode/autoupdate_disabled.sh b/test/opencode/autoupdate_disabled.sh index 4a0fc3b..97e709c 100644 --- a/test/opencode/autoupdate_disabled.sh +++ b/test/opencode/autoupdate_disabled.sh @@ -15,4 +15,12 @@ check "opencode.json has lsp key" jq -e '.lsp' "$OPENCODE_CONFIG" check "opencode.json lsp is empty" test "$(jq '.lsp | length' "$OPENCODE_CONFIG")" -eq 0 +# test postStartCommand recreates config when missing (volume mount case) +OPENCODE_CONFIG_BAK="$OPENCODE_CONFIG" +rm -f "$OPENCODE_CONFIG_BAK" +AUTOUPDATE=false bash /usr/local/share/devcontainer-features/opencode-postStartCommand.sh || true +check "postStartCommand recreates opencode.json" test -f "$OPENCODE_CONFIG_BAK" +check "recreated opencode.json has lsp key" jq -e '.lsp' "$OPENCODE_CONFIG_BAK" +check "recreated opencode.json lsp is empty" test "$(jq '.lsp | length' "$OPENCODE_CONFIG_BAK")" -eq 0 + reportResults \ No newline at end of file diff --git a/test/opencode/autoupdate_enabled.sh b/test/opencode/autoupdate_enabled.sh index 3b27772..861436a 100644 --- a/test/opencode/autoupdate_enabled.sh +++ b/test/opencode/autoupdate_enabled.sh @@ -17,4 +17,12 @@ check "opencode.json has lsp key" jq -e '.lsp' "$OPENCODE_CONFIG" check "opencode.json lsp is empty" test "$(jq '.lsp | length' "$OPENCODE_CONFIG")" -eq 0 +# test postStartCommand recreates config when missing (volume mount case) +OPENCODE_CONFIG_BAK="$OPENCODE_CONFIG" +rm -f "$OPENCODE_CONFIG_BAK" +AUTOUPDATE=false bash /usr/local/share/devcontainer-features/opencode-postStartCommand.sh || true +check "postStartCommand recreates opencode.json" test -f "$OPENCODE_CONFIG_BAK" +check "recreated opencode.json has lsp key" jq -e '.lsp' "$OPENCODE_CONFIG_BAK" +check "recreated opencode.json lsp is empty" test "$(jq '.lsp | length' "$OPENCODE_CONFIG_BAK")" -eq 0 + reportResults \ No newline at end of file diff --git a/test/opencode/custom_username.sh b/test/opencode/custom_username.sh index d249541..74d8104 100755 --- a/test/opencode/custom_username.sh +++ b/test/opencode/custom_username.sh @@ -17,4 +17,12 @@ check "opencode.json has lsp key" jq -e '.lsp' "$OPENCODE_CONFIG" check "opencode.json lsp is empty" test "$(jq '.lsp | length' "$OPENCODE_CONFIG")" -eq 0 +# test postStartCommand recreates config when missing (volume mount case) +OPENCODE_CONFIG_BAK="$OPENCODE_CONFIG" +rm -f "$OPENCODE_CONFIG_BAK" +AUTOUPDATE=false bash /usr/local/share/devcontainer-features/opencode-postStartCommand.sh || true +check "postStartCommand recreates opencode.json" test -f "$OPENCODE_CONFIG_BAK" +check "recreated opencode.json has lsp key" jq -e '.lsp' "$OPENCODE_CONFIG_BAK" +check "recreated opencode.json lsp is empty" test "$(jq '.lsp | length' "$OPENCODE_CONFIG_BAK")" -eq 0 + reportResults diff --git a/test/opencode/default.sh b/test/opencode/default.sh index d249541..74d8104 100755 --- a/test/opencode/default.sh +++ b/test/opencode/default.sh @@ -17,4 +17,12 @@ check "opencode.json has lsp key" jq -e '.lsp' "$OPENCODE_CONFIG" check "opencode.json lsp is empty" test "$(jq '.lsp | length' "$OPENCODE_CONFIG")" -eq 0 +# test postStartCommand recreates config when missing (volume mount case) +OPENCODE_CONFIG_BAK="$OPENCODE_CONFIG" +rm -f "$OPENCODE_CONFIG_BAK" +AUTOUPDATE=false bash /usr/local/share/devcontainer-features/opencode-postStartCommand.sh || true +check "postStartCommand recreates opencode.json" test -f "$OPENCODE_CONFIG_BAK" +check "recreated opencode.json has lsp key" jq -e '.lsp' "$OPENCODE_CONFIG_BAK" +check "recreated opencode.json lsp is empty" test "$(jq '.lsp | length' "$OPENCODE_CONFIG_BAK")" -eq 0 + reportResults diff --git a/test/opencode/specific_version.sh b/test/opencode/specific_version.sh index 872d49c..1dd0182 100755 --- a/test/opencode/specific_version.sh +++ b/test/opencode/specific_version.sh @@ -21,4 +21,12 @@ check "opencode.json has lsp key" jq -e '.lsp' "$OPENCODE_CONFIG" check "opencode.json lsp is empty" test "$(jq '.lsp | length' "$OPENCODE_CONFIG")" -eq 0 +# test postStartCommand recreates config when missing (volume mount case) +OPENCODE_CONFIG_BAK="$OPENCODE_CONFIG" +rm -f "$OPENCODE_CONFIG_BAK" +AUTOUPDATE=false bash /usr/local/share/devcontainer-features/opencode-postStartCommand.sh || true +check "postStartCommand recreates opencode.json" test -f "$OPENCODE_CONFIG_BAK" +check "recreated opencode.json has lsp key" jq -e '.lsp' "$OPENCODE_CONFIG_BAK" +check "recreated opencode.json lsp is empty" test "$(jq '.lsp | length' "$OPENCODE_CONFIG_BAK")" -eq 0 + reportResults diff --git a/test/opencode/test.sh b/test/opencode/test.sh index 5c30acb..3e2f4ae 100755 --- a/test/opencode/test.sh +++ b/test/opencode/test.sh @@ -19,4 +19,12 @@ check "opencode.json has lsp key" jq -e '.lsp' "$OPENCODE_CONFIG" check "opencode.json lsp is empty" test "$(jq '.lsp | length' "$OPENCODE_CONFIG")" -eq 0 +# test postStartCommand recreates config when missing (volume mount case) +OPENCODE_CONFIG_BAK="$OPENCODE_CONFIG" +rm -f "$OPENCODE_CONFIG_BAK" +AUTOUPDATE=false bash /usr/local/share/devcontainer-features/opencode-postStartCommand.sh || true +check "postStartCommand recreates opencode.json" test -f "$OPENCODE_CONFIG_BAK" +check "recreated opencode.json has lsp key" jq -e '.lsp' "$OPENCODE_CONFIG_BAK" +check "recreated opencode.json lsp is empty" test "$(jq '.lsp | length' "$OPENCODE_CONFIG_BAK")" -eq 0 + reportResults diff --git a/wiki/architecture.md b/wiki/architecture.md index e423018..43c91c2 100644 --- a/wiki/architecture.md +++ b/wiki/architecture.md @@ -8,8 +8,15 @@ └── validate.yaml # Validate features src/ # Feature implementations ├── opencode/ +│ ├── devcontainer-feature.json # metadata + options +│ ├── install.sh # build-time setup +│ ├── postStartCommand.sh # runtime config (volume resilience) +│ ├── README.md +│ └── NOTES.md +├── agents-workspace/ │ ├── devcontainer-feature.json │ ├── install.sh +│ ├── postStartCommand.sh │ ├── README.md │ └── NOTES.md └── agency-agents/ diff --git a/wiki/conventions.md b/wiki/conventions.md index c9246cc..24e4f04 100644 --- a/wiki/conventions.md +++ b/wiki/conventions.md @@ -4,7 +4,14 @@ Each feature lives in `src//` with: - `devcontainer-feature.json` — metadata + options -- `install.sh` — entrypoint script +- `install.sh` — entrypoint script (build time) +- `postStartCommand.sh` — post-start script (runtime) + +## Volume Mount Resilience + +Config files and directories that must survive Docker volume mounts must be created or ensured in `postStartCommand.sh`, not only in `install.sh`. Volumes mounted at runtime replace build-time directories; `postStartCommand.sh` runs after volumes are mounted and can recreate any missing state. + +`install.sh` may still create these files at build time for the non-volume case, but `postStartCommand.sh` must be the authoritative source for runtime state. ## Options diff --git a/wiki/decisions/poststart-config-creation.md b/wiki/decisions/poststart-config-creation.md new file mode 100644 index 0000000..26bed9a --- /dev/null +++ b/wiki/decisions/poststart-config-creation.md @@ -0,0 +1,22 @@ +--- +title: PostStart config creation for volume mount resilience +date: 2026-05-26 +status: accepted +--- + +## Context + +The `opencode` feature creates `~/.config/opencode/opencode.json` (with `lsp: {}`) during `install.sh` at build time. When a Docker volume is mounted at `/home/vscode/.config/opencode`, the volume starts empty at runtime and hides the build-time file. The `postStartCommand.sh` did not recreate it, so LSP servers remained unconfigured. + +The `agents-workspace` feature demonstrates the correct pattern: `install.sh` only copies the postStart script, and all actual setup happens in `postStartCommand.sh` at runtime. + +## Decision + +Move `opencode.json` creation (and directory ownership fixes) from `install.sh` into `postStartCommand.sh`, which runs every container start. `install.sh` still creates the file at build time (harmless for non-volume cases), but `postStartCommand.sh` guarantees it exists even when a volume replaces the directory. + +## Consequences + +- Volume mounts no longer break LSP server configuration +- `postStartCommand.sh` is idempotent — if the file exists with `lsp`, it skips creation; if it exists without `lsp`, it merges it in +- Ownership is corrected at runtime in case the volume changes UID/GID mappings +- Tests now validate that `postStartCommand.sh` can recreate the config independently diff --git a/wiki/features.md b/wiki/features.md index 3022250..cf1d1d0 100644 --- a/wiki/features.md +++ b/wiki/features.md @@ -2,13 +2,17 @@ ## opencode -Installs opencode CLI and fixes volume-mounted directory permissions. +Installs opencode CLI and ensures volume-mounted config directories are writable. Options: - `username` (default: _REMOTE_USER) — user to install for - `version` (default: empty/latest) — install specific version - `autoupdate` (default: true) — auto-upgrade on container start +Behavior: +- `install.sh` installs the CLI, creates directories, writes `~/.config/opencode/opencode.json` with `lsp: {}`, and generates a fix-permissions script. +- `postStartCommand.sh` ensures `opencode.json` exists at every start (critical when `~/.config/opencode` is a volume mount), installing opencode if missing and running autoupgrade when enabled. + Idempotency: marker uses version if specified, else "latest" ## agency-agents diff --git a/wiki/index.md b/wiki/index.md index 1d37162..c05ad09 100644 --- a/wiki/index.md +++ b/wiki/index.md @@ -1,10 +1,11 @@ # Wiki - [architecture.md](architecture.md) — project structure and feature layout -- [conventions.md](conventions.md) — feature structure and option patterns +- [conventions.md](conventions.md) — feature structure, option patterns, volume mount resilience - [features.md](features.md) — available features and their purpose - [publishing.md](publishing.md) — release workflow and GHCR publishing - [testing.md](testing.md) — testing commands and CLI usage +- [decisions/poststart-config-creation.md](decisions/poststart-config-creation.md) — runtime config creation for volume mount support ## Operations