backeer-opencode 0.1.4 → 0.1.5

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/CHANGELOG.md CHANGED
@@ -2,6 +2,16 @@
2
2
 
3
3
  All notable changes to `backeer-opencode` are documented here.
4
4
 
5
+ ## 0.1.5 - 2026-09-01
6
+
7
+ - Check the NPM registry for a newer Backeer plugin version once per day.
8
+ - Notify the OpenCode agent once per session when an update is available.
9
+ - Add `/backeer update-check` for an explicit registry check.
10
+ - Add `/backeer update` to delegate an exact-version installation and
11
+ verification workflow to the OpenCode agent.
12
+ - Require the agent workflow to preserve the token and avoid `@latest` cache
13
+ ambiguity.
14
+
5
15
  ## 0.1.4 - 2026-09-01
6
16
 
7
17
  - Add `/backeer` and `/backeer status` for an in-session integration report.
package/README.md CHANGED
@@ -156,6 +156,8 @@ The plugin registers a `/backeer` command inside OpenCode:
156
156
  ```text
157
157
  /backeer
158
158
  /backeer status
159
+ /backeer update-check
160
+ /backeer update
159
161
  /backeer token
160
162
  /backeer token-file /absolute/path/to/a/protected-token-file
161
163
  ```
@@ -173,6 +175,19 @@ atomically copies it to the configured token location, applies mode `0600`, and
173
175
  activates it immediately without restarting OpenCode. The source file is not
174
176
  removed.
175
177
 
178
+ The plugin checks the NPM registry at most once every 24 hours. When a newer
179
+ version exists, it adds one synthetic update notice to each OpenCode session so
180
+ the agent can inform the user. The notice never authorizes installation by
181
+ itself.
182
+
183
+ `/backeer update-check` performs an explicit check. `/backeer update` is the
184
+ user authorization point: it gives the OpenCode agent an exact-version install
185
+ command, requires verification of both the global version pin and package
186
+ cache, and forbids reading or changing the Backeer token. The agent does not
187
+ terminate the active OpenCode process; restart OpenCode after a successful
188
+ update. Exact versions are used instead of `@latest` to avoid stale OpenCode
189
+ package caches.
190
+
176
191
  ## Events and decisions
177
192
 
178
193
  The plugin publishes:
@@ -357,8 +357,130 @@ import {
357
357
  writeFileSync
358
358
  } from "node:fs";
359
359
  import { dirname, resolve } from "node:path";
360
- var PLUGIN_VERSION = "0.1.4";
361
- var COMMAND_DESCRIPTION = "Show Backeer status or manage its token: /backeer [status|token|token-file PATH]";
360
+
361
+ // src/updates.js
362
+ var PLUGIN_VERSION = "0.1.5";
363
+ var PACKAGE_NAME = "backeer-opencode";
364
+ var NPM_REGISTRY_URL = "https://registry.npmjs.org";
365
+ var CHECK_TIMEOUT_MS = 3e3;
366
+ var CHECK_INTERVAL_MS = 24 * 60 * 60 * 1e3;
367
+ function parseVersion(value) {
368
+ const match = String(value).trim().match(
369
+ /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/
370
+ );
371
+ if (!match) return;
372
+ return {
373
+ parts: [Number(match[1]), Number(match[2]), Number(match[3])],
374
+ prerelease: match[4]
375
+ };
376
+ }
377
+ function isVersionNewer(latestValue, currentValue) {
378
+ const latest = parseVersion(latestValue);
379
+ const current = parseVersion(currentValue);
380
+ if (!latest || !current) return false;
381
+ for (let index = 0; index < 3; index++) {
382
+ if (latest.parts[index] > current.parts[index]) return true;
383
+ if (latest.parts[index] < current.parts[index]) return false;
384
+ }
385
+ if (!latest.prerelease && current.prerelease) return true;
386
+ if (latest.prerelease && !current.prerelease) return false;
387
+ return Boolean(
388
+ latest.prerelease && current.prerelease && latest.prerelease !== current.prerelease
389
+ );
390
+ }
391
+ function createUpdateChecker({
392
+ fetchImplementation = globalThis.fetch,
393
+ now = () => Date.now()
394
+ } = {}) {
395
+ let checkedAt = 0;
396
+ let cachedResult;
397
+ let pendingCheck;
398
+ async function fetchLatest() {
399
+ const controller = new AbortController();
400
+ const timeout = setTimeout(() => controller.abort(), CHECK_TIMEOUT_MS);
401
+ try {
402
+ const response = await fetchImplementation(
403
+ `${NPM_REGISTRY_URL}/${PACKAGE_NAME}/latest`,
404
+ { signal: controller.signal }
405
+ );
406
+ if (!response.ok) return { state: "unavailable", currentVersion: PLUGIN_VERSION };
407
+ const data = await response.json();
408
+ const latestVersion = typeof data?.version === "string" ? data.version.trim() : "";
409
+ if (!parseVersion(latestVersion)) {
410
+ return { state: "unavailable", currentVersion: PLUGIN_VERSION };
411
+ }
412
+ if (!isVersionNewer(latestVersion, PLUGIN_VERSION)) {
413
+ return {
414
+ state: "current",
415
+ currentVersion: PLUGIN_VERSION,
416
+ latestVersion
417
+ };
418
+ }
419
+ return {
420
+ state: "available",
421
+ currentVersion: PLUGIN_VERSION,
422
+ latestVersion,
423
+ updateCommand: `opencode plugin ${PACKAGE_NAME}@${latestVersion} -g --force`
424
+ };
425
+ } catch {
426
+ return { state: "unavailable", currentVersion: PLUGIN_VERSION };
427
+ } finally {
428
+ clearTimeout(timeout);
429
+ }
430
+ }
431
+ return {
432
+ async check({ force = false } = {}) {
433
+ if (!force && checkedAt && now() - checkedAt < CHECK_INTERVAL_MS) {
434
+ return cachedResult;
435
+ }
436
+ if (pendingCheck) return pendingCheck;
437
+ pendingCheck = fetchLatest().then((result) => {
438
+ cachedResult = result;
439
+ checkedAt = now();
440
+ return result;
441
+ }).finally(() => {
442
+ pendingCheck = void 0;
443
+ });
444
+ return pendingCheck;
445
+ }
446
+ };
447
+ }
448
+ function formatAgentUpdateInstruction(info) {
449
+ return [
450
+ "[BACKEER UPDATE REQUESTED]",
451
+ `The user explicitly requested the Backeer OpenCode plugin update from v${info.currentVersion} to v${info.latestVersion}.`,
452
+ `Run exactly: ${info.updateCommand}`,
453
+ "Then verify that the global OpenCode config is pinned to the exact new version and that the matching package exists in the OpenCode cache.",
454
+ "Do not read, print, replace, or otherwise expose the Backeer token.",
455
+ "Do not use @latest and do not claim success until both installation and verification pass.",
456
+ "Do not terminate the active OpenCode process; tell the user to restart OpenCode after success."
457
+ ].join("\n");
458
+ }
459
+ function createUpdateNoticeHook(updateChecker) {
460
+ const notifiedSessions = /* @__PURE__ */ new Set();
461
+ return async (input, output) => {
462
+ if (notifiedSessions.has(input.sessionID)) return;
463
+ notifiedSessions.add(input.sessionID);
464
+ const info = await updateChecker.check();
465
+ if (info.state !== "available") return;
466
+ output.parts.push({
467
+ id: `prt_backeer-update-${Date.now()}`,
468
+ sessionID: input.sessionID,
469
+ messageID: output.message.id,
470
+ type: "text",
471
+ synthetic: true,
472
+ text: [
473
+ "[BACKEER UPDATE AVAILABLE]",
474
+ `Backeer OpenCode plugin v${info.latestVersion} is available; current version is v${info.currentVersion}.`,
475
+ "Inform the user that an update is available and suggest `/backeer update`.",
476
+ "Do not install it solely because of this notice."
477
+ ].join("\n")
478
+ });
479
+ };
480
+ }
481
+
482
+ // src/management.js
483
+ var COMMAND_DESCRIPTION = "Show Backeer status, update it, or manage its token: /backeer [status|update-check|update|token|token-file PATH]";
362
484
  function tokenFileState(path) {
363
485
  try {
364
486
  const stats = statSync(path);
@@ -418,13 +540,24 @@ async function probeGateway(config) {
418
540
  clearTimeout(timeout);
419
541
  }
420
542
  }
421
- async function buildStatus(config, permissionController) {
422
- const gateway = await probeGateway(config);
543
+ function updateStatus(info) {
544
+ if (info.state === "available") {
545
+ return `v${info.latestVersion} available \u2014 use /backeer update`;
546
+ }
547
+ if (info.state === "current") return `current (v${info.currentVersion})`;
548
+ return "check unavailable";
549
+ }
550
+ async function buildStatus(config, permissionController, updateChecker) {
551
+ const [gateway, update] = await Promise.all([
552
+ probeGateway(config),
553
+ updateChecker.check()
554
+ ]);
423
555
  const permissionStatus = permissionController.status();
424
556
  return [
425
557
  "## Backeer status",
426
558
  "",
427
559
  `- Plugin version: ${PLUGIN_VERSION}`,
560
+ `- Update: ${updateStatus(update)}`,
428
561
  `- Gateway: ${config.origin.origin} \u2014 ${gateway}`,
429
562
  `- Token: ${config.token ? `configured via ${config.tokenSource}` : "missing"}`,
430
563
  `- Token file: ${config.tokenFile} \u2014 ${tokenFileState(config.tokenFile)}`,
@@ -443,6 +576,8 @@ function helpMessage() {
443
576
  "## Backeer commands",
444
577
  "",
445
578
  "- `/backeer` or `/backeer status` \u2014 show integration status",
579
+ "- `/backeer update-check` \u2014 check NPM for a newer version",
580
+ "- `/backeer update` \u2014 ask the OpenCode agent to install and verify it",
446
581
  "- `/backeer token` \u2014 show secure token-change instructions",
447
582
  "- `/backeer token-file /absolute/protected/path` \u2014 import and activate a token",
448
583
  "",
@@ -486,7 +621,8 @@ function createBackeerCommandHandler({
486
621
  client,
487
622
  config,
488
623
  permissionController,
489
- reporter
624
+ reporter,
625
+ updateChecker
490
626
  }) {
491
627
  return async (input, output) => {
492
628
  if (input.command !== "backeer") return;
@@ -498,7 +634,26 @@ function createBackeerCommandHandler({
498
634
  let message;
499
635
  try {
500
636
  if (!subcommand || subcommand === "status") {
501
- message = await buildStatus(config, permissionController);
637
+ message = await buildStatus(config, permissionController, updateChecker);
638
+ } else if (subcommand === "update-check") {
639
+ const update = await updateChecker.check({ force: true });
640
+ message = [
641
+ "## Backeer update check",
642
+ "",
643
+ update.state === "available" ? `Version ${update.latestVersion} is available; current version is ${update.currentVersion}. Use \`/backeer update\` to let the OpenCode agent install and verify it.` : update.state === "current" ? `Backeer is current at version ${update.currentVersion}.` : "The NPM registry could not be reached. No update was attempted."
644
+ ].join("\n");
645
+ } else if (subcommand === "update") {
646
+ const update = await updateChecker.check({ force: true });
647
+ if (update.state === "available") {
648
+ output.parts.push({
649
+ type: "text",
650
+ text: formatAgentUpdateInstruction(update)
651
+ });
652
+ return;
653
+ }
654
+ message = update.state === "current" ? `## Backeer update
655
+
656
+ Backeer is already current at version ${update.currentVersion}.` : "## Backeer update\n\nThe NPM registry could not be reached. No update was attempted.";
502
657
  } else if (subcommand === "token") {
503
658
  message = tokenInstructions();
504
659
  } else if (subcommand === "token-file") {
@@ -835,12 +990,15 @@ async function BackeerPlugin({ client, directory }, options = {}) {
835
990
  });
836
991
  const todoCompletionBySessionID = /* @__PURE__ */ new Map();
837
992
  const publishedQuestions = /* @__PURE__ */ new Map();
993
+ const updateChecker = createUpdateChecker();
838
994
  const handleBackeerCommand = createBackeerCommandHandler({
839
995
  client,
840
996
  config,
841
997
  permissionController,
842
- reporter
998
+ reporter,
999
+ updateChecker
843
1000
  });
1001
+ const handleUpdateNotice = createUpdateNoticeHook(updateChecker);
844
1002
  if (!config.token) {
845
1003
  reporter.error(
846
1004
  "Notifications disabled \u2014 use /backeer token",
@@ -903,6 +1061,7 @@ async function BackeerPlugin({ client, directory }, options = {}) {
903
1061
  registerBackeerCommand(opencodeConfig);
904
1062
  },
905
1063
  "command.execute.before": handleBackeerCommand,
1064
+ "chat.message": handleUpdateNotice,
906
1065
  event: async ({ event }) => {
907
1066
  if (!config.token) return;
908
1067
  if (config.permissions && event.type === "permission.asked") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backeer-opencode",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "description": "Backeer notifications and remote permission decisions for OpenCode",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
package/src/management.js CHANGED
@@ -8,10 +8,13 @@ import {
8
8
  } from "node:fs"
9
9
  import { dirname, resolve } from "node:path"
10
10
 
11
- export const PLUGIN_VERSION = "0.1.4"
11
+ import {
12
+ formatAgentUpdateInstruction,
13
+ PLUGIN_VERSION,
14
+ } from "./updates.js"
12
15
 
13
16
  const COMMAND_DESCRIPTION =
14
- "Show Backeer status or manage its token: /backeer [status|token|token-file PATH]"
17
+ "Show Backeer status, update it, or manage its token: /backeer [status|update-check|update|token|token-file PATH]"
15
18
 
16
19
  function tokenFileState(path) {
17
20
  try {
@@ -83,13 +86,25 @@ async function probeGateway(config) {
83
86
  }
84
87
  }
85
88
 
86
- async function buildStatus(config, permissionController) {
87
- const gateway = await probeGateway(config)
89
+ function updateStatus(info) {
90
+ if (info.state === "available") {
91
+ return `v${info.latestVersion} available — use /backeer update`
92
+ }
93
+ if (info.state === "current") return `current (v${info.currentVersion})`
94
+ return "check unavailable"
95
+ }
96
+
97
+ async function buildStatus(config, permissionController, updateChecker) {
98
+ const [gateway, update] = await Promise.all([
99
+ probeGateway(config),
100
+ updateChecker.check(),
101
+ ])
88
102
  const permissionStatus = permissionController.status()
89
103
  return [
90
104
  "## Backeer status",
91
105
  "",
92
106
  `- Plugin version: ${PLUGIN_VERSION}`,
107
+ `- Update: ${updateStatus(update)}`,
93
108
  `- Gateway: ${config.origin.origin} — ${gateway}`,
94
109
  `- Token: ${config.token ? `configured via ${config.tokenSource}` : "missing"}`,
95
110
  `- Token file: ${config.tokenFile} — ${tokenFileState(config.tokenFile)}`,
@@ -109,6 +124,8 @@ function helpMessage() {
109
124
  "## Backeer commands",
110
125
  "",
111
126
  "- `/backeer` or `/backeer status` — show integration status",
127
+ "- `/backeer update-check` — check NPM for a newer version",
128
+ "- `/backeer update` — ask the OpenCode agent to install and verify it",
112
129
  "- `/backeer token` — show secure token-change instructions",
113
130
  "- `/backeer token-file /absolute/protected/path` — import and activate a token",
114
131
  "",
@@ -157,6 +174,7 @@ export function createBackeerCommandHandler({
157
174
  config,
158
175
  permissionController,
159
176
  reporter,
177
+ updateChecker,
160
178
  }) {
161
179
  return async (input, output) => {
162
180
  if (input.command !== "backeer") return
@@ -174,7 +192,30 @@ export function createBackeerCommandHandler({
174
192
  let message
175
193
  try {
176
194
  if (!subcommand || subcommand === "status") {
177
- message = await buildStatus(config, permissionController)
195
+ message = await buildStatus(config, permissionController, updateChecker)
196
+ } else if (subcommand === "update-check") {
197
+ const update = await updateChecker.check({ force: true })
198
+ message = [
199
+ "## Backeer update check",
200
+ "",
201
+ update.state === "available"
202
+ ? `Version ${update.latestVersion} is available; current version is ${update.currentVersion}. Use \`/backeer update\` to let the OpenCode agent install and verify it.`
203
+ : update.state === "current"
204
+ ? `Backeer is current at version ${update.currentVersion}.`
205
+ : "The NPM registry could not be reached. No update was attempted.",
206
+ ].join("\n")
207
+ } else if (subcommand === "update") {
208
+ const update = await updateChecker.check({ force: true })
209
+ if (update.state === "available") {
210
+ output.parts.push({
211
+ type: "text",
212
+ text: formatAgentUpdateInstruction(update),
213
+ })
214
+ return
215
+ }
216
+ message = update.state === "current"
217
+ ? `## Backeer update\n\nBackeer is already current at version ${update.currentVersion}.`
218
+ : "## Backeer update\n\nThe NPM registry could not be reached. No update was attempted."
178
219
  } else if (subcommand === "token") {
179
220
  message = tokenInstructions()
180
221
  } else if (subcommand === "token-file") {
package/src/plugin.js CHANGED
@@ -12,6 +12,10 @@ import {
12
12
  } from "./management.js"
13
13
  import { createPermissionController } from "./permission-controller.js"
14
14
  import { createConsoleReporter } from "./reporter.js"
15
+ import {
16
+ createUpdateChecker,
17
+ createUpdateNoticeHook,
18
+ } from "./updates.js"
15
19
  import { rememberBounded } from "./utilities.js"
16
20
 
17
21
  /**
@@ -43,12 +47,15 @@ export default async function BackeerPlugin({ client, directory }, options = {})
43
47
  })
44
48
  const todoCompletionBySessionID = new Map()
45
49
  const publishedQuestions = new Map()
50
+ const updateChecker = createUpdateChecker()
46
51
  const handleBackeerCommand = createBackeerCommandHandler({
47
52
  client,
48
53
  config,
49
54
  permissionController,
50
55
  reporter,
56
+ updateChecker,
51
57
  })
58
+ const handleUpdateNotice = createUpdateNoticeHook(updateChecker)
52
59
 
53
60
  if (!config.token) {
54
61
  reporter.error(
@@ -123,6 +130,8 @@ export default async function BackeerPlugin({ client, directory }, options = {})
123
130
 
124
131
  "command.execute.before": handleBackeerCommand,
125
132
 
133
+ "chat.message": handleUpdateNotice,
134
+
126
135
  event: async ({ event }) => {
127
136
  if (!config.token) return
128
137
 
package/src/updates.js ADDED
@@ -0,0 +1,136 @@
1
+ export const PLUGIN_VERSION = "0.1.5"
2
+
3
+ const PACKAGE_NAME = "backeer-opencode"
4
+ const NPM_REGISTRY_URL = "https://registry.npmjs.org"
5
+ const CHECK_TIMEOUT_MS = 3000
6
+ const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000
7
+
8
+ export function parseVersion(value) {
9
+ const match = String(value).trim().match(
10
+ /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/,
11
+ )
12
+ if (!match) return
13
+ return {
14
+ parts: [Number(match[1]), Number(match[2]), Number(match[3])],
15
+ prerelease: match[4],
16
+ }
17
+ }
18
+
19
+ export function isVersionNewer(latestValue, currentValue) {
20
+ const latest = parseVersion(latestValue)
21
+ const current = parseVersion(currentValue)
22
+ if (!latest || !current) return false
23
+
24
+ for (let index = 0; index < 3; index++) {
25
+ if (latest.parts[index] > current.parts[index]) return true
26
+ if (latest.parts[index] < current.parts[index]) return false
27
+ }
28
+ if (!latest.prerelease && current.prerelease) return true
29
+ if (latest.prerelease && !current.prerelease) return false
30
+ return Boolean(
31
+ latest.prerelease &&
32
+ current.prerelease &&
33
+ latest.prerelease !== current.prerelease,
34
+ )
35
+ }
36
+
37
+ export function createUpdateChecker({
38
+ fetchImplementation = globalThis.fetch,
39
+ now = () => Date.now(),
40
+ } = {}) {
41
+ let checkedAt = 0
42
+ let cachedResult
43
+ let pendingCheck
44
+
45
+ async function fetchLatest() {
46
+ const controller = new AbortController()
47
+ const timeout = setTimeout(() => controller.abort(), CHECK_TIMEOUT_MS)
48
+ try {
49
+ const response = await fetchImplementation(
50
+ `${NPM_REGISTRY_URL}/${PACKAGE_NAME}/latest`,
51
+ { signal: controller.signal },
52
+ )
53
+ if (!response.ok) return { state: "unavailable", currentVersion: PLUGIN_VERSION }
54
+ const data = await response.json()
55
+ const latestVersion = typeof data?.version === "string"
56
+ ? data.version.trim()
57
+ : ""
58
+ if (!parseVersion(latestVersion)) {
59
+ return { state: "unavailable", currentVersion: PLUGIN_VERSION }
60
+ }
61
+ if (!isVersionNewer(latestVersion, PLUGIN_VERSION)) {
62
+ return {
63
+ state: "current",
64
+ currentVersion: PLUGIN_VERSION,
65
+ latestVersion,
66
+ }
67
+ }
68
+ return {
69
+ state: "available",
70
+ currentVersion: PLUGIN_VERSION,
71
+ latestVersion,
72
+ updateCommand:
73
+ `opencode plugin ${PACKAGE_NAME}@${latestVersion} -g --force`,
74
+ }
75
+ } catch {
76
+ return { state: "unavailable", currentVersion: PLUGIN_VERSION }
77
+ } finally {
78
+ clearTimeout(timeout)
79
+ }
80
+ }
81
+
82
+ return {
83
+ async check({ force = false } = {}) {
84
+ if (!force && checkedAt && now() - checkedAt < CHECK_INTERVAL_MS) {
85
+ return cachedResult
86
+ }
87
+ if (pendingCheck) return pendingCheck
88
+
89
+ pendingCheck = fetchLatest()
90
+ .then((result) => {
91
+ cachedResult = result
92
+ checkedAt = now()
93
+ return result
94
+ })
95
+ .finally(() => { pendingCheck = undefined })
96
+ return pendingCheck
97
+ },
98
+ }
99
+ }
100
+
101
+ export function formatAgentUpdateInstruction(info) {
102
+ return [
103
+ "[BACKEER UPDATE REQUESTED]",
104
+ `The user explicitly requested the Backeer OpenCode plugin update from v${info.currentVersion} to v${info.latestVersion}.`,
105
+ `Run exactly: ${info.updateCommand}`,
106
+ "Then verify that the global OpenCode config is pinned to the exact new version and that the matching package exists in the OpenCode cache.",
107
+ "Do not read, print, replace, or otherwise expose the Backeer token.",
108
+ "Do not use @latest and do not claim success until both installation and verification pass.",
109
+ "Do not terminate the active OpenCode process; tell the user to restart OpenCode after success.",
110
+ ].join("\n")
111
+ }
112
+
113
+ export function createUpdateNoticeHook(updateChecker) {
114
+ const notifiedSessions = new Set()
115
+
116
+ return async (input, output) => {
117
+ if (notifiedSessions.has(input.sessionID)) return
118
+ notifiedSessions.add(input.sessionID)
119
+
120
+ const info = await updateChecker.check()
121
+ if (info.state !== "available") return
122
+ output.parts.push({
123
+ id: `prt_backeer-update-${Date.now()}`,
124
+ sessionID: input.sessionID,
125
+ messageID: output.message.id,
126
+ type: "text",
127
+ synthetic: true,
128
+ text: [
129
+ "[BACKEER UPDATE AVAILABLE]",
130
+ `Backeer OpenCode plugin v${info.latestVersion} is available; current version is v${info.currentVersion}.`,
131
+ "Inform the user that an update is available and suggest `/backeer update`.",
132
+ "Do not install it solely because of this notice.",
133
+ ].join("\n"),
134
+ })
135
+ }
136
+ }