From 960652cdc04cfaa9335af4c9cdb6354f480f7ac3 Mon Sep 17 00:00:00 2001 From: ranxianglei Date: Fri, 21 Aug 2026 21:07:56 +0800 Subject: [PATCH 1/2] fix: auto-update tracks installed dist-tag, not hardcoded latest (#328) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - isAutoUpdatableSpec now accepts registry dist-tag words (stable, dev, pr-N) — previously rejected, so README installs (@stable) never auto-updated - specUpdateTag maps install spec → dist-tag: tag word → itself, */ranges → latest, exact pins/non-registry → undefined (never update) - fetchLatestVersion queries /name/ so @stable follows the stable track - updateTarget returns {removeDir, spec}; updateRemoveDir kept as wrapper - tests: +5 (fail-first verified), docs: autoUpdate tag semantics in 4 files, devlog: 2026-08-21_autoupdate-by-tag --- CONFIGURATION.md | 2 +- CONFIGURATION.zh-CN.md | 2 +- README.md | 3 +- README.zh-CN.md | 4 +- devlog/2026-08-21_autoupdate-by-tag/REQ.md | 44 +++++++++++++ .../2026-08-21_autoupdate-by-tag/WORKLOG.md | 37 +++++++++++ lib/update.ts | 62 +++++++++++++++--- tests/update.test.ts | 64 ++++++++++++++++++- 8 files changed, 203 insertions(+), 15 deletions(-) create mode 100644 devlog/2026-08-21_autoupdate-by-tag/REQ.md create mode 100644 devlog/2026-08-21_autoupdate-by-tag/WORKLOG.md diff --git a/CONFIGURATION.md b/CONFIGURATION.md index 2bc3f346..30722213 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -51,7 +51,7 @@ Status legend: **ACTIVE** = currently used | **DEPRECATED** = accepted but no ef - **Type:** `boolean` - **Default:** `true` - **Status:** ACTIVE -- **Description:** Automatically check for and install ACP updates on startup. +- **Description:** Automatically check for and install ACP updates on startup, tracking the dist-tag/spec the plugin was installed with (`opencode-acp@stable` follows the `stable` tag; range specs like `^1.14.0` follow `latest`). Version-locked specs are never updated. #### `debug` - **Type:** `boolean` diff --git a/CONFIGURATION.zh-CN.md b/CONFIGURATION.zh-CN.md index 5f285494..17b7e9cc 100644 --- a/CONFIGURATION.zh-CN.md +++ b/CONFIGURATION.zh-CN.md @@ -51,7 +51,7 @@ ACP 从最多三层配置文件中读取(后加载的覆盖先加载的): - **类型:** `boolean` - **默认值:** `true` - **状态:** ACTIVE -- **说明:** 启动时自动检查并安装 ACP 更新。 +- **说明:** 启动时自动检查并安装 ACP 更新,跟踪安装时所用的 dist-tag/规范(`opencode-acp@stable` 跟随 `stable` 标签;`^1.14.0` 等范围规范跟随 `latest`)。版本锁定的规范永不更新。 #### `debug` - **类型:** `boolean` diff --git a/README.md b/README.md index 44141957..ae280002 100644 --- a/README.md +++ b/README.md @@ -278,7 +278,8 @@ Each level overrides the previous, so project settings take priority over global "$schema": "https://raw.githubusercontent.com/ranxianglei/opencode-acp/master/dcp.schema.json", // Enable or disable the plugin "enabled": true, - // Automatically update npm-installed ACP when a newer npm latest is available. + // Automatically update npm-installed ACP when a newer version is available + // on the installed dist-tag/spec (@stable follows stable, @latest follows latest). // Version-locked plugin specs are not updated. "autoUpdate": true, // Enable INFO/DEBUG logging + per-request snapshots to ~/.config/opencode/logs/acp/ diff --git a/README.zh-CN.md b/README.zh-CN.md index b61b9ed1..b842e543 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -233,8 +233,8 @@ ACP 使用自己的配置文件,按以下顺序搜索: "$schema": "https://raw.githubusercontent.com/ranxianglei/opencode-acp/master/dcp.schema.json", // Enable or disable the plugin "enabled": true, - // Automatically update npm-installed ACP when a newer npm latest is available. - // Version-locked plugin specs are not updated. + // 自动更新 npm 安装的 ACP:跟踪安装所用 dist-tag/规范(@stable 跟随 stable,@latest 跟随 latest)。 + // 版本锁定的规范不会被更新。 "autoUpdate": true, // Enable INFO/DEBUG logging + per-request snapshots to ~/.config/opencode/logs/acp/ // (WARN/ERROR are always logged to daily/.log) diff --git a/devlog/2026-08-21_autoupdate-by-tag/REQ.md b/devlog/2026-08-21_autoupdate-by-tag/REQ.md new file mode 100644 index 00000000..32055209 --- /dev/null +++ b/devlog/2026-08-21_autoupdate-by-tag/REQ.md @@ -0,0 +1,44 @@ +# REQ — Auto-Update Tracks Installed Dist-Tag + +## Problem (Issue #328: "auto update by tag not latest") + +The README's canonical install is `opencode plugin opencode-acp@stable --global`, which makes +opencode cache the plugin at `~/.cache/opencode/packages/opencode-acp@stable/node_modules/opencode-acp/` +and pin the exact resolved version in the wrapper's `package.json` (Arborist `savePrefix: ""`). + +The inherited DCP auto-updater had two defects for that layout: + +1. `isAutoUpdatableSpec("stable")` → `false`. Bare dist-tag words (`stable`, `dev`, `pr-N`) match + none of the allowed forms (`latest`, `*`, `~`/`^`, comparators, ranges), so auto-update + **silently never fired** for every user following the README. +2. `fetchLatestVersion` hardcoded the `latest` dist-tag. Even if the gate had passed, a `@stable` + install would be compared against the `latest` track; removing the wrapper makes opencode + reinstall `@stable`, so the update would never land (and the toast would claim a version the + user cannot get). The package publishes many dist-tags (`stable`, `dev`, `pr-325`…`pr-327`), + so the channel must follow the install spec. + +## Fix + +Auto-update now tracks the dist-tag the user installed from: + +- `wrapperSpec` (directory name suffix `name@`) remains the source of truth for user intent. +- New `isDistTag`: bare word without `/:@` whitespace, not an exact semver pin, not an x-range. +- `isAutoUpdatableSpec` accepts dist-tags; `pr-N` CI preview installs become auto-updatable. +- New `specUpdateTag(spec)`: dist-tag word → that tag; `*`/ranges → `latest`; pins/non-registry → + `undefined`. +- `fetchLatestVersion(name, tag)` hits `/name/` (registry resolves dist-tags; verified: + `/opencode-acp/stable` → 1.14.19, `/opencode-acp/pr-327` → 1.14.22-pr.327.46). +- `updateRemoveDir` refactored to `updateTarget` returning `{removeDir, spec}`; the old export is + kept as a wrapper for compatibility. + +Behavior matrix: + +| Install spec | Gate | Registry endpoint checked | +| ------------------- | ------ | --------------------------- | +| `opencode-acp@stable` | ✅ | `/opencode-acp/stable` | +| `opencode-acp@pr-327` | ✅ | `/opencode-acp/pr-327` | +| `opencode-acp@latest` | ✅ | `/opencode-acp/latest` | +| `^1.14.0` / `*` | ✅ | `/opencode-acp/latest` | +| `1.14.22` (pin) | ❌ | — | +| `file:` / `github:` | ❌ | — | +| bare name (no tag) | ❌ | — (opencode pins exact deps) | diff --git a/devlog/2026-08-21_autoupdate-by-tag/WORKLOG.md b/devlog/2026-08-21_autoupdate-by-tag/WORKLOG.md new file mode 100644 index 00000000..4b843afc --- /dev/null +++ b/devlog/2026-08-21_autoupdate-by-tag/WORKLOG.md @@ -0,0 +1,37 @@ +# WORKLOG — Auto-Update Tracks Installed Dist-Tag + +## Changes + +- `lib/update.ts` + - `checkAutoUpdate`: resolves install spec via new `updateTarget`, maps it to a dist-tag via + new `specUpdateTag`, fetches that tag's current version. + - `updateTarget` (new export): `{removeDir, spec}`; `updateRemoveDir` kept as thin wrapper + (existing public API/tests unchanged). + - `isAutoUpdatableSpec`: now accepts registry dist-tag words (`stable`, `dev`, `pr-327`, …). + - `isDistTag` (new): charset `[A-Za-z0-9._-]`, no leading punctuation; rejects exact semver + pins (`parseVersion`) and x-ranges (`1.x`). + - `specUpdateTag` (new export): tag word → itself; `*`/range → `latest`; otherwise `undefined`. + - `fetchLatestVersion(name, tag, signal)`: `/name/` instead of hardcoded `/latest`. +- `tests/update.test.ts`: +5 tests (dist-tag acceptance, pin/git/file rejection, tag mapping, + `opencode-acp@stable` README layout now updatable, `updateTarget` spec exposure). TDD order: + new tests failed on pre-fix code (`isAutoUpdatableSpec("stable") === false` verified), then + pass after fix. +- `README.md` / `README.zh-CN.md` / `CONFIGURATION.md` / `CONFIGURATION.zh-CN.md`: autoUpdate + documented as tag-tracking. + +## Root-cause evidence + +- opencode v1.18.20 `packages/core/src/npm.ts`: `directory(pkg) = path.join(global.cache, + "packages", sanitize(pkg))` — wrapper dir named after the user spec; `savePrefix: ""` pins + exact versions in wrapper `package.json`; existing `node_modules/` short-circuits + re-reify, so ACP's rm-wrapper self-update is the only refresh path. +- `https://registry.npmjs.org/-/package/opencode-acp/dist-tags`: `latest` 1.14.22, `stable` + 1.14.19, `dev`, 25 `pr-*` tags. +- `GET /opencode-acp/stable` → version 1.14.19; `GET /opencode-acp/pr-327` → 1.14.22-pr.327.46 + (dist-tag resolution on the version endpoint confirmed). + +## Verification + +- `node --import tsx --test tests/update.test.ts`: 10/10 pass (was 5/5 before, new 5 fail-first) +- `npx tsc --noEmit`: 0 errors +- Full suite: 1015 pass / 0 fail (1010 master baseline + 5 new) diff --git a/lib/update.ts b/lib/update.ts index c07305bf..8e55a4ec 100644 --- a/lib/update.ts +++ b/lib/update.ts @@ -46,14 +46,19 @@ async function checkAutoUpdate(signal: AbortSignal): Promise { const pkg = await readPackageJson(join(packageDir, "package.json")) if (!pkg?.name || !pkg.version) return { updated: false } - const latest = await fetchLatestVersion(pkg.name, signal) - if (!latest || !isVersionNewer(latest, pkg.version)) return { updated: false } + const target = await updateTarget(packageDir, pkg.name) + if (!target) return { updated: false } + + // Update within the channel the user installed from (dist-tag), not the + // global `latest` dist-tag: an @stable install must follow `stable`. + const tag = specUpdateTag(target.spec) + if (!tag) return { updated: false } - const removeDir = await updateRemoveDir(packageDir, pkg.name) - if (!removeDir) return { updated: false } + const latest = await fetchLatestVersion(pkg.name, tag, signal) + if (!latest || !isVersionNewer(latest, pkg.version)) return { updated: false } try { - await rm(removeDir, { recursive: true, force: true }) + await rm(target.removeDir, { recursive: true, force: true }) } catch { return { updated: false, @@ -79,7 +84,14 @@ async function findPackageDir(name: string) { } } -export async function updateRemoveDir(packageDir: string, name: string) { +export type UpdateTarget = { + /** Wrapper directory to remove so opencode reinstalls on next start. */ + removeDir: string + /** Installed plugin spec (from the wrapper directory name, e.g. `stable`, `^1.14.0`). */ + spec: string +} + +export async function updateTarget(packageDir: string, name: string): Promise { const packageParent = dirname(packageDir) const nodeModulesDir = basename(packageParent).startsWith("@") ? dirname(packageParent) @@ -91,7 +103,11 @@ export async function updateRemoveDir(packageDir: string, name: string) { const spec = wrapperSpec(wrapperDir, name) ?? wrapperPkg?.dependencies?.[name] if (!spec || !isAutoUpdatableSpec(spec)) return undefined - return wrapperDir + return { removeDir: wrapperDir, spec } +} + +export async function updateRemoveDir(packageDir: string, name: string) { + return (await updateTarget(packageDir, name))?.removeDir } function wrapperSpec(wrapperDir: string, name: string) { @@ -115,9 +131,35 @@ export function isAutoUpdatableSpec(spec: string) { if (/^[~^]/.test(value)) return true if (/^(?:>=|>|<=|<)/.test(value)) return true if (/\s+(?:\|\||-|[<>=])\s+/.test(value)) return true + if (isDistTag(value)) return true return false } +/** + * Registry dist-tag the auto-updater should track for a spec. + * + * - `stable`, `dev`, `pr-327`, `latest` → that dist-tag + * - ranges (`^1.2.3`, `>=1.0.0`, `*`) → `latest` + * - exact pins / non-registry specs → undefined (never auto-update) + */ +export function specUpdateTag(spec: string) { + const value = spec.trim() + if (!isAutoUpdatableSpec(value)) return undefined + if (value === "*") return "latest" + if (isDistTag(value)) return value + return "latest" +} + +function isDistTag(value: string) { + // npm dist-tag names contain no `/`, `@`, `:`, or whitespace. Anything with + // those is a path/git/URL spec; a bare exact version (or x-range) is a pin, + // not a tag. + if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(value)) return false + if (parseVersion(value)) return false + if (/\.x(\.|$)/i.test(value)) return false + return true +} + async function readPackageJson(path: string): Promise { try { const data = JSON.parse(await readFile(path, "utf-8")) @@ -127,10 +169,12 @@ async function readPackageJson(path: string): Promise { } } -async function fetchLatestVersion(name: string, signal: AbortSignal) { +async function fetchLatestVersion(name: string, tag: string, signal: AbortSignal) { try { + // `/name/` resolves dist-tags on the registry (`/pkg/stable` returns + // the manifest of the version currently tagged `stable`). const response = await fetch( - `https://registry.npmjs.org/${encodeURIComponent(name)}/latest`, + `https://registry.npmjs.org/${encodeURIComponent(name)}/${encodeURIComponent(tag)}`, { signal, }, diff --git a/tests/update.test.ts b/tests/update.test.ts index f3f67168..063bba85 100644 --- a/tests/update.test.ts +++ b/tests/update.test.ts @@ -3,7 +3,7 @@ import { join } from "node:path" import { tmpdir } from "node:os" import test from "node:test" import assert from "node:assert/strict" -import { isAutoUpdatableSpec, isVersionNewer, updateRemoveDir } from "../lib/update" +import { isAutoUpdatableSpec, isVersionNewer, specUpdateTag, updateRemoveDir, updateTarget } from "../lib/update" test("isVersionNewer compares semver versions", () => { assert.equal(isVersionNewer("3.2.0", "3.1.9"), true) @@ -55,6 +55,68 @@ test("updateRemoveDir skips version-locked opencode installs", async () => { assert.equal(await updateRemoveDir(packageDir, "@tarquinen/opencode-dcp"), undefined) }) +test("isAutoUpdatableSpec accepts registry dist-tags", () => { + assert.equal(isAutoUpdatableSpec("stable"), true) + assert.equal(isAutoUpdatableSpec("dev"), true) + assert.equal(isAutoUpdatableSpec("next"), true) + assert.equal(isAutoUpdatableSpec("pr-327"), true) +}) + +test("isAutoUpdatableSpec still rejects exact pins and non-registry specs", () => { + assert.equal(isAutoUpdatableSpec("1.14.22"), false) + assert.equal(isAutoUpdatableSpec("v1.14.22"), false) + assert.equal(isAutoUpdatableSpec("1.14.22-beta.1"), false) + assert.equal(isAutoUpdatableSpec("file:../opencode-acp"), false) + assert.equal(isAutoUpdatableSpec("github:user/repo"), false) + assert.equal(isAutoUpdatableSpec("git+https://example.com/repo.git"), false) +}) + +test("specUpdateTag tracks installed dist-tag; ranges fall back to latest", () => { + assert.equal(specUpdateTag("stable"), "stable") + assert.equal(specUpdateTag("pr-327"), "pr-327") + assert.equal(specUpdateTag("latest"), "latest") + assert.equal(specUpdateTag("*"), "latest") + assert.equal(specUpdateTag("^1.14.0"), "latest") + assert.equal(specUpdateTag("~1.14.0"), "latest") + assert.equal(specUpdateTag(">=1.0.0"), "latest") + assert.equal(specUpdateTag("1.14.22"), undefined) + assert.equal(specUpdateTag("file:../opencode-acp"), undefined) +}) + +test("updateRemoveDir removes wrapper for README tag installs (opencode-acp@stable)", async () => { + const rootDir = await mkdtemp(join(tmpdir(), "acp-update-")) + const wrapperDir = join(rootDir, "opencode-acp@stable") + const packageDir = join(wrapperDir, "node_modules", "opencode-acp") + // opencode pins exact resolved versions in the wrapper package.json (savePrefix: "") + await writePackageJson(wrapperDir, { + dependencies: { "opencode-acp": "1.14.19" }, + }) + await writePackageJson(packageDir, { + name: "opencode-acp", + version: "1.14.19", + }) + + assert.equal(await updateRemoveDir(packageDir, "opencode-acp"), wrapperDir) +}) + +test("updateTarget exposes the installed spec for tag-aware version fetch", async () => { + const rootDir = await mkdtemp(join(tmpdir(), "acp-update-")) + const wrapperDir = join(rootDir, "opencode-acp@pr-327") + const packageDir = join(wrapperDir, "node_modules", "opencode-acp") + await writePackageJson(wrapperDir, { + dependencies: { "opencode-acp": "1.14.22-pr.327.46" }, + }) + await writePackageJson(packageDir, { + name: "opencode-acp", + version: "1.14.22-pr.327.46", + }) + + const target = await updateTarget(packageDir, "opencode-acp") + assert.equal(target?.removeDir, wrapperDir) + assert.equal(target?.spec, "pr-327") + assert.equal(specUpdateTag(target?.spec ?? ""), "pr-327") +}) + async function writePackageJson(dir: string, data: Record) { await mkdir(dir, { recursive: true }) await writeFile(join(dir, "package.json"), `${JSON.stringify(data)}\n`, "utf-8") From 5c703405ab64e1a2e058ee704c01d21c12791b97 Mon Sep 17 00:00:00 2001 From: ranxianglei Date: Fri, 21 Aug 2026 21:15:11 +0800 Subject: [PATCH 2/2] test: version comparison within dev and pr-N preview channels - same-PR preview increments compare numerically (pr.327.47 > pr.327.46) - dev track moves forward; release outranks preview per semver - channel pinning keeps @pr-N installs compared only against their own tag --- tests/update.test.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tests/update.test.ts b/tests/update.test.ts index 063bba85..a8d09a7f 100644 --- a/tests/update.test.ts +++ b/tests/update.test.ts @@ -71,6 +71,19 @@ test("isAutoUpdatableSpec still rejects exact pins and non-registry specs", () = assert.equal(isAutoUpdatableSpec("git+https://example.com/repo.git"), false) }) +test("isVersionNewer updates within dev and pr-N preview channels", () => { + // Same PR, new CI preview build: numeric pre-release identifiers compare numerically + assert.equal(isVersionNewer("1.14.22-pr.327.47", "1.14.22-pr.327.46"), true) + assert.equal(isVersionNewer("1.14.22-pr.327.46", "1.14.22-pr.327.47"), false) + assert.equal(isVersionNewer("1.14.22-pr.327.46", "1.14.22-pr.327.46"), false) + // dev track moves forward + assert.equal(isVersionNewer("1.14.23-dev.0", "1.14.14-dev.1"), true) + // release outranks preview of the same version (semver precedence) — but channel + // pinning means an @pr-327 install is only ever compared against the pr-327 tag, + // never against latest/stable releases + assert.equal(isVersionNewer("1.14.22", "1.14.22-pr.327.46"), true) +}) + test("specUpdateTag tracks installed dist-tag; ranges fall back to latest", () => { assert.equal(specUpdateTag("stable"), "stable") assert.equal(specUpdateTag("pr-327"), "pr-327")