backeer-opencode 0.1.3 → 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,24 @@
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
+
15
+ ## 0.1.4 - 2026-09-01
16
+
17
+ - Add `/backeer` and `/backeer status` for an in-session integration report.
18
+ - Add secure, immediate token rotation with `/backeer token-file PATH`.
19
+ - Keep raw tokens out of slash-command arguments, session messages, and logs.
20
+ - Keep the management command available when notifications are disabled by a
21
+ missing token.
22
+
5
23
  ## 0.1.3 - 2026-09-01
6
24
 
7
25
  - Split configuration, logging, HTTP, SSE, event publishing, and permission
package/README.md CHANGED
@@ -149,6 +149,45 @@ connection errors are suppressed for 30 seconds. Normal `info` output omits HTTP
149
149
  status codes and internal errors; set `BACKEER_LOG_LEVEL=debug` when technical
150
150
  details are needed. Set it to `error` for failures only or `silent` for no output.
151
151
 
152
+ ## OpenCode command
153
+
154
+ The plugin registers a `/backeer` command inside OpenCode:
155
+
156
+ ```text
157
+ /backeer
158
+ /backeer status
159
+ /backeer update-check
160
+ /backeer update
161
+ /backeer token
162
+ /backeer token-file /absolute/path/to/a/protected-token-file
163
+ ```
164
+
165
+ The status report shows the plugin version, gateway reachability, token source,
166
+ notification switches, command-stream state, pending permissions, and log level.
167
+ It never displays the token. Gateway reachability does not validate the source
168
+ credential.
169
+
170
+ `/backeer token` displays safe rotation instructions. Never paste a raw token
171
+ into slash-command arguments because OpenCode may retain command text in session
172
+ history. To rotate from inside OpenCode, first place the new token in an absolute
173
+ file readable only by its owner (`chmod 600`), then use `token-file`. The plugin
174
+ atomically copies it to the configured token location, applies mode `0600`, and
175
+ activates it immediately without restarting OpenCode. The source file is not
176
+ removed.
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
+
152
191
  ## Events and decisions
153
192
 
154
193
  The plugin publishes:
@@ -83,11 +83,12 @@ function defaultConfigDirectory() {
83
83
  }
84
84
  function loadToken(tokenFile) {
85
85
  const environmentToken = process.env.BACKEER_INGEST_TOKEN?.trim();
86
- if (environmentToken) return environmentToken;
86
+ if (environmentToken) return { token: environmentToken, source: "environment" };
87
87
  try {
88
- return readFileSync(tokenFile, "utf8").trim();
88
+ const token = readFileSync(tokenFile, "utf8").trim();
89
+ return { token, source: token ? "file" : "missing" };
89
90
  } catch {
90
- return "";
91
+ return { token: "", source: "missing" };
91
92
  }
92
93
  }
93
94
  function loadConfig(options = {}) {
@@ -98,11 +99,13 @@ function loadConfig(options = {}) {
98
99
  throw new Error("Backeer origin must use HTTPS");
99
100
  }
100
101
  const tokenFile = options.tokenFile ?? process.env.BACKEER_TOKEN_FILE ?? join(defaultConfigDirectory(), "secrets", "backeer-ingest-token");
102
+ const loadedToken = loadToken(tokenFile);
101
103
  return {
102
104
  origin,
103
105
  eventsEndpoint: new URL("/v1/events", origin),
104
106
  commandStreamEndpoint: new URL("/v1/commands/stream", origin),
105
- token: loadToken(tokenFile),
107
+ token: loadedToken.token,
108
+ tokenSource: loadedToken.source,
106
109
  tokenFile,
107
110
  permissions: enabled(
108
111
  options.permissions ?? process.env.BACKEER_NOTIFY_PERMISSIONS
@@ -134,16 +137,16 @@ function isAbortError(error) {
134
137
  return error instanceof Error && error.name === "AbortError";
135
138
  }
136
139
  function sleep(milliseconds, signal) {
137
- return new Promise((resolve) => {
140
+ return new Promise((resolve2) => {
138
141
  if (signal?.aborted) {
139
- resolve();
142
+ resolve2();
140
143
  return;
141
144
  }
142
145
  const timeout = setTimeout(done, milliseconds);
143
146
  function done() {
144
147
  clearTimeout(timeout);
145
148
  signal?.removeEventListener("abort", done);
146
- resolve();
149
+ resolve2();
147
150
  }
148
151
  signal?.addEventListener("abort", done, { once: true });
149
152
  });
@@ -344,6 +347,343 @@ async function publishTodoCompletion(config, reporter, request, eventID, client,
344
347
  reporter.success("Todo completion sent", `HTTP ${result.status}`);
345
348
  }
346
349
 
350
+ // src/management.js
351
+ import {
352
+ chmodSync,
353
+ mkdirSync,
354
+ readFileSync as readFileSync2,
355
+ renameSync,
356
+ statSync,
357
+ writeFileSync
358
+ } from "node:fs";
359
+ import { dirname, resolve } from "node: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]";
484
+ function tokenFileState(path) {
485
+ try {
486
+ const stats = statSync(path);
487
+ const mode = (stats.mode & 511).toString(8).padStart(3, "0");
488
+ return stats.isFile() ? `present (mode ${mode})` : "not a regular file";
489
+ } catch {
490
+ return "missing";
491
+ }
492
+ }
493
+ function cleanPathArgument(value) {
494
+ const path = value.trim();
495
+ if (path.length >= 2 && (path.startsWith('"') && path.endsWith('"') || path.startsWith("'") && path.endsWith("'"))) {
496
+ return path.slice(1, -1);
497
+ }
498
+ return path;
499
+ }
500
+ function writeTokenAtomically(path, token) {
501
+ mkdirSync(dirname(path), { recursive: true, mode: 448 });
502
+ const temporaryPath = `${path}.${process.pid}.${Date.now()}.tmp`;
503
+ writeFileSync(temporaryPath, `${token}
504
+ `, { encoding: "utf8", mode: 384 });
505
+ chmodSync(temporaryPath, 384);
506
+ renameSync(temporaryPath, path);
507
+ }
508
+ function rotateTokenFromFile(config, sourcePathValue) {
509
+ if (!sourcePathValue.trim()) throw new Error("A token file path is required");
510
+ const sourcePath = resolve(cleanPathArgument(sourcePathValue));
511
+ const sourceStats = statSync(sourcePath);
512
+ if (!sourceStats.isFile()) throw new Error("The token source is not a regular file");
513
+ if ((sourceStats.mode & 63) !== 0) {
514
+ throw new Error("The token source must not be readable by group or other users");
515
+ }
516
+ const token = readFileSync2(sourcePath, "utf8").trim();
517
+ if (!token || /\s/.test(token)) {
518
+ throw new Error("The token source must contain exactly one non-empty token");
519
+ }
520
+ const targetPath = resolve(config.tokenFile);
521
+ if (sourcePath !== targetPath) writeTokenAtomically(targetPath, token);
522
+ else chmodSync(targetPath, 384);
523
+ config.token = token;
524
+ config.tokenSource = "file";
525
+ return targetPath;
526
+ }
527
+ async function probeGateway(config) {
528
+ const controller = new AbortController();
529
+ const timeout = setTimeout(() => controller.abort(), 3e3);
530
+ try {
531
+ const response = await fetch(config.origin, {
532
+ method: "HEAD",
533
+ redirect: "manual",
534
+ signal: controller.signal
535
+ });
536
+ return `reachable (HTTP ${response.status}; authentication not tested)`;
537
+ } catch {
538
+ return "unreachable";
539
+ } finally {
540
+ clearTimeout(timeout);
541
+ }
542
+ }
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
+ ]);
555
+ const permissionStatus = permissionController.status();
556
+ return [
557
+ "## Backeer status",
558
+ "",
559
+ `- Plugin version: ${PLUGIN_VERSION}`,
560
+ `- Update: ${updateStatus(update)}`,
561
+ `- Gateway: ${config.origin.origin} \u2014 ${gateway}`,
562
+ `- Token: ${config.token ? `configured via ${config.tokenSource}` : "missing"}`,
563
+ `- Token file: ${config.tokenFile} \u2014 ${tokenFileState(config.tokenFile)}`,
564
+ `- Permission notifications: ${config.permissions ? "enabled" : "disabled"}`,
565
+ `- Question notifications: ${config.questions ? "enabled" : "disabled"}`,
566
+ `- Todo notifications: ${config.todos ? "enabled" : "disabled"}`,
567
+ `- Command stream: ${permissionStatus.stream}`,
568
+ `- Pending permissions: ${permissionStatus.pending}`,
569
+ `- Log level: ${config.logLevel}`,
570
+ "",
571
+ "The token value is never displayed."
572
+ ].join("\n");
573
+ }
574
+ function helpMessage() {
575
+ return [
576
+ "## Backeer commands",
577
+ "",
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",
581
+ "- `/backeer token` \u2014 show secure token-change instructions",
582
+ "- `/backeer token-file /absolute/protected/path` \u2014 import and activate a token",
583
+ "",
584
+ "Never place a token directly in a slash command. Slash-command arguments may be stored in session history."
585
+ ].join("\n");
586
+ }
587
+ function tokenInstructions() {
588
+ return [
589
+ "## Change the Backeer token securely",
590
+ "",
591
+ "Do not paste the token into OpenCode or `/backeer` arguments.",
592
+ "",
593
+ "Use either:",
594
+ "",
595
+ "1. Hidden terminal input: `npx backeer-opencode@latest configure`",
596
+ "2. A protected file: `chmod 600 /path/to/token && /backeer token-file /path/to/token`",
597
+ "",
598
+ "`token-file` activates the new token immediately; no OpenCode restart is required."
599
+ ].join("\n");
600
+ }
601
+ async function sendCommandMessage(client, sessionID, text) {
602
+ if (typeof client?.session?.prompt !== "function") {
603
+ throw new Error("OpenCode session messaging is unavailable");
604
+ }
605
+ await client.session.prompt({
606
+ path: { id: sessionID },
607
+ body: {
608
+ noReply: true,
609
+ parts: [{ type: "text", text, ignored: true }]
610
+ }
611
+ });
612
+ }
613
+ function registerBackeerCommand(opencodeConfig) {
614
+ opencodeConfig.command ??= {};
615
+ opencodeConfig.command.backeer = {
616
+ template: "",
617
+ description: COMMAND_DESCRIPTION
618
+ };
619
+ }
620
+ function createBackeerCommandHandler({
621
+ client,
622
+ config,
623
+ permissionController,
624
+ reporter,
625
+ updateChecker
626
+ }) {
627
+ return async (input, output) => {
628
+ if (input.command !== "backeer") return;
629
+ output.parts.length = 0;
630
+ const argumentsValue = (input.arguments ?? "").trim();
631
+ const separator = argumentsValue.indexOf(" ");
632
+ const subcommand = (separator === -1 ? argumentsValue : argumentsValue.slice(0, separator)).toLowerCase();
633
+ const subcommandArguments = separator === -1 ? "" : argumentsValue.slice(separator + 1).trim();
634
+ let message;
635
+ try {
636
+ if (!subcommand || subcommand === "status") {
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.";
657
+ } else if (subcommand === "token") {
658
+ message = tokenInstructions();
659
+ } else if (subcommand === "token-file") {
660
+ const targetPath = rotateTokenFromFile(config, subcommandArguments);
661
+ permissionController.refreshCredentials();
662
+ reporter.success("Access token updated");
663
+ message = [
664
+ "## Backeer token updated",
665
+ "",
666
+ `- Protected token file: ${targetPath}`,
667
+ "- File mode: 600",
668
+ "- The new token is active immediately."
669
+ ].join("\n");
670
+ } else {
671
+ message = helpMessage();
672
+ }
673
+ } catch (error) {
674
+ reporter.error("Access token could not be updated", error);
675
+ message = [
676
+ "## Backeer command failed",
677
+ "",
678
+ error instanceof Error ? error.message : String(error),
679
+ "",
680
+ "Use `/backeer token` for secure token-change instructions."
681
+ ].join("\n");
682
+ }
683
+ await sendCommandMessage(client, input.sessionID, message);
684
+ };
685
+ }
686
+
347
687
  // src/sse.js
348
688
  function parseSseFrame(frame) {
349
689
  const data = [];
@@ -605,6 +945,16 @@ function createPermissionController({
605
945
  removePendingByRequestID(requestID);
606
946
  stopCommandStreamIfIdle();
607
947
  },
948
+ refreshCredentials() {
949
+ commandStream?.controller.abort();
950
+ if (!commandStream) ensureCommandStream();
951
+ },
952
+ status() {
953
+ return {
954
+ pending: pendingByExternalID.size,
955
+ stream: commandStream ? "active" : "idle"
956
+ };
957
+ },
608
958
  async dispose() {
609
959
  disposed = true;
610
960
  commandStream?.controller.abort();
@@ -632,13 +982,6 @@ async function BackeerPlugin({ client, directory }, options = {}) {
632
982
  return {};
633
983
  }
634
984
  const reporter = createConsoleReporter(config.logLevel);
635
- if (!config.token) {
636
- reporter.error(
637
- "Plugin disabled \u2014 run: npx backeer-opencode@latest configure",
638
- `Token not found at ${config.tokenFile}`
639
- );
640
- return {};
641
- }
642
985
  const permissionController = createPermissionController({
643
986
  config,
644
987
  reporter,
@@ -647,6 +990,21 @@ async function BackeerPlugin({ client, directory }, options = {}) {
647
990
  });
648
991
  const todoCompletionBySessionID = /* @__PURE__ */ new Map();
649
992
  const publishedQuestions = /* @__PURE__ */ new Map();
993
+ const updateChecker = createUpdateChecker();
994
+ const handleBackeerCommand = createBackeerCommandHandler({
995
+ client,
996
+ config,
997
+ permissionController,
998
+ reporter,
999
+ updateChecker
1000
+ });
1001
+ const handleUpdateNotice = createUpdateNoticeHook(updateChecker);
1002
+ if (!config.token) {
1003
+ reporter.error(
1004
+ "Notifications disabled \u2014 use /backeer token",
1005
+ `Token not found at ${config.tokenFile}`
1006
+ );
1007
+ }
650
1008
  async function handlePermissionAsked(event) {
651
1009
  const identity = permissionIdentity(event.properties);
652
1010
  if (!identity) {
@@ -699,7 +1057,13 @@ async function BackeerPlugin({ client, directory }, options = {}) {
699
1057
  ).catch((error) => reporter.error("Question could not be sent", error));
700
1058
  }
701
1059
  return {
1060
+ config: async (opencodeConfig) => {
1061
+ registerBackeerCommand(opencodeConfig);
1062
+ },
1063
+ "command.execute.before": handleBackeerCommand,
1064
+ "chat.message": handleUpdateNotice,
702
1065
  event: async ({ event }) => {
1066
+ if (!config.token) return;
703
1067
  if (config.permissions && event.type === "permission.asked") {
704
1068
  await handlePermissionAsked(event);
705
1069
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backeer-opencode",
3
- "version": "0.1.3",
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/config.js CHANGED
@@ -14,12 +14,13 @@ function defaultConfigDirectory() {
14
14
 
15
15
  function loadToken(tokenFile) {
16
16
  const environmentToken = process.env.BACKEER_INGEST_TOKEN?.trim()
17
- if (environmentToken) return environmentToken
17
+ if (environmentToken) return { token: environmentToken, source: "environment" }
18
18
 
19
19
  try {
20
- return readFileSync(tokenFile, "utf8").trim()
20
+ const token = readFileSync(tokenFile, "utf8").trim()
21
+ return { token, source: token ? "file" : "missing" }
21
22
  } catch {
22
- return ""
23
+ return { token: "", source: "missing" }
23
24
  }
24
25
  }
25
26
 
@@ -36,11 +37,13 @@ export function loadConfig(options = {}) {
36
37
  process.env.BACKEER_TOKEN_FILE ??
37
38
  join(defaultConfigDirectory(), "secrets", "backeer-ingest-token")
38
39
 
40
+ const loadedToken = loadToken(tokenFile)
39
41
  return {
40
42
  origin,
41
43
  eventsEndpoint: new URL("/v1/events", origin),
42
44
  commandStreamEndpoint: new URL("/v1/commands/stream", origin),
43
- token: loadToken(tokenFile),
45
+ token: loadedToken.token,
46
+ tokenSource: loadedToken.source,
44
47
  tokenFile,
45
48
  permissions: enabled(
46
49
  options.permissions ?? process.env.BACKEER_NOTIFY_PERMISSIONS,
@@ -0,0 +1,248 @@
1
+ import {
2
+ chmodSync,
3
+ mkdirSync,
4
+ readFileSync,
5
+ renameSync,
6
+ statSync,
7
+ writeFileSync,
8
+ } from "node:fs"
9
+ import { dirname, resolve } from "node:path"
10
+
11
+ import {
12
+ formatAgentUpdateInstruction,
13
+ PLUGIN_VERSION,
14
+ } from "./updates.js"
15
+
16
+ const COMMAND_DESCRIPTION =
17
+ "Show Backeer status, update it, or manage its token: /backeer [status|update-check|update|token|token-file PATH]"
18
+
19
+ function tokenFileState(path) {
20
+ try {
21
+ const stats = statSync(path)
22
+ const mode = (stats.mode & 0o777).toString(8).padStart(3, "0")
23
+ return stats.isFile() ? `present (mode ${mode})` : "not a regular file"
24
+ } catch {
25
+ return "missing"
26
+ }
27
+ }
28
+
29
+ function cleanPathArgument(value) {
30
+ const path = value.trim()
31
+ if (
32
+ path.length >= 2 &&
33
+ ((path.startsWith('"') && path.endsWith('"')) ||
34
+ (path.startsWith("'") && path.endsWith("'")))
35
+ ) {
36
+ return path.slice(1, -1)
37
+ }
38
+ return path
39
+ }
40
+
41
+ function writeTokenAtomically(path, token) {
42
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 })
43
+ const temporaryPath = `${path}.${process.pid}.${Date.now()}.tmp`
44
+ writeFileSync(temporaryPath, `${token}\n`, { encoding: "utf8", mode: 0o600 })
45
+ chmodSync(temporaryPath, 0o600)
46
+ renameSync(temporaryPath, path)
47
+ }
48
+
49
+ export function rotateTokenFromFile(config, sourcePathValue) {
50
+ if (!sourcePathValue.trim()) throw new Error("A token file path is required")
51
+ const sourcePath = resolve(cleanPathArgument(sourcePathValue))
52
+
53
+ const sourceStats = statSync(sourcePath)
54
+ if (!sourceStats.isFile()) throw new Error("The token source is not a regular file")
55
+ if ((sourceStats.mode & 0o077) !== 0) {
56
+ throw new Error("The token source must not be readable by group or other users")
57
+ }
58
+
59
+ const token = readFileSync(sourcePath, "utf8").trim()
60
+ if (!token || /\s/.test(token)) {
61
+ throw new Error("The token source must contain exactly one non-empty token")
62
+ }
63
+
64
+ const targetPath = resolve(config.tokenFile)
65
+ if (sourcePath !== targetPath) writeTokenAtomically(targetPath, token)
66
+ else chmodSync(targetPath, 0o600)
67
+ config.token = token
68
+ config.tokenSource = "file"
69
+ return targetPath
70
+ }
71
+
72
+ async function probeGateway(config) {
73
+ const controller = new AbortController()
74
+ const timeout = setTimeout(() => controller.abort(), 3000)
75
+ try {
76
+ const response = await fetch(config.origin, {
77
+ method: "HEAD",
78
+ redirect: "manual",
79
+ signal: controller.signal,
80
+ })
81
+ return `reachable (HTTP ${response.status}; authentication not tested)`
82
+ } catch {
83
+ return "unreachable"
84
+ } finally {
85
+ clearTimeout(timeout)
86
+ }
87
+ }
88
+
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
+ ])
102
+ const permissionStatus = permissionController.status()
103
+ return [
104
+ "## Backeer status",
105
+ "",
106
+ `- Plugin version: ${PLUGIN_VERSION}`,
107
+ `- Update: ${updateStatus(update)}`,
108
+ `- Gateway: ${config.origin.origin} — ${gateway}`,
109
+ `- Token: ${config.token ? `configured via ${config.tokenSource}` : "missing"}`,
110
+ `- Token file: ${config.tokenFile} — ${tokenFileState(config.tokenFile)}`,
111
+ `- Permission notifications: ${config.permissions ? "enabled" : "disabled"}`,
112
+ `- Question notifications: ${config.questions ? "enabled" : "disabled"}`,
113
+ `- Todo notifications: ${config.todos ? "enabled" : "disabled"}`,
114
+ `- Command stream: ${permissionStatus.stream}`,
115
+ `- Pending permissions: ${permissionStatus.pending}`,
116
+ `- Log level: ${config.logLevel}`,
117
+ "",
118
+ "The token value is never displayed.",
119
+ ].join("\n")
120
+ }
121
+
122
+ function helpMessage() {
123
+ return [
124
+ "## Backeer commands",
125
+ "",
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",
129
+ "- `/backeer token` — show secure token-change instructions",
130
+ "- `/backeer token-file /absolute/protected/path` — import and activate a token",
131
+ "",
132
+ "Never place a token directly in a slash command. Slash-command arguments may be stored in session history.",
133
+ ].join("\n")
134
+ }
135
+
136
+ function tokenInstructions() {
137
+ return [
138
+ "## Change the Backeer token securely",
139
+ "",
140
+ "Do not paste the token into OpenCode or `/backeer` arguments.",
141
+ "",
142
+ "Use either:",
143
+ "",
144
+ "1. Hidden terminal input: `npx backeer-opencode@latest configure`",
145
+ "2. A protected file: `chmod 600 /path/to/token && /backeer token-file /path/to/token`",
146
+ "",
147
+ "`token-file` activates the new token immediately; no OpenCode restart is required.",
148
+ ].join("\n")
149
+ }
150
+
151
+ async function sendCommandMessage(client, sessionID, text) {
152
+ if (typeof client?.session?.prompt !== "function") {
153
+ throw new Error("OpenCode session messaging is unavailable")
154
+ }
155
+ await client.session.prompt({
156
+ path: { id: sessionID },
157
+ body: {
158
+ noReply: true,
159
+ parts: [{ type: "text", text, ignored: true }],
160
+ },
161
+ })
162
+ }
163
+
164
+ export function registerBackeerCommand(opencodeConfig) {
165
+ opencodeConfig.command ??= {}
166
+ opencodeConfig.command.backeer = {
167
+ template: "",
168
+ description: COMMAND_DESCRIPTION,
169
+ }
170
+ }
171
+
172
+ export function createBackeerCommandHandler({
173
+ client,
174
+ config,
175
+ permissionController,
176
+ reporter,
177
+ updateChecker,
178
+ }) {
179
+ return async (input, output) => {
180
+ if (input.command !== "backeer") return
181
+ output.parts.length = 0
182
+
183
+ const argumentsValue = (input.arguments ?? "").trim()
184
+ const separator = argumentsValue.indexOf(" ")
185
+ const subcommand = (
186
+ separator === -1 ? argumentsValue : argumentsValue.slice(0, separator)
187
+ ).toLowerCase()
188
+ const subcommandArguments = separator === -1
189
+ ? ""
190
+ : argumentsValue.slice(separator + 1).trim()
191
+
192
+ let message
193
+ try {
194
+ if (!subcommand || subcommand === "status") {
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."
219
+ } else if (subcommand === "token") {
220
+ message = tokenInstructions()
221
+ } else if (subcommand === "token-file") {
222
+ const targetPath = rotateTokenFromFile(config, subcommandArguments)
223
+ permissionController.refreshCredentials()
224
+ reporter.success("Access token updated")
225
+ message = [
226
+ "## Backeer token updated",
227
+ "",
228
+ `- Protected token file: ${targetPath}`,
229
+ "- File mode: 600",
230
+ "- The new token is active immediately.",
231
+ ].join("\n")
232
+ } else {
233
+ message = helpMessage()
234
+ }
235
+ } catch (error) {
236
+ reporter.error("Access token could not be updated", error)
237
+ message = [
238
+ "## Backeer command failed",
239
+ "",
240
+ error instanceof Error ? error.message : String(error),
241
+ "",
242
+ "Use `/backeer token` for secure token-change instructions.",
243
+ ].join("\n")
244
+ }
245
+
246
+ await sendCommandMessage(client, input.sessionID, message)
247
+ }
248
+ }
@@ -169,6 +169,18 @@ export function createPermissionController({
169
169
  stopCommandStreamIfIdle()
170
170
  },
171
171
 
172
+ refreshCredentials() {
173
+ commandStream?.controller.abort()
174
+ if (!commandStream) ensureCommandStream()
175
+ },
176
+
177
+ status() {
178
+ return {
179
+ pending: pendingByExternalID.size,
180
+ stream: commandStream ? "active" : "idle",
181
+ }
182
+ },
183
+
172
184
  async dispose() {
173
185
  disposed = true
174
186
  commandStream?.controller.abort()
package/src/plugin.js CHANGED
@@ -6,8 +6,16 @@ import {
6
6
  publishTodoCompletion,
7
7
  questionIdentity,
8
8
  } from "./event-publishers.js"
9
+ import {
10
+ createBackeerCommandHandler,
11
+ registerBackeerCommand,
12
+ } from "./management.js"
9
13
  import { createPermissionController } from "./permission-controller.js"
10
14
  import { createConsoleReporter } from "./reporter.js"
15
+ import {
16
+ createUpdateChecker,
17
+ createUpdateNoticeHook,
18
+ } from "./updates.js"
11
19
  import { rememberBounded } from "./utilities.js"
12
20
 
13
21
  /**
@@ -31,14 +39,6 @@ export default async function BackeerPlugin({ client, directory }, options = {})
31
39
 
32
40
  const reporter = createConsoleReporter(config.logLevel)
33
41
 
34
- if (!config.token) {
35
- reporter.error(
36
- "Plugin disabled — run: npx backeer-opencode@latest configure",
37
- `Token not found at ${config.tokenFile}`,
38
- )
39
- return {}
40
- }
41
-
42
42
  const permissionController = createPermissionController({
43
43
  config,
44
44
  reporter,
@@ -47,6 +47,22 @@ export default async function BackeerPlugin({ client, directory }, options = {})
47
47
  })
48
48
  const todoCompletionBySessionID = new Map()
49
49
  const publishedQuestions = new Map()
50
+ const updateChecker = createUpdateChecker()
51
+ const handleBackeerCommand = createBackeerCommandHandler({
52
+ client,
53
+ config,
54
+ permissionController,
55
+ reporter,
56
+ updateChecker,
57
+ })
58
+ const handleUpdateNotice = createUpdateNoticeHook(updateChecker)
59
+
60
+ if (!config.token) {
61
+ reporter.error(
62
+ "Notifications disabled — use /backeer token",
63
+ `Token not found at ${config.tokenFile}`,
64
+ )
65
+ }
50
66
 
51
67
  async function handlePermissionAsked(event) {
52
68
  const identity = permissionIdentity(event.properties)
@@ -108,7 +124,17 @@ export default async function BackeerPlugin({ client, directory }, options = {})
108
124
  }
109
125
 
110
126
  return {
127
+ config: async (opencodeConfig) => {
128
+ registerBackeerCommand(opencodeConfig)
129
+ },
130
+
131
+ "command.execute.before": handleBackeerCommand,
132
+
133
+ "chat.message": handleUpdateNotice,
134
+
111
135
  event: async ({ event }) => {
136
+ if (!config.token) return
137
+
112
138
  if (config.permissions && event.type === "permission.asked") {
113
139
  await handlePermissionAsked(event)
114
140
  return
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
+ }