opencode-plugin-hub 0.0.0-stage → 0.1.0

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,1004 @@
1
+ import { spawn } from "node:child_process"
2
+ import { homedir, tmpdir } from "node:os"
3
+ import { basename, join } from "node:path"
4
+ import { copyFile, cp, mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from "node:fs/promises"
5
+ import { applyEdits, modify, parse, type ParseError } from "jsonc-parser"
6
+
7
+ export interface InstalledEntryValue {
8
+ spec: string
9
+ name: string
10
+ kind: string
11
+ version: string
12
+ removable: boolean
13
+ }
14
+
15
+ export interface HubStateValue {
16
+ configPath: string
17
+ configKey: string
18
+ plugins: InstalledEntryValue[]
19
+ error: string
20
+ }
21
+
22
+ export interface SearchHitValue {
23
+ name: string
24
+ version: string
25
+ description: string
26
+ /** Publish date of the latest version, ISO 8601. */
27
+ published: string
28
+ /** Legacy alias of `published`, so an older window bundle can still decode this payload. */
29
+ updated: string
30
+ installed: boolean
31
+ /** Version currently on disk, empty when it cannot be read. */
32
+ current: string
33
+ /** True when the registry has a version newer than the installed one. */
34
+ hasUpdate: boolean
35
+ /** Downloads in the last week. */
36
+ weekly: number
37
+ /** Downloads in the last month. */
38
+ monthly: number
39
+ publisher: string
40
+ license: string
41
+ keywords: string[]
42
+ homepage: string
43
+ repo: string
44
+ npm: string
45
+ }
46
+
47
+ /** The detail dialog's payload: npm metadata plus the README that says what the plugin does. */
48
+ export interface PackageDetailValue {
49
+ name: string
50
+ version: string
51
+ description: string
52
+ homepage: string
53
+ repo: string
54
+ /** Monorepo subdirectory, e.g. `packages/trae`. */
55
+ repoDir: string
56
+ npm: string
57
+ bugs: string
58
+ license: string
59
+ keywords: string[]
60
+ maintainers: string[]
61
+ weekly: number
62
+ monthly: number
63
+ published: string
64
+ created: string
65
+ versions: number
66
+ unpacked: number
67
+ files: number
68
+ dependencies: string[]
69
+ deprecated: string
70
+ readme: string
71
+ readmeTruncated: boolean
72
+ installed: boolean
73
+ current: string
74
+ hasUpdate: boolean
75
+ error: string
76
+ }
77
+
78
+ export interface SearchResultValue {
79
+ hits: SearchHitValue[]
80
+ error: string
81
+ }
82
+
83
+ export interface UpdateInfoValue {
84
+ spec: string
85
+ /** Real package name: for a directory plugin this comes from its package.json, not the folder name. */
86
+ name: string
87
+ kind: string
88
+ current: string
89
+ latest: string
90
+ hasUpdate: boolean
91
+ /** False for directory plugins, whose files are copied rather than resolved from the registry. */
92
+ managed: boolean
93
+ error: string
94
+ }
95
+
96
+ export interface UpdatesResultValue {
97
+ items: UpdateInfoValue[]
98
+ error: string
99
+ }
100
+
101
+ export interface MutationValue {
102
+ ok: boolean
103
+ message: string
104
+ state: HubStateValue
105
+ }
106
+
107
+ const SELF_PACKAGE = "opencode-plugin-hub"
108
+ const NPM_KEYWORD = "opencode-plugin"
109
+ /** A registry lookup is reused for this long, so opening the page twice is not two requests. */
110
+ const VERSION_TTL = 5 * 60 * 1000
111
+
112
+ function configDir() {
113
+ const xdg = process.env.XDG_CONFIG_HOME
114
+ return xdg ? join(xdg, "opencode") : join(homedir(), ".config", "opencode")
115
+ }
116
+
117
+ function cacheDir() {
118
+ const xdg = process.env.XDG_CACHE_HOME
119
+ return xdg ? join(xdg, "opencode", "npm") : join(homedir(), ".cache", "opencode", "npm")
120
+ }
121
+
122
+ interface ConfigFile {
123
+ path: string
124
+ text: string
125
+ key: string
126
+ plugins: string[]
127
+ }
128
+
129
+ async function readConfig(): Promise<ConfigFile> {
130
+ const dir = configDir()
131
+ const candidates = [join(dir, "opencode.json"), join(dir, "opencode.jsonc")]
132
+ let fallback: ConfigFile | undefined
133
+ for (const path of candidates) {
134
+ let text: string
135
+ try {
136
+ text = await readFile(path, "utf8")
137
+ } catch {
138
+ continue
139
+ }
140
+ const errors: ParseError[] = []
141
+ const doc = parse(text, errors, { allowTrailingComma: true, disallowComments: false }) as
142
+ | Record<string, unknown>
143
+ | undefined
144
+ const record = doc && typeof doc === "object" ? doc : {}
145
+ const key = Array.isArray(record.plugins) ? "plugins" : Array.isArray(record.plugin) ? "plugin" : "plugins"
146
+ const list = Array.isArray(record[key]) ? (record[key] as unknown[]).filter((v) => typeof v === "string") : []
147
+ const file: ConfigFile = { path, text, key, plugins: list as string[] }
148
+ if (file.plugins.length > 0) return file
149
+ if (!fallback) fallback = file
150
+ }
151
+ if (fallback) return fallback
152
+ // Nothing on disk yet: create the JSON config next to whatever exists.
153
+ return { path: join(dir, "opencode.json"), text: "{}\n", key: "plugins", plugins: [] }
154
+ }
155
+
156
+ /** A registry package name: optional `@scope/`, then letters, digits, `-`, `_`, `.` — but no path traversal. */
157
+ function isPackageName(name: string): boolean {
158
+ if (!/^(@[a-z0-9-_.]+\/)?[a-z0-9-_.]+$/i.test(name)) return false
159
+ if (name.includes("..")) return false
160
+ const base = name.split("/").pop() ?? name
161
+ return base.length > 0 && !base.startsWith(".")
162
+ }
163
+
164
+ export function splitSpec(spec: string): { name: string; range: string; kind: string } {
165
+ const trimmed = spec.trim()
166
+ if (trimmed.startsWith("/") || trimmed.startsWith(".") || trimmed.startsWith("~/")) {
167
+ const base = trimmed.replace(/\/+$/, "").split("/").pop() ?? trimmed
168
+ return { name: base, range: "", kind: "path" }
169
+ }
170
+ const scoped = trimmed.startsWith("@")
171
+ const at = trimmed.indexOf("@", scoped ? 1 : 0)
172
+ if (at > 0) return { name: trimmed.slice(0, at), range: trimmed.slice(at + 1), kind: "package" }
173
+ return { name: trimmed, range: "", kind: "package" }
174
+ }
175
+
176
+ function expandPath(spec: string): string {
177
+ const trimmed = spec.trim()
178
+ if (trimmed === "~") return homedir()
179
+ if (trimmed.startsWith("~/")) return join(homedir(), trimmed.slice(2))
180
+ return trimmed
181
+ }
182
+
183
+ interface Manifest {
184
+ name?: string
185
+ version?: string
186
+ }
187
+
188
+ /** The package.json of a directory plugin: the only place its real name and version live. */
189
+ async function readManifest(dir: string): Promise<Manifest | undefined> {
190
+ try {
191
+ const raw = await readFile(join(dir, "package.json"), "utf8")
192
+ const json = JSON.parse(raw) as Manifest
193
+ return typeof json === "object" && json !== null ? json : undefined
194
+ } catch {
195
+ return undefined
196
+ }
197
+ }
198
+
199
+ /** Best-effort installed version of a registry plugin, read from OpenCode's unpacked npm cache. */
200
+ async function installedVersion(name: string, range: string): Promise<string> {
201
+ const wanted = range && range !== "latest" ? range : "latest"
202
+ const roots = Array.from(new Set([`${name}@${wanted}`, `${name}@latest`]))
203
+ for (const root of roots) {
204
+ const dir = join(cacheDir(), root)
205
+ let stamps: string[]
206
+ try {
207
+ stamps = await readdir(dir)
208
+ } catch {
209
+ continue
210
+ }
211
+ for (const stamp of stamps.reverse()) {
212
+ try {
213
+ const file = join(dir, stamp, "node_modules", name, "package.json")
214
+ const raw = await readFile(file, "utf8")
215
+ const json = JSON.parse(raw) as Manifest
216
+ if (json.version) return json.version
217
+ } catch {
218
+ // try the next cached install
219
+ }
220
+ }
221
+ }
222
+ return ""
223
+ }
224
+
225
+ /** A version range is not a version: only a bare `x.y.z` can be compared. */
226
+ function literalVersion(range: string): string {
227
+ const cleaned = range.trim().replace(/^[\^~=v]+/, "").replace(/^>\=?|^<\=?/, "")
228
+ return /^\d+(\.\d+)*([-+][\w.-]+)?$/.test(cleaned) ? cleaned : ""
229
+ }
230
+
231
+ function parts(version: string): number[] {
232
+ return version
233
+ .split("-")[0]!
234
+ .split(".")
235
+ .map((n) => Number.parseInt(n, 10) || 0)
236
+ }
237
+
238
+ function isNewer(candidate: string, current: string): boolean {
239
+ if (!candidate) return false
240
+ if (!current) return true
241
+ const a = parts(candidate)
242
+ const b = parts(current)
243
+ for (let i = 0; i < Math.max(a.length, b.length); i += 1) {
244
+ const left = a[i] ?? 0
245
+ const right = b[i] ?? 0
246
+ if (left !== right) return left > right
247
+ }
248
+ return false
249
+ }
250
+
251
+ interface VersionLookup {
252
+ /** Newest published version; empty when the package has none. */
253
+ version: string
254
+ /** False when the registry could not be reached, so `version` carries no meaning. */
255
+ ok: boolean
256
+ }
257
+
258
+ const versionCache = new Map<string, { at: number; value: VersionLookup }>()
259
+
260
+ /**
261
+ * Newest published version. `ok` separates "the registry says there is nothing newer" from
262
+ * "the registry was never reached" — showing the second as "up to date" would be a lie.
263
+ *
264
+ * A package only counts as published when it has at least one version: the registry serves
265
+ * 200 for placeholder names such as `mem0` that carry no tarball at all.
266
+ */
267
+ export async function npmLatest(name: string, force = false): Promise<VersionLookup> {
268
+ const cached = versionCache.get(name)
269
+ if (!force && cached && Date.now() - cached.at < VERSION_TTL) return cached.value
270
+ const url = `https://registry.npmjs.org/${name.replace("/", "%2f")}`
271
+ let response: Response
272
+ try {
273
+ response = await fetch(url, {
274
+ signal: AbortSignal.timeout(10_000),
275
+ headers: { accept: "application/vnd.npm.install-v1+json" },
276
+ })
277
+ } catch {
278
+ return { version: "", ok: false }
279
+ }
280
+ // 404 is a definite answer: no such package.
281
+ if (response.status === 404) return { version: "", ok: true }
282
+ if (!response.ok) return { version: "", ok: false }
283
+ try {
284
+ const body = (await response.json()) as { "dist-tags"?: Record<string, string>; versions?: Record<string, unknown> }
285
+ const versions = Object.keys(body.versions ?? {})
286
+ if (versions.length === 0) return { version: "", ok: true }
287
+ const latest = body["dist-tags"]?.latest
288
+ const value: VersionLookup = {
289
+ version: latest && versions.includes(latest) ? latest : versions[versions.length - 1] ?? "",
290
+ ok: true,
291
+ }
292
+ versionCache.set(name, { at: Date.now(), value })
293
+ return value
294
+ } catch {
295
+ return { version: "", ok: false }
296
+ }
297
+ }
298
+
299
+ interface ResolvedEntry {
300
+ spec: string
301
+ name: string
302
+ kind: string
303
+ version: string
304
+ }
305
+
306
+ /** Normalizes one config entry, looking inside directory plugins for their real identity. */
307
+ async function resolveEntry(spec: string): Promise<ResolvedEntry> {
308
+ const parsed = splitSpec(spec)
309
+ if (parsed.kind !== "path") {
310
+ const version = literalVersion(parsed.range) || (await installedVersion(parsed.name, parsed.range))
311
+ return { spec, name: parsed.name, kind: parsed.kind, version }
312
+ }
313
+ const manifest = await readManifest(expandPath(spec))
314
+ return {
315
+ spec,
316
+ name: manifest?.name && isPackageName(manifest.name) ? manifest.name : parsed.name,
317
+ kind: "path",
318
+ version: manifest?.version ?? "",
319
+ }
320
+ }
321
+
322
+ async function resolveAll(file: ConfigFile): Promise<ResolvedEntry[]> {
323
+ return Promise.all(file.plugins.map((spec) => resolveEntry(spec)))
324
+ }
325
+
326
+ export async function snapshot(): Promise<HubStateValue> {
327
+ try {
328
+ const file = await readConfig()
329
+ const resolved = await resolveAll(file)
330
+ const plugins: InstalledEntryValue[] = resolved.map((entry) => ({
331
+ spec: entry.spec,
332
+ name: entry.name,
333
+ kind: entry.kind,
334
+ version: entry.version,
335
+ removable: entry.name !== SELF_PACKAGE,
336
+ }))
337
+ return { configPath: file.path, configKey: file.key, plugins, error: "" }
338
+ } catch (error) {
339
+ return {
340
+ configPath: join(configDir(), "opencode.json"),
341
+ configKey: "plugins",
342
+ plugins: [],
343
+ error: describe(error),
344
+ }
345
+ }
346
+ }
347
+
348
+ /** Compares every configured plugin against the registry. One request per package, in parallel. */
349
+ export async function updates(): Promise<UpdatesResultValue> {
350
+ try {
351
+ const file = await readConfig()
352
+ const resolved = await resolveAll(file)
353
+ const items = await Promise.all(
354
+ resolved.map(async (entry): Promise<UpdateInfoValue> => {
355
+ const lookupable = isPackageName(entry.name)
356
+ const lookup: VersionLookup = lookupable ? await npmLatest(entry.name) : { version: "", ok: false }
357
+ return {
358
+ spec: entry.spec,
359
+ name: entry.name,
360
+ kind: entry.kind,
361
+ current: entry.version,
362
+ latest: lookup.version,
363
+ hasUpdate: lookup.ok && isNewer(lookup.version, entry.version),
364
+ managed: entry.kind !== "path",
365
+ error: !lookupable
366
+ ? "不是 npm 包名,无法查询版本"
367
+ : !lookup.ok
368
+ ? "无法连接 npm registry,本次未检查"
369
+ : "",
370
+ }
371
+ }),
372
+ )
373
+ return { items, error: "" }
374
+ } catch (error) {
375
+ return { items: [], error: describe(error) }
376
+ }
377
+ }
378
+
379
+ /**
380
+ * Edits one value in place, so the user's comments, ordering and indentation survive on every other line.
381
+ * `path` is a jsonc path such as `["plugins", 2]`.
382
+ */
383
+ async function applyEdit(file: ConfigFile, path: (string | number)[], value: unknown): Promise<HubStateValue> {
384
+ const text = file.text.trim().length > 0 ? file.text : "{}\n"
385
+ const options = { formattingOptions: { insertSpaces: true, tabSize: 2, eol: "\n" } }
386
+ const edits = modify(text, path, value, options)
387
+ await writeFile(file.path, applyEdits(text, edits), "utf8")
388
+ return snapshot()
389
+ }
390
+
391
+ /** Index of the entry that owns `name`, matching either the raw name or a directory's package.json name. */
392
+ async function findEntry(file: ConfigFile, name: string): Promise<number> {
393
+ for (const [index, spec] of file.plugins.entries()) {
394
+ if (splitSpec(spec).name === name) return index
395
+ }
396
+ for (const [index, spec] of file.plugins.entries()) {
397
+ const parsed = splitSpec(spec)
398
+ if (parsed.kind !== "path") continue
399
+ const manifest = await readManifest(expandPath(spec))
400
+ if (manifest?.name === name) return index
401
+ }
402
+ return -1
403
+ }
404
+
405
+ interface Packument {
406
+ "dist-tags"?: Record<string, string>
407
+ versions?: Record<string, { dist?: { tarball?: string } }>
408
+ }
409
+
410
+ async function packument(name: string, version: string): Promise<{ tarball: string; version: string } | undefined> {
411
+ const url = `https://registry.npmjs.org/${name.replace("/", "%2f")}`
412
+ try {
413
+ const response = await fetch(url, {
414
+ signal: AbortSignal.timeout(15_000),
415
+ headers: { accept: "application/vnd.npm.install-v1+json" },
416
+ })
417
+ if (!response.ok) return undefined
418
+ const body = (await response.json()) as Packument
419
+ const resolved = version || body["dist-tags"]?.latest || ""
420
+ const entry = resolved ? body.versions?.[resolved] : undefined
421
+ const tarball = entry?.dist?.tarball
422
+ return tarball ? { tarball, version: resolved } : undefined
423
+ } catch {
424
+ return undefined
425
+ }
426
+ }
427
+
428
+ async function copyTree(from: string, to: string): Promise<void> {
429
+ const entries = await readdir(from, { withFileTypes: true })
430
+ await mkdir(to, { recursive: true })
431
+ for (const entry of entries) {
432
+ const source = join(from, entry.name)
433
+ const target = join(to, entry.name)
434
+ if (entry.isDirectory()) await copyTree(source, target)
435
+ else if (entry.isFile()) await copyFile(source, target)
436
+ }
437
+ }
438
+
439
+ /** Puts a directory back exactly as it was: drop files the update added, then copy the backup over. */
440
+ async function restoreTree(from: string, to: string): Promise<void> {
441
+ const keep = new Set(await readdir(from))
442
+ for (const entry of await readdir(to, { withFileTypes: true })) {
443
+ if (keep.has(entry.name)) continue
444
+ await rm(join(to, entry.name), { recursive: true, force: true })
445
+ }
446
+ await copyTree(from, to)
447
+ }
448
+
449
+ function run(command: string, args: string[], cwd: string): Promise<{ code: number; output: string }> {
450
+ return new Promise((resolve) => {
451
+ const child = spawn(command, args, { cwd, stdio: ["ignore", "pipe", "pipe"] })
452
+ let output = ""
453
+ child.stdout?.on("data", (chunk) => (output += String(chunk)))
454
+ child.stderr?.on("data", (chunk) => (output += String(chunk)))
455
+ child.on("error", (error) => resolve({ code: -1, output: describe(error) }))
456
+ child.on("close", (code) => resolve({ code: code ?? -1, output }))
457
+ })
458
+ }
459
+
460
+ /**
461
+ * Refreshes a directory plugin from the registry: unpack the new tarball over the folder, then
462
+ * rerun `scripts/repatch.mjs` when present so a local patch is reapplied rather than silently lost.
463
+ * The previous contents are kept next to the folder; on any failure they are copied back.
464
+ */
465
+ async function refreshDirectory(dir: string, name: string, version: string): Promise<{ ok: boolean; message: string }> {
466
+ const target = await packument(name, version)
467
+ if (!target) return { ok: false, message: "在 npm 上找不到这个版本" }
468
+
469
+ let work = ""
470
+ try {
471
+ work = await mkdtemp(join(tmpdir(), "plugin-hub-"))
472
+ const archive = join(work, "package.tgz")
473
+ const download = await fetch(target.tarball, { signal: AbortSignal.timeout(60_000) })
474
+ if (!download.ok) return { ok: false, message: `下载失败:${download.status}` }
475
+ await writeFile(archive, Buffer.from(await download.arrayBuffer()))
476
+
477
+ const unpacked = join(work, "unpacked")
478
+ await mkdir(unpacked, { recursive: true })
479
+ const tar = await run("tar", ["-xzf", archive, "-C", unpacked], work)
480
+ if (tar.code !== 0) return { ok: false, message: `解包失败:${tar.output.slice(0, 160)}` }
481
+
482
+ const staged = join(unpacked, "package")
483
+ const stagedManifest = await readManifest(staged)
484
+ if (!stagedManifest) return { ok: false, message: "下载的包里没有 package.json" }
485
+
486
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19)
487
+ const backup = `${dir}.backup-${stamp}`
488
+ await cp(dir, backup, { recursive: true })
489
+ try {
490
+ await copyTree(staged, dir)
491
+ const repatch = join(dir, "scripts", "repatch.mjs")
492
+ const hasRepatch = await stat(repatch).then(
493
+ () => true,
494
+ () => false,
495
+ )
496
+ let patched = ""
497
+ if (hasRepatch) {
498
+ // Patch from the freshly unpacked copy: the npm cache still holds the old version.
499
+ const result = await run(process.execPath, [repatch, "--from", staged], dir)
500
+ if (result.code !== 0) throw new Error(`重打补丁失败:${result.output.slice(0, 160)}`)
501
+ patched = ",已自动重打补丁"
502
+ }
503
+ const after = await readManifest(dir)
504
+ return {
505
+ ok: true,
506
+ message: `已更新到 ${after?.version ?? target.version}${patched};旧版本备份在 ${basename(backup)}`,
507
+ }
508
+ } catch (error) {
509
+ await restoreTree(backup, dir).catch(() => undefined)
510
+ return { ok: false, message: `更新中断,已从备份还原:${describe(error)}` }
511
+ }
512
+ } catch (error) {
513
+ return { ok: false, message: describe(error) }
514
+ } finally {
515
+ if (work) await rm(work, { recursive: true, force: true }).catch(() => undefined)
516
+ }
517
+ }
518
+
519
+ export async function install(spec: string, version: string): Promise<MutationValue> {
520
+ try {
521
+ const file = await readConfig()
522
+ const parsed = splitSpec(spec)
523
+ if (parsed.kind !== "package" || !isPackageName(parsed.name)) {
524
+ return { ok: false, message: "只能按 npm 包名安装", state: await snapshot() }
525
+ }
526
+ const name = parsed.name
527
+ const wanted = version.trim() ? `${name}@${version.trim()}` : name
528
+ const index = await findEntry(file, name)
529
+
530
+ // A directory plugin cannot be updated by editing the config: its files have to be replaced.
531
+ if (index >= 0 && splitSpec(file.plugins[index]!).kind === "path") {
532
+ const dir = expandPath(file.plugins[index]!)
533
+ const result = await refreshDirectory(dir, name, version.trim())
534
+ return { ok: result.ok, message: result.message, state: await snapshot() }
535
+ }
536
+
537
+ const state =
538
+ index >= 0
539
+ ? await applyEdit(file, [file.key, index], wanted)
540
+ : await applyEdit(file, [file.key, file.plugins.length], wanted)
541
+ return { ok: true, message: index >= 0 ? `已更新 ${wanted}` : `已添加 ${wanted}`, state }
542
+ } catch (error) {
543
+ return { ok: false, message: describe(error), state: await snapshot() }
544
+ }
545
+ }
546
+
547
+ export async function remove(spec: string): Promise<MutationValue> {
548
+ try {
549
+ const file = await readConfig()
550
+ const { name } = splitSpec(spec)
551
+ const index = file.plugins.findIndex((entry) => entry.trim() === spec.trim())
552
+ if (index < 0) return { ok: false, message: "配置中没有这一项", state: await snapshot() }
553
+ const resolved = await resolveEntry(file.plugins[index]!)
554
+ if (resolved.name === SELF_PACKAGE) {
555
+ return { ok: false, message: "不能移除正在运行的插件中心自身", state: await snapshot() }
556
+ }
557
+ const state = await applyEdit(file, [file.key, index], undefined)
558
+ return { ok: true, message: `已移除 ${resolved.name || name}`, state }
559
+ } catch (error) {
560
+ return { ok: false, message: describe(error), state: await snapshot() }
561
+ }
562
+ }
563
+
564
+ interface RegistrySearchPackage {
565
+ name?: string
566
+ version?: string
567
+ description?: string
568
+ date?: string
569
+ keywords?: string[]
570
+ publisher?: { username?: string }
571
+ maintainers?: { username?: string }[]
572
+ license?: string | { type?: string }
573
+ links?: { npm?: string; homepage?: string; repository?: string; bugs?: string }
574
+ }
575
+
576
+ interface RegistrySearch {
577
+ objects?: {
578
+ downloads?: { weekly?: number; monthly?: number }
579
+ package?: RegistrySearchPackage
580
+ }[]
581
+ }
582
+
583
+ /** Tags every plugin carries; they say nothing about what a particular one does. */
584
+ const GENERIC_KEYWORDS = new Set(["opencode", "opencode-plugin", "opencode-plugins", "plugin", "plugins", "ai"])
585
+
586
+ function keywordList(input: readonly string[] | undefined): string[] {
587
+ const seen = new Set<string>()
588
+ const out: string[] = []
589
+ for (const item of input ?? []) {
590
+ const value = item.trim()
591
+ if (!value) continue
592
+ const key = value.toLowerCase()
593
+ if (GENERIC_KEYWORDS.has(key) || seen.has(key)) continue
594
+ seen.add(key)
595
+ out.push(value)
596
+ if (out.length >= 8) break
597
+ }
598
+ return out
599
+ }
600
+
601
+ function cleanUrl(value: string | undefined): string {
602
+ const url = (value ?? "").trim()
603
+ if (!url) return ""
604
+ if (url.startsWith("//")) return `https:${url}`
605
+ return /^https?:\/\//i.test(url) ? url : ""
606
+ }
607
+
608
+ interface RepositoryValue {
609
+ url: string
610
+ directory: string
611
+ }
612
+
613
+ /** Normalizes the many shapes a `repository` field takes into one browsable HTTPS URL. */
614
+ function repository(field: string | { url?: string; directory?: string } | undefined): RepositoryValue {
615
+ const raw = typeof field === "string" ? field : field?.url ?? ""
616
+ const directory = typeof field === "object" && field ? field.directory ?? "" : ""
617
+ const url = raw
618
+ .trim()
619
+ .replace(/^git\+/i, "")
620
+ .replace(/^github:/i, "https://github.com/")
621
+ .replace(/^git:\/\//i, "https://")
622
+ .replace(/^ssh:\/\/git@/i, "https://")
623
+ .replace(/^git@([^:]+):/i, "https://$1/")
624
+ .replace(/#.*$/, "")
625
+ .replace(/\.git\/?$/i, "")
626
+ .replace(/\/+$/, "")
627
+ return /^https?:\/\//i.test(url) ? { url, directory } : { url: "", directory: "" }
628
+ }
629
+
630
+ function licenseText(field: string | { type?: string } | undefined): string {
631
+ if (typeof field === "string") return field
632
+ return field?.type ?? ""
633
+ }
634
+
635
+ function bugsUrl(field: string | { url?: string } | undefined): string {
636
+ const raw = typeof field === "string" ? field : field?.url ?? ""
637
+ return cleanUrl(raw)
638
+ }
639
+
640
+ interface RegistryManifest {
641
+ name?: string
642
+ version?: string
643
+ description?: string
644
+ homepage?: string
645
+ repository?: string | { url?: string; directory?: string }
646
+ license?: string | { type?: string }
647
+ keywords?: string[]
648
+ dependencies?: Record<string, string>
649
+ deprecated?: string
650
+ bugs?: string | { url?: string }
651
+ dist?: { unpackedSize?: number; fileCount?: number }
652
+ }
653
+
654
+ interface NpmPackument extends RegistryManifest {
655
+ readme?: string
656
+ maintainers?: { name?: string; username?: string }[]
657
+ time?: Record<string, string>
658
+ "dist-tags"?: Record<string, string>
659
+ versions?: Record<string, RegistryManifest>
660
+ }
661
+
662
+ type RemoteDetail = Omit<PackageDetailValue, "npm" | "installed" | "current" | "hasUpdate" | "error">
663
+
664
+ const DETAIL_TTL = 10 * 60 * 1000
665
+ const README_LIMIT = 30_000
666
+ const NPM_API = "https://api.npmjs.org"
667
+ const NPM_REGISTRY = "https://registry.npmjs.org"
668
+
669
+ const detailCache = new Map<string, { at: number; value: RemoteDetail }>()
670
+
671
+ function trimReadme(raw: string): { text: string; truncated: boolean } {
672
+ if (raw.length <= README_LIMIT) return { text: raw, truncated: false }
673
+ const head = raw.slice(0, README_LIMIT)
674
+ const cut = head.lastIndexOf("\n")
675
+ const text = cut > README_LIMIT * 0.75 ? head.slice(0, cut) : head
676
+ return { text: `${text}\n\n…`, truncated: true }
677
+ }
678
+
679
+ async function loadDownloads(name: string, period: "last-week" | "last-month"): Promise<number> {
680
+ try {
681
+ const response = await fetch(`${NPM_API}/downloads/point/${period}/${encodeURIComponent(name)}`, {
682
+ signal: AbortSignal.timeout(8_000),
683
+ headers: { accept: "application/json" },
684
+ })
685
+ if (!response.ok) return 0
686
+ const body = (await response.json()) as { downloads?: number }
687
+ return Number(body.downloads ?? 0) || 0
688
+ } catch {
689
+ return 0
690
+ }
691
+ }
692
+
693
+ /**
694
+ * The full packument carries the README, which is the only place many plugins explain what they do.
695
+ * Huge packages (types bundles and the like) are read through their small `/latest` manifest instead.
696
+ */
697
+ async function loadPackument(name: string): Promise<{ body?: NpmPackument; error: string }> {
698
+ try {
699
+ const response = await fetch(`${NPM_REGISTRY}/${encodeURIComponent(name)}`, {
700
+ signal: AbortSignal.timeout(20_000),
701
+ headers: { accept: "application/json" },
702
+ })
703
+ if (response.status === 404) return { error: "npm 上找不到这个包" }
704
+ if (!response.ok) return { error: `npm registry 返回 ${response.status}` }
705
+ const size = Number(response.headers.get("content-length") ?? 0)
706
+ if (size > 12 * 1024 * 1024) return loadLatestManifest(name, "包元数据过大,只读取了最新版本")
707
+ let body: NpmPackument
708
+ try {
709
+ body = (await response.json()) as NpmPackument
710
+ } catch {
711
+ return loadLatestManifest(name, "npm 返回的数据无法解析")
712
+ }
713
+ if (!body || typeof body !== "object") return { error: "npm 返回的数据不完整" }
714
+ // Unpublished placeholders have no `versions` at all; buildRemoteDetail turns that into a clear message.
715
+ return { body, error: "" }
716
+ } catch (error) {
717
+ return { error: describe(error) }
718
+ }
719
+ }
720
+
721
+ async function loadLatestManifest(name: string, reason: string): Promise<{ body?: NpmPackument; error: string }> {
722
+ try {
723
+ const response = await fetch(`${NPM_REGISTRY}/${encodeURIComponent(name)}/latest`, {
724
+ signal: AbortSignal.timeout(10_000),
725
+ headers: { accept: "application/json" },
726
+ })
727
+ if (!response.ok) return { error: reason || `npm registry 返回 ${response.status}` }
728
+ const manifest = (await response.json()) as RegistryManifest & { readme?: string; time?: Record<string, string> }
729
+ if (!manifest?.version) return { error: reason || "npm 返回的数据不完整" }
730
+ return {
731
+ body: {
732
+ ...manifest,
733
+ readme: typeof manifest.readme === "string" ? manifest.readme : "",
734
+ "dist-tags": { latest: manifest.version },
735
+ versions: { [manifest.version]: manifest },
736
+ time: manifest.time,
737
+ },
738
+ error: "",
739
+ }
740
+ } catch (error) {
741
+ return { error: reason || describe(error) }
742
+ }
743
+ }
744
+
745
+ async function buildRemoteDetail(name: string): Promise<RemoteDetail | { error: string }> {
746
+ const loaded = await loadPackument(name)
747
+ if (!loaded.body) return { error: loaded.error }
748
+ const doc = loaded.body
749
+ const versions = doc.versions ?? {}
750
+ const keys = Object.keys(versions)
751
+ if (keys.length === 0) return { error: "npm 上只有占位条目,还没有发布过版本" }
752
+ const tagged = doc["dist-tags"]?.latest ?? ""
753
+ const version = tagged && versions[tagged] ? tagged : keys[keys.length - 1] ?? doc.version ?? ""
754
+ const manifest = version ? versions[version] ?? {} : (doc as RegistryManifest)
755
+ const repo = repository(manifest.repository ?? doc.repository)
756
+ const readme = trimReadme(typeof doc.readme === "string" ? doc.readme : "")
757
+ const [weekly, monthly] = await Promise.all([
758
+ loadDownloads(name, "last-week"),
759
+ loadDownloads(name, "last-month"),
760
+ ])
761
+ const maintainers = (doc.maintainers ?? [])
762
+ .map((person) => person.name ?? person.username ?? "")
763
+ .filter((value) => value !== "")
764
+ const dependencies = Object.entries(manifest.dependencies ?? {}).map(([dep, range]) =>
765
+ range ? `${dep}@${range}` : dep,
766
+ )
767
+ return {
768
+ name,
769
+ version,
770
+ description: manifest.description ?? doc.description ?? "",
771
+ homepage: cleanUrl(manifest.homepage ?? doc.homepage) || repo.url,
772
+ repo: repo.url,
773
+ repoDir: repo.directory,
774
+ bugs: bugsUrl(manifest.bugs ?? doc.bugs),
775
+ license: licenseText(manifest.license ?? doc.license),
776
+ keywords: keywordList(manifest.keywords ?? doc.keywords),
777
+ maintainers,
778
+ weekly,
779
+ monthly,
780
+ published: doc.time?.[version] ?? "",
781
+ created: doc.time?.created ?? "",
782
+ versions: Object.keys(versions).length,
783
+ unpacked: manifest.dist?.unpackedSize ?? 0,
784
+ files: manifest.dist?.fileCount ?? 0,
785
+ dependencies,
786
+ deprecated: typeof manifest.deprecated === "string" ? manifest.deprecated : "",
787
+ readme: readme.text,
788
+ readmeTruncated: readme.truncated,
789
+ }
790
+ }
791
+
792
+ async function loadRemoteDetail(name: string): Promise<RemoteDetail | { error: string }> {
793
+ const cached = detailCache.get(name)
794
+ if (cached && Date.now() - cached.at < DETAIL_TTL) return cached.value
795
+ const value = await buildRemoteDetail(name)
796
+ if (!("error" in value)) detailCache.set(name, { at: Date.now(), value })
797
+ return value
798
+ }
799
+
800
+ /** npm metadata, README and the installed state merged into one payload for the detail dialog. */
801
+ export async function detail(name: string): Promise<PackageDetailValue> {
802
+ const base = (): PackageDetailValue => ({
803
+ name,
804
+ version: "",
805
+ description: "",
806
+ homepage: "",
807
+ repo: "",
808
+ repoDir: "",
809
+ npm: `https://www.npmjs.com/package/${name}`,
810
+ bugs: "",
811
+ license: "",
812
+ keywords: [],
813
+ maintainers: [],
814
+ weekly: 0,
815
+ monthly: 0,
816
+ published: "",
817
+ created: "",
818
+ versions: 0,
819
+ unpacked: 0,
820
+ files: 0,
821
+ dependencies: [],
822
+ deprecated: "",
823
+ readme: "",
824
+ readmeTruncated: false,
825
+ installed: false,
826
+ current: "",
827
+ hasUpdate: false,
828
+ error: "",
829
+ })
830
+ if (!isPackageName(name)) return { ...base(), error: "不是 npm 包名,无法查询详情" }
831
+
832
+ let installed = false
833
+ let current = ""
834
+ try {
835
+ const file = await readConfig()
836
+ const resolved = await resolveAll(file)
837
+ const entry = resolved.find((item) => item.name === name)
838
+ installed = entry !== undefined
839
+ current = entry?.version ?? ""
840
+ } catch {
841
+ // The detail still loads when the config cannot be read; only the installed badge is missing.
842
+ }
843
+
844
+ const remote = await loadRemoteDetail(name)
845
+ if ("error" in remote) return { ...base(), installed, current, error: remote.error }
846
+ return {
847
+ ...remote,
848
+ npm: base().npm,
849
+ installed,
850
+ current,
851
+ hasUpdate: installed && isNewer(remote.version, current),
852
+ error: "",
853
+ }
854
+ }
855
+
856
+ export async function search(query: string): Promise<SearchResultValue> {
857
+ const trimmed = query.trim()
858
+ const text = trimmed ? `${trimmed} keywords:${NPM_KEYWORD}` : `keywords:${NPM_KEYWORD}`
859
+ try {
860
+ const response = await fetch(`https://registry.npmjs.org/-/v1/search?size=50&text=${encodeURIComponent(text)}`, {
861
+ signal: AbortSignal.timeout(12_000),
862
+ headers: { accept: "application/json" },
863
+ })
864
+ if (!response.ok) return { hits: [], error: `npm registry 返回 ${response.status}` }
865
+ const body = (await response.json()) as RegistrySearch
866
+ const file = await readConfig()
867
+ const resolved = await resolveAll(file)
868
+ const installed = new Map(resolved.map((entry) => [entry.name, entry.version]))
869
+ const hits: SearchHitValue[] = []
870
+ for (const item of body.objects ?? []) {
871
+ const pkg = item.package
872
+ if (!pkg?.name) continue
873
+ const name = pkg.name
874
+ const isInstalled = installed.has(name)
875
+ const current = isInstalled ? installed.get(name) ?? "" : ""
876
+ const repo = repository(pkg.links?.repository)
877
+ hits.push({
878
+ name,
879
+ version: pkg.version ?? "",
880
+ description: pkg.description ?? "",
881
+ published: pkg.date ?? "",
882
+ updated: pkg.date ?? "",
883
+ installed: isInstalled,
884
+ current,
885
+ hasUpdate: isInstalled && isNewer(pkg.version ?? "", current),
886
+ weekly: Number(item.downloads?.weekly ?? 0) || 0,
887
+ monthly: Number(item.downloads?.monthly ?? 0) || 0,
888
+ // Prefer a human maintainer: CI-published packages list "GitHub Actions" as the publisher.
889
+ publisher: pkg.maintainers?.[0]?.username ?? pkg.publisher?.username ?? "",
890
+ license: licenseText(pkg.license),
891
+ keywords: keywordList(pkg.keywords),
892
+ homepage: cleanUrl(pkg.links?.homepage),
893
+ repo: repo.url,
894
+ npm: pkg.links?.npm || `https://www.npmjs.com/package/${name}`,
895
+ })
896
+ }
897
+ return { hits, error: "" }
898
+ } catch (error) {
899
+ return { hits: [], error: describe(error) }
900
+ }
901
+ }
902
+
903
+ function opener(): string {
904
+ if (process.platform === "darwin") return "open"
905
+ if (process.platform === "win32") return "explorer"
906
+ return "xdg-open"
907
+ }
908
+
909
+ /** Opens a link in the user's browser; the window itself cannot navigate. */
910
+ export function openUrl(url: string): boolean {
911
+ if (!/^https?:\/\//i.test(url)) return false
912
+ try {
913
+ const child = spawn(opener(), [url], { detached: true, stdio: "ignore" })
914
+ child.unref()
915
+ return true
916
+ } catch {
917
+ return false
918
+ }
919
+ }
920
+
921
+ /** Opens opencode.json in the user's default editor. */
922
+ export async function openConfig(): Promise<boolean> {
923
+ try {
924
+ const file = await readConfig()
925
+ const child = spawn(opener(), [file.path], { detached: true, stdio: "ignore" })
926
+ child.unref()
927
+ return true
928
+ } catch {
929
+ return false
930
+ }
931
+ }
932
+
933
+ /**
934
+ * Fully restarts the desktop app: quit it, make sure no stale `serve --service` keeps the old
935
+ * plugin bundle alive, then open it again.
936
+ *
937
+ * The shell has to be detached because this process is one of the things being killed. `ps` is
938
+ * used instead of `pgrep -f` for the app PID: on macOS the packaged Electron main process is not
939
+ * visible to `pgrep -f`, while `ps -axo args` lists it. The bracket in each pattern (`[/]…`,
940
+ * `[s]erve`) keeps this script's own command line from matching the kill target.
941
+ */
942
+ export function restart(): boolean {
943
+ if (process.platform !== "darwin") return false
944
+ const script = `
945
+ export PATH=/usr/bin:/bin:/usr/sbin:/sbin
946
+ log="$HOME/.cache/opencode/plugin-hub/restart.log"
947
+ mkdir -p "$HOME/.cache/opencode/plugin-hub" 2>/dev/null
948
+ say() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" >> "$log" 2>/dev/null; }
949
+
950
+ sleep 1
951
+ say "restart requested"
952
+
953
+ app_pids=$(ps -axo pid=,args= | grep -E '[/]OpenCode\\.app/Contents/MacOS/OpenCode($| )' | grep -v 'Helper' | awk '{print $1}')
954
+ say "app pids: \${app_pids:-none}"
955
+ [ -n "$app_pids" ] && kill -TERM $app_pids 2>/dev/null
956
+
957
+ sleep 2
958
+
959
+ # The service survives window closes, so it has to be stopped explicitly.
960
+ service_pids=$(ps -axo pid=,args= | grep '[s]erve --service' | awk '{print $1}')
961
+ say "service pids: \${service_pids:-none}"
962
+ [ -n "$service_pids" ] && kill -TERM $service_pids 2>/dev/null
963
+
964
+ sleep 1
965
+
966
+ i=0
967
+ while [ "$i" -lt 6 ]; do
968
+ app_pids=$(ps -axo pid=,args= | grep -E '[/]OpenCode\\.app/Contents/MacOS/OpenCode($| )' | grep -v 'Helper' | awk '{print $1}')
969
+ [ -z "$app_pids" ] && break
970
+ sleep 1
971
+ i=$((i + 1))
972
+ done
973
+ [ -n "$app_pids" ] && kill -KILL $app_pids 2>/dev/null
974
+
975
+ sleep 1
976
+ say "opening app"
977
+ open -b ai.opencode.desktop 2>/dev/null || open -a OpenCode
978
+ `
979
+ try {
980
+ const child = spawn("/bin/sh", ["-c", script], { detached: true, stdio: "ignore" })
981
+ child.unref()
982
+ return true
983
+ } catch {
984
+ return false
985
+ }
986
+ }
987
+
988
+ /** Asks the CLI to reload its configuration. A full restart is still needed for provider snapshots. */
989
+ export async function reload(argv: readonly string[]): Promise<MutationValue> {
990
+ const state = await snapshot()
991
+ try {
992
+ if (argv.length === 0) return { ok: false, message: "找不到 opencode CLI", state }
993
+ const child = spawn(argv[0]!, [...argv.slice(1), "reload"], { detached: true, stdio: "ignore" })
994
+ child.unref()
995
+ return { ok: true, message: "已请求重载配置;插件列表变化建议完全退出应用再打开", state }
996
+ } catch (error) {
997
+ return { ok: false, message: describe(error), state }
998
+ }
999
+ }
1000
+
1001
+ function describe(error: unknown): string {
1002
+ if (error instanceof Error) return error.message
1003
+ return String(error)
1004
+ }