@cocorograph/hub-agent 0.4.1
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/LICENSE +5 -0
- package/README.md +108 -0
- package/bin/hub-agent.mjs +201 -0
- package/package.json +56 -0
- package/plugins/10-tailscale-remote/README.md +71 -0
- package/plugins/10-tailscale-remote/config.example.json +6 -0
- package/plugins/10-tailscale-remote/plugin.mjs +99 -0
- package/scripts/fix-node-pty-perms.mjs +55 -0
- package/scripts/install.sh +130 -0
- package/src/config.mjs +76 -0
- package/src/enroll.mjs +75 -0
- package/src/hooks.mjs +68 -0
- package/src/main.mjs +362 -0
- package/src/plugin-install.mjs +111 -0
- package/src/plugin-loader.mjs +105 -0
- package/src/pty-bridge.mjs +176 -0
- package/src/service-install.mjs +144 -0
- package/src/skills.mjs +142 -0
- package/src/state.mjs +125 -0
- package/src/tmux.mjs +181 -0
- package/src/usage.mjs +288 -0
- package/src/ws-client.mjs +194 -0
- package/templates/co.cocorograph.hub-agent.plist +46 -0
- package/templates/hub-agent.service +18 -0
package/src/main.mjs
ADDED
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* hub-agent daemon 本体。
|
|
3
|
+
*
|
|
4
|
+
* - 設定読み込み (~/.hub/agent.json) → WsClient 起動
|
|
5
|
+
* - plugin discovery + onAgentStart broadcast
|
|
6
|
+
* - WS メッセージのディスパッチ:
|
|
7
|
+
* - pty.attach / pty.data / pty.resize / pty.detach → PtyBridge
|
|
8
|
+
* - pty 出力 → WsClient で type=pty.data として送信
|
|
9
|
+
* - tmux.exec は Sprint E で tmux module に渡す
|
|
10
|
+
*
|
|
11
|
+
* 仕様書: ナレッジ/インフラ/cockpit-hub-hosted-integration-spec (id=6080)
|
|
12
|
+
*/
|
|
13
|
+
import pino from "pino"
|
|
14
|
+
|
|
15
|
+
import { readConfig } from "./config.mjs"
|
|
16
|
+
import { loadPlugins, runHookBroadcast, runHookChain } from "./plugin-loader.mjs"
|
|
17
|
+
import { WsClient } from "./ws-client.mjs"
|
|
18
|
+
import { PtyBridge } from "./pty-bridge.mjs"
|
|
19
|
+
import { listSkills } from "./skills.mjs"
|
|
20
|
+
import { listSessionStates } from "./state.mjs"
|
|
21
|
+
import {
|
|
22
|
+
createSession as createTmuxSession,
|
|
23
|
+
execTmux,
|
|
24
|
+
killManySessions,
|
|
25
|
+
killSession as killTmuxSession,
|
|
26
|
+
listSessions as listTmuxSessions,
|
|
27
|
+
} from "./tmux.mjs"
|
|
28
|
+
import { getSessionUsages, getUsage } from "./usage.mjs"
|
|
29
|
+
|
|
30
|
+
const logger = pino({ name: "hub-agent" })
|
|
31
|
+
|
|
32
|
+
export async function startDaemon({ version, ptyModule } = {}) {
|
|
33
|
+
const config = await readConfig()
|
|
34
|
+
if (!config) {
|
|
35
|
+
throw new Error(
|
|
36
|
+
"~/.hub/agent.json が見つかりません。`hub-agent enroll <token>` を先に実行してください。",
|
|
37
|
+
)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const plugins = await loadPlugins(logger)
|
|
41
|
+
const ctx = { logger, config, plugins }
|
|
42
|
+
|
|
43
|
+
await runHookBroadcast(plugins, "onAgentStart", ctx)
|
|
44
|
+
|
|
45
|
+
const resolvedPty = ptyModule || (await import("node-pty"))
|
|
46
|
+
const ptyBridge = new PtyBridge({ ptyModule: resolvedPty, logger, plugins })
|
|
47
|
+
|
|
48
|
+
const client = new WsClient(config, { logger, version })
|
|
49
|
+
|
|
50
|
+
// EventEmitter の 'error' は listener が無いと process が落ちる。
|
|
51
|
+
// ws-client は close 側で reconnect を予約しているので、ここでは log だけ。
|
|
52
|
+
client.on("error", (err) => {
|
|
53
|
+
logger.warn({ err: err.message }, "ws client error (will retry via close handler)")
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
ptyBridge.on("output", ({ stream_id, data }) => {
|
|
57
|
+
client.send({ type: "pty.data", stream_id, data })
|
|
58
|
+
})
|
|
59
|
+
ptyBridge.on("exit", ({ stream_id, code }) => {
|
|
60
|
+
client.send({ type: "pty.exit", stream_id, code })
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
client.on("message", async (msg) => {
|
|
64
|
+
await dispatch(msg, { ...ctx, client, ptyBridge })
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
client.connect()
|
|
68
|
+
|
|
69
|
+
// 5s 周期で全 tmux session の状態を捕捉し、変化したものだけ session.state を push。
|
|
70
|
+
// browser がフォーカスしていない session でも常時更新するためのバックグラウンド職人。
|
|
71
|
+
const stateLoop = startStateLoop({ client, plugins, logger, intervalMs: 5_000 })
|
|
72
|
+
|
|
73
|
+
const shutdown = async (signal) => {
|
|
74
|
+
logger.info({ signal }, "shutting down")
|
|
75
|
+
await runHookBroadcast(plugins, "onAgentStop", ctx)
|
|
76
|
+
stateLoop.stop()
|
|
77
|
+
ptyBridge.shutdown()
|
|
78
|
+
client.stop()
|
|
79
|
+
process.exit(0)
|
|
80
|
+
}
|
|
81
|
+
process.on("SIGINT", () => shutdown("SIGINT"))
|
|
82
|
+
process.on("SIGTERM", () => shutdown("SIGTERM"))
|
|
83
|
+
|
|
84
|
+
return { client, plugins, ptyBridge }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* 全 tmux session の状態を定期 capture し、変化したものだけ Hub に push する。
|
|
89
|
+
*
|
|
90
|
+
* - 前回値との比較で status / context_pct のどちらかが変化したら送る
|
|
91
|
+
* - 新規 session は無条件 push、消えた session は無視 (browser 側で
|
|
92
|
+
* pty.exit 受信時に処理する)
|
|
93
|
+
* - tmux 自体が動いてない場合 (listSessionStates → []) は何も push しない
|
|
94
|
+
*/
|
|
95
|
+
function startStateLoop({ client, plugins, logger, intervalMs }) {
|
|
96
|
+
const lastByName = new Map() // session_name → {status, context_pct}
|
|
97
|
+
let stopped = false
|
|
98
|
+
|
|
99
|
+
const tick = async () => {
|
|
100
|
+
if (stopped) return
|
|
101
|
+
try {
|
|
102
|
+
const states = await listSessionStates({ plugins, logger })
|
|
103
|
+
for (const s of states) {
|
|
104
|
+
const prev = lastByName.get(s.session_name)
|
|
105
|
+
if (
|
|
106
|
+
!prev ||
|
|
107
|
+
prev.status !== s.status ||
|
|
108
|
+
prev.context_pct !== s.context_pct
|
|
109
|
+
) {
|
|
110
|
+
lastByName.set(s.session_name, {
|
|
111
|
+
status: s.status,
|
|
112
|
+
context_pct: s.context_pct,
|
|
113
|
+
})
|
|
114
|
+
client.send({
|
|
115
|
+
type: "session.state",
|
|
116
|
+
session_name: s.session_name,
|
|
117
|
+
status: s.status,
|
|
118
|
+
context_pct: s.context_pct,
|
|
119
|
+
})
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
} catch (err) {
|
|
123
|
+
logger?.warn({ err: err.message }, "state loop tick failed")
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// 起動直後に 1 回 + 以後 intervalMs 周期
|
|
128
|
+
const t0 = setTimeout(tick, 500)
|
|
129
|
+
const ti = setInterval(tick, intervalMs)
|
|
130
|
+
ti.unref?.()
|
|
131
|
+
t0.unref?.()
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
stop() {
|
|
135
|
+
stopped = true
|
|
136
|
+
clearInterval(ti)
|
|
137
|
+
clearTimeout(t0)
|
|
138
|
+
},
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Hub からのメッセージを type で分岐する。
|
|
144
|
+
*
|
|
145
|
+
* `pty.*` は PtyBridge にそのまま流す。`tmux.exec` は Sprint E で tmux 抽象に
|
|
146
|
+
* 渡す予定なので、現状は plugin hook chain だけ走らせて log に残す。
|
|
147
|
+
*/
|
|
148
|
+
async function dispatch(msg, ctx) {
|
|
149
|
+
const t = msg?.type || ""
|
|
150
|
+
try {
|
|
151
|
+
switch (t) {
|
|
152
|
+
case "ready":
|
|
153
|
+
ctx.logger.info({ msg }, "hub ready")
|
|
154
|
+
return
|
|
155
|
+
case "ack":
|
|
156
|
+
ctx.logger.debug({ echo: msg.echo }, "hub ack")
|
|
157
|
+
return
|
|
158
|
+
case "error":
|
|
159
|
+
ctx.logger.warn({ msg }, "hub error")
|
|
160
|
+
return
|
|
161
|
+
case "pty.attach": {
|
|
162
|
+
const stream_id = msg.stream_id
|
|
163
|
+
try {
|
|
164
|
+
const info = await ctx.ptyBridge.attach({
|
|
165
|
+
stream_id,
|
|
166
|
+
sessionName: msg.session_name || "",
|
|
167
|
+
cols: msg.cols,
|
|
168
|
+
rows: msg.rows,
|
|
169
|
+
})
|
|
170
|
+
ctx.client.send({
|
|
171
|
+
type: "pty.ready",
|
|
172
|
+
stream_id,
|
|
173
|
+
session_name: msg.session_name || "",
|
|
174
|
+
plugin: info.plugin,
|
|
175
|
+
})
|
|
176
|
+
} catch (err) {
|
|
177
|
+
ctx.client.send({
|
|
178
|
+
type: "pty.exit",
|
|
179
|
+
stream_id,
|
|
180
|
+
code: -1,
|
|
181
|
+
error: err.message,
|
|
182
|
+
})
|
|
183
|
+
}
|
|
184
|
+
return
|
|
185
|
+
}
|
|
186
|
+
case "pty.data":
|
|
187
|
+
ctx.ptyBridge.write({ stream_id: msg.stream_id, data: msg.data })
|
|
188
|
+
return
|
|
189
|
+
case "pty.resize":
|
|
190
|
+
ctx.ptyBridge.resize({
|
|
191
|
+
stream_id: msg.stream_id,
|
|
192
|
+
cols: msg.cols,
|
|
193
|
+
rows: msg.rows,
|
|
194
|
+
})
|
|
195
|
+
return
|
|
196
|
+
case "pty.detach":
|
|
197
|
+
ctx.ptyBridge.detach({ stream_id: msg.stream_id })
|
|
198
|
+
return
|
|
199
|
+
case "tmux.exec": {
|
|
200
|
+
const args = Array.isArray(msg.args) ? msg.args : []
|
|
201
|
+
const hookResult = await runHookChain(ctx.plugins, "interceptTmuxExec", {
|
|
202
|
+
logger: ctx.logger,
|
|
203
|
+
args,
|
|
204
|
+
env: process.env,
|
|
205
|
+
})
|
|
206
|
+
let result
|
|
207
|
+
if (hookResult?.result?.command) {
|
|
208
|
+
// plugin が独自 command で差し替え (例: remote-ops プラグインで ssh 越し)
|
|
209
|
+
const { command, args: spawnArgs, env } = hookResult.result
|
|
210
|
+
try {
|
|
211
|
+
const { execFile } = await import("node:child_process")
|
|
212
|
+
const { promisify } = await import("node:util")
|
|
213
|
+
const execFileP = promisify(execFile)
|
|
214
|
+
const { stdout, stderr } = await execFileP(command, spawnArgs, {
|
|
215
|
+
env: env || process.env,
|
|
216
|
+
maxBuffer: 4 * 1024 * 1024,
|
|
217
|
+
})
|
|
218
|
+
result = { stdout, stderr, exit_code: 0 }
|
|
219
|
+
} catch (err) {
|
|
220
|
+
result = {
|
|
221
|
+
stdout: err.stdout || "",
|
|
222
|
+
stderr: err.stderr || err.message || "",
|
|
223
|
+
exit_code: typeof err.code === "number" ? err.code : 1,
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
} else {
|
|
227
|
+
result = await execTmux(args)
|
|
228
|
+
}
|
|
229
|
+
ctx.client.send({
|
|
230
|
+
type: "tmux.result",
|
|
231
|
+
request_id: msg.request_id,
|
|
232
|
+
stdout: result.stdout,
|
|
233
|
+
stderr: result.stderr,
|
|
234
|
+
exit_code: result.exit_code,
|
|
235
|
+
plugin: hookResult?.plugin || null,
|
|
236
|
+
})
|
|
237
|
+
return
|
|
238
|
+
}
|
|
239
|
+
case "tmux.list_sessions": {
|
|
240
|
+
try {
|
|
241
|
+
const sessions = await listTmuxSessions({ plugins: ctx.plugins, logger: ctx.logger })
|
|
242
|
+
ctx.client.send({
|
|
243
|
+
type: "tmux.sessions",
|
|
244
|
+
request_id: msg.request_id,
|
|
245
|
+
sessions,
|
|
246
|
+
})
|
|
247
|
+
} catch (err) {
|
|
248
|
+
ctx.client.send({
|
|
249
|
+
type: "tmux.sessions",
|
|
250
|
+
request_id: msg.request_id,
|
|
251
|
+
sessions: [],
|
|
252
|
+
error: err.message,
|
|
253
|
+
})
|
|
254
|
+
}
|
|
255
|
+
return
|
|
256
|
+
}
|
|
257
|
+
case "tmux.create_session": {
|
|
258
|
+
const name = (msg.session_name || "").trim()
|
|
259
|
+
const cwd = (msg.cwd || process.env.HOME || "/").trim()
|
|
260
|
+
if (!name) {
|
|
261
|
+
ctx.client.send({
|
|
262
|
+
type: "tmux.create_session.result",
|
|
263
|
+
request_id: msg.request_id,
|
|
264
|
+
ok: false,
|
|
265
|
+
error: "session_name is required",
|
|
266
|
+
})
|
|
267
|
+
return
|
|
268
|
+
}
|
|
269
|
+
try {
|
|
270
|
+
await createTmuxSession(name, cwd, {
|
|
271
|
+
claudeCmd: typeof msg.claude_cmd === "string" ? msg.claude_cmd : undefined,
|
|
272
|
+
})
|
|
273
|
+
ctx.client.send({
|
|
274
|
+
type: "tmux.create_session.result",
|
|
275
|
+
request_id: msg.request_id,
|
|
276
|
+
ok: true,
|
|
277
|
+
session_name: name,
|
|
278
|
+
})
|
|
279
|
+
} catch (err) {
|
|
280
|
+
ctx.client.send({
|
|
281
|
+
type: "tmux.create_session.result",
|
|
282
|
+
request_id: msg.request_id,
|
|
283
|
+
ok: false,
|
|
284
|
+
error: err.message,
|
|
285
|
+
})
|
|
286
|
+
}
|
|
287
|
+
return
|
|
288
|
+
}
|
|
289
|
+
case "tmux.kill_session": {
|
|
290
|
+
const names = Array.isArray(msg.session_names)
|
|
291
|
+
? msg.session_names
|
|
292
|
+
: msg.session_name
|
|
293
|
+
? [msg.session_name]
|
|
294
|
+
: []
|
|
295
|
+
if (names.length === 0) {
|
|
296
|
+
ctx.client.send({
|
|
297
|
+
type: "tmux.kill_session.result",
|
|
298
|
+
request_id: msg.request_id,
|
|
299
|
+
killed: [],
|
|
300
|
+
failed: [{ name: "", reason: "session_name(s) required" }],
|
|
301
|
+
})
|
|
302
|
+
return
|
|
303
|
+
}
|
|
304
|
+
const r = await killManySessions(names)
|
|
305
|
+
ctx.client.send({
|
|
306
|
+
type: "tmux.kill_session.result",
|
|
307
|
+
request_id: msg.request_id,
|
|
308
|
+
killed: r.killed,
|
|
309
|
+
failed: r.failed,
|
|
310
|
+
})
|
|
311
|
+
return
|
|
312
|
+
}
|
|
313
|
+
case "skills.request": {
|
|
314
|
+
try {
|
|
315
|
+
const skills = await listSkills({ sessionName: msg.session_name })
|
|
316
|
+
ctx.client.send({
|
|
317
|
+
type: "skills.response",
|
|
318
|
+
request_id: msg.request_id,
|
|
319
|
+
session_name: msg.session_name || "",
|
|
320
|
+
skills,
|
|
321
|
+
})
|
|
322
|
+
} catch (err) {
|
|
323
|
+
ctx.client.send({
|
|
324
|
+
type: "skills.response",
|
|
325
|
+
request_id: msg.request_id,
|
|
326
|
+
session_name: msg.session_name || "",
|
|
327
|
+
skills: [],
|
|
328
|
+
error: err.message,
|
|
329
|
+
})
|
|
330
|
+
}
|
|
331
|
+
return
|
|
332
|
+
}
|
|
333
|
+
case "usage.request": {
|
|
334
|
+
try {
|
|
335
|
+
const [usage, sessions] = await Promise.all([
|
|
336
|
+
getUsage(),
|
|
337
|
+
getSessionUsages(),
|
|
338
|
+
])
|
|
339
|
+
ctx.client.send({
|
|
340
|
+
type: "usage.response",
|
|
341
|
+
request_id: msg.request_id,
|
|
342
|
+
usage,
|
|
343
|
+
sessions,
|
|
344
|
+
})
|
|
345
|
+
} catch (err) {
|
|
346
|
+
ctx.client.send({
|
|
347
|
+
type: "usage.response",
|
|
348
|
+
request_id: msg.request_id,
|
|
349
|
+
usage: null,
|
|
350
|
+
sessions: [],
|
|
351
|
+
error: err.message,
|
|
352
|
+
})
|
|
353
|
+
}
|
|
354
|
+
return
|
|
355
|
+
}
|
|
356
|
+
default:
|
|
357
|
+
ctx.logger.debug({ type: t }, "unknown message type")
|
|
358
|
+
}
|
|
359
|
+
} catch (err) {
|
|
360
|
+
ctx.logger.error({ type: t, err: err.message }, "dispatch threw")
|
|
361
|
+
}
|
|
362
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `hub-agent plugins install <name>` / `uninstall <name>` の実装 (Sprint K)。
|
|
3
|
+
*
|
|
4
|
+
* リポ同梱の `plugins/<name>/` を `~/.hub/plugins/<name>/` にコピーする。
|
|
5
|
+
* uninstall は `~/.hub/plugins/<name>/` を rm -rf する。
|
|
6
|
+
*
|
|
7
|
+
* config.json は plugin によって user 編集が必要なため、existing なら上書き
|
|
8
|
+
* しない (force オプションで強制可)。
|
|
9
|
+
*/
|
|
10
|
+
import { promises as fs } from "node:fs"
|
|
11
|
+
import path from "node:path"
|
|
12
|
+
import { fileURLToPath } from "node:url"
|
|
13
|
+
|
|
14
|
+
import { paths as configPaths } from "./config.mjs"
|
|
15
|
+
|
|
16
|
+
function repoPluginsDir() {
|
|
17
|
+
return path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "plugins")
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function listInstallableExamples() {
|
|
21
|
+
const dir = repoPluginsDir()
|
|
22
|
+
let entries
|
|
23
|
+
try {
|
|
24
|
+
entries = await fs.readdir(dir, { withFileTypes: true })
|
|
25
|
+
} catch {
|
|
26
|
+
return []
|
|
27
|
+
}
|
|
28
|
+
const out = []
|
|
29
|
+
for (const e of entries) {
|
|
30
|
+
if (!e.isDirectory()) continue
|
|
31
|
+
const pluginFile = path.join(dir, e.name, "plugin.mjs")
|
|
32
|
+
try {
|
|
33
|
+
await fs.access(pluginFile)
|
|
34
|
+
out.push(e.name)
|
|
35
|
+
} catch {
|
|
36
|
+
/* plugin.mjs 無いディレクトリは skip */
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return out
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function copyDir(src, dest, { skipExisting = [] } = {}) {
|
|
43
|
+
await fs.mkdir(dest, { recursive: true, mode: 0o700 })
|
|
44
|
+
const entries = await fs.readdir(src, { withFileTypes: true })
|
|
45
|
+
for (const e of entries) {
|
|
46
|
+
const s = path.join(src, e.name)
|
|
47
|
+
const d = path.join(dest, e.name)
|
|
48
|
+
if (e.isDirectory()) {
|
|
49
|
+
await copyDir(s, d, { skipExisting })
|
|
50
|
+
} else {
|
|
51
|
+
if (skipExisting.includes(e.name)) {
|
|
52
|
+
try {
|
|
53
|
+
await fs.access(d)
|
|
54
|
+
continue // 既存ならコピーしない (user 編集を尊重)
|
|
55
|
+
} catch {
|
|
56
|
+
/* fall through to copy */
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
await fs.copyFile(s, d)
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** @returns {Promise<{ source: string, dest: string, copied: boolean }>} */
|
|
65
|
+
export async function installPlugin(name, { force = false } = {}) {
|
|
66
|
+
if (!name || typeof name !== "string") {
|
|
67
|
+
throw new Error("plugin name is required")
|
|
68
|
+
}
|
|
69
|
+
const src = path.join(repoPluginsDir(), name)
|
|
70
|
+
try {
|
|
71
|
+
const st = await fs.stat(src)
|
|
72
|
+
if (!st.isDirectory()) throw new Error("not a directory")
|
|
73
|
+
} catch {
|
|
74
|
+
const available = await listInstallableExamples()
|
|
75
|
+
const hint = available.length ? ` Available: ${available.join(", ")}` : ""
|
|
76
|
+
throw new Error(`plugin "${name}" not found in repo plugins/.${hint}`)
|
|
77
|
+
}
|
|
78
|
+
const dest = path.join(configPaths.pluginsDir, name)
|
|
79
|
+
if (!force) {
|
|
80
|
+
try {
|
|
81
|
+
await fs.access(dest)
|
|
82
|
+
throw new Error(
|
|
83
|
+
`${dest} already exists. Pass --force to overwrite (config.json は常に温存)`,
|
|
84
|
+
)
|
|
85
|
+
} catch (err) {
|
|
86
|
+
if (err.code !== "ENOENT") {
|
|
87
|
+
// dest exists かつ force でない
|
|
88
|
+
if (err.message && err.message.includes("already exists")) throw err
|
|
89
|
+
if (err.code !== undefined) throw err
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
// config.json は既存なら skip して user 編集を温存
|
|
94
|
+
await copyDir(src, dest, { skipExisting: ["config.json"] })
|
|
95
|
+
return { source: src, dest, copied: true }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export async function uninstallPlugin(name) {
|
|
99
|
+
if (!name || typeof name !== "string") {
|
|
100
|
+
throw new Error("plugin name is required")
|
|
101
|
+
}
|
|
102
|
+
const dest = path.join(configPaths.pluginsDir, name)
|
|
103
|
+
try {
|
|
104
|
+
await fs.rm(dest, { recursive: true, force: true })
|
|
105
|
+
} catch (err) {
|
|
106
|
+
throw new Error(`failed to remove ${dest}: ${err.message}`)
|
|
107
|
+
}
|
|
108
|
+
return { dest, removed: true }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export const _internal = { repoPluginsDir, copyDir }
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `~/.hub/plugins/<prio>-<name>/plugin.mjs` を discovery し、hook chain を構築する。
|
|
3
|
+
*
|
|
4
|
+
* - ディレクトリ名の `<prio>` 数字昇順で並ぶ
|
|
5
|
+
* - 各 plugin の default export が `{ name, hooks }` を持つ
|
|
6
|
+
* - `runHookChain(name, ctx)` は最初に non-null を返した hook の戻り値を採用
|
|
7
|
+
*
|
|
8
|
+
* 仕様書: ナレッジ/インフラ/cockpit-hub-hosted-integration-spec (id=6080)
|
|
9
|
+
*/
|
|
10
|
+
import { promises as fs } from "node:fs"
|
|
11
|
+
import path from "node:path"
|
|
12
|
+
import { pathToFileURL } from "node:url"
|
|
13
|
+
|
|
14
|
+
import { paths } from "./config.mjs"
|
|
15
|
+
|
|
16
|
+
const PLUGIN_DIR_REGEX = /^(\d+)-(.+)$/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Plugins discovery。読み込み順は dirname の数値昇順。
|
|
20
|
+
*
|
|
21
|
+
* @returns {Promise<import('./hooks.mjs').Plugin[]>}
|
|
22
|
+
*/
|
|
23
|
+
export async function loadPlugins(logger) {
|
|
24
|
+
const pluginsDir = paths.pluginsDir
|
|
25
|
+
let entries
|
|
26
|
+
try {
|
|
27
|
+
entries = await fs.readdir(pluginsDir, { withFileTypes: true })
|
|
28
|
+
} catch (err) {
|
|
29
|
+
if (err.code === "ENOENT") return []
|
|
30
|
+
throw err
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const candidates = entries
|
|
34
|
+
.filter((d) => d.isDirectory())
|
|
35
|
+
.map((d) => {
|
|
36
|
+
const m = PLUGIN_DIR_REGEX.exec(d.name)
|
|
37
|
+
if (!m) return null
|
|
38
|
+
return { dir: d.name, prio: Number(m[1]), name: m[2] }
|
|
39
|
+
})
|
|
40
|
+
.filter(Boolean)
|
|
41
|
+
.sort((a, b) => a.prio - b.prio)
|
|
42
|
+
|
|
43
|
+
const plugins = []
|
|
44
|
+
for (const c of candidates) {
|
|
45
|
+
const pluginFile = path.join(pluginsDir, c.dir, "plugin.mjs")
|
|
46
|
+
// plugin.mjs が存在しないディレクトリ (空 stub, README only 等) は警告のみで skip。
|
|
47
|
+
// import 例外を always-error にすると Sprint K 前の段階的展開で煩い。
|
|
48
|
+
try {
|
|
49
|
+
await fs.access(pluginFile)
|
|
50
|
+
} catch {
|
|
51
|
+
logger?.warn({ pluginFile }, "plugin.mjs not found (skipped)")
|
|
52
|
+
continue
|
|
53
|
+
}
|
|
54
|
+
try {
|
|
55
|
+
const mod = await import(pathToFileURL(pluginFile).href)
|
|
56
|
+
const plugin = mod.default
|
|
57
|
+
if (!plugin || typeof plugin !== "object" || !plugin.hooks) {
|
|
58
|
+
logger?.warn({ pluginFile }, "plugin shape invalid (default export must be { hooks })")
|
|
59
|
+
continue
|
|
60
|
+
}
|
|
61
|
+
plugin.name = plugin.name || c.name
|
|
62
|
+
plugin._prio = c.prio
|
|
63
|
+
plugins.push(plugin)
|
|
64
|
+
logger?.info({ plugin: plugin.name, prio: c.prio }, "plugin loaded")
|
|
65
|
+
} catch (err) {
|
|
66
|
+
logger?.error({ pluginFile, err: err.message }, "plugin load failed")
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return plugins
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* 指定 hook を chain 実行し、最初に non-null を返した結果を採用する。
|
|
74
|
+
* すべて null なら null を返す (= caller 側で default 実装に fall through)。
|
|
75
|
+
*/
|
|
76
|
+
export async function runHookChain(plugins, hookName, ctx) {
|
|
77
|
+
for (const plugin of plugins) {
|
|
78
|
+
const hook = plugin.hooks?.[hookName]
|
|
79
|
+
if (typeof hook !== "function") continue
|
|
80
|
+
try {
|
|
81
|
+
const result = await hook(ctx)
|
|
82
|
+
if (result !== null && result !== undefined) {
|
|
83
|
+
return { plugin: plugin.name, result }
|
|
84
|
+
}
|
|
85
|
+
} catch (err) {
|
|
86
|
+
ctx?.logger?.error?.({ plugin: plugin.name, hookName, err: err.message }, "hook threw")
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return null
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* void を返す hook (onAgentStart / onAgentStop) を全 plugin で実行する。
|
|
94
|
+
*/
|
|
95
|
+
export async function runHookBroadcast(plugins, hookName, ctx) {
|
|
96
|
+
for (const plugin of plugins) {
|
|
97
|
+
const hook = plugin.hooks?.[hookName]
|
|
98
|
+
if (typeof hook !== "function") continue
|
|
99
|
+
try {
|
|
100
|
+
await hook(ctx)
|
|
101
|
+
} catch (err) {
|
|
102
|
+
ctx?.logger?.error?.({ plugin: plugin.name, hookName, err: err.message }, "hook threw")
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|