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 +18 -0
- package/README.md +39 -0
- package/dist/backeer-permission.js +378 -14
- package/package.json +1 -1
- package/src/config.js +7 -4
- package/src/management.js +248 -0
- package/src/permission-controller.js +12 -0
- package/src/plugin.js +34 -8
- package/src/updates.js +136 -0
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
|
-
|
|
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:
|
|
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((
|
|
140
|
+
return new Promise((resolve2) => {
|
|
138
141
|
if (signal?.aborted) {
|
|
139
|
-
|
|
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
|
-
|
|
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
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
|
-
|
|
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:
|
|
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
|
+
}
|