diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index d9b6f58..75dbec9 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -4,6 +4,9 @@ on: push: pull_request: +permissions: + contents: read + jobs: package: name: Package validation @@ -11,12 +14,35 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v7 with: python-version: "3.11" - name: Validate package run: python scripts/validate-package.py + + posix-installers: + name: POSIX installers + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Test install and uninstall + run: sh scripts/test-installers.sh + + powershell-installers: + name: Windows PowerShell installers + runs-on: windows-latest + + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Test install and uninstall + shell: powershell + run: .\scripts\test-installers.ps1 diff --git a/CHANGELOG.md b/CHANGELOG.md index a18a126..93b6774 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ - Documented global default activation after install. - Added `CAVEMAN_OPENCODE_DEFAULT_LEVEL` install-time override for `lite`, `full`, `ultra`, `wenyan`, `wenyan-lite`, and `wenyan-ultra`. +- Accepted trailing commas in existing OpenCode configuration without changing comma-like text inside JSON strings. +- Added functional install/uninstall tests for POSIX and Windows PowerShell environments. ## 0.2.0 diff --git a/install-opencode.ps1 b/install-opencode.ps1 index 8ca9cd4..f63c5b0 100644 --- a/install-opencode.ps1 +++ b/install-opencode.ps1 @@ -38,6 +38,60 @@ function Ensure-Property($Object, [string]$Name, $Value) { } } +function Remove-JsonTrailingCommas([string]$Content) { + $result = [System.Text.StringBuilder]::new($Content.Length) + $inString = $false + $escaped = $false + + for ($index = 0; $index -lt $Content.Length; $index++) { + $character = $Content[$index] + + if ($inString) { + [void]$result.Append($character) + if ($escaped) { + $escaped = $false + } elseif ($character -eq '\') { + $escaped = $true + } elseif ($character -eq '"') { + $inString = $false + } + continue + } + + if ($character -eq '"') { + $inString = $true + [void]$result.Append($character) + continue + } + + if ($character -eq ',') { + $next = $index + 1 + while ($next -lt $Content.Length -and [char]::IsWhiteSpace($Content[$next])) { + $next++ + } + if ($next -lt $Content.Length -and ($Content[$next] -eq '}' -or $Content[$next] -eq ']')) { + continue + } + } + + [void]$result.Append($character) + } + + return $result.ToString() +} + +function ConvertFrom-OpenCodeJson([string]$Content) { + try { + return $Content | ConvertFrom-Json + } catch { + $normalized = Remove-JsonTrailingCommas $Content + if ($normalized -eq $Content) { + throw + } + return $normalized | ConvertFrom-Json + } +} + if (-not (Test-Path -LiteralPath (Join-Path $sourceOpenCode "AGENTS.md"))) { $archiveUrl = $env:CAVEMAN_OPENCODE_ARCHIVE_URL if ([string]::IsNullOrWhiteSpace($archiveUrl)) { @@ -81,7 +135,8 @@ $agentsText = $agentsText -replace "Default mode: full\.", "Default mode: $defau $agentsText | Set-Content -LiteralPath $agentsFile -Encoding UTF8 if (Test-Path -LiteralPath $configFile) { - $config = Get-Content -LiteralPath $configFile -Raw -Encoding UTF8 | ConvertFrom-Json + $content = Get-Content -LiteralPath $configFile -Raw -Encoding UTF8 + $config = ConvertFrom-OpenCodeJson $content } else { $config = [pscustomobject]@{} } diff --git a/install-opencode.sh b/install-opencode.sh index c960b82..55aa275 100755 --- a/install-opencode.sh +++ b/install-opencode.sh @@ -74,8 +74,63 @@ const [configFile, agentsFile] = process.argv.slice(2) const skills = ["caveman", "caveman-commit", "caveman-review", "caveman-help", "caveman-compress"] let config = {} +function stripTrailingCommas(content) { + let result = "" + let inString = false + let escaped = false + + for (let index = 0; index < content.length; index += 1) { + const character = content[index] + + if (inString) { + result += character + if (escaped) { + escaped = false + } else if (character === "\\") { + escaped = true + } else if (character === '"') { + inString = false + } + continue + } + + if (character === '"') { + inString = true + result += character + continue + } + + if (character === ",") { + let next = index + 1 + while (next < content.length && /\s/.test(content[next])) { + next += 1 + } + if (content[next] === "}" || content[next] === "]") { + continue + } + } + + result += character + } + + return result +} + +function parseConfig(content) { + try { + return JSON.parse(content) + } catch (error) { + const normalized = stripTrailingCommas(content) + if (normalized === content) { + throw error + } + return JSON.parse(normalized) + } +} + if (fs.existsSync(configFile)) { - config = JSON.parse(fs.readFileSync(configFile, "utf8")) + const content = fs.readFileSync(configFile, "utf8") + config = parseConfig(content) } config.instructions = Array.isArray(config.instructions) ? config.instructions : [] diff --git a/scripts/test-installers.ps1 b/scripts/test-installers.ps1 new file mode 100644 index 0000000..d4786a0 --- /dev/null +++ b/scripts/test-installers.ps1 @@ -0,0 +1,80 @@ +$ErrorActionPreference = "Stop" + +function Assert-True([bool]$Condition, [string]$Message) { + if (-not $Condition) { + throw "Assertion failed: $Message" + } +} + +$repo = Split-Path -Parent $PSScriptRoot +$testRoot = Join-Path ([System.IO.Path]::GetTempPath()) ("caveman-opencode-test-" + [guid]::NewGuid().ToString("N")) +$originalUserProfile = $env:USERPROFILE +$originalDefaultLevel = $env:CAVEMAN_OPENCODE_DEFAULT_LEVEL + +try { + $env:USERPROFILE = $testRoot + $env:CAVEMAN_OPENCODE_DEFAULT_LEVEL = "ultra" + + $configDir = Join-Path $testRoot ".config\opencode" + $configFile = Join-Path $configDir "opencode.json" + $agentsFile = Join-Path $configDir "AGENTS.caveman.md" + New-Item -ItemType Directory -Force -Path $configDir | Out-Null + + @' +{ + "instructions": [ + "keep.md", + ], + "custom": "literal,}", + "nested": { + "value": "literal,]", + }, + "permission": { + "skill": { + "existing": "allow", + }, + }, +} +'@ | Set-Content -LiteralPath $configFile -Encoding UTF8 + + & (Join-Path $repo "install-opencode.ps1") + + $config = Get-Content -LiteralPath $configFile -Raw -Encoding UTF8 | ConvertFrom-Json + $skills = @("caveman", "caveman-commit", "caveman-review", "caveman-help", "caveman-compress") + + Assert-True (@($config.instructions).Count -eq 2) "installer should preserve and append instructions" + Assert-True (@($config.instructions) -contains "keep.md") "existing instruction should remain" + Assert-True (@($config.instructions) -contains $agentsFile) "Caveman instruction should be added" + Assert-True ($config.custom -eq "literal,}") "comma before a brace inside a string should remain" + Assert-True ($config.nested.value -eq "literal,]") "comma before a bracket inside a string should remain" + Assert-True ($config.permission.skill.existing -eq "allow") "existing skill permission should remain" + foreach ($skill in $skills) { + Assert-True ($config.permission.skill.$skill -eq "allow") "$skill should be allowed" + } + Assert-True ((Get-Content -LiteralPath $agentsFile -Raw -Encoding UTF8) -match "(?m)^Default mode: ultra\.") "default intensity should be applied" + + & (Join-Path $repo "uninstall-opencode.ps1") + + $config = Get-Content -LiteralPath $configFile -Raw -Encoding UTF8 | ConvertFrom-Json + Assert-True (@($config.instructions).Count -eq 1) "uninstaller should remove only the Caveman instruction" + Assert-True (@($config.instructions) -contains "keep.md") "existing instruction should survive uninstall" + Assert-True ($config.custom -eq "literal,}") "custom string should survive uninstall" + Assert-True ($config.nested.value -eq "literal,]") "nested string should survive uninstall" + Assert-True ($config.permission.skill.existing -eq "allow") "existing permission should survive uninstall" + foreach ($skill in $skills) { + Assert-True ($config.permission.skill.PSObject.Properties.Name -notcontains $skill) "$skill permission should be removed" + } + Assert-True (-not (Test-Path -LiteralPath $agentsFile)) "Caveman instruction file should be removed" + + Write-Host "PowerShell installer tests passed." +} finally { + $env:USERPROFILE = $originalUserProfile + if ($null -eq $originalDefaultLevel) { + Remove-Item Env:CAVEMAN_OPENCODE_DEFAULT_LEVEL -ErrorAction SilentlyContinue + } else { + $env:CAVEMAN_OPENCODE_DEFAULT_LEVEL = $originalDefaultLevel + } + if (Test-Path -LiteralPath $testRoot) { + Remove-Item -Recurse -Force -LiteralPath $testRoot + } +} diff --git a/scripts/test-installers.sh b/scripts/test-installers.sh new file mode 100755 index 0000000..f72ad6b --- /dev/null +++ b/scripts/test-installers.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env sh +set -eu + +repo=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +test_root=$(mktemp -d "${TMPDIR:-/tmp}/caveman-opencode-test.XXXXXX") +config_home="$test_root/config" +config_dir="$config_home/opencode" +config_file="$config_dir/opencode.json" +agents_file="$config_dir/AGENTS.caveman.md" + +cleanup() { + rm -rf "$test_root" +} +trap cleanup EXIT + +mkdir -p "$config_dir" + +node - "$config_file" <<'NODE' +const fs = require("fs") + +const [configFile] = process.argv.slice(2) +fs.writeFileSync( + configFile, + `{ + "instructions": [ + "keep.md", + ], + "custom": "literal,}", + "nested": { + "value": "literal,]", + }, + "permission": { + "skill": { + "existing": "allow", + }, + }, +} +`, +) +NODE + +CAVEMAN_OPENCODE_DEFAULT_LEVEL=ultra XDG_CONFIG_HOME="$config_home" sh "$repo/install-opencode.sh" + +node - "$config_file" "$agents_file" <<'NODE' +const assert = require("assert").strict +const fs = require("fs") + +const [configFile, agentsFile] = process.argv.slice(2) +const config = JSON.parse(fs.readFileSync(configFile, "utf8")) +const skills = ["caveman", "caveman-commit", "caveman-review", "caveman-help", "caveman-compress"] + +assert.deepEqual(config.instructions, ["keep.md", agentsFile]) +assert.equal(config.custom, "literal,}") +assert.equal(config.nested.value, "literal,]") +assert.equal(config.permission.skill.existing, "allow") +for (const skill of skills) { + assert.equal(config.permission.skill[skill], "allow") +} +assert.match(fs.readFileSync(agentsFile, "utf8"), /^Default mode: ultra\./m) +NODE + +XDG_CONFIG_HOME="$config_home" sh "$repo/uninstall-opencode.sh" + +node - "$config_file" "$agents_file" <<'NODE' +const assert = require("assert").strict +const fs = require("fs") + +const [configFile, agentsFile] = process.argv.slice(2) +const config = JSON.parse(fs.readFileSync(configFile, "utf8")) +const skills = ["caveman", "caveman-commit", "caveman-review", "caveman-help", "caveman-compress"] + +assert.deepEqual(config.instructions, ["keep.md"]) +assert.equal(config.custom, "literal,}") +assert.equal(config.nested.value, "literal,]") +assert.equal(config.permission.skill.existing, "allow") +for (const skill of skills) { + assert.equal(config.permission.skill[skill], undefined) +} +assert.equal(fs.existsSync(agentsFile), false) +console.log("POSIX installer tests passed.") +NODE diff --git a/uninstall-opencode.ps1 b/uninstall-opencode.ps1 index 7de4a47..acff446 100644 --- a/uninstall-opencode.ps1 +++ b/uninstall-opencode.ps1 @@ -14,6 +14,60 @@ $skills = @( "caveman-compress" ) +function Remove-JsonTrailingCommas([string]$Content) { + $result = [System.Text.StringBuilder]::new($Content.Length) + $inString = $false + $escaped = $false + + for ($index = 0; $index -lt $Content.Length; $index++) { + $character = $Content[$index] + + if ($inString) { + [void]$result.Append($character) + if ($escaped) { + $escaped = $false + } elseif ($character -eq '\') { + $escaped = $true + } elseif ($character -eq '"') { + $inString = $false + } + continue + } + + if ($character -eq '"') { + $inString = $true + [void]$result.Append($character) + continue + } + + if ($character -eq ',') { + $next = $index + 1 + while ($next -lt $Content.Length -and [char]::IsWhiteSpace($Content[$next])) { + $next++ + } + if ($next -lt $Content.Length -and ($Content[$next] -eq '}' -or $Content[$next] -eq ']')) { + continue + } + } + + [void]$result.Append($character) + } + + return $result.ToString() +} + +function ConvertFrom-OpenCodeJson([string]$Content) { + try { + return $Content | ConvertFrom-Json + } catch { + $normalized = Remove-JsonTrailingCommas $Content + if ($normalized -eq $Content) { + throw + } + return $normalized | ConvertFrom-Json + } +} + foreach ($skill in $skills) { $skillPath = Join-Path $skillsDir $skill if (Test-Path -LiteralPath $skillPath) { @@ -33,7 +87,8 @@ if (Test-Path -LiteralPath $agentsFile) { } if (Test-Path -LiteralPath $configFile) { - $config = Get-Content -LiteralPath $configFile -Raw -Encoding UTF8 | ConvertFrom-Json + $content = Get-Content -LiteralPath $configFile -Raw -Encoding UTF8 + $config = ConvertFrom-OpenCodeJson $content if ($config.PSObject.Properties.Name -contains "instructions") { $config.instructions = @(@($config.instructions) | Where-Object { $_ -ne $agentsFile }) } diff --git a/uninstall-opencode.sh b/uninstall-opencode.sh index 4b09d85..97e7f29 100755 --- a/uninstall-opencode.sh +++ b/uninstall-opencode.sh @@ -24,7 +24,63 @@ const fs = require("fs") const [configFile, agentsFile] = process.argv.slice(2) const skills = ["caveman", "caveman-commit", "caveman-review", "caveman-help", "caveman-compress"] -const config = JSON.parse(fs.readFileSync(configFile, "utf8")) + +function stripTrailingCommas(content) { + let result = "" + let inString = false + let escaped = false + + for (let index = 0; index < content.length; index += 1) { + const character = content[index] + + if (inString) { + result += character + if (escaped) { + escaped = false + } else if (character === "\\") { + escaped = true + } else if (character === '"') { + inString = false + } + continue + } + + if (character === '"') { + inString = true + result += character + continue + } + + if (character === ",") { + let next = index + 1 + while (next < content.length && /\s/.test(content[next])) { + next += 1 + } + if (content[next] === "}" || content[next] === "]") { + continue + } + } + + result += character + } + + return result +} + +function parseConfig(content) { + try { + return JSON.parse(content) + } catch (error) { + const normalized = stripTrailingCommas(content) + if (normalized === content) { + throw error + } + return JSON.parse(normalized) + } +} + +const content = fs.readFileSync(configFile, "utf8") +const config = parseConfig(content) if (Array.isArray(config.instructions)) { config.instructions = config.instructions.filter((item) => item !== agentsFile)