fix(ce-update): compare against main plugin.json, not release tags (#660)
This commit is contained in:
@@ -13,8 +13,9 @@ ce_platforms: [claude]
|
||||
|
||||
# Check Plugin Version
|
||||
|
||||
Verify the installed compound-engineering plugin version matches the latest
|
||||
release, and recommend the update command if it doesn't. Claude Code only.
|
||||
Verify the installed compound-engineering plugin version matches the upstream
|
||||
`plugin.json` on `main`, and recommend the update command if it doesn't.
|
||||
Claude Code only.
|
||||
|
||||
## Pre-resolved context
|
||||
|
||||
@@ -28,11 +29,17 @@ at skill-load time. For a marketplace-cached install it looks like
|
||||
`~/.claude/plugins/cache/<marketplace>/compound-engineering/<version>/skills/ce-update`,
|
||||
so the currently-loaded version is the basename two `dirname` levels up.
|
||||
|
||||
The upstream version comes from `plugins/compound-engineering/.claude-plugin/plugin.json`
|
||||
on `main` rather than the latest GitHub release tag, because the marketplace
|
||||
installs plugin contents from `main` HEAD. Comparing against release tags
|
||||
false-positives whenever `main` is ahead of the last tag (the normal state
|
||||
between releases).
|
||||
|
||||
**Skill directory:**
|
||||
!`echo "${CLAUDE_SKILL_DIR}"`
|
||||
|
||||
**Latest released version:**
|
||||
!`gh release list --repo EveryInc/compound-engineering-plugin --limit 30 --json tagName --jq '[.[] | select(.tagName | startswith("compound-engineering-v"))][0].tagName | sub("compound-engineering-v";"")' 2>/dev/null || echo '__CE_UPDATE_VERSION_FAILED__'`
|
||||
**Latest upstream version:**
|
||||
!`version=$(gh api repos/EveryInc/compound-engineering-plugin/contents/plugins/compound-engineering/.claude-plugin/plugin.json --jq '.content | @base64d | fromjson | .version' 2>/dev/null) && [ -n "$version" ] && echo "$version" || echo '__CE_UPDATE_VERSION_FAILED__'`
|
||||
|
||||
**Currently loaded version:**
|
||||
!`case "${CLAUDE_SKILL_DIR}" in */plugins/cache/*/compound-engineering/*/skills/ce-update) basename "$(dirname "$(dirname "${CLAUDE_SKILL_DIR}")")" ;; *) echo '__CE_UPDATE_NOT_MARKETPLACE__' ;; esac`
|
||||
@@ -49,8 +56,8 @@ requires Claude Code and stop. No further action.
|
||||
|
||||
### 2. Handle failure cases
|
||||
|
||||
If **Latest released version** contains `__CE_UPDATE_VERSION_FAILED__`: tell
|
||||
the user the latest release could not be fetched (gh may be unavailable or
|
||||
If **Latest upstream version** contains `__CE_UPDATE_VERSION_FAILED__`: tell
|
||||
the user the upstream version could not be fetched (gh may be unavailable or
|
||||
rate-limited) and stop.
|
||||
|
||||
If **Currently loaded version** contains `__CE_UPDATE_NOT_MARKETPLACE__`: this
|
||||
@@ -68,13 +75,13 @@ Then stop.
|
||||
|
||||
### 3. Compare versions
|
||||
|
||||
**Up to date** — `{currently loaded} == {latest}`:
|
||||
**Up to date** — `{currently loaded} == {latest upstream}`:
|
||||
|
||||
> "compound-engineering **v{version}** is installed and up to date."
|
||||
|
||||
**Out of date** — `{currently loaded} != {latest}`:
|
||||
**Out of date** — `{currently loaded} != {latest upstream}`:
|
||||
|
||||
> "compound-engineering is on **v{currently loaded}** but **v{latest}** is available.
|
||||
> "compound-engineering is on **v{currently loaded}** but **v{latest upstream}** is available.
|
||||
>
|
||||
> Update with:
|
||||
> ```
|
||||
|
||||
@@ -1,32 +1,165 @@
|
||||
import { readFileSync } from "fs"
|
||||
import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "fs"
|
||||
import { execFileSync } from "child_process"
|
||||
import { tmpdir } from "os"
|
||||
import path from "path"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
|
||||
// Regression guard for https://github.com/EveryInc/compound-engineering-plugin/issues/556.
|
||||
//
|
||||
// `CLAUDE_PLUGIN_ROOT` is set by the Claude Code harness to the currently-loaded
|
||||
// plugin version directory (e.g. `~/.claude/plugins/cache/<marketplace>/compound-engineering/<version>`).
|
||||
// It is NOT the plugins cache root. Appending `/cache/<anything>/compound-engineering/`
|
||||
// onto it produces a path that never exists, which causes the cache-probe to fail
|
||||
// and emit the `__CE_UPDATE_CACHE_FAILED__` sentinel on every healthy install.
|
||||
//
|
||||
// This has regressed twice. Fail fast if the antipattern reappears.
|
||||
const SKILL_PATH = path.join(
|
||||
process.cwd(),
|
||||
"plugins/compound-engineering/skills/ce-update/SKILL.md",
|
||||
)
|
||||
const SKILL_BODY = readFileSync(SKILL_PATH, "utf8")
|
||||
|
||||
describe("ce-update SKILL.md", () => {
|
||||
const skillPath = path.join(
|
||||
process.cwd(),
|
||||
"plugins/compound-engineering/skills/ce-update/SKILL.md",
|
||||
)
|
||||
const body = readFileSync(skillPath, "utf8")
|
||||
|
||||
// Regression guard for https://github.com/EveryInc/compound-engineering-plugin/issues/556.
|
||||
//
|
||||
// `CLAUDE_PLUGIN_ROOT` points at the currently-loaded plugin version directory
|
||||
// (e.g. `~/.claude/plugins/cache/<marketplace>/compound-engineering/<version>`),
|
||||
// NOT the plugins cache root. Appending `/cache/<anything>/compound-engineering/`
|
||||
// produces a path that never exists, which caused the cache-probe to fail and
|
||||
// emit `__CE_UPDATE_CACHE_FAILED__` on every healthy install. Has regressed twice.
|
||||
test("does not append a /cache/<marketplace>/ suffix onto CLAUDE_PLUGIN_ROOT", () => {
|
||||
// Matches any `${CLAUDE_PLUGIN_ROOT}/cache/<segment>/` or `${CLAUDE_PLUGIN_ROOT}/cache/<segment>"`
|
||||
// pattern. The harness variable is already under `cache/<marketplace>/`,
|
||||
// so concatenating another `cache/...` segment is always wrong.
|
||||
const antiPattern = /\$\{CLAUDE_PLUGIN_ROOT\}\/cache\//
|
||||
expect(
|
||||
antiPattern.test(body),
|
||||
antiPattern.test(SKILL_BODY),
|
||||
"ce-update/SKILL.md reintroduced the ${CLAUDE_PLUGIN_ROOT}/cache/... antipattern — derive the cache dir from dirname \"${CLAUDE_PLUGIN_ROOT}\" instead.",
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// Regression guard for https://github.com/EveryInc/compound-engineering-plugin/issues/659.
|
||||
//
|
||||
// The marketplace installs plugin contents from `main` HEAD, so the cache
|
||||
// folder basename reflects `plugin.json` at install time — not any release tag.
|
||||
// Comparing the installed folder against the latest GitHub release tag caused
|
||||
// a persistent false-positive "Out of date" whenever `main` was ahead of the
|
||||
// last tag (the normal state between releases), and the prescribed fix
|
||||
// (`claude plugin update ...`) reinstalled the same version, looping forever.
|
||||
//
|
||||
// Rather than grep-testing the literal shell string, this suite extracts the
|
||||
// "Latest upstream version" pre-resolution command and executes it against a
|
||||
// mocked `gh` that returns distinguishable values for `gh api` vs
|
||||
// `gh release list`. The command must report the version from `plugin.json`,
|
||||
// not from release tags.
|
||||
describe("ce-update 'Latest upstream version' pre-resolution command", () => {
|
||||
test("declares a 'Latest upstream version' pre-resolution section", () => {
|
||||
// Fails loudly if the SKILL structure regresses (renamed section, removed
|
||||
// pre-resolution block) so behavioral tests below can rely on it.
|
||||
expect(SKILL_BODY).toMatch(/\*\*Latest upstream version:\*\*\s*\n!`[^`\n]+`/)
|
||||
})
|
||||
|
||||
test("returns the version from main's plugin.json, not any release tag", () => {
|
||||
// Chosen so a tag-based fallback would produce a clearly different value
|
||||
// than the plugin.json-based read. Either 1.0.0 or an empty/sentinel
|
||||
// output indicates the command is reading the wrong source.
|
||||
const pluginJsonVersion = "99.0.0"
|
||||
const releaseTagVersion = "1.0.0"
|
||||
|
||||
const stdout = runUpstreamCommand(extractUpstreamVersionCommand(SKILL_BODY), {
|
||||
pluginJsonVersion,
|
||||
releaseTagVersion,
|
||||
})
|
||||
|
||||
expect(stdout).toBe(pluginJsonVersion)
|
||||
})
|
||||
|
||||
test("emits __CE_UPDATE_VERSION_FAILED__ when upstream plugin.json cannot be read", () => {
|
||||
// Simulates gh failing entirely (missing auth, offline, rate-limited).
|
||||
// The fallback must produce the sentinel so the skill's decision logic
|
||||
// can stop rather than silently compare against an empty string — a
|
||||
// pipeline-style `|| echo` only catches last-stage failures, and jq on
|
||||
// empty input exits 0 with no output.
|
||||
const stdout = runUpstreamCommand(extractUpstreamVersionCommand(SKILL_BODY), {
|
||||
ghExitCode: 1,
|
||||
})
|
||||
expect(stdout).toContain("__CE_UPDATE_VERSION_FAILED__")
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Extract the shell command between `**Latest upstream version:**` and the
|
||||
* next blank line in SKILL.md. The command sits on the next line wrapped in
|
||||
* backticks with a leading `!` (Claude Code's pre-resolution syntax).
|
||||
*/
|
||||
function extractUpstreamVersionCommand(body: string): string {
|
||||
const match = body.match(/\*\*Latest upstream version:\*\*\s*\n!`([^`\n]+)`/)
|
||||
if (!match) {
|
||||
throw new Error(
|
||||
`Could not extract 'Latest upstream version' pre-resolution command from ${SKILL_PATH}`,
|
||||
)
|
||||
}
|
||||
return match[1]
|
||||
}
|
||||
|
||||
type MockOptions = {
|
||||
pluginJsonVersion?: string
|
||||
releaseTagVersion?: string
|
||||
ghExitCode?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the skill's upstream-version command with a mocked `gh` on PATH.
|
||||
* The mock emits distinct payloads for `gh api` vs `gh release list` so the
|
||||
* test can prove which source the command actually reads from.
|
||||
*/
|
||||
function runUpstreamCommand(command: string, options: MockOptions): string {
|
||||
const { pluginJsonVersion, releaseTagVersion, ghExitCode } = options
|
||||
const mockDir = mkdtempSync(path.join(tmpdir(), "ce-update-gh-"))
|
||||
try {
|
||||
const pluginJsonB64 = pluginJsonVersion
|
||||
? Buffer.from(
|
||||
JSON.stringify({ name: "compound-engineering", version: pluginJsonVersion }),
|
||||
).toString("base64")
|
||||
: ""
|
||||
const releaseJson = releaseTagVersion
|
||||
? JSON.stringify([{ tagName: `compound-engineering-v${releaseTagVersion}` }])
|
||||
: "[]"
|
||||
|
||||
// Emulate gh's behaviour without requiring host `jq`: real `gh --jq` uses
|
||||
// gojq embedded in the binary, so neither the skill nor this mock needs
|
||||
// an external jq on PATH. When the skill asks a `--jq` filter that
|
||||
// extracts `.version`, we emit the pre-computed plugin.json version; when
|
||||
// it asks for `.tagName`, we emit the pre-computed release tag. Any other
|
||||
// filter is unexpected and the mock fails loudly so the test doesn't pass
|
||||
// by accident.
|
||||
const ghScript = `#!/bin/bash
|
||||
${ghExitCode !== undefined ? `exit ${ghExitCode}` : `
|
||||
subcommand="$1"; shift
|
||||
jq_filter=""
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--jq) jq_filter="$2"; shift 2 ;;
|
||||
*) shift ;;
|
||||
esac
|
||||
done
|
||||
case "$subcommand" in
|
||||
api)
|
||||
case "$jq_filter" in
|
||||
*'.version'*) printf '%s\\n' '${pluginJsonVersion ?? ""}' ;;
|
||||
'') printf '%s\\n' '{"content":"${pluginJsonB64}"}' ;;
|
||||
*) echo "unexpected --jq filter for gh api: $jq_filter" >&2; exit 2 ;;
|
||||
esac
|
||||
;;
|
||||
release)
|
||||
# If the skill ever falls back to release-tag lookup, this is what it gets.
|
||||
case "$jq_filter" in
|
||||
*'tagName'*) printf '%s\\n' '${releaseTagVersion ?? ""}' ;;
|
||||
'') printf '%s\\n' '${releaseJson}' ;;
|
||||
*) echo "unexpected --jq filter for gh release: $jq_filter" >&2; exit 2 ;;
|
||||
esac
|
||||
;;
|
||||
*) exit 1 ;;
|
||||
esac
|
||||
`}`
|
||||
const ghPath = path.join(mockDir, "gh")
|
||||
writeFileSync(ghPath, ghScript)
|
||||
chmodSync(ghPath, 0o755)
|
||||
|
||||
return execFileSync("bash", ["-c", command], {
|
||||
env: { ...process.env, PATH: `${mockDir}:${process.env.PATH ?? ""}` },
|
||||
encoding: "utf8",
|
||||
}).trim()
|
||||
} finally {
|
||||
rmSync(mockDir, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user