Skip to content

Commit e00bd5b

Browse files
authored
Merge pull request #515 from Opencode-DCP/auto-update
Auto update
2 parents 5042d3e + 7332f62 commit e00bd5b

6 files changed

Lines changed: 249 additions & 2 deletions

File tree

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,9 @@ Each level overrides the previous, so project settings take priority over global
6464
"$schema": "https://raw.githubusercontent.com/Opencode-DCP/opencode-dynamic-context-pruning/master/dcp.schema.json",
6565
// Enable or disable the plugin
6666
"enabled": true,
67+
// Automatically update npm-installed DCP when a newer npm latest is available.
68+
// Version-locked plugin specs are not updated.
69+
"autoUpdate": true,
6770
// Enable debug logging to ~/.config/opencode/logs/dcp/
6871
"debug": false,
6972
// Notification display: "off", "minimal", or "detailed"

dcp.schema.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,11 @@
1515
"default": true,
1616
"description": "Enable or disable the DCP plugin"
1717
},
18+
"autoUpdate": {
19+
"type": "boolean",
20+
"default": true,
21+
"description": "Automatically update npm-installed DCP when a newer npm latest version is available. Version-locked plugin specs are not updated."
22+
},
1823
"debug": {
1924
"type": "boolean",
2025
"default": false,

index.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,7 @@ import {
1717
createTextCompleteHandler,
1818
} from "./lib/hooks"
1919
import { configureClientAuth, isSecureMode } from "./lib/auth"
20-
21-
const id = "opencode-dynamic-context-pruning"
20+
import { startAutoUpdate } from "./lib/update"
2221

2322
const server: Plugin = (async (ctx) => {
2423
const config = getConfig(ctx)
@@ -44,6 +43,8 @@ const server: Plugin = (async (ctx) => {
4443
strategies: config.strategies,
4544
})
4645

46+
startAutoUpdate(ctx, config.autoUpdate)
47+
4748
const compressToolContext = {
4849
client: ctx.client,
4950
state,

lib/config.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ export interface ExperimentalConfig {
5656

5757
export interface PluginConfig {
5858
enabled: boolean
59+
autoUpdate: boolean
5960
debug: boolean
6061
pruneNotification: "off" | "minimal" | "detailed"
6162
pruneNotificationType: "chat" | "toast"
@@ -91,6 +92,7 @@ const COMPRESS_DEFAULT_PROTECTED_TOOLS = ["task", "skill", "todowrite", "todorea
9192
export const VALID_CONFIG_KEYS = new Set([
9293
"$schema",
9394
"enabled",
95+
"autoUpdate",
9496
"debug",
9597
"showUpdateToasts",
9698
"pruneNotification",
@@ -168,6 +170,10 @@ export function validateConfigTypes(config: Record<string, any>): ValidationErro
168170
errors.push({ key: "enabled", expected: "boolean", actual: typeof config.enabled })
169171
}
170172

173+
if (config.autoUpdate !== undefined && typeof config.autoUpdate !== "boolean") {
174+
errors.push({ key: "autoUpdate", expected: "boolean", actual: typeof config.autoUpdate })
175+
}
176+
171177
if (config.debug !== undefined && typeof config.debug !== "boolean") {
172178
errors.push({ key: "debug", expected: "boolean", actual: typeof config.debug })
173179
}
@@ -639,6 +645,7 @@ function showConfigWarnings(
639645

640646
const defaultConfig: PluginConfig = {
641647
enabled: true,
648+
autoUpdate: true,
642649
debug: false,
643650
pruneNotification: "detailed",
644651
pruneNotificationType: "chat",
@@ -913,6 +920,7 @@ function deepCloneConfig(config: PluginConfig): PluginConfig {
913920
function mergeLayer(config: PluginConfig, data: Record<string, any>): PluginConfig {
914921
return {
915922
enabled: data.enabled ?? config.enabled,
923+
autoUpdate: data.autoUpdate ?? config.autoUpdate,
916924
debug: data.debug ?? config.debug,
917925
pruneNotification: data.pruneNotification ?? config.pruneNotification,
918926
pruneNotificationType: data.pruneNotificationType ?? config.pruneNotificationType,

lib/update.ts

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
import { readFile, rm } from "node:fs/promises"
2+
import { basename, dirname, join } from "node:path"
3+
import { fileURLToPath } from "node:url"
4+
import type { PluginInput } from "@opencode-ai/plugin"
5+
6+
type PackageJson = {
7+
name?: string
8+
version?: string
9+
dependencies?: Record<string, string>
10+
}
11+
12+
type UpdateResult =
13+
| { updated: true; name: string; current: string; latest: string }
14+
| { updated: false; error: "remove_failed"; name: string; current: string; latest: string }
15+
| { updated: false }
16+
17+
const PACKAGE_NAME = "@tarquinen/opencode-dcp"
18+
19+
export function startAutoUpdate(ctx: PluginInput, enabled: boolean): void {
20+
if (!enabled) return
21+
22+
const controller = new AbortController()
23+
const timeout = setTimeout(() => controller.abort(), 10_000)
24+
void checkAutoUpdate(controller.signal)
25+
.then((result) => {
26+
if (!result.updated) return
27+
setTimeout(() => {
28+
ctx.client.tui.showToast({
29+
body: {
30+
title: "DCP update ready",
31+
message: `Updated ${result.name} from ${result.current} to ${result.latest}. Restart OpenCode to finish.`,
32+
variant: "info",
33+
duration: 7000,
34+
},
35+
})
36+
}, 5000)
37+
})
38+
.catch(() => {})
39+
.finally(() => clearTimeout(timeout))
40+
}
41+
42+
export async function checkAutoUpdate(signal: AbortSignal): Promise<UpdateResult> {
43+
const packageDir = await findPackageDir(PACKAGE_NAME)
44+
if (!packageDir) return { updated: false }
45+
46+
const pkg = await readPackageJson(join(packageDir, "package.json"))
47+
if (!pkg?.name || !pkg.version) return { updated: false }
48+
49+
const latest = await fetchLatestVersion(pkg.name, signal)
50+
if (!latest || !isVersionNewer(latest, pkg.version)) return { updated: false }
51+
52+
const removeDir = await updateRemoveDir(packageDir, pkg.name)
53+
if (!removeDir) return { updated: false }
54+
55+
try {
56+
await rm(removeDir, { recursive: true, force: true })
57+
} catch {
58+
return {
59+
updated: false,
60+
error: "remove_failed",
61+
name: pkg.name,
62+
current: pkg.version,
63+
latest,
64+
}
65+
}
66+
67+
return { updated: true, name: pkg.name, current: pkg.version, latest }
68+
}
69+
70+
async function findPackageDir(name: string) {
71+
let dir = dirname(fileURLToPath(import.meta.url))
72+
for (;;) {
73+
const pkg = await readPackageJson(join(dir, "package.json"))
74+
if (pkg?.name === name) return dir
75+
76+
const parent = dirname(dir)
77+
if (parent === dir) return undefined
78+
dir = parent
79+
}
80+
}
81+
82+
export async function updateRemoveDir(packageDir: string, name: string) {
83+
const packageParent = dirname(packageDir)
84+
const nodeModulesDir = basename(packageParent).startsWith("@")
85+
? dirname(packageParent)
86+
: packageParent
87+
if (basename(nodeModulesDir) !== "node_modules") return undefined
88+
89+
const wrapperDir = dirname(nodeModulesDir)
90+
const wrapperPkg = await readPackageJson(join(wrapperDir, "package.json"))
91+
const spec = wrapperPkg?.dependencies?.[name]
92+
if (!spec || !isAutoUpdatableSpec(spec)) return undefined
93+
94+
return wrapperDir
95+
}
96+
97+
export function isAutoUpdatableSpec(spec: string) {
98+
const value = spec.trim()
99+
if (!value) return false
100+
if (value === "latest" || value === "*") return true
101+
if (/^[~^]/.test(value)) return true
102+
if (/^(?:>=|>|<=|<)/.test(value)) return true
103+
if (/\s+(?:\|\||-|[<>=])\s+/.test(value)) return true
104+
return false
105+
}
106+
107+
async function readPackageJson(path: string): Promise<PackageJson | undefined> {
108+
try {
109+
const data = JSON.parse(await readFile(path, "utf-8"))
110+
return data && typeof data === "object" ? (data as PackageJson) : undefined
111+
} catch {
112+
return undefined
113+
}
114+
}
115+
116+
async function fetchLatestVersion(name: string, signal: AbortSignal) {
117+
try {
118+
const response = await fetch(
119+
`https://registry.npmjs.org/${encodeURIComponent(name)}/latest`,
120+
{
121+
signal,
122+
},
123+
)
124+
if (!response.ok) return undefined
125+
const data: unknown = await response.json()
126+
if (!data || typeof data !== "object") return undefined
127+
const version = (data as { version?: unknown }).version
128+
return typeof version === "string" ? version : undefined
129+
} catch {
130+
return undefined
131+
}
132+
}
133+
134+
export function isVersionNewer(latest: string, current: string) {
135+
const next = parseVersion(latest)
136+
const prev = parseVersion(current)
137+
if (!next || !prev) return false
138+
139+
for (let i = 0; i < 3; i++) {
140+
if (next.parts[i] !== prev.parts[i]) return next.parts[i] > prev.parts[i]
141+
}
142+
143+
if (!next.pre.length && prev.pre.length) return true
144+
if (next.pre.length && !prev.pre.length) return false
145+
146+
for (let i = 0; i < Math.max(next.pre.length, prev.pre.length); i++) {
147+
const a = next.pre[i]
148+
const b = prev.pre[i]
149+
if (a === undefined) return false
150+
if (b === undefined) return true
151+
if (a === b) continue
152+
153+
const aNumber = /^\d+$/.test(a) ? Number(a) : undefined
154+
const bNumber = /^\d+$/.test(b) ? Number(b) : undefined
155+
if (aNumber !== undefined && bNumber !== undefined) return aNumber > bNumber
156+
if (aNumber !== undefined) return false
157+
if (bNumber !== undefined) return true
158+
return a > b
159+
}
160+
161+
return false
162+
}
163+
164+
function parseVersion(version: string) {
165+
const match = version.match(/^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+.+)?$/)
166+
if (!match) return undefined
167+
return {
168+
parts: [Number(match[1]), Number(match[2]), Number(match[3])],
169+
pre: match[4]?.split(".") ?? [],
170+
}
171+
}

tests/update.test.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { mkdir, mkdtemp, writeFile } from "node:fs/promises"
2+
import { join } from "node:path"
3+
import { tmpdir } from "node:os"
4+
import test from "node:test"
5+
import assert from "node:assert/strict"
6+
import { isAutoUpdatableSpec, isVersionNewer, updateRemoveDir } from "../lib/update"
7+
8+
test("isVersionNewer compares semver versions", () => {
9+
assert.equal(isVersionNewer("3.2.0", "3.1.9"), true)
10+
assert.equal(isVersionNewer("3.1.9", "3.1.9"), false)
11+
assert.equal(isVersionNewer("3.1.9", "3.2.0"), false)
12+
assert.equal(isVersionNewer("3.1.9", "3.1.9-beta.1"), true)
13+
})
14+
15+
test("isAutoUpdatableSpec allows latest and ranges", () => {
16+
assert.equal(isAutoUpdatableSpec("latest"), true)
17+
assert.equal(isAutoUpdatableSpec("*"), true)
18+
assert.equal(isAutoUpdatableSpec("^3.1.9"), true)
19+
assert.equal(isAutoUpdatableSpec(">=3.1.9"), true)
20+
})
21+
22+
test("isAutoUpdatableSpec rejects pinned and non-registry specs", () => {
23+
assert.equal(isAutoUpdatableSpec("3.1.9"), false)
24+
assert.equal(isAutoUpdatableSpec("file:../opencode-dcp"), false)
25+
assert.equal(isAutoUpdatableSpec("github:user/repo"), false)
26+
})
27+
28+
test("updateRemoveDir removes opencode npm wrapper for latest installs", async () => {
29+
const wrapperDir = await mkdtemp(join(tmpdir(), "dcp-update-"))
30+
const packageDir = join(wrapperDir, "node_modules", "@tarquinen", "opencode-dcp")
31+
await writePackageJson(wrapperDir, {
32+
dependencies: { "@tarquinen/opencode-dcp": "latest" },
33+
})
34+
await writePackageJson(packageDir, {
35+
name: "@tarquinen/opencode-dcp",
36+
version: "3.1.9",
37+
})
38+
39+
assert.equal(await updateRemoveDir(packageDir, "@tarquinen/opencode-dcp"), wrapperDir)
40+
})
41+
42+
test("updateRemoveDir skips version-locked opencode installs", async () => {
43+
const wrapperDir = await mkdtemp(join(tmpdir(), "dcp-update-"))
44+
const packageDir = join(wrapperDir, "node_modules", "@tarquinen", "opencode-dcp")
45+
await writePackageJson(wrapperDir, {
46+
dependencies: { "@tarquinen/opencode-dcp": "3.1.9" },
47+
})
48+
await writePackageJson(packageDir, {
49+
name: "@tarquinen/opencode-dcp",
50+
version: "3.1.9",
51+
})
52+
53+
assert.equal(await updateRemoveDir(packageDir, "@tarquinen/opencode-dcp"), undefined)
54+
})
55+
56+
async function writePackageJson(dir: string, data: Record<string, unknown>) {
57+
await mkdir(dir, { recursive: true })
58+
await writeFile(join(dir, "package.json"), `${JSON.stringify(data)}\n`, "utf-8")
59+
}

0 commit comments

Comments
 (0)