opencode-plugin-hub 0.1.4 → 0.3.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/README.md +3 -1
- package/dist/gui-experimental.cjs +44 -17
- package/package.json +2 -2
- package/src/contract.ts +22 -0
- package/src/gui/admin.ts +127 -9
- package/src/gui/page.tsx +245 -50
- package/src/gui.tsx +2 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-plugin-hub",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Plugin manager for OpenCode Desktop: browse community plugins, search npm, inspect README and downloads, install and update without editing JSON.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -58,6 +58,6 @@
|
|
|
58
58
|
"dev": "vite build --watch",
|
|
59
59
|
"typecheck": "tsc --noEmit",
|
|
60
60
|
"refresh": "node scripts/refresh-registry.mjs",
|
|
61
|
-
"prepublishOnly": "npm run typecheck && npm run build"
|
|
61
|
+
"prepublishOnly": "node scripts/ensure-ci-publish.mjs && npm run typecheck && npm run build"
|
|
62
62
|
}
|
|
63
63
|
}
|
package/src/contract.ts
CHANGED
|
@@ -65,7 +65,9 @@ export const PackageDetail = Schema.Struct({
|
|
|
65
65
|
license: Schema.String,
|
|
66
66
|
keywords: Schema.Array(Schema.String),
|
|
67
67
|
maintainers: Schema.Array(Schema.String),
|
|
68
|
+
/** Weekly downloads; `-1` when the download API could not be asked. */
|
|
68
69
|
weekly: Schema.Number,
|
|
70
|
+
/** Monthly downloads; `-1` when the download API could not be asked. */
|
|
69
71
|
monthly: Schema.Number,
|
|
70
72
|
/** Publish date of the latest version, ISO 8601. */
|
|
71
73
|
published: Schema.String,
|
|
@@ -156,6 +158,25 @@ export const Mutation = Schema.Struct({
|
|
|
156
158
|
state: HubState,
|
|
157
159
|
})
|
|
158
160
|
|
|
161
|
+
/** One configured plugin's runtime truth as the running server reports it. */
|
|
162
|
+
export const RuntimeInfo = Schema.Struct({
|
|
163
|
+
spec: Schema.String,
|
|
164
|
+
name: Schema.String,
|
|
165
|
+
/** "loaded" (server runs it), "failed" (server reports an error) or "unknown" (no runtime answer). */
|
|
166
|
+
status: Schema.Union([Schema.Literal("loaded"), Schema.Literal("failed"), Schema.Literal("unknown")]),
|
|
167
|
+
/** Version the running server loaded, empty when unknown. */
|
|
168
|
+
runtimeVersion: Schema.String,
|
|
169
|
+
/** The server's failure message for a plugin that failed to load. */
|
|
170
|
+
error: Schema.String,
|
|
171
|
+
/** True when the server itself could not be asked; per-plugin status stays "unknown". */
|
|
172
|
+
available: Schema.Boolean,
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
export const RuntimeResult = Schema.Struct({
|
|
176
|
+
items: Schema.Array(RuntimeInfo),
|
|
177
|
+
error: Schema.String,
|
|
178
|
+
})
|
|
179
|
+
|
|
159
180
|
export const Target = Schema.Struct({
|
|
160
181
|
spec: Schema.String,
|
|
161
182
|
version: Schema.String,
|
|
@@ -184,5 +205,6 @@ export const Admin = Ipc.define({
|
|
|
184
205
|
remove: { input: Target, output: Mutation },
|
|
185
206
|
reload: { output: Mutation },
|
|
186
207
|
restart: { output: Opened },
|
|
208
|
+
runtime: { output: RuntimeResult },
|
|
187
209
|
},
|
|
188
210
|
})
|
package/src/gui/admin.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { spawn } from "node:child_process"
|
|
2
2
|
import { homedir, tmpdir } from "node:os"
|
|
3
3
|
import { basename, join } from "node:path"
|
|
4
|
-
import { copyFile, cp, mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from "node:fs/promises"
|
|
4
|
+
import { copyFile, cp, mkdir, mkdtemp, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises"
|
|
5
5
|
import { applyEdits, modify, parse, type ParseError } from "jsonc-parser"
|
|
6
6
|
|
|
7
7
|
export interface InstalledEntryValue {
|
|
@@ -58,7 +58,9 @@ export interface PackageDetailValue {
|
|
|
58
58
|
license: string
|
|
59
59
|
keywords: string[]
|
|
60
60
|
maintainers: string[]
|
|
61
|
+
/** Weekly downloads; `-1` when the download API could not be asked (unknown, not zero). */
|
|
61
62
|
weekly: number
|
|
63
|
+
/** Monthly downloads; `-1` when the download API could not be asked (unknown, not zero). */
|
|
62
64
|
monthly: number
|
|
63
65
|
published: string
|
|
64
66
|
created: string
|
|
@@ -379,13 +381,40 @@ export async function updates(): Promise<UpdatesResultValue> {
|
|
|
379
381
|
/**
|
|
380
382
|
* Edits one value in place, so the user's comments, ordering and indentation survive on every other line.
|
|
381
383
|
* `path` is a jsonc path such as `["plugins", 2]`.
|
|
384
|
+
*
|
|
385
|
+
* All config mutations funnel through this function while holding a module-level
|
|
386
|
+
* queue: two overlapping edits (two cards, or the settings page and the dialog)
|
|
387
|
+
* each re-read the file inside their turn, so neither clobbers the other.
|
|
388
|
+
* Writes land via tmp-file + rename so a crash cannot leave a half-written config.
|
|
382
389
|
*/
|
|
390
|
+
let configQueue: Promise<unknown> = Promise.resolve()
|
|
391
|
+
function enqueue<T>(task: () => Promise<T>): Promise<T> {
|
|
392
|
+
const next = configQueue.then(task, task)
|
|
393
|
+
configQueue = next.catch(() => undefined)
|
|
394
|
+
return next
|
|
395
|
+
}
|
|
396
|
+
|
|
383
397
|
async function applyEdit(file: ConfigFile, path: (string | number)[], value: unknown): Promise<HubStateValue> {
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
398
|
+
return enqueue(async () => {
|
|
399
|
+
// Re-read inside the queue: the file may have changed since the caller read it.
|
|
400
|
+
const fresh = await readConfig()
|
|
401
|
+
const text = fresh.text.trim().length > 0 ? fresh.text : "{}\n"
|
|
402
|
+
const options = { formattingOptions: { insertSpaces: true, tabSize: 2, eol: "\n" } }
|
|
403
|
+
const edits = modify(text, path, value, options)
|
|
404
|
+
await atomicWrite(fresh.path, applyEdits(text, edits))
|
|
405
|
+
return snapshot()
|
|
406
|
+
})
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/** Write via a temp file in the same directory, then rename over the target (atomic on the same volume). */
|
|
410
|
+
async function atomicWrite(path: string, text: string): Promise<void> {
|
|
411
|
+
const tmp = `${path}.plugin-hub-${process.pid}-${Date.now()}.tmp`
|
|
412
|
+
try {
|
|
413
|
+
await writeFile(tmp, text, "utf8")
|
|
414
|
+
await rename(tmp, path)
|
|
415
|
+
} finally {
|
|
416
|
+
await rm(tmp, { force: true }).catch(() => undefined)
|
|
417
|
+
}
|
|
389
418
|
}
|
|
390
419
|
|
|
391
420
|
/** Index of the entry that owns `name`, matching either the raw name or a directory's package.json name. */
|
|
@@ -530,7 +559,9 @@ export async function install(spec: string, version: string): Promise<MutationVa
|
|
|
530
559
|
// A directory plugin cannot be updated by editing the config: its files have to be replaced.
|
|
531
560
|
if (index >= 0 && splitSpec(file.plugins[index]!).kind === "path") {
|
|
532
561
|
const dir = expandPath(file.plugins[index]!)
|
|
533
|
-
|
|
562
|
+
// The directory refresh rewrites files, not the config — still run it
|
|
563
|
+
// through the queue so it cannot interleave with a config edit.
|
|
564
|
+
const result = await enqueue(() => refreshDirectory(dir, name, version.trim()))
|
|
534
565
|
return { ok: result.ok, message: result.message, state: await snapshot() }
|
|
535
566
|
}
|
|
536
567
|
|
|
@@ -676,17 +707,18 @@ function trimReadme(raw: string): { text: string; truncated: boolean } {
|
|
|
676
707
|
return { text: `${text}\n\n…`, truncated: true }
|
|
677
708
|
}
|
|
678
709
|
|
|
710
|
+
/** `-1` means the download API could not be asked: the UI must show 暂不可用, not 0. */
|
|
679
711
|
async function loadDownloads(name: string, period: "last-week" | "last-month"): Promise<number> {
|
|
680
712
|
try {
|
|
681
713
|
const response = await fetch(`${NPM_API}/downloads/point/${period}/${encodeURIComponent(name)}`, {
|
|
682
714
|
signal: AbortSignal.timeout(8_000),
|
|
683
715
|
headers: { accept: "application/json" },
|
|
684
716
|
})
|
|
685
|
-
if (!response.ok) return
|
|
717
|
+
if (!response.ok) return -1
|
|
686
718
|
const body = (await response.json()) as { downloads?: number }
|
|
687
719
|
return Number(body.downloads ?? 0) || 0
|
|
688
720
|
} catch {
|
|
689
|
-
return
|
|
721
|
+
return -1
|
|
690
722
|
}
|
|
691
723
|
}
|
|
692
724
|
|
|
@@ -998,6 +1030,92 @@ export async function reload(argv: readonly string[]): Promise<MutationValue> {
|
|
|
998
1030
|
}
|
|
999
1031
|
}
|
|
1000
1032
|
|
|
1033
|
+
/** One configured plugin's runtime truth, as the running server reports it. */
|
|
1034
|
+
export interface RuntimeInfoValue {
|
|
1035
|
+
spec: string
|
|
1036
|
+
name: string
|
|
1037
|
+
status: "loaded" | "failed" | "unknown"
|
|
1038
|
+
runtimeVersion: string
|
|
1039
|
+
error: string
|
|
1040
|
+
available: boolean
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
export interface RuntimeResultValue {
|
|
1044
|
+
items: RuntimeInfoValue[]
|
|
1045
|
+
error: string
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
interface PluginListInfo {
|
|
1049
|
+
source?:
|
|
1050
|
+
| { type: "builtin" }
|
|
1051
|
+
| { type: "package"; target?: string; version?: string; outdated?: true; updating?: true }
|
|
1052
|
+
| { type: "local"; path?: string }
|
|
1053
|
+
| { type: "sdk" }
|
|
1054
|
+
state?: { status: "active" } | { status: "failed"; error?: string }
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
/**
|
|
1058
|
+
* Cross-references the config with the server's running plugin list, so the UI
|
|
1059
|
+
* can tell 已配置待生效 from 已加载. Best effort: when the server cannot be
|
|
1060
|
+
* asked, every item stays "unknown" instead of pretending to know.
|
|
1061
|
+
*/
|
|
1062
|
+
export async function runtime(endpoint: { url: string; headers: Readonly<Record<string, string>> } | undefined): Promise<RuntimeResultValue> {
|
|
1063
|
+
const state = await snapshot()
|
|
1064
|
+
if (!endpoint) return { items: state.plugins.map(toUnknown), error: "无法连接后台服务,运行状态未知" }
|
|
1065
|
+
try {
|
|
1066
|
+
const response = await fetch(`${endpoint.url.replace(/\/$/, "")}/plugin`, {
|
|
1067
|
+
signal: AbortSignal.timeout(8_000),
|
|
1068
|
+
headers: { accept: "application/json", ...endpoint.headers },
|
|
1069
|
+
})
|
|
1070
|
+
if (!response.ok) {
|
|
1071
|
+
return { items: state.plugins.map(toUnknown), error: `后台服务返回 ${response.status},运行状态未知` }
|
|
1072
|
+
}
|
|
1073
|
+
const body = (await response.json()) as { data?: PluginListInfo[] } | PluginListInfo[]
|
|
1074
|
+
const list = Array.isArray(body) ? body : body.data ?? []
|
|
1075
|
+
const items = state.plugins.map((entry) => {
|
|
1076
|
+
// The server reports the source target; the config holds the spec (name, name@range or a path).
|
|
1077
|
+
const name = splitSpec(entry.spec).name
|
|
1078
|
+
const loaded = list.find((info) => {
|
|
1079
|
+
const source = info.source
|
|
1080
|
+
if (!source) return false
|
|
1081
|
+
if (source.type === "package" && typeof source.target === "string") {
|
|
1082
|
+
return splitSpec(source.target).name === name
|
|
1083
|
+
}
|
|
1084
|
+
if (source.type === "local" && entry.kind === "path") {
|
|
1085
|
+
return source.path === expandPath(entry.spec)
|
|
1086
|
+
}
|
|
1087
|
+
return false
|
|
1088
|
+
})
|
|
1089
|
+
// Only the fields in RuntimeInfo cross the IPC boundary; config details stay on this side.
|
|
1090
|
+
const base = { spec: entry.spec, name: entry.name }
|
|
1091
|
+
if (!loaded) return { ...base, status: "unknown" as const, runtimeVersion: "", error: "", available: true }
|
|
1092
|
+
if (loaded.state?.status === "failed") {
|
|
1093
|
+
return {
|
|
1094
|
+
...base,
|
|
1095
|
+
status: "failed" as const,
|
|
1096
|
+
runtimeVersion: "",
|
|
1097
|
+
error: loaded.state.error ?? "加载失败",
|
|
1098
|
+
available: true,
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
return {
|
|
1102
|
+
...base,
|
|
1103
|
+
status: "loaded" as const,
|
|
1104
|
+
runtimeVersion: loaded.source?.type === "package" ? (loaded.source.version ?? "") : entry.version,
|
|
1105
|
+
error: "",
|
|
1106
|
+
available: true,
|
|
1107
|
+
}
|
|
1108
|
+
})
|
|
1109
|
+
return { items, error: "" }
|
|
1110
|
+
} catch (error) {
|
|
1111
|
+
return { items: state.plugins.map(toUnknown), error: `无法读取运行状态:${describe(error)}` }
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
function toUnknown(entry: InstalledEntryValue): RuntimeInfoValue {
|
|
1116
|
+
return { spec: entry.spec, name: entry.name, status: "unknown", runtimeVersion: "", error: "", available: false }
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1001
1119
|
function describe(error: unknown): string {
|
|
1002
1120
|
if (error instanceof Error) return error.message
|
|
1003
1121
|
return String(error)
|