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.
- package/LICENSE +21 -0
- package/README.md +240 -2
- package/dist/gui-experimental.cjs +557 -0
- package/dist/index.js +11 -0
- package/package.json +61 -4
- package/src/contract.ts +186 -0
- package/src/gui/admin.ts +1004 -0
- package/src/gui/catalog.ts +227 -0
- package/src/gui/page.tsx +1860 -0
- package/src/gui/registry-snapshot.json +1 -0
- package/src/gui.tsx +77 -0
- package/src/index.ts +13 -0
package/src/gui/admin.ts
ADDED
|
@@ -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
|
+
}
|