Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
2 changes: 1 addition & 1 deletion CONFIGURATION.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ ACP 从最多三层配置文件中读取(后加载的覆盖先加载的):
- **类型:** `boolean`
- **默认值:** `true`
- **状态:** ACTIVE
- **说明:** 启动时自动检查并安装 ACP 更新。
- **说明:** 启动时自动检查并安装 ACP 更新,跟踪安装时所用的 dist-tag/规范(`opencode-acp@stable` 跟随 `stable` 标签;`^1.14.0` 等范围规范跟随 `latest`)。版本锁定的规范永不更新

#### `debug`
- **类型:** `boolean`
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Expand Down
4 changes: 2 additions & 2 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<date>.log)
Expand Down
44 changes: 44 additions & 0 deletions devlog/2026-08-21_autoupdate-by-tag/REQ.md
Original file line number Diff line number Diff line change
@@ -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@<spec>`) 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/<tag>` (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) |
37 changes: 37 additions & 0 deletions devlog/2026-08-21_autoupdate-by-tag/WORKLOG.md
Original file line number Diff line number Diff line change
@@ -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/<tag>` 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/<name>` 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)
62 changes: 53 additions & 9 deletions lib/update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,14 +46,19 @@ async function checkAutoUpdate(signal: AbortSignal): Promise<UpdateResult> {
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,
Expand All @@ -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<UpdateTarget | undefined> {
const packageParent = dirname(packageDir)
const nodeModulesDir = basename(packageParent).startsWith("@")
? dirname(packageParent)
Expand All @@ -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) {
Expand All @@ -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<PackageJson | undefined> {
try {
const data = JSON.parse(await readFile(path, "utf-8"))
Expand All @@ -127,10 +169,12 @@ async function readPackageJson(path: string): Promise<PackageJson | undefined> {
}
}

async function fetchLatestVersion(name: string, signal: AbortSignal) {
async function fetchLatestVersion(name: string, tag: string, signal: AbortSignal) {
try {
// `/name/<tag>` 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,
},
Expand Down
77 changes: 76 additions & 1 deletion tests/update.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -55,6 +55,81 @@ 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("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")
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<string, unknown>) {
await mkdir(dir, { recursive: true })
await writeFile(join(dir, "package.json"), `${JSON.stringify(data)}\n`, "utf-8")
Expand Down
Loading