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
|
# Check Plugin Version
|
||||||
|
|
||||||
Verify the installed compound-engineering plugin version matches the latest
|
Verify the installed compound-engineering plugin version matches the upstream
|
||||||
release, and recommend the update command if it doesn't. Claude Code only.
|
`plugin.json` on `main`, and recommend the update command if it doesn't.
|
||||||
|
Claude Code only.
|
||||||
|
|
||||||
## Pre-resolved context
|
## 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`,
|
`~/.claude/plugins/cache/<marketplace>/compound-engineering/<version>/skills/ce-update`,
|
||||||
so the currently-loaded version is the basename two `dirname` levels up.
|
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:**
|
**Skill directory:**
|
||||||
!`echo "${CLAUDE_SKILL_DIR}"`
|
!`echo "${CLAUDE_SKILL_DIR}"`
|
||||||
|
|
||||||
**Latest released version:**
|
**Latest upstream 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__'`
|
!`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:**
|
**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`
|
!`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
|
### 2. Handle failure cases
|
||||||
|
|
||||||
If **Latest released version** contains `__CE_UPDATE_VERSION_FAILED__`: tell
|
If **Latest upstream version** contains `__CE_UPDATE_VERSION_FAILED__`: tell
|
||||||
the user the latest release could not be fetched (gh may be unavailable or
|
the user the upstream version could not be fetched (gh may be unavailable or
|
||||||
rate-limited) and stop.
|
rate-limited) and stop.
|
||||||
|
|
||||||
If **Currently loaded version** contains `__CE_UPDATE_NOT_MARKETPLACE__`: this
|
If **Currently loaded version** contains `__CE_UPDATE_NOT_MARKETPLACE__`: this
|
||||||
@@ -68,13 +75,13 @@ Then stop.
|
|||||||
|
|
||||||
### 3. Compare versions
|
### 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."
|
> "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:
|
> 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 path from "path"
|
||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
|
|
||||||
// Regression guard for https://github.com/EveryInc/compound-engineering-plugin/issues/556.
|
const SKILL_PATH = path.join(
|
||||||
//
|
process.cwd(),
|
||||||
// `CLAUDE_PLUGIN_ROOT` is set by the Claude Code harness to the currently-loaded
|
"plugins/compound-engineering/skills/ce-update/SKILL.md",
|
||||||
// plugin version directory (e.g. `~/.claude/plugins/cache/<marketplace>/compound-engineering/<version>`).
|
)
|
||||||
// It is NOT the plugins cache root. Appending `/cache/<anything>/compound-engineering/`
|
const SKILL_BODY = readFileSync(SKILL_PATH, "utf8")
|
||||||
// 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.
|
|
||||||
|
|
||||||
describe("ce-update SKILL.md", () => {
|
describe("ce-update SKILL.md", () => {
|
||||||
const skillPath = path.join(
|
// Regression guard for https://github.com/EveryInc/compound-engineering-plugin/issues/556.
|
||||||
process.cwd(),
|
//
|
||||||
"plugins/compound-engineering/skills/ce-update/SKILL.md",
|
// `CLAUDE_PLUGIN_ROOT` points at the currently-loaded plugin version directory
|
||||||
)
|
// (e.g. `~/.claude/plugins/cache/<marketplace>/compound-engineering/<version>`),
|
||||||
const body = readFileSync(skillPath, "utf8")
|
// 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", () => {
|
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\//
|
const antiPattern = /\$\{CLAUDE_PLUGIN_ROOT\}\/cache\//
|
||||||
expect(
|
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.",
|
"ce-update/SKILL.md reintroduced the ${CLAUDE_PLUGIN_ROOT}/cache/... antipattern — derive the cache dir from dirname \"${CLAUDE_PLUGIN_ROOT}\" instead.",
|
||||||
).toBe(false)
|
).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