backeer-opencode 0.1.1 → 0.1.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backeer-opencode",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "Backeer notifications and remote permission decisions for OpenCode",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -12,16 +12,20 @@
12
12
  },
13
13
  "files": [
14
14
  "src",
15
+ "dist",
15
16
  "bin",
16
17
  "README.md",
17
18
  "CHANGELOG.md",
18
19
  "LICENSE"
19
20
  ],
20
21
  "scripts": {
21
- "check": "node --check src/index.js && node --check bin/backeer-opencode.js && node --test",
22
+ "build": "esbuild src/index.js --bundle --platform=node --format=esm --target=node18 --outfile=dist/backeer-permission.js --legal-comments=none",
23
+ "check": "node --check bin/backeer-opencode.js && npm test",
24
+ "pretest": "npm run build",
22
25
  "test": "node --test",
23
26
  "pack:check": "npm pack --dry-run",
24
27
  "publish:check": "npm publish --dry-run --access public",
28
+ "prepack": "npm run build",
25
29
  "prepublishOnly": "npm run check"
26
30
  },
27
31
  "keywords": [
@@ -45,5 +49,8 @@
45
49
  "publishConfig": {
46
50
  "access": "public"
47
51
  },
52
+ "devDependencies": {
53
+ "esbuild": "^0.28.2"
54
+ },
48
55
  "license": "SEE LICENSE IN LICENSE"
49
56
  }
package/src/config.js ADDED
@@ -0,0 +1,56 @@
1
+ import { readFileSync } from "node:fs"
2
+ import { homedir } from "node:os"
3
+ import { join } from "node:path"
4
+
5
+ import { normalizeLogLevel } from "./reporter.js"
6
+ import { enabled } from "./utilities.js"
7
+
8
+ const DEFAULT_ORIGIN = "https://external-gateway.backeer.com"
9
+
10
+ function defaultConfigDirectory() {
11
+ const base = process.env.XDG_CONFIG_HOME?.trim()
12
+ return base ? join(base, "opencode") : join(homedir(), ".config", "opencode")
13
+ }
14
+
15
+ function loadToken(tokenFile) {
16
+ const environmentToken = process.env.BACKEER_INGEST_TOKEN?.trim()
17
+ if (environmentToken) return environmentToken
18
+
19
+ try {
20
+ return readFileSync(tokenFile, "utf8").trim()
21
+ } catch {
22
+ return ""
23
+ }
24
+ }
25
+
26
+ export function loadConfig(options = {}) {
27
+ const origin = new URL(
28
+ options.origin ?? process.env.BACKEER_ORIGIN ?? DEFAULT_ORIGIN,
29
+ )
30
+ if (origin.protocol !== "https:") {
31
+ throw new Error("Backeer origin must use HTTPS")
32
+ }
33
+
34
+ const tokenFile =
35
+ options.tokenFile ??
36
+ process.env.BACKEER_TOKEN_FILE ??
37
+ join(defaultConfigDirectory(), "secrets", "backeer-ingest-token")
38
+
39
+ return {
40
+ origin,
41
+ eventsEndpoint: new URL("/v1/events", origin),
42
+ commandStreamEndpoint: new URL("/v1/commands/stream", origin),
43
+ token: loadToken(tokenFile),
44
+ tokenFile,
45
+ permissions: enabled(
46
+ options.permissions ?? process.env.BACKEER_NOTIFY_PERMISSIONS,
47
+ ),
48
+ questions: enabled(
49
+ options.questions ?? process.env.BACKEER_NOTIFY_QUESTIONS,
50
+ ),
51
+ todos: enabled(options.todos ?? process.env.BACKEER_NOTIFY_TODOS),
52
+ logLevel: normalizeLogLevel(
53
+ options.logLevel ?? process.env.BACKEER_LOG_LEVEL,
54
+ ),
55
+ }
56
+ }
@@ -0,0 +1,214 @@
1
+ import { postJsonWithRetry } from "./http.js"
2
+ import { limit, stableHash } from "./utilities.js"
3
+
4
+ const ACTION_PROTOCOL = "opencode-permission/v1"
5
+
6
+ export function permissionIdentity(request) {
7
+ const requestID = request.id ?? request.requestID
8
+ const sessionID = request.sessionID
9
+ if (typeof requestID !== "string" || typeof sessionID !== "string") return
10
+
11
+ return {
12
+ requestID,
13
+ sessionID,
14
+ externalId: `opencode:${sessionID}:${requestID}`,
15
+ }
16
+ }
17
+
18
+ export async function publishPermission(
19
+ config,
20
+ reporter,
21
+ request,
22
+ directory,
23
+ identity,
24
+ ) {
25
+ const permissionType = request.permission ?? request.action ?? "unknown"
26
+ const resources = Array.isArray(request.patterns)
27
+ ? request.patterns.join(", ")
28
+ : Array.isArray(request.resources)
29
+ ? request.resources.join(", ")
30
+ : ""
31
+
32
+ const payload = [
33
+ { key: "Permission", value: limit(permissionType) },
34
+ { key: "Session", value: limit(identity.sessionID) },
35
+ { key: "Request ID", value: limit(identity.requestID) },
36
+ { key: "Patterns", value: limit(resources) },
37
+ { key: "Working directory", value: limit(directory) },
38
+ { key: "Action protocol", value: ACTION_PROTOCOL },
39
+ { key: "Available decisions", value: "once, always, reject" },
40
+ ].filter((item) => item.value.length > 0)
41
+
42
+ const result = await postJsonWithRetry(config, config.eventsEndpoint, {
43
+ type: "application",
44
+ severity: "warning",
45
+ title: `OpenCode permission required: ${limit(permissionType, 200)}`,
46
+ body: resources
47
+ ? `OpenCode is waiting for approval to access: ${limit(resources, 500)}`
48
+ : `OpenCode is waiting for approval for: ${limit(permissionType, 500)}`,
49
+ payload,
50
+ externalId: identity.externalId,
51
+ timestamp: new Date().toISOString(),
52
+ notify: true,
53
+ direction: "in",
54
+ })
55
+ reporter.success("Permission request sent", `HTTP ${result.status}`)
56
+ }
57
+
58
+ export function questionIdentity(request) {
59
+ const requestID = request.id ?? request.requestID
60
+ const sessionID = request.sessionID
61
+ if (typeof requestID !== "string" || typeof sessionID !== "string") return
62
+
63
+ return {
64
+ requestID,
65
+ sessionID,
66
+ externalId: `opencode-question:${sessionID}:${requestID}`,
67
+ }
68
+ }
69
+
70
+ function formatQuestionOptions(options) {
71
+ if (!Array.isArray(options)) return ""
72
+
73
+ return options
74
+ .map((option) => {
75
+ const label = limit(option?.label, 200)
76
+ const description = limit(option?.description, 500)
77
+ if (!label) return ""
78
+ return description ? `${label} — ${description}` : label
79
+ })
80
+ .filter(Boolean)
81
+ .join(" | ")
82
+ }
83
+
84
+ async function resolveSessionTitle(client, directory, sessionID, reporter) {
85
+ if (typeof client?.session?.get !== "function") return ""
86
+
87
+ try {
88
+ const result = await client.session.get({
89
+ path: { id: sessionID },
90
+ query: { directory },
91
+ throwOnError: true,
92
+ })
93
+ return limit((result?.data ?? result)?.title)
94
+ } catch (error) {
95
+ reporter.debug("Session title unavailable", error)
96
+ return ""
97
+ }
98
+ }
99
+
100
+ export async function publishQuestion(
101
+ config,
102
+ reporter,
103
+ request,
104
+ identity,
105
+ client,
106
+ directory,
107
+ ) {
108
+ const questions = Array.isArray(request.questions) ? request.questions : []
109
+ if (questions.length === 0) return
110
+
111
+ const sessionTitle = await resolveSessionTitle(
112
+ client,
113
+ directory,
114
+ identity.sessionID,
115
+ reporter,
116
+ )
117
+ const payload = [
118
+ { key: "OpenCode session", value: sessionTitle },
119
+ ...questions.flatMap((question, index) => {
120
+ const suffix = questions.length > 1 ? ` ${index + 1}` : ""
121
+ return [
122
+ { key: `Question title${suffix}`, value: limit(question?.header) },
123
+ { key: `Question${suffix}`, value: limit(question?.question) },
124
+ {
125
+ key: `Options${suffix}`,
126
+ value: limit(formatQuestionOptions(question?.options)),
127
+ },
128
+ ]
129
+ }),
130
+ ].filter((item) => item.value.length > 0)
131
+
132
+ const firstQuestion = questions[0]
133
+ const body = questions
134
+ .map((question) => {
135
+ const header = limit(question?.header, 200)
136
+ const text = limit(question?.question, 500)
137
+ const options = limit(formatQuestionOptions(question?.options), 500)
138
+ return [header, text, options ? `Options: ${options}` : ""]
139
+ .filter(Boolean)
140
+ .join("\n")
141
+ })
142
+ .join("\n\n")
143
+
144
+ const result = await postJsonWithRetry(config, config.eventsEndpoint, {
145
+ type: "application",
146
+ severity: "info",
147
+ title: `OpenCode question: ${limit(firstQuestion?.header, 200) || "Question"}`,
148
+ body: limit(body, 2000),
149
+ payload,
150
+ externalId: identity.externalId,
151
+ timestamp: new Date().toISOString(),
152
+ notify: true,
153
+ direction: "in",
154
+ })
155
+ reporter.success("Question sent", `HTTP ${result.status}`)
156
+ }
157
+
158
+ export async function publishTodoCompletion(
159
+ config,
160
+ reporter,
161
+ request,
162
+ eventID,
163
+ client,
164
+ directory,
165
+ ) {
166
+ const todos = request.todos
167
+ const sessionTitle = await resolveSessionTitle(
168
+ client,
169
+ directory,
170
+ request.sessionID,
171
+ reporter,
172
+ )
173
+ const correlation =
174
+ typeof eventID === "string" && eventID.length > 0
175
+ ? limit(eventID, 200)
176
+ : stableHash(JSON.stringify(todos))
177
+ const externalId = `opencode-todo:${request.sessionID}:${correlation}`
178
+ const visibleTodos = todos.slice(0, 20)
179
+ const payload = [
180
+ { key: "OpenCode session", value: sessionTitle },
181
+ { key: "Completed tasks", value: String(todos.length) },
182
+ ...visibleTodos.flatMap((todo, index) => [
183
+ { key: `Task ${index + 1}`, value: limit(todo?.content) },
184
+ { key: `Priority ${index + 1}`, value: limit(todo?.priority, 100) },
185
+ ]),
186
+ ...(todos.length > visibleTodos.length
187
+ ? [{ key: "Additional tasks", value: String(todos.length - visibleTodos.length) }]
188
+ : []),
189
+ ].filter((item) => item.value.length > 0)
190
+
191
+ const summary = todos
192
+ .slice(0, 5)
193
+ .map((todo) => limit(todo?.content, 200))
194
+ .filter(Boolean)
195
+ .join(" • ")
196
+ const location = sessionTitle ? ` in ${sessionTitle}` : ""
197
+ const body = [
198
+ `All ${todos.length} OpenCode tasks completed${location}.`,
199
+ summary,
200
+ ].filter(Boolean).join("\n")
201
+
202
+ const result = await postJsonWithRetry(config, config.eventsEndpoint, {
203
+ type: "application",
204
+ severity: "info",
205
+ title: "OpenCode todo list completed",
206
+ body: limit(body, 2000),
207
+ payload,
208
+ externalId,
209
+ timestamp: new Date().toISOString(),
210
+ notify: true,
211
+ direction: "in",
212
+ })
213
+ reporter.success("Todo completion sent", `HTTP ${result.status}`)
214
+ }
package/src/http.js ADDED
@@ -0,0 +1,87 @@
1
+ import { limit } from "./utilities.js"
2
+
3
+ const RETRY_DELAYS_MS = [0, 500, 1500]
4
+
5
+ export class HttpError extends Error {
6
+ constructor(message, status, responseBody = "") {
7
+ super(message)
8
+ this.name = "HttpError"
9
+ this.status = status
10
+ this.responseBody = responseBody
11
+ }
12
+ }
13
+
14
+ function isRetryable(status) {
15
+ return status === 429 || status >= 500
16
+ }
17
+
18
+ export function isAbortError(error) {
19
+ return error instanceof Error && error.name === "AbortError"
20
+ }
21
+
22
+ export function sleep(milliseconds, signal) {
23
+ return new Promise((resolve) => {
24
+ if (signal?.aborted) {
25
+ resolve()
26
+ return
27
+ }
28
+
29
+ const timeout = setTimeout(done, milliseconds)
30
+
31
+ function done() {
32
+ clearTimeout(timeout)
33
+ signal?.removeEventListener("abort", done)
34
+ resolve()
35
+ }
36
+
37
+ signal?.addEventListener("abort", done, { once: true })
38
+ })
39
+ }
40
+
41
+ export async function postJsonWithRetry(config, url, body, options = {}) {
42
+ const retryDelays = options.retryDelays ?? RETRY_DELAYS_MS
43
+
44
+ for (let attempt = 0; attempt < retryDelays.length; attempt++) {
45
+ if (retryDelays[attempt] > 0) {
46
+ await sleep(retryDelays[attempt], options.signal)
47
+ }
48
+ if (options.signal?.aborted) throw new DOMException("Aborted", "AbortError")
49
+
50
+ const controller = new AbortController()
51
+ const abort = () => controller.abort()
52
+ const timeout = setTimeout(abort, options.timeoutMs ?? 5000)
53
+ options.signal?.addEventListener("abort", abort, { once: true })
54
+
55
+ try {
56
+ const response = await fetch(url, {
57
+ method: "POST",
58
+ headers: {
59
+ Authorization: `Bearer ${config.token}`,
60
+ "Content-Type": "application/json",
61
+ ...options.headers,
62
+ },
63
+ body: JSON.stringify(body),
64
+ signal: controller.signal,
65
+ })
66
+ const responseBody = limit(await response.text(), 1000)
67
+
68
+ if (response.ok) return { status: response.status, responseBody }
69
+ if (!isRetryable(response.status) || attempt === retryDelays.length - 1) {
70
+ throw new HttpError(
71
+ `HTTP request failed with status ${response.status}`,
72
+ response.status,
73
+ responseBody,
74
+ )
75
+ }
76
+ } catch (error) {
77
+ if (options.signal?.aborted) throw new DOMException("Aborted", "AbortError")
78
+ if (error instanceof HttpError) throw error
79
+ if (attempt === retryDelays.length - 1) throw error
80
+ } finally {
81
+ clearTimeout(timeout)
82
+ options.signal?.removeEventListener("abort", abort)
83
+ }
84
+ }
85
+
86
+ throw new Error("HTTP retry loop ended unexpectedly")
87
+ }