opencode-plugin-hub 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +270 -2
- package/dist/gui-experimental.cjs +544 -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 +1821 -0
- package/src/gui/registry-snapshot.json +1 -0
- package/src/gui.tsx +77 -0
- package/src/index.ts +13 -0
|
@@ -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
|
+
}
|