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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-plugin-hub",
3
- "version": "0.1.4",
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
- 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()
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
- const result = await refreshDirectory(dir, name, version.trim())
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 0
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 0
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)