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,227 @@
1
+ import { homedir } from "node:os"
2
+ import { join } from "node:path"
3
+ import { mkdir, readFile, writeFile } from "node:fs/promises"
4
+ import builtin from "./registry-snapshot.json"
5
+
6
+ export interface CatalogEntryValue {
7
+ /** Human-facing label from the curated registry. */
8
+ name: string
9
+ /** npm package name, empty when the project only ships from GitHub. */
10
+ pkg: string
11
+ /** Latest npm version, or the version verified at build time. */
12
+ version: string
13
+ /** `plugins`, `mcp` or `themes`. */
14
+ type: string
15
+ /** One-line tagline. */
16
+ desc: string
17
+ /** Long description, may be empty. */
18
+ long: string
19
+ /** Source repository, used for the "open" action. */
20
+ repo: string
21
+ tags: string[]
22
+ /** True when this package already sits in the config. */
23
+ installed: boolean
24
+ }
25
+
26
+ export interface CatalogResultValue {
27
+ entries: CatalogEntryValue[]
28
+ /** Where the list came from: `network`, `cache` or `builtin`. */
29
+ source: string
30
+ /** Epoch milliseconds of the data, 0 for the built-in snapshot. */
31
+ fetchedAt: number
32
+ error: string
33
+ }
34
+
35
+ interface SnapshotEntry {
36
+ name: string
37
+ pkg: string
38
+ ver: string
39
+ type: string
40
+ desc: string
41
+ long: string
42
+ repo: string
43
+ tags: string[]
44
+ }
45
+
46
+ /**
47
+ * Two community mirrors of the same curated list. The first is the upstream project; the
48
+ * second is a fork that occasionally carries newer entries, so it only acts as a fallback.
49
+ */
50
+ const SOURCES = [
51
+ "https://raw.githubusercontent.com/awesome-opencode/awesome-opencode/main/dist/registry.json",
52
+ "https://raw.githubusercontent.com/Gracdjd/awesome-opencode/main/dist/registry.json",
53
+ ]
54
+
55
+ /** Re-fetch the remote list at most this often; otherwise the cache wins. */
56
+ const TTL = 12 * 60 * 60 * 1000
57
+
58
+ const SNAPSHOT = builtin as SnapshotEntry[]
59
+
60
+ function cacheDir() {
61
+ const xdg = process.env.XDG_CACHE_HOME
62
+ return xdg ? join(xdg, "opencode", "plugin-hub") : join(homedir(), ".cache", "opencode", "plugin-hub")
63
+ }
64
+
65
+ function cacheFile() {
66
+ return join(cacheDir(), "registry.json")
67
+ }
68
+
69
+ interface RawEntry {
70
+ displayName?: string
71
+ type?: string
72
+ tagline?: string
73
+ description?: string
74
+ repoUrl?: string
75
+ tags?: string[]
76
+ /** Free-form install instructions; sometimes the only place the npm name appears. */
77
+ installation?: string
78
+ }
79
+
80
+ const installPattern = /"plugin"\s*:\s*\[\s*"([^"]+)"/
81
+ const packagePattern = /^(@[a-z0-9][\w.-]*\/)?[a-z0-9][\w.-]*$/i
82
+
83
+ /** Best-effort package name for an entry the built-in snapshot has never seen. */
84
+ function guessPackage(entry: RawEntry): string {
85
+ const fromInstall = installPattern.exec(entry.installation ?? "")
86
+ if (fromInstall?.[1]) return fromInstall[1]
87
+ const display = (entry.displayName ?? "").trim()
88
+ if (packagePattern.test(display) && !display.includes(" ")) return display
89
+ const repoUrl = entry.repoUrl ?? ""
90
+ const repo = repoUrl.replace(/\/+$/, "").split("/").pop()?.replace(".git", "") ?? ""
91
+ return packagePattern.test(repo) ? repo : ""
92
+ }
93
+
94
+ /** Lookup table built from the verified snapshot, keyed by repository URL. */
95
+ const verified = new Map<string, SnapshotEntry>()
96
+ for (const entry of SNAPSHOT) {
97
+ verified.set(entry.repo, entry)
98
+ if (entry.pkg) verified.set(`pkg:${entry.pkg}`, entry)
99
+ }
100
+
101
+ function normalizeRepo(url: string) {
102
+ return url.replace(/\/+$/, "").replace(/\.git$/, "")
103
+ }
104
+
105
+ function toEntries(raw: RawEntry[], installed: Set<string>): CatalogEntryValue[] {
106
+ const out: CatalogEntryValue[] = []
107
+ const seen = new Set<string>()
108
+ for (const entry of raw) {
109
+ const type = entry.type ?? "plugins"
110
+ if (type !== "plugins" && type !== "mcp" && type !== "themes") continue
111
+ const repo = normalizeRepo(entry.repoUrl ?? "")
112
+ if (!repo || seen.has(repo)) continue
113
+ seen.add(repo)
114
+ // Prefer the version we verified at build time; fall back to a guess for brand-new entries.
115
+ const known = verified.get(repo) ?? verified.get(`pkg:${guessPackage(entry)}`)
116
+ const pkg = known?.pkg ?? guessPackage(entry)
117
+ out.push({
118
+ name: entry.displayName ?? pkg ?? repo,
119
+ pkg,
120
+ version: known?.ver ?? "",
121
+ type,
122
+ desc: (entry.tagline ?? "").trim(),
123
+ long: (entry.description ?? "").trim().slice(0, 300),
124
+ repo,
125
+ tags: (entry.tags ?? []).slice(0, 6),
126
+ installed: pkg ? installed.has(pkg) : false,
127
+ })
128
+ }
129
+ return out
130
+ }
131
+
132
+ async function fetchRemote(): Promise<RawEntry[]> {
133
+ let lastError = ""
134
+ for (const url of SOURCES) {
135
+ try {
136
+ const response = await fetch(url, { signal: AbortSignal.timeout(12_000), headers: { accept: "application/json" } })
137
+ if (!response.ok) {
138
+ lastError = `${new URL(url).host} 返回 ${response.status}`
139
+ continue
140
+ }
141
+ const body = (await response.json()) as unknown
142
+ if (!Array.isArray(body)) {
143
+ lastError = "registry 格式不是数组"
144
+ continue
145
+ }
146
+ return body as RawEntry[]
147
+ } catch (error) {
148
+ lastError = describe(error)
149
+ }
150
+ }
151
+ throw new Error(lastError || "无法连接插件目录")
152
+ }
153
+
154
+ interface CacheFile {
155
+ fetchedAt: number
156
+ entries: RawEntry[]
157
+ }
158
+
159
+ async function readCache(): Promise<CacheFile | undefined> {
160
+ try {
161
+ const text = await readFile(cacheFile(), "utf8")
162
+ const parsed = JSON.parse(text) as CacheFile
163
+ if (!Array.isArray(parsed.entries)) return undefined
164
+ return parsed
165
+ } catch {
166
+ return undefined
167
+ }
168
+ }
169
+
170
+ async function writeCache(entries: RawEntry[]): Promise<number> {
171
+ const fetchedAt = Date.now()
172
+ try {
173
+ await mkdir(cacheDir(), { recursive: true })
174
+ await writeFile(cacheFile(), JSON.stringify({ fetchedAt, entries }), "utf8")
175
+ } catch {
176
+ // A failed cache write still leaves the in-memory result usable.
177
+ }
178
+ return fetchedAt
179
+ }
180
+
181
+ /**
182
+ * The curated community list. Network first (cached for half a day), then the cache, then the
183
+ * snapshot baked into this bundle — so the page is never empty, even offline.
184
+ */
185
+ export async function browse(installed: Set<string>, force: boolean): Promise<CatalogResultValue> {
186
+ const cache = await readCache()
187
+ const fresh = cache !== undefined && Date.now() - cache.fetchedAt < TTL
188
+ if (cache && fresh && !force) {
189
+ return { entries: toEntries(cache.entries, installed), source: "cache", fetchedAt: cache.fetchedAt, error: "" }
190
+ }
191
+ try {
192
+ const remote = await fetchRemote()
193
+ const fetchedAt = await writeCache(remote)
194
+ return { entries: toEntries(remote, installed), source: "network", fetchedAt, error: "" }
195
+ } catch (error) {
196
+ if (cache) {
197
+ return {
198
+ entries: toEntries(cache.entries, installed),
199
+ source: "cache",
200
+ fetchedAt: cache.fetchedAt,
201
+ error: describe(error),
202
+ }
203
+ }
204
+ return {
205
+ entries: toEntries(SNAPSHOT.map(toRaw), installed),
206
+ source: "builtin",
207
+ fetchedAt: 0,
208
+ error: describe(error),
209
+ }
210
+ }
211
+ }
212
+
213
+ function toRaw(entry: SnapshotEntry): RawEntry {
214
+ return {
215
+ displayName: entry.name,
216
+ type: entry.type,
217
+ tagline: entry.desc,
218
+ description: entry.long,
219
+ repoUrl: entry.repo,
220
+ tags: entry.tags,
221
+ }
222
+ }
223
+
224
+ function describe(error: unknown): string {
225
+ if (error instanceof Error) return error.message
226
+ return String(error)
227
+ }