backeer-opencode 0.1.3 → 0.1.4
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 +8 -0
- package/README.md +24 -0
- package/dist/backeer-permission.js +219 -14
- package/package.json +1 -1
- package/src/config.js +7 -4
- package/src/management.js +207 -0
- package/src/permission-controller.js +12 -0
- package/src/plugin.js +25 -8
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to `backeer-opencode` are documented here.
|
|
4
4
|
|
|
5
|
+
## 0.1.4 - 2026-09-01
|
|
6
|
+
|
|
7
|
+
- Add `/backeer` and `/backeer status` for an in-session integration report.
|
|
8
|
+
- Add secure, immediate token rotation with `/backeer token-file PATH`.
|
|
9
|
+
- Keep raw tokens out of slash-command arguments, session messages, and logs.
|
|
10
|
+
- Keep the management command available when notifications are disabled by a
|
|
11
|
+
missing token.
|
|
12
|
+
|
|
5
13
|
## 0.1.3 - 2026-09-01
|
|
6
14
|
|
|
7
15
|
- Split configuration, logging, HTTP, SSE, event publishing, and permission
|
package/README.md
CHANGED
|
@@ -149,6 +149,30 @@ 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 token
|
|
160
|
+
/backeer token-file /absolute/path/to/a/protected-token-file
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
The status report shows the plugin version, gateway reachability, token source,
|
|
164
|
+
notification switches, command-stream state, pending permissions, and log level.
|
|
165
|
+
It never displays the token. Gateway reachability does not validate the source
|
|
166
|
+
credential.
|
|
167
|
+
|
|
168
|
+
`/backeer token` displays safe rotation instructions. Never paste a raw token
|
|
169
|
+
into slash-command arguments because OpenCode may retain command text in session
|
|
170
|
+
history. To rotate from inside OpenCode, first place the new token in an absolute
|
|
171
|
+
file readable only by its owner (`chmod 600`), then use `token-file`. The plugin
|
|
172
|
+
atomically copies it to the configured token location, applies mode `0600`, and
|
|
173
|
+
activates it immediately without restarting OpenCode. The source file is not
|
|
174
|
+
removed.
|
|
175
|
+
|
|
152
176
|
## Events and decisions
|
|
153
177
|
|
|
154
178
|
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,188 @@ 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
|
+
var PLUGIN_VERSION = "0.1.4";
|
|
361
|
+
var COMMAND_DESCRIPTION = "Show Backeer status or manage its token: /backeer [status|token|token-file PATH]";
|
|
362
|
+
function tokenFileState(path) {
|
|
363
|
+
try {
|
|
364
|
+
const stats = statSync(path);
|
|
365
|
+
const mode = (stats.mode & 511).toString(8).padStart(3, "0");
|
|
366
|
+
return stats.isFile() ? `present (mode ${mode})` : "not a regular file";
|
|
367
|
+
} catch {
|
|
368
|
+
return "missing";
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
function cleanPathArgument(value) {
|
|
372
|
+
const path = value.trim();
|
|
373
|
+
if (path.length >= 2 && (path.startsWith('"') && path.endsWith('"') || path.startsWith("'") && path.endsWith("'"))) {
|
|
374
|
+
return path.slice(1, -1);
|
|
375
|
+
}
|
|
376
|
+
return path;
|
|
377
|
+
}
|
|
378
|
+
function writeTokenAtomically(path, token) {
|
|
379
|
+
mkdirSync(dirname(path), { recursive: true, mode: 448 });
|
|
380
|
+
const temporaryPath = `${path}.${process.pid}.${Date.now()}.tmp`;
|
|
381
|
+
writeFileSync(temporaryPath, `${token}
|
|
382
|
+
`, { encoding: "utf8", mode: 384 });
|
|
383
|
+
chmodSync(temporaryPath, 384);
|
|
384
|
+
renameSync(temporaryPath, path);
|
|
385
|
+
}
|
|
386
|
+
function rotateTokenFromFile(config, sourcePathValue) {
|
|
387
|
+
if (!sourcePathValue.trim()) throw new Error("A token file path is required");
|
|
388
|
+
const sourcePath = resolve(cleanPathArgument(sourcePathValue));
|
|
389
|
+
const sourceStats = statSync(sourcePath);
|
|
390
|
+
if (!sourceStats.isFile()) throw new Error("The token source is not a regular file");
|
|
391
|
+
if ((sourceStats.mode & 63) !== 0) {
|
|
392
|
+
throw new Error("The token source must not be readable by group or other users");
|
|
393
|
+
}
|
|
394
|
+
const token = readFileSync2(sourcePath, "utf8").trim();
|
|
395
|
+
if (!token || /\s/.test(token)) {
|
|
396
|
+
throw new Error("The token source must contain exactly one non-empty token");
|
|
397
|
+
}
|
|
398
|
+
const targetPath = resolve(config.tokenFile);
|
|
399
|
+
if (sourcePath !== targetPath) writeTokenAtomically(targetPath, token);
|
|
400
|
+
else chmodSync(targetPath, 384);
|
|
401
|
+
config.token = token;
|
|
402
|
+
config.tokenSource = "file";
|
|
403
|
+
return targetPath;
|
|
404
|
+
}
|
|
405
|
+
async function probeGateway(config) {
|
|
406
|
+
const controller = new AbortController();
|
|
407
|
+
const timeout = setTimeout(() => controller.abort(), 3e3);
|
|
408
|
+
try {
|
|
409
|
+
const response = await fetch(config.origin, {
|
|
410
|
+
method: "HEAD",
|
|
411
|
+
redirect: "manual",
|
|
412
|
+
signal: controller.signal
|
|
413
|
+
});
|
|
414
|
+
return `reachable (HTTP ${response.status}; authentication not tested)`;
|
|
415
|
+
} catch {
|
|
416
|
+
return "unreachable";
|
|
417
|
+
} finally {
|
|
418
|
+
clearTimeout(timeout);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
async function buildStatus(config, permissionController) {
|
|
422
|
+
const gateway = await probeGateway(config);
|
|
423
|
+
const permissionStatus = permissionController.status();
|
|
424
|
+
return [
|
|
425
|
+
"## Backeer status",
|
|
426
|
+
"",
|
|
427
|
+
`- Plugin version: ${PLUGIN_VERSION}`,
|
|
428
|
+
`- Gateway: ${config.origin.origin} \u2014 ${gateway}`,
|
|
429
|
+
`- Token: ${config.token ? `configured via ${config.tokenSource}` : "missing"}`,
|
|
430
|
+
`- Token file: ${config.tokenFile} \u2014 ${tokenFileState(config.tokenFile)}`,
|
|
431
|
+
`- Permission notifications: ${config.permissions ? "enabled" : "disabled"}`,
|
|
432
|
+
`- Question notifications: ${config.questions ? "enabled" : "disabled"}`,
|
|
433
|
+
`- Todo notifications: ${config.todos ? "enabled" : "disabled"}`,
|
|
434
|
+
`- Command stream: ${permissionStatus.stream}`,
|
|
435
|
+
`- Pending permissions: ${permissionStatus.pending}`,
|
|
436
|
+
`- Log level: ${config.logLevel}`,
|
|
437
|
+
"",
|
|
438
|
+
"The token value is never displayed."
|
|
439
|
+
].join("\n");
|
|
440
|
+
}
|
|
441
|
+
function helpMessage() {
|
|
442
|
+
return [
|
|
443
|
+
"## Backeer commands",
|
|
444
|
+
"",
|
|
445
|
+
"- `/backeer` or `/backeer status` \u2014 show integration status",
|
|
446
|
+
"- `/backeer token` \u2014 show secure token-change instructions",
|
|
447
|
+
"- `/backeer token-file /absolute/protected/path` \u2014 import and activate a token",
|
|
448
|
+
"",
|
|
449
|
+
"Never place a token directly in a slash command. Slash-command arguments may be stored in session history."
|
|
450
|
+
].join("\n");
|
|
451
|
+
}
|
|
452
|
+
function tokenInstructions() {
|
|
453
|
+
return [
|
|
454
|
+
"## Change the Backeer token securely",
|
|
455
|
+
"",
|
|
456
|
+
"Do not paste the token into OpenCode or `/backeer` arguments.",
|
|
457
|
+
"",
|
|
458
|
+
"Use either:",
|
|
459
|
+
"",
|
|
460
|
+
"1. Hidden terminal input: `npx backeer-opencode@latest configure`",
|
|
461
|
+
"2. A protected file: `chmod 600 /path/to/token && /backeer token-file /path/to/token`",
|
|
462
|
+
"",
|
|
463
|
+
"`token-file` activates the new token immediately; no OpenCode restart is required."
|
|
464
|
+
].join("\n");
|
|
465
|
+
}
|
|
466
|
+
async function sendCommandMessage(client, sessionID, text) {
|
|
467
|
+
if (typeof client?.session?.prompt !== "function") {
|
|
468
|
+
throw new Error("OpenCode session messaging is unavailable");
|
|
469
|
+
}
|
|
470
|
+
await client.session.prompt({
|
|
471
|
+
path: { id: sessionID },
|
|
472
|
+
body: {
|
|
473
|
+
noReply: true,
|
|
474
|
+
parts: [{ type: "text", text, ignored: true }]
|
|
475
|
+
}
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
function registerBackeerCommand(opencodeConfig) {
|
|
479
|
+
opencodeConfig.command ??= {};
|
|
480
|
+
opencodeConfig.command.backeer = {
|
|
481
|
+
template: "",
|
|
482
|
+
description: COMMAND_DESCRIPTION
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
function createBackeerCommandHandler({
|
|
486
|
+
client,
|
|
487
|
+
config,
|
|
488
|
+
permissionController,
|
|
489
|
+
reporter
|
|
490
|
+
}) {
|
|
491
|
+
return async (input, output) => {
|
|
492
|
+
if (input.command !== "backeer") return;
|
|
493
|
+
output.parts.length = 0;
|
|
494
|
+
const argumentsValue = (input.arguments ?? "").trim();
|
|
495
|
+
const separator = argumentsValue.indexOf(" ");
|
|
496
|
+
const subcommand = (separator === -1 ? argumentsValue : argumentsValue.slice(0, separator)).toLowerCase();
|
|
497
|
+
const subcommandArguments = separator === -1 ? "" : argumentsValue.slice(separator + 1).trim();
|
|
498
|
+
let message;
|
|
499
|
+
try {
|
|
500
|
+
if (!subcommand || subcommand === "status") {
|
|
501
|
+
message = await buildStatus(config, permissionController);
|
|
502
|
+
} else if (subcommand === "token") {
|
|
503
|
+
message = tokenInstructions();
|
|
504
|
+
} else if (subcommand === "token-file") {
|
|
505
|
+
const targetPath = rotateTokenFromFile(config, subcommandArguments);
|
|
506
|
+
permissionController.refreshCredentials();
|
|
507
|
+
reporter.success("Access token updated");
|
|
508
|
+
message = [
|
|
509
|
+
"## Backeer token updated",
|
|
510
|
+
"",
|
|
511
|
+
`- Protected token file: ${targetPath}`,
|
|
512
|
+
"- File mode: 600",
|
|
513
|
+
"- The new token is active immediately."
|
|
514
|
+
].join("\n");
|
|
515
|
+
} else {
|
|
516
|
+
message = helpMessage();
|
|
517
|
+
}
|
|
518
|
+
} catch (error) {
|
|
519
|
+
reporter.error("Access token could not be updated", error);
|
|
520
|
+
message = [
|
|
521
|
+
"## Backeer command failed",
|
|
522
|
+
"",
|
|
523
|
+
error instanceof Error ? error.message : String(error),
|
|
524
|
+
"",
|
|
525
|
+
"Use `/backeer token` for secure token-change instructions."
|
|
526
|
+
].join("\n");
|
|
527
|
+
}
|
|
528
|
+
await sendCommandMessage(client, input.sessionID, message);
|
|
529
|
+
};
|
|
530
|
+
}
|
|
531
|
+
|
|
347
532
|
// src/sse.js
|
|
348
533
|
function parseSseFrame(frame) {
|
|
349
534
|
const data = [];
|
|
@@ -605,6 +790,16 @@ function createPermissionController({
|
|
|
605
790
|
removePendingByRequestID(requestID);
|
|
606
791
|
stopCommandStreamIfIdle();
|
|
607
792
|
},
|
|
793
|
+
refreshCredentials() {
|
|
794
|
+
commandStream?.controller.abort();
|
|
795
|
+
if (!commandStream) ensureCommandStream();
|
|
796
|
+
},
|
|
797
|
+
status() {
|
|
798
|
+
return {
|
|
799
|
+
pending: pendingByExternalID.size,
|
|
800
|
+
stream: commandStream ? "active" : "idle"
|
|
801
|
+
};
|
|
802
|
+
},
|
|
608
803
|
async dispose() {
|
|
609
804
|
disposed = true;
|
|
610
805
|
commandStream?.controller.abort();
|
|
@@ -632,13 +827,6 @@ async function BackeerPlugin({ client, directory }, options = {}) {
|
|
|
632
827
|
return {};
|
|
633
828
|
}
|
|
634
829
|
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
830
|
const permissionController = createPermissionController({
|
|
643
831
|
config,
|
|
644
832
|
reporter,
|
|
@@ -647,6 +835,18 @@ async function BackeerPlugin({ client, directory }, options = {}) {
|
|
|
647
835
|
});
|
|
648
836
|
const todoCompletionBySessionID = /* @__PURE__ */ new Map();
|
|
649
837
|
const publishedQuestions = /* @__PURE__ */ new Map();
|
|
838
|
+
const handleBackeerCommand = createBackeerCommandHandler({
|
|
839
|
+
client,
|
|
840
|
+
config,
|
|
841
|
+
permissionController,
|
|
842
|
+
reporter
|
|
843
|
+
});
|
|
844
|
+
if (!config.token) {
|
|
845
|
+
reporter.error(
|
|
846
|
+
"Notifications disabled \u2014 use /backeer token",
|
|
847
|
+
`Token not found at ${config.tokenFile}`
|
|
848
|
+
);
|
|
849
|
+
}
|
|
650
850
|
async function handlePermissionAsked(event) {
|
|
651
851
|
const identity = permissionIdentity(event.properties);
|
|
652
852
|
if (!identity) {
|
|
@@ -699,7 +899,12 @@ async function BackeerPlugin({ client, directory }, options = {}) {
|
|
|
699
899
|
).catch((error) => reporter.error("Question could not be sent", error));
|
|
700
900
|
}
|
|
701
901
|
return {
|
|
902
|
+
config: async (opencodeConfig) => {
|
|
903
|
+
registerBackeerCommand(opencodeConfig);
|
|
904
|
+
},
|
|
905
|
+
"command.execute.before": handleBackeerCommand,
|
|
702
906
|
event: async ({ event }) => {
|
|
907
|
+
if (!config.token) return;
|
|
703
908
|
if (config.permissions && event.type === "permission.asked") {
|
|
704
909
|
await handlePermissionAsked(event);
|
|
705
910
|
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,207 @@
|
|
|
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
|
+
export const PLUGIN_VERSION = "0.1.4"
|
|
12
|
+
|
|
13
|
+
const COMMAND_DESCRIPTION =
|
|
14
|
+
"Show Backeer status or manage its token: /backeer [status|token|token-file PATH]"
|
|
15
|
+
|
|
16
|
+
function tokenFileState(path) {
|
|
17
|
+
try {
|
|
18
|
+
const stats = statSync(path)
|
|
19
|
+
const mode = (stats.mode & 0o777).toString(8).padStart(3, "0")
|
|
20
|
+
return stats.isFile() ? `present (mode ${mode})` : "not a regular file"
|
|
21
|
+
} catch {
|
|
22
|
+
return "missing"
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function cleanPathArgument(value) {
|
|
27
|
+
const path = value.trim()
|
|
28
|
+
if (
|
|
29
|
+
path.length >= 2 &&
|
|
30
|
+
((path.startsWith('"') && path.endsWith('"')) ||
|
|
31
|
+
(path.startsWith("'") && path.endsWith("'")))
|
|
32
|
+
) {
|
|
33
|
+
return path.slice(1, -1)
|
|
34
|
+
}
|
|
35
|
+
return path
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function writeTokenAtomically(path, token) {
|
|
39
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 })
|
|
40
|
+
const temporaryPath = `${path}.${process.pid}.${Date.now()}.tmp`
|
|
41
|
+
writeFileSync(temporaryPath, `${token}\n`, { encoding: "utf8", mode: 0o600 })
|
|
42
|
+
chmodSync(temporaryPath, 0o600)
|
|
43
|
+
renameSync(temporaryPath, path)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function rotateTokenFromFile(config, sourcePathValue) {
|
|
47
|
+
if (!sourcePathValue.trim()) throw new Error("A token file path is required")
|
|
48
|
+
const sourcePath = resolve(cleanPathArgument(sourcePathValue))
|
|
49
|
+
|
|
50
|
+
const sourceStats = statSync(sourcePath)
|
|
51
|
+
if (!sourceStats.isFile()) throw new Error("The token source is not a regular file")
|
|
52
|
+
if ((sourceStats.mode & 0o077) !== 0) {
|
|
53
|
+
throw new Error("The token source must not be readable by group or other users")
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const token = readFileSync(sourcePath, "utf8").trim()
|
|
57
|
+
if (!token || /\s/.test(token)) {
|
|
58
|
+
throw new Error("The token source must contain exactly one non-empty token")
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const targetPath = resolve(config.tokenFile)
|
|
62
|
+
if (sourcePath !== targetPath) writeTokenAtomically(targetPath, token)
|
|
63
|
+
else chmodSync(targetPath, 0o600)
|
|
64
|
+
config.token = token
|
|
65
|
+
config.tokenSource = "file"
|
|
66
|
+
return targetPath
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function probeGateway(config) {
|
|
70
|
+
const controller = new AbortController()
|
|
71
|
+
const timeout = setTimeout(() => controller.abort(), 3000)
|
|
72
|
+
try {
|
|
73
|
+
const response = await fetch(config.origin, {
|
|
74
|
+
method: "HEAD",
|
|
75
|
+
redirect: "manual",
|
|
76
|
+
signal: controller.signal,
|
|
77
|
+
})
|
|
78
|
+
return `reachable (HTTP ${response.status}; authentication not tested)`
|
|
79
|
+
} catch {
|
|
80
|
+
return "unreachable"
|
|
81
|
+
} finally {
|
|
82
|
+
clearTimeout(timeout)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function buildStatus(config, permissionController) {
|
|
87
|
+
const gateway = await probeGateway(config)
|
|
88
|
+
const permissionStatus = permissionController.status()
|
|
89
|
+
return [
|
|
90
|
+
"## Backeer status",
|
|
91
|
+
"",
|
|
92
|
+
`- Plugin version: ${PLUGIN_VERSION}`,
|
|
93
|
+
`- Gateway: ${config.origin.origin} — ${gateway}`,
|
|
94
|
+
`- Token: ${config.token ? `configured via ${config.tokenSource}` : "missing"}`,
|
|
95
|
+
`- Token file: ${config.tokenFile} — ${tokenFileState(config.tokenFile)}`,
|
|
96
|
+
`- Permission notifications: ${config.permissions ? "enabled" : "disabled"}`,
|
|
97
|
+
`- Question notifications: ${config.questions ? "enabled" : "disabled"}`,
|
|
98
|
+
`- Todo notifications: ${config.todos ? "enabled" : "disabled"}`,
|
|
99
|
+
`- Command stream: ${permissionStatus.stream}`,
|
|
100
|
+
`- Pending permissions: ${permissionStatus.pending}`,
|
|
101
|
+
`- Log level: ${config.logLevel}`,
|
|
102
|
+
"",
|
|
103
|
+
"The token value is never displayed.",
|
|
104
|
+
].join("\n")
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function helpMessage() {
|
|
108
|
+
return [
|
|
109
|
+
"## Backeer commands",
|
|
110
|
+
"",
|
|
111
|
+
"- `/backeer` or `/backeer status` — show integration status",
|
|
112
|
+
"- `/backeer token` — show secure token-change instructions",
|
|
113
|
+
"- `/backeer token-file /absolute/protected/path` — import and activate a token",
|
|
114
|
+
"",
|
|
115
|
+
"Never place a token directly in a slash command. Slash-command arguments may be stored in session history.",
|
|
116
|
+
].join("\n")
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function tokenInstructions() {
|
|
120
|
+
return [
|
|
121
|
+
"## Change the Backeer token securely",
|
|
122
|
+
"",
|
|
123
|
+
"Do not paste the token into OpenCode or `/backeer` arguments.",
|
|
124
|
+
"",
|
|
125
|
+
"Use either:",
|
|
126
|
+
"",
|
|
127
|
+
"1. Hidden terminal input: `npx backeer-opencode@latest configure`",
|
|
128
|
+
"2. A protected file: `chmod 600 /path/to/token && /backeer token-file /path/to/token`",
|
|
129
|
+
"",
|
|
130
|
+
"`token-file` activates the new token immediately; no OpenCode restart is required.",
|
|
131
|
+
].join("\n")
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function sendCommandMessage(client, sessionID, text) {
|
|
135
|
+
if (typeof client?.session?.prompt !== "function") {
|
|
136
|
+
throw new Error("OpenCode session messaging is unavailable")
|
|
137
|
+
}
|
|
138
|
+
await client.session.prompt({
|
|
139
|
+
path: { id: sessionID },
|
|
140
|
+
body: {
|
|
141
|
+
noReply: true,
|
|
142
|
+
parts: [{ type: "text", text, ignored: true }],
|
|
143
|
+
},
|
|
144
|
+
})
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function registerBackeerCommand(opencodeConfig) {
|
|
148
|
+
opencodeConfig.command ??= {}
|
|
149
|
+
opencodeConfig.command.backeer = {
|
|
150
|
+
template: "",
|
|
151
|
+
description: COMMAND_DESCRIPTION,
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function createBackeerCommandHandler({
|
|
156
|
+
client,
|
|
157
|
+
config,
|
|
158
|
+
permissionController,
|
|
159
|
+
reporter,
|
|
160
|
+
}) {
|
|
161
|
+
return async (input, output) => {
|
|
162
|
+
if (input.command !== "backeer") return
|
|
163
|
+
output.parts.length = 0
|
|
164
|
+
|
|
165
|
+
const argumentsValue = (input.arguments ?? "").trim()
|
|
166
|
+
const separator = argumentsValue.indexOf(" ")
|
|
167
|
+
const subcommand = (
|
|
168
|
+
separator === -1 ? argumentsValue : argumentsValue.slice(0, separator)
|
|
169
|
+
).toLowerCase()
|
|
170
|
+
const subcommandArguments = separator === -1
|
|
171
|
+
? ""
|
|
172
|
+
: argumentsValue.slice(separator + 1).trim()
|
|
173
|
+
|
|
174
|
+
let message
|
|
175
|
+
try {
|
|
176
|
+
if (!subcommand || subcommand === "status") {
|
|
177
|
+
message = await buildStatus(config, permissionController)
|
|
178
|
+
} else if (subcommand === "token") {
|
|
179
|
+
message = tokenInstructions()
|
|
180
|
+
} else if (subcommand === "token-file") {
|
|
181
|
+
const targetPath = rotateTokenFromFile(config, subcommandArguments)
|
|
182
|
+
permissionController.refreshCredentials()
|
|
183
|
+
reporter.success("Access token updated")
|
|
184
|
+
message = [
|
|
185
|
+
"## Backeer token updated",
|
|
186
|
+
"",
|
|
187
|
+
`- Protected token file: ${targetPath}`,
|
|
188
|
+
"- File mode: 600",
|
|
189
|
+
"- The new token is active immediately.",
|
|
190
|
+
].join("\n")
|
|
191
|
+
} else {
|
|
192
|
+
message = helpMessage()
|
|
193
|
+
}
|
|
194
|
+
} catch (error) {
|
|
195
|
+
reporter.error("Access token could not be updated", error)
|
|
196
|
+
message = [
|
|
197
|
+
"## Backeer command failed",
|
|
198
|
+
"",
|
|
199
|
+
error instanceof Error ? error.message : String(error),
|
|
200
|
+
"",
|
|
201
|
+
"Use `/backeer token` for secure token-change instructions.",
|
|
202
|
+
].join("\n")
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
await sendCommandMessage(client, input.sessionID, message)
|
|
206
|
+
}
|
|
207
|
+
}
|
|
@@ -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,6 +6,10 @@ 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"
|
|
11
15
|
import { rememberBounded } from "./utilities.js"
|
|
@@ -31,14 +35,6 @@ export default async function BackeerPlugin({ client, directory }, options = {})
|
|
|
31
35
|
|
|
32
36
|
const reporter = createConsoleReporter(config.logLevel)
|
|
33
37
|
|
|
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
38
|
const permissionController = createPermissionController({
|
|
43
39
|
config,
|
|
44
40
|
reporter,
|
|
@@ -47,6 +43,19 @@ export default async function BackeerPlugin({ client, directory }, options = {})
|
|
|
47
43
|
})
|
|
48
44
|
const todoCompletionBySessionID = new Map()
|
|
49
45
|
const publishedQuestions = new Map()
|
|
46
|
+
const handleBackeerCommand = createBackeerCommandHandler({
|
|
47
|
+
client,
|
|
48
|
+
config,
|
|
49
|
+
permissionController,
|
|
50
|
+
reporter,
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
if (!config.token) {
|
|
54
|
+
reporter.error(
|
|
55
|
+
"Notifications disabled — use /backeer token",
|
|
56
|
+
`Token not found at ${config.tokenFile}`,
|
|
57
|
+
)
|
|
58
|
+
}
|
|
50
59
|
|
|
51
60
|
async function handlePermissionAsked(event) {
|
|
52
61
|
const identity = permissionIdentity(event.properties)
|
|
@@ -108,7 +117,15 @@ export default async function BackeerPlugin({ client, directory }, options = {})
|
|
|
108
117
|
}
|
|
109
118
|
|
|
110
119
|
return {
|
|
120
|
+
config: async (opencodeConfig) => {
|
|
121
|
+
registerBackeerCommand(opencodeConfig)
|
|
122
|
+
},
|
|
123
|
+
|
|
124
|
+
"command.execute.before": handleBackeerCommand,
|
|
125
|
+
|
|
111
126
|
event: async ({ event }) => {
|
|
127
|
+
if (!config.token) return
|
|
128
|
+
|
|
112
129
|
if (config.permissions && event.type === "permission.asked") {
|
|
113
130
|
await handlePermissionAsked(event)
|
|
114
131
|
return
|