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.
- package/CHANGELOG.md +16 -0
- package/README.md +44 -0
- package/bin/backeer-opencode.js +1 -1
- package/dist/backeer-permission.js +935 -0
- package/package.json +9 -2
- package/src/config.js +59 -0
- package/src/event-publishers.js +214 -0
- package/src/http.js +87 -0
- package/src/index.js +1 -829
- package/src/management.js +207 -0
- package/src/permission-controller.js +195 -0
- package/src/plugin.js +160 -0
- package/src/reporter.js +60 -0
- package/src/sse.js +139 -0
- package/src/utilities.js +25 -0
|
@@ -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
|
+
}
|
package/src/reporter.js
ADDED
|
@@ -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
|
+
}
|