backeer-opencode 0.1.2 → 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.
@@ -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
+ }
@@ -0,0 +1,195 @@
1
+ import { HttpError, postJsonWithRetry } from "./http.js"
2
+ import { runCommandStream } from "./sse.js"
3
+ import { rememberBounded } from "./utilities.js"
4
+
5
+ const ALLOWED_DECISIONS = new Set(["once", "always", "reject"])
6
+ const DECISION_LABELS = {
7
+ once: "Allow once",
8
+ always: "Always allow",
9
+ reject: "Reject",
10
+ }
11
+
12
+ async function replyToOpenCode(client, pending, decision) {
13
+ const result = await client.postSessionIdPermissionsPermissionId({
14
+ path: { id: pending.sessionID, permissionID: pending.requestID },
15
+ query: { directory: pending.directory },
16
+ body: { response: decision },
17
+ throwOnError: true,
18
+ })
19
+
20
+ if (result?.data === false || result === false) {
21
+ throw new HttpError("OpenCode did not apply the permission reply", 409)
22
+ }
23
+ }
24
+
25
+ async function acknowledgeCommand(config, commandID, acknowledgement) {
26
+ const url = new URL(
27
+ `/v1/commands/${encodeURIComponent(commandID)}/ack`,
28
+ config.origin,
29
+ )
30
+ await postJsonWithRetry(config, url, acknowledgement)
31
+ }
32
+
33
+ export function createPermissionController({
34
+ config,
35
+ reporter,
36
+ client,
37
+ directory,
38
+ }) {
39
+ const pendingByExternalID = new Map()
40
+ const externalIDByRequestID = new Map()
41
+ const processingCommands = new Set()
42
+ const completedCommands = new Map()
43
+ const publishedPermissions = new Map()
44
+ let commandStream
45
+ let disposed = false
46
+
47
+ function removePendingByRequestID(requestID) {
48
+ const externalId = externalIDByRequestID.get(requestID)
49
+ if (!externalId) return
50
+ externalIDByRequestID.delete(requestID)
51
+ pendingByExternalID.delete(externalId)
52
+ }
53
+
54
+ function stopCommandStreamIfIdle() {
55
+ if (pendingByExternalID.size === 0) commandStream?.controller.abort()
56
+ }
57
+
58
+ async function handleCommand(command) {
59
+ if (!command || typeof command !== "object") return
60
+ if (command.type !== "opencode_permission_reply") return
61
+ if (typeof command.id !== "string" || command.id.length > 200) return
62
+
63
+ const previousAcknowledgement = completedCommands.get(command.id)
64
+ if (previousAcknowledgement) {
65
+ await acknowledgeCommand(config, command.id, previousAcknowledgement)
66
+ .catch((error) => reporter.warning("Decision receipt retry failed", error))
67
+ return
68
+ }
69
+ if (processingCommands.has(command.id)) return
70
+ if (typeof command.externalId !== "string") return
71
+
72
+ const pending = pendingByExternalID.get(command.externalId)
73
+ if (!pending) return
74
+
75
+ processingCommands.add(command.id)
76
+ let acknowledgement
77
+ try {
78
+ if (!ALLOWED_DECISIONS.has(command.decision)) {
79
+ acknowledgement = { status: "rejected", errorCode: "invalid_decision" }
80
+ } else {
81
+ const expiresAt = Date.parse(command.expiresAt)
82
+ if (!Number.isFinite(expiresAt) || expiresAt <= Date.now()) {
83
+ acknowledgement = { status: "expired", decision: command.decision }
84
+ } else if (command.decision === "always" && command.confirmed !== true) {
85
+ acknowledgement = {
86
+ status: "rejected",
87
+ decision: command.decision,
88
+ errorCode: "confirmation_required",
89
+ }
90
+ } else {
91
+ await replyToOpenCode(client, pending, command.decision)
92
+ acknowledgement = { status: "applied", decision: command.decision }
93
+ removePendingByRequestID(pending.requestID)
94
+ }
95
+ }
96
+ } catch (error) {
97
+ const stale = error instanceof HttpError && error.status === 404
98
+ acknowledgement = {
99
+ status: stale ? "stale" : "failed",
100
+ decision: ALLOWED_DECISIONS.has(command.decision)
101
+ ? command.decision
102
+ : undefined,
103
+ errorCode: stale ? "permission_not_found" : "opencode_reply_failed",
104
+ }
105
+ if (stale) removePendingByRequestID(pending.requestID)
106
+ reporter.error("Decision could not be applied", error)
107
+ } finally {
108
+ processingCommands.delete(command.id)
109
+ }
110
+
111
+ acknowledgement = {
112
+ ...acknowledgement,
113
+ type: "opencode_permission_reply_ack",
114
+ externalId: command.externalId,
115
+ processedAt: new Date().toISOString(),
116
+ }
117
+ rememberBounded(completedCommands, command.id, acknowledgement)
118
+
119
+ if (acknowledgement.status === "applied") {
120
+ reporter.applied(
121
+ `Decision applied: ${DECISION_LABELS[acknowledgement.decision]}`,
122
+ )
123
+ } else if (acknowledgement.status === "expired") {
124
+ reporter.warning("Decision expired before it could be applied")
125
+ } else if (acknowledgement.status === "stale") {
126
+ reporter.warning("Permission was already answered locally")
127
+ } else if (acknowledgement.status === "rejected") {
128
+ reporter.warning("Decision was rejected by validation")
129
+ }
130
+
131
+ await acknowledgeCommand(config, command.id, acknowledgement).catch((error) => {
132
+ reporter.warning("Decision receipt could not be sent", error)
133
+ })
134
+ stopCommandStreamIfIdle()
135
+ }
136
+
137
+ function ensureCommandStream() {
138
+ if (disposed || commandStream || pendingByExternalID.size === 0) return
139
+
140
+ const controller = new AbortController()
141
+ const current = {
142
+ controller,
143
+ task: runCommandStream(config, reporter, {
144
+ signal: controller.signal,
145
+ hasPending: () => pendingByExternalID.size > 0,
146
+ onCommand: handleCommand,
147
+ }),
148
+ }
149
+ commandStream = current
150
+ void current.task.finally(() => {
151
+ if (commandStream === current) commandStream = undefined
152
+ if (!disposed && pendingByExternalID.size > 0) ensureCommandStream()
153
+ })
154
+ }
155
+
156
+ return {
157
+ register(identity) {
158
+ pendingByExternalID.set(identity.externalId, { ...identity, directory })
159
+ externalIDByRequestID.set(identity.requestID, identity.externalId)
160
+ ensureCommandStream()
161
+
162
+ if (publishedPermissions.has(identity.externalId)) return false
163
+ rememberBounded(publishedPermissions, identity.externalId, true)
164
+ return true
165
+ },
166
+
167
+ resolveLocally(requestID) {
168
+ removePendingByRequestID(requestID)
169
+ stopCommandStreamIfIdle()
170
+ },
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
+
184
+ async dispose() {
185
+ disposed = true
186
+ commandStream?.controller.abort()
187
+ await commandStream?.task.catch(() => {})
188
+ pendingByExternalID.clear()
189
+ externalIDByRequestID.clear()
190
+ processingCommands.clear()
191
+ completedCommands.clear()
192
+ publishedPermissions.clear()
193
+ },
194
+ }
195
+ }
package/src/plugin.js ADDED
@@ -0,0 +1,160 @@
1
+ import { loadConfig } from "./config.js"
2
+ import {
3
+ permissionIdentity,
4
+ publishPermission,
5
+ publishQuestion,
6
+ publishTodoCompletion,
7
+ questionIdentity,
8
+ } from "./event-publishers.js"
9
+ import {
10
+ createBackeerCommandHandler,
11
+ registerBackeerCommand,
12
+ } from "./management.js"
13
+ import { createPermissionController } from "./permission-controller.js"
14
+ import { createConsoleReporter } from "./reporter.js"
15
+ import { rememberBounded } from "./utilities.js"
16
+
17
+ /**
18
+ * OpenCode server plugin entrypoint.
19
+ *
20
+ * Supported options: origin, tokenFile, permissions, questions, todos, logLevel.
21
+ * The ingest token is intentionally accepted only through the environment or a
22
+ * protected file so it cannot accidentally be committed in opencode.json.
23
+ */
24
+ export default async function BackeerPlugin({ client, directory }, options = {}) {
25
+ const bootstrapReporter = createConsoleReporter(
26
+ options.logLevel ?? process.env.BACKEER_LOG_LEVEL,
27
+ )
28
+ let config
29
+ try {
30
+ config = loadConfig(options)
31
+ } catch (error) {
32
+ bootstrapReporter.error("Plugin disabled — invalid configuration", error)
33
+ return {}
34
+ }
35
+
36
+ const reporter = createConsoleReporter(config.logLevel)
37
+
38
+ const permissionController = createPermissionController({
39
+ config,
40
+ reporter,
41
+ client,
42
+ directory,
43
+ })
44
+ const todoCompletionBySessionID = new Map()
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
+ }
59
+
60
+ async function handlePermissionAsked(event) {
61
+ const identity = permissionIdentity(event.properties)
62
+ if (!identity) {
63
+ reporter.error("Permission event is missing correlation IDs")
64
+ return
65
+ }
66
+ if (!permissionController.register(identity)) return
67
+
68
+ void publishPermission(
69
+ config,
70
+ reporter,
71
+ event.properties,
72
+ directory,
73
+ identity,
74
+ ).catch((error) => reporter.error("Permission request could not be sent", error))
75
+ }
76
+
77
+ async function handleTodoUpdated(event) {
78
+ const { sessionID, todos } = event.properties
79
+ if (typeof sessionID !== "string" || !Array.isArray(todos)) {
80
+ reporter.error("Todo event is missing a valid session or list")
81
+ return
82
+ }
83
+
84
+ const isComplete = todos.length > 0 &&
85
+ todos.every((todo) => todo?.status === "completed")
86
+ const wasComplete = todoCompletionBySessionID.get(sessionID) === true
87
+ rememberBounded(todoCompletionBySessionID, sessionID, isComplete)
88
+ if (!isComplete || wasComplete) return
89
+
90
+ void publishTodoCompletion(
91
+ config,
92
+ reporter,
93
+ event.properties,
94
+ event.id,
95
+ client,
96
+ directory,
97
+ ).catch((error) => reporter.error("Todo completion could not be sent", error))
98
+ }
99
+
100
+ async function handleQuestionAsked(event) {
101
+ const identity = questionIdentity(event.properties)
102
+ if (!identity) {
103
+ reporter.error("Question event is missing correlation IDs")
104
+ return
105
+ }
106
+ if (publishedQuestions.has(identity.externalId)) return
107
+ rememberBounded(publishedQuestions, identity.externalId, true)
108
+
109
+ void publishQuestion(
110
+ config,
111
+ reporter,
112
+ event.properties,
113
+ identity,
114
+ client,
115
+ directory,
116
+ ).catch((error) => reporter.error("Question could not be sent", error))
117
+ }
118
+
119
+ return {
120
+ config: async (opencodeConfig) => {
121
+ registerBackeerCommand(opencodeConfig)
122
+ },
123
+
124
+ "command.execute.before": handleBackeerCommand,
125
+
126
+ event: async ({ event }) => {
127
+ if (!config.token) return
128
+
129
+ if (config.permissions && event.type === "permission.asked") {
130
+ await handlePermissionAsked(event)
131
+ return
132
+ }
133
+
134
+ if (config.permissions && event.type === "permission.replied") {
135
+ permissionController.resolveLocally(
136
+ event.properties.requestID ?? event.properties.id,
137
+ )
138
+ return
139
+ }
140
+
141
+ if (config.todos && event.type === "todo.updated") {
142
+ await handleTodoUpdated(event)
143
+ return
144
+ }
145
+
146
+ if (
147
+ config.questions &&
148
+ (event.type === "question.asked" || event.type === "question.v2.asked")
149
+ ) {
150
+ await handleQuestionAsked(event)
151
+ }
152
+ },
153
+
154
+ dispose: async () => {
155
+ await permissionController.dispose()
156
+ todoCompletionBySessionID.clear()
157
+ publishedQuestions.clear()
158
+ },
159
+ }
160
+ }
@@ -0,0 +1,60 @@
1
+ import { limit } from "./utilities.js"
2
+
3
+ const LOG_LEVELS = new Set(["silent", "error", "info", "debug"])
4
+ const LOG_RANK = { silent: 0, error: 1, info: 2, debug: 3 }
5
+ const LOG_DEDUPLICATION_MS = 30_000
6
+
7
+ export function normalizeLogLevel(value) {
8
+ const level = String(value ?? "info").trim().toLowerCase()
9
+ return LOG_LEVELS.has(level) ? level : "info"
10
+ }
11
+
12
+ export function createConsoleReporter(levelValue) {
13
+ const level = normalizeLogLevel(levelValue)
14
+ const useColor = Boolean(process.stdout?.isTTY) &&
15
+ !("NO_COLOR" in process.env) && process.env.TERM !== "dumb"
16
+ const recentlyEmitted = new Map()
17
+
18
+ const decorate = (code, text) => useColor ? `\u001b[${code}m${text}\u001b[0m` : text
19
+ const symbols = {
20
+ info: decorate("36", "◆"),
21
+ success: decorate("32", "◆"),
22
+ applied: decorate("32", "✓"),
23
+ warning: decorate("33", "!"),
24
+ error: decorate("31", "×"),
25
+ debug: decorate("2", "·"),
26
+ }
27
+ const brand = decorate("1", "Backeer")
28
+
29
+ function emit(requiredLevel, kind, message, detail, deduplicate = false) {
30
+ if (LOG_RANK[level] < LOG_RANK[requiredLevel]) return
31
+
32
+ const key = `${kind}:${message}`
33
+ const now = Date.now()
34
+ if (deduplicate) {
35
+ const previous = recentlyEmitted.get(key)
36
+ if (previous && now - previous < LOG_DEDUPLICATION_MS) return
37
+ recentlyEmitted.delete(key)
38
+ recentlyEmitted.set(key, now)
39
+ while (recentlyEmitted.size > 100) {
40
+ recentlyEmitted.delete(recentlyEmitted.keys().next().value)
41
+ }
42
+ }
43
+
44
+ const technical = level === "debug" && detail
45
+ ? decorate("2", ` · ${limit(detail, 500)}`)
46
+ : ""
47
+ const line = `${symbols[kind]} ${brand} ${message}${technical}`
48
+ if (kind === "error" || kind === "warning") console.error(line)
49
+ else console.log(line)
50
+ }
51
+
52
+ return {
53
+ info: (message, detail) => emit("info", "info", message, detail),
54
+ success: (message, detail) => emit("info", "success", message, detail),
55
+ applied: (message, detail) => emit("info", "applied", message, detail),
56
+ warning: (message, detail) => emit("error", "warning", message, detail, true),
57
+ error: (message, detail) => emit("error", "error", message, detail, true),
58
+ debug: (message, detail) => emit("debug", "debug", message, detail),
59
+ }
60
+ }