opencode-plugin-auto-update 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,127 @@
1
+ # Opencode Plugin Auto-Update
2
+
3
+ [![npm version](https://img.shields.io/npm/v/opencode-plugin-auto-update.svg)](https://www.npmjs.com/package/opencode-plugin-auto-update)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
5
+
6
+ Automatically updates OpenCode plugins in the background on startup. No prompts, no blocking IO, and minimal logging by default.
7
+
8
+ ## 🚀 Overview
9
+
10
+ - Runs a background update pass on startup
11
+ - Throttled to once every 24 hours
12
+ - Uses a lock file to prevent concurrent runs
13
+ - Updates to latest by default (optional pin-preserving mode)
14
+
15
+ ## ✨ Features
16
+
17
+ - Zero startup delay (async, detached update)
18
+ - Bun-first installs with npm fallback
19
+ - Silent by default; debug logging via env
20
+ - Skips local/path/git plugins
21
+
22
+ ## 📦 Installation
23
+
24
+ ### 🤖 Let an LLM do it (Recommended)
25
+
26
+ Paste this into any LLM agent (OpenCode, Claude Code, Cursor, etc.):
27
+
28
+ ```text
29
+ Install the opencode-plugin-auto-update plugin by following: https://raw.githubusercontent.com/AnganSamadder/opencode-plugin-auto-update/main/README.md
30
+ ```
31
+
32
+ <details>
33
+ <summary><b>Instructions for LLM Agents</b></summary>
34
+
35
+ To install this plugin, please perform the following steps for the user:
36
+
37
+ 1. **Install the NPM package globally:**
38
+ ```bash
39
+ bun add -g opencode-plugin-auto-update
40
+ # or
41
+ npm install -g opencode-plugin-auto-update
42
+ ```
43
+
44
+ 2. **Configure OpenCode:**
45
+ Add `"opencode-plugin-auto-update"` to the `plugin` array in the user's config file (usually `~/.config/opencode/opencode.json`).
46
+
47
+ ```json
48
+ {
49
+ "plugin": [
50
+ "opencode-plugin-auto-update"
51
+ ]
52
+ }
53
+ ```
54
+
55
+ 3. **Verify & Finalize:**
56
+ Tell the user: **"Installation complete! Your OpenCode plugins will now update automatically in the background."**
57
+
58
+ </details>
59
+
60
+ ### 👤 For Humans (Manual)
61
+
62
+ 1. **Install:**
63
+ ```bash
64
+ bun add -g opencode-plugin-auto-update
65
+ # or
66
+ npm install -g opencode-plugin-auto-update
67
+ ```
68
+
69
+ 2. **Enable the plugin:**
70
+ Add `"opencode-plugin-auto-update"` to `~/.config/opencode/opencode.json`:
71
+ ```json
72
+ {
73
+ "plugin": [
74
+ "opencode-plugin-auto-update"
75
+ ]
76
+ }
77
+ ```
78
+
79
+ 3. **Restart OpenCode:**
80
+ The plugin runs on the next OpenCode startup.
81
+
82
+ ## 🛠️ How it works
83
+
84
+ 1. On startup, schedules a background update task.
85
+ 2. Applies a 24h throttle and lock to avoid repeated updates.
86
+ 3. Installs latest versions into `~/.config/opencode/node_modules`.
87
+ 4. Rewrites `opencode.json` plugin versions to the latest (unless pinned).
88
+
89
+ ## ⚙️ Configuration
90
+
91
+ Configure via environment variables:
92
+
93
+ | Variable | Default | Description |
94
+ |----------|---------|-------------|
95
+ | `OPENCODE_AUTO_UPDATE_DISABLED` | `false` | Disable all updates when `true` |
96
+ | `OPENCODE_AUTO_UPDATE_INTERVAL_HOURS` | `24` | Throttle interval in hours |
97
+ | `OPENCODE_AUTO_UPDATE_DEBUG` | `false` | Enable debug logs |
98
+ | `OPENCODE_AUTO_UPDATE_PINNED` | `false` | Preserve pinned versions |
99
+ | `OPENCODE_AUTO_UPDATE_BYPASS_THROTTLE` | `false` | Ignore throttle (useful for testing) |
100
+
101
+ ### CLI Flags
102
+
103
+ - `opencode --log-level DEBUG`: enable debug mode for OpenCode and trigger verbose update logs with throttle bypass.
104
+
105
+ ## ❓ Troubleshooting
106
+
107
+ 1. **Updates not running**: ensure `OPENCODE_AUTO_UPDATE_DISABLED` is not set to `true`.
108
+ 2. **No logs**: run `opencode --log-level DEBUG` or set `OPENCODE_AUTO_UPDATE_DEBUG=true` for verbose output.
109
+ 3. **Plugin not loading**: check the `plugin` array in `~/.config/opencode/opencode.json`.
110
+ 4. **Testing updates**: run `opencode --log-level DEBUG` to bypass the 24h throttle and see detailed update logs.
111
+
112
+ ## 🚀 Release Process
113
+
114
+ 1. Update version in `package.json`
115
+ 2. Update `CHANGELOG.md`
116
+ 3. `bun run build`
117
+ 4. `npm publish`
118
+ 5. `git tag vX.Y.Z && git push --tags`
119
+ 6. `gh release create vX.Y.Z --notes "..."`
120
+
121
+ ## 📄 License
122
+
123
+ MIT
124
+
125
+ ## 🙏 Acknowledgements
126
+
127
+ Inspired by opencode-agent-tmux and the OpenCode plugin ecosystem.
@@ -0,0 +1,31 @@
1
+ interface PluginInput {
2
+ directory: string;
3
+ serverUrl?: URL | string;
4
+ client: {
5
+ session: {
6
+ status(): Promise<{
7
+ data?: Record<string, {
8
+ type: string;
9
+ }>;
10
+ }>;
11
+ subscribe(callback: (event: {
12
+ type: string;
13
+ properties?: unknown;
14
+ }) => void): () => void;
15
+ };
16
+ };
17
+ }
18
+ interface PluginOutput {
19
+ name: string;
20
+ event?: (input: {
21
+ event: {
22
+ type: string;
23
+ properties?: unknown;
24
+ };
25
+ }) => Promise<void>;
26
+ tool?: Record<string, unknown>;
27
+ config?: unknown;
28
+ }
29
+ declare function export_default(ctx: PluginInput): Promise<PluginOutput>;
30
+
31
+ export { export_default as default };
package/dist/index.js ADDED
@@ -0,0 +1,386 @@
1
+ // src/update.ts
2
+ import { readFile as readFile2, writeFile as writeFile2, mkdir as mkdir2 } from "fs/promises";
3
+ import { dirname, join as join2 } from "path";
4
+ import { homedir as homedir2 } from "os";
5
+ import { spawn } from "child_process";
6
+
7
+ // src/lock.ts
8
+ import { readFile, writeFile, unlink, mkdir } from "fs/promises";
9
+ import { join } from "path";
10
+ import { homedir, hostname as osHostname } from "os";
11
+ var DEFAULT_CONFIG_DIR = join(homedir(), ".config", "opencode");
12
+ var STALE_LOCK_MS = 2 * 60 * 60 * 1e3;
13
+ function resolvePaths(configDir) {
14
+ const resolvedConfigDir = configDir ?? DEFAULT_CONFIG_DIR;
15
+ return {
16
+ configDir: resolvedConfigDir,
17
+ lockFile: join(resolvedConfigDir, ".auto-update.lock"),
18
+ throttleFile: join(resolvedConfigDir, ".auto-update.json")
19
+ };
20
+ }
21
+ async function isLockStale(lockPath) {
22
+ try {
23
+ const content = await readFile(lockPath, "utf-8");
24
+ const lockData = JSON.parse(content);
25
+ const age = Date.now() - lockData.timestamp;
26
+ return age > STALE_LOCK_MS;
27
+ } catch (error) {
28
+ return false;
29
+ }
30
+ }
31
+ async function acquireLock(options = {}) {
32
+ const { force = false, debug = false, configDir } = options;
33
+ const { configDir: resolvedConfigDir, lockFile } = resolvePaths(configDir);
34
+ try {
35
+ await mkdir(resolvedConfigDir, { recursive: true });
36
+ try {
37
+ const existingLock = await readFile(lockFile, "utf-8");
38
+ const lockData2 = JSON.parse(existingLock);
39
+ const stale = await isLockStale(lockFile);
40
+ if (stale && force) {
41
+ if (debug) {
42
+ console.log(`[lock] Stale lock detected (pid: ${lockData2.pid}), forcing acquisition`);
43
+ }
44
+ } else if (!stale) {
45
+ if (debug) {
46
+ console.log(`[lock] Lock already held by pid ${lockData2.pid}`);
47
+ }
48
+ return false;
49
+ } else {
50
+ if (debug) {
51
+ console.log(`[lock] Stale lock exists but force=false`);
52
+ }
53
+ return false;
54
+ }
55
+ } catch (error) {
56
+ }
57
+ const lockData = {
58
+ pid: process.pid,
59
+ timestamp: Date.now(),
60
+ hostname: await getHostname()
61
+ };
62
+ await writeFile(lockFile, JSON.stringify(lockData, null, 2), "utf-8");
63
+ if (debug) {
64
+ console.log(`[lock] Lock acquired by pid ${process.pid}`);
65
+ }
66
+ return true;
67
+ } catch (error) {
68
+ if (debug) {
69
+ console.error("[lock] Failed to acquire lock:", error);
70
+ }
71
+ return false;
72
+ }
73
+ }
74
+ async function releaseLock(options = {}) {
75
+ const { debug = false, configDir } = options;
76
+ const { lockFile } = resolvePaths(configDir);
77
+ try {
78
+ await unlink(lockFile);
79
+ if (debug) {
80
+ console.log(`[lock] Lock released by pid ${process.pid}`);
81
+ }
82
+ } catch (error) {
83
+ if (debug && error.code !== "ENOENT") {
84
+ console.error("[lock] Failed to release lock:", error);
85
+ }
86
+ }
87
+ }
88
+ async function readThrottleState(options = {}) {
89
+ const { configDir } = options;
90
+ const { throttleFile } = resolvePaths(configDir);
91
+ try {
92
+ const data = await readFile(throttleFile, "utf-8");
93
+ return JSON.parse(data);
94
+ } catch (error) {
95
+ return {};
96
+ }
97
+ }
98
+ async function writeThrottleState(state, options = {}) {
99
+ const { debug = false, configDir } = options;
100
+ const { configDir: resolvedConfigDir, throttleFile } = resolvePaths(configDir);
101
+ try {
102
+ await mkdir(resolvedConfigDir, { recursive: true });
103
+ await writeFile(throttleFile, JSON.stringify(state, null, 2), "utf-8");
104
+ if (debug) {
105
+ console.log("[throttle] State written:", state);
106
+ }
107
+ } catch (error) {
108
+ if (debug) {
109
+ console.error("[throttle] Failed to write state:", error);
110
+ }
111
+ throw error;
112
+ }
113
+ }
114
+ async function getHostname() {
115
+ try {
116
+ return osHostname();
117
+ } catch {
118
+ return "unknown";
119
+ }
120
+ }
121
+
122
+ // src/update.ts
123
+ var DEFAULT_CONFIG_DIR2 = join2(homedir2(), ".config", "opencode");
124
+ async function runAutoUpdate(options = {}) {
125
+ const disabled = options.disabled ?? envFlag("OPENCODE_AUTO_UPDATE_DISABLED");
126
+ if (disabled) {
127
+ return;
128
+ }
129
+ const debug = options.debug ?? envFlag("OPENCODE_AUTO_UPDATE_DEBUG");
130
+ const ignoreThrottle = options.ignoreThrottle ?? envFlag("OPENCODE_AUTO_UPDATE_BYPASS_THROTTLE");
131
+ const intervalHours = options.intervalHours ?? envNumber("OPENCODE_AUTO_UPDATE_INTERVAL_HOURS", 24);
132
+ const preservePinned = options.preservePinned ?? envFlag("OPENCODE_AUTO_UPDATE_PINNED");
133
+ const configDir = options.configDir ?? DEFAULT_CONFIG_DIR2;
134
+ const configPath = join2(configDir, "opencode.json");
135
+ const log = (...args) => {
136
+ if (debug) {
137
+ console.log(...args);
138
+ }
139
+ };
140
+ const error = (...args) => {
141
+ if (debug) {
142
+ console.error(...args);
143
+ }
144
+ };
145
+ const lockAcquired = await acquireLock({ debug, configDir });
146
+ if (!lockAcquired) {
147
+ log("[auto-update] Lock already held, skipping.");
148
+ return;
149
+ }
150
+ try {
151
+ const state = await readThrottleState({ configDir });
152
+ const now = Date.now();
153
+ const intervalMs = Math.max(intervalHours, 1) * 60 * 60 * 1e3;
154
+ if (!ignoreThrottle && state.lastRun && now - state.lastRun < intervalMs) {
155
+ log("[auto-update] Throttled, skipping update.");
156
+ return;
157
+ }
158
+ await writeThrottleState({ ...state, lastRun: now }, { debug, configDir });
159
+ const config = await readConfig(configPath);
160
+ const { plugins, key } = getPluginList(config);
161
+ if (!plugins || plugins.length === 0 || !key) {
162
+ log("[auto-update] No plugins found to update.");
163
+ return;
164
+ }
165
+ const useBun = await commandExists("bun");
166
+ log("[auto-update] Starting update", {
167
+ pluginCount: plugins.length,
168
+ useBun,
169
+ preservePinned,
170
+ ignoreThrottle
171
+ });
172
+ const updateResult = await updatePlugins({
173
+ plugins,
174
+ configDir,
175
+ preservePinned,
176
+ useBun,
177
+ log,
178
+ error
179
+ });
180
+ if (updateResult.changed) {
181
+ const updatedConfig = { ...config, [key]: updateResult.plugins };
182
+ await writeConfig(configPath, updatedConfig);
183
+ }
184
+ await writeThrottleState(
185
+ { ...state, lastRun: now, lastSuccess: Date.now() },
186
+ { debug, configDir }
187
+ );
188
+ log("[auto-update] Update complete.");
189
+ } catch (err) {
190
+ error("[auto-update] Failed to update plugins:", err);
191
+ } finally {
192
+ await releaseLock({ debug, configDir });
193
+ }
194
+ }
195
+ async function readConfig(configPath) {
196
+ try {
197
+ const contents = await readFile2(configPath, "utf-8");
198
+ return JSON.parse(contents);
199
+ } catch {
200
+ return null;
201
+ }
202
+ }
203
+ async function writeConfig(configPath, config) {
204
+ await mkdir2(dirname(configPath), { recursive: true });
205
+ const contents = JSON.stringify(config, null, 2);
206
+ await writeFile2(configPath, `${contents}
207
+ `, "utf-8");
208
+ }
209
+ function getPluginList(config) {
210
+ if (!config) {
211
+ return { plugins: null, key: null };
212
+ }
213
+ if (Array.isArray(config.plugin)) {
214
+ return { plugins: config.plugin, key: "plugin" };
215
+ }
216
+ if (Array.isArray(config.plugins)) {
217
+ return { plugins: config.plugins, key: "plugins" };
218
+ }
219
+ return { plugins: null, key: null };
220
+ }
221
+ async function updatePlugins(options) {
222
+ const { plugins, configDir, preservePinned, useBun, log, error } = options;
223
+ const updated = [];
224
+ let changed = false;
225
+ for (const entry of plugins) {
226
+ if (isNonRegistryPlugin(entry)) {
227
+ log("[auto-update] Skipping non-registry plugin:", entry);
228
+ updated.push(entry);
229
+ continue;
230
+ }
231
+ const { name, version } = parsePackageSpec(entry);
232
+ if (preservePinned && version) {
233
+ log("[auto-update] Preserving pinned plugin:", entry);
234
+ updated.push(entry);
235
+ continue;
236
+ }
237
+ log("[auto-update] Updating plugin:", name);
238
+ const installedVersion = await installLatest({
239
+ name,
240
+ configDir,
241
+ useBun,
242
+ log,
243
+ error
244
+ });
245
+ if (!installedVersion) {
246
+ updated.push(entry);
247
+ continue;
248
+ }
249
+ const nextEntry = `${name}@${installedVersion}`;
250
+ log("[auto-update] Installed:", nextEntry);
251
+ updated.push(nextEntry);
252
+ if (nextEntry !== entry) {
253
+ changed = true;
254
+ }
255
+ }
256
+ return { plugins: updated, changed };
257
+ }
258
+ async function installLatest(options) {
259
+ const { name, configDir, useBun, log, error } = options;
260
+ await mkdir2(configDir, { recursive: true });
261
+ if (useBun) {
262
+ const result = await runCommand("bun", ["add", `${name}@latest`, "--cwd", configDir]);
263
+ if (result.code !== 0) {
264
+ error("[auto-update] bun add failed:", result.stderr || result.stdout);
265
+ return null;
266
+ }
267
+ } else {
268
+ const result = await runCommand("npm", [
269
+ "install",
270
+ `${name}@latest`,
271
+ "--prefix",
272
+ configDir,
273
+ "--no-save"
274
+ ]);
275
+ if (result.code !== 0) {
276
+ error("[auto-update] npm install failed:", result.stderr || result.stdout);
277
+ return null;
278
+ }
279
+ }
280
+ const version = await readInstalledVersion(name, configDir);
281
+ if (!version) {
282
+ log("[auto-update] Unable to read installed version for", name);
283
+ }
284
+ return version;
285
+ }
286
+ async function readInstalledVersion(name, configDir) {
287
+ try {
288
+ const packagePath = name.startsWith("@") ? join2(configDir, "node_modules", ...name.split("/"), "package.json") : join2(configDir, "node_modules", name, "package.json");
289
+ const data = await readFile2(packagePath, "utf-8");
290
+ const parsed = JSON.parse(data);
291
+ return parsed.version ?? null;
292
+ } catch {
293
+ return null;
294
+ }
295
+ }
296
+ function isNonRegistryPlugin(spec) {
297
+ const trimmed = spec.trim();
298
+ if (!trimmed) {
299
+ return true;
300
+ }
301
+ const lower = trimmed.toLowerCase();
302
+ if (lower.startsWith("file:") || lower.startsWith("git+") || lower.startsWith("git:") || lower.startsWith("ssh://") || lower.startsWith("http://") || lower.startsWith("https://") || lower.startsWith("github:") || lower.startsWith("workspace:")) {
303
+ return true;
304
+ }
305
+ return trimmed.startsWith("./") || trimmed.startsWith("../") || trimmed.startsWith("/") || trimmed.startsWith("~");
306
+ }
307
+ function parsePackageSpec(spec) {
308
+ if (spec.startsWith("@")) {
309
+ const secondAt = spec.indexOf("@", 1);
310
+ if (secondAt === -1) {
311
+ return { name: spec };
312
+ }
313
+ return {
314
+ name: spec.slice(0, secondAt),
315
+ version: spec.slice(secondAt + 1) || void 0
316
+ };
317
+ }
318
+ const at = spec.lastIndexOf("@");
319
+ if (at <= 0) {
320
+ return { name: spec };
321
+ }
322
+ return {
323
+ name: spec.slice(0, at),
324
+ version: spec.slice(at + 1) || void 0
325
+ };
326
+ }
327
+ async function commandExists(command) {
328
+ const result = await runCommand(command, ["--version"]);
329
+ return result.code === 0;
330
+ }
331
+ async function runCommand(command, args, options = {}) {
332
+ return new Promise((resolve) => {
333
+ const child = spawn(command, args, {
334
+ cwd: options.cwd,
335
+ stdio: ["ignore", "pipe", "pipe"]
336
+ });
337
+ let stdout = "";
338
+ let stderr = "";
339
+ child.stdout?.on("data", (chunk) => {
340
+ stdout += chunk.toString();
341
+ });
342
+ child.stderr?.on("data", (chunk) => {
343
+ stderr += chunk.toString();
344
+ });
345
+ child.on("error", (err) => {
346
+ resolve({ code: 1, stdout, stderr: `${stderr}${err.message}` });
347
+ });
348
+ child.on("close", (code) => {
349
+ resolve({ code: code ?? 0, stdout, stderr });
350
+ });
351
+ });
352
+ }
353
+ function envFlag(name) {
354
+ return process.env[name]?.toLowerCase() === "true";
355
+ }
356
+ function envNumber(name, fallback) {
357
+ const raw = process.env[name];
358
+ if (!raw) {
359
+ return fallback;
360
+ }
361
+ const parsed = Number(raw);
362
+ return Number.isFinite(parsed) ? parsed : fallback;
363
+ }
364
+
365
+ // src/index.ts
366
+ async function src_default(ctx) {
367
+ const args = process.argv ?? [];
368
+ const debugLevelFlag = args.includes("--log-level") && args.includes("DEBUG");
369
+ const envDebug = process.env.OPENCODE_AUTO_UPDATE_DEBUG?.toLowerCase() === "true";
370
+ const envBypassThrottle = process.env.OPENCODE_AUTO_UPDATE_BYPASS_THROTTLE?.toLowerCase() === "true";
371
+ const debug = debugLevelFlag || envDebug;
372
+ const ignoreThrottle = debugLevelFlag || envBypassThrottle;
373
+ setTimeout(() => {
374
+ runAutoUpdate({ debug, ignoreThrottle }).catch((error) => {
375
+ if (debug) {
376
+ console.error("[opencode-plugin-auto-update] Update check failed:", error);
377
+ }
378
+ });
379
+ }, 0);
380
+ return {
381
+ name: "opencode-plugin-auto-update"
382
+ };
383
+ }
384
+ export {
385
+ src_default as default
386
+ };
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "opencode-plugin-auto-update",
3
+ "version": "0.1.1",
4
+ "description": "OpenCode plugin that auto-updates plugins in the background",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
+ "files": [
9
+ "dist"
10
+ ],
11
+ "exports": {
12
+ ".": "./dist/index.js"
13
+ },
14
+ "scripts": {
15
+ "build": "tsup",
16
+ "dev": "tsup --watch",
17
+ "typecheck": "tsc --noEmit",
18
+ "prepublishOnly": "bun run build"
19
+ },
20
+ "keywords": [
21
+ "opencode",
22
+ "opencode-plugin",
23
+ "auto-update",
24
+ "dependencies"
25
+ ],
26
+ "repository": {
27
+ "type": "git",
28
+ "url": "https://github.com/AnganSamadder/opencode-plugin-auto-update.git"
29
+ },
30
+ "bugs": {
31
+ "url": "https://github.com/AnganSamadder/opencode-plugin-auto-update/issues"
32
+ },
33
+ "homepage": "https://github.com/AnganSamadder/opencode-plugin-auto-update#readme",
34
+ "author": "Angan Samadder",
35
+ "license": "MIT",
36
+ "devDependencies": {
37
+ "@types/bun": "^1.2.4",
38
+ "@types/node": "^25.0.10",
39
+ "tsup": "^8.3.6",
40
+ "typescript": "^5.7.3"
41
+ }
42
+ }