@wntic/ocm 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/README.md +286 -0
- package/bin/ocm.ts +7 -0
- package/loader/config.js +47 -0
- package/loader/core.d.ts +256 -0
- package/loader/core.js +40 -0
- package/loader/discovery.js +162 -0
- package/loader/links.js +193 -0
- package/loader/lint.js +83 -0
- package/loader/manifest.js +158 -0
- package/loader/marketplace.js +199 -0
- package/loader/materialize.js +218 -0
- package/loader/mcp.js +124 -0
- package/loader/mutations.js +102 -0
- package/loader/ocm-loader.js +21 -0
- package/loader/paths.js +20 -0
- package/loader/registry.js +163 -0
- package/loader/search.js +55 -0
- package/loader/source.js +100 -0
- package/loader/sync.js +151 -0
- package/loader/trust.js +114 -0
- package/loader/ui-dialog.js +46 -0
- package/loader/ui-marketplaces.js +187 -0
- package/loader/ui-plugins.js +101 -0
- package/loader/ui-trust.js +68 -0
- package/loader/ui.js +109 -0
- package/package.json +32 -0
- package/src/commands/doctor-config.ts +79 -0
- package/src/commands/doctor-links.ts +170 -0
- package/src/commands/doctor.ts +144 -0
- package/src/commands/info.ts +159 -0
- package/src/commands/list.ts +44 -0
- package/src/commands/marketplace.ts +99 -0
- package/src/commands/plugins.ts +127 -0
- package/src/commands/search.ts +86 -0
- package/src/commands/trust.ts +146 -0
- package/src/commands/update-report.ts +123 -0
- package/src/commands/update.ts +160 -0
- package/src/commands/validate-files.ts +145 -0
- package/src/commands/validate.ts +77 -0
- package/src/discovery.ts +36 -0
- package/src/findings.ts +33 -0
- package/src/git.ts +24 -0
- package/src/index.ts +175 -0
- package/src/install.ts +15 -0
- package/src/loader.ts +185 -0
- package/src/manifest-lint.ts +189 -0
- package/src/migrate.ts +117 -0
- package/src/paths.ts +22 -0
- package/src/probe.ts +52 -0
- package/src/registry.ts +25 -0
- package/src/renames.ts +83 -0
- package/src/report.ts +13 -0
- package/src/types.ts +70 -0
- package/template/marketplace.json +17 -0
- package/template/plugins/demo-kit/agents/reviewer.md +20 -0
- package/template/plugins/demo-kit/commands/tdd.md +12 -0
- package/template/plugins/demo-kit/mcp.json +3 -0
- package/template/plugins/demo-kit/plugin/notify.js +4 -0
- package/template/plugins/demo-kit/plugin.json +6 -0
- package/template/plugins/demo-kit/skills/code-review/SKILL.md +21 -0
- package/template/plugins/release-kit/commands/ship.md +11 -0
- package/template/plugins/release-kit/commands.claude/ship.md +11 -0
- package/template/plugins/release-kit/plugin.json +6 -0
- package/template/plugins/release-kit/skills/release-notes/SKILL.md +14 -0
package/src/loader.ts
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { createHash } from "node:crypto"
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs"
|
|
3
|
+
import { dirname, join } from "node:path"
|
|
4
|
+
import { fileURLToPath } from "node:url"
|
|
5
|
+
import { OCM_DIR, OCM_LEGACY_REGISTRY_FILE, OCM_LOADER_NAME, OPENCODE_GLOBAL_DIR, OPENCODE_PLUGINS_DIR } from "./paths"
|
|
6
|
+
|
|
7
|
+
const TUI_CONFIG_FILE = join(OPENCODE_GLOBAL_DIR, "tui.json")
|
|
8
|
+
const TUI_PLUGIN_ENTRY = "./ocm/ui.js"
|
|
9
|
+
const LEGACY_TUI_PLUGIN_ENTRY = "./plugins/ocm-ui.js"
|
|
10
|
+
const LEGACY_PLUGIN_FILES = ["ocm-core.js", "ocm-ui.js", "ocm-core.d.ts"]
|
|
11
|
+
|
|
12
|
+
// only a subset proves a candidate is the loader source dir; the full install
|
|
13
|
+
// list is derived from the directory itself (spec 01, Installation set)
|
|
14
|
+
const REQUIRED_LOADER_FILES = [OCM_LOADER_NAME, "core.js", "ui.js"]
|
|
15
|
+
|
|
16
|
+
function loaderSourceDir(): string {
|
|
17
|
+
const here = dirname(fileURLToPath(import.meta.url))
|
|
18
|
+
const candidates = [
|
|
19
|
+
join(here, "..", "..", "loader"),
|
|
20
|
+
join(here, "..", "loader"),
|
|
21
|
+
join(here, "loader"),
|
|
22
|
+
here,
|
|
23
|
+
]
|
|
24
|
+
for (const candidate of candidates) {
|
|
25
|
+
if (REQUIRED_LOADER_FILES.every((name) => existsSync(join(candidate, name)))) return candidate
|
|
26
|
+
}
|
|
27
|
+
throw new Error(`loader files not found (${REQUIRED_LOADER_FILES.join(", ")})`)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// the installed set is the loader directory by definition: ocm-loader.js to
|
|
31
|
+
// plugins/, every other *.js / *.d.ts to ocm/; the filter keeps editor junk
|
|
32
|
+
// (e.g. .DS_Store) out of the install
|
|
33
|
+
function loaderFiles(sourceDir: string): { source: string; target: string }[] {
|
|
34
|
+
return readdirSync(sourceDir)
|
|
35
|
+
.filter((name) => /\.(js|d\.ts)$/.test(name))
|
|
36
|
+
.sort()
|
|
37
|
+
.map((name) => ({
|
|
38
|
+
source: name,
|
|
39
|
+
target: join(name === OCM_LOADER_NAME ? OPENCODE_PLUGINS_DIR : OCM_DIR, name),
|
|
40
|
+
}))
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function packageVersion(): string {
|
|
44
|
+
const here = dirname(fileURLToPath(import.meta.url))
|
|
45
|
+
const parsed = JSON.parse(readFileSync(join(here, "..", "package.json"), "utf8")) as { version?: unknown }
|
|
46
|
+
return typeof parsed.version === "string" ? parsed.version : "0"
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function stamped(source: string): string {
|
|
50
|
+
const content = readFileSync(source, "utf8")
|
|
51
|
+
const hash = createHash("sha256").update(content).digest("hex").slice(0, 8)
|
|
52
|
+
return `${content.trimEnd()}\n// ocm-version: ${packageVersion()} ${hash}\n`
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function readTuiConfig(): Record<string, unknown> | undefined {
|
|
56
|
+
if (!existsSync(TUI_CONFIG_FILE)) return {}
|
|
57
|
+
try {
|
|
58
|
+
const parsed = JSON.parse(readFileSync(TUI_CONFIG_FILE, "utf8")) as unknown
|
|
59
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed as Record<string, unknown>
|
|
60
|
+
} catch {}
|
|
61
|
+
return undefined
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function writeTuiConfig(config: Record<string, unknown>): void {
|
|
65
|
+
const tmp = `${TUI_CONFIG_FILE}.ocm-tmp`
|
|
66
|
+
writeFileSync(tmp, `${JSON.stringify(config, null, 2)}\n`)
|
|
67
|
+
renameSync(tmp, TUI_CONFIG_FILE)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function warnTuiManual(reason: string): void {
|
|
71
|
+
console.error(`warning: ${TUI_CONFIG_FILE} ${reason}, left untouched`)
|
|
72
|
+
console.error(`warning: add "${TUI_PLUGIN_ENTRY}" to its "plugin" array manually for the /ocm TUI command`)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function ensureTuiPluginEntry(): void {
|
|
76
|
+
const config = readTuiConfig()
|
|
77
|
+
if (!config) {
|
|
78
|
+
warnTuiManual("is not valid JSON or not a JSON object")
|
|
79
|
+
return
|
|
80
|
+
}
|
|
81
|
+
const plugin = config.plugin
|
|
82
|
+
if (plugin !== undefined && !Array.isArray(plugin)) {
|
|
83
|
+
warnTuiManual('has a "plugin" key that is not an array')
|
|
84
|
+
return
|
|
85
|
+
}
|
|
86
|
+
const entries = Array.isArray(plugin) ? plugin : []
|
|
87
|
+
if (entries.includes(TUI_PLUGIN_ENTRY)) return
|
|
88
|
+
config.plugin = [...entries, TUI_PLUGIN_ENTRY]
|
|
89
|
+
writeTuiConfig(config)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function removeTuiPluginEntry(): void {
|
|
93
|
+
const config = readTuiConfig()
|
|
94
|
+
if (!config) return
|
|
95
|
+
const plugin = config.plugin
|
|
96
|
+
if (!Array.isArray(plugin)) return
|
|
97
|
+
const filtered = plugin.filter((entry) => entry !== TUI_PLUGIN_ENTRY)
|
|
98
|
+
if (filtered.length === plugin.length) return
|
|
99
|
+
config.plugin = filtered
|
|
100
|
+
writeTuiConfig(config)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function rewriteLegacyTuiPluginEntry(): void {
|
|
104
|
+
const config = readTuiConfig()
|
|
105
|
+
if (!config) {
|
|
106
|
+
warnTuiManual("is not valid JSON or not a JSON object")
|
|
107
|
+
return
|
|
108
|
+
}
|
|
109
|
+
const plugin = config.plugin
|
|
110
|
+
if (!Array.isArray(plugin) || !plugin.includes(LEGACY_TUI_PLUGIN_ENTRY)) return
|
|
111
|
+
config.plugin = plugin.map((entry) => (entry === LEGACY_TUI_PLUGIN_ENTRY ? TUI_PLUGIN_ENTRY : entry))
|
|
112
|
+
writeTuiConfig(config)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function installFiles(sourceDir: string): void {
|
|
116
|
+
if (existsSync(OCM_DIR) && !statSync(OCM_DIR).isDirectory()) {
|
|
117
|
+
throw new Error(`${OCM_DIR} exists but is not a directory; remove it or move it aside, then re-run ocm init`)
|
|
118
|
+
}
|
|
119
|
+
mkdirSync(OPENCODE_PLUGINS_DIR, { recursive: true })
|
|
120
|
+
mkdirSync(OCM_DIR, { recursive: true })
|
|
121
|
+
for (const file of loaderFiles(sourceDir)) {
|
|
122
|
+
const content = stamped(join(sourceDir, file.source))
|
|
123
|
+
let current: string | undefined
|
|
124
|
+
try {
|
|
125
|
+
current = readFileSync(file.target, "utf8")
|
|
126
|
+
} catch {
|
|
127
|
+
current = undefined
|
|
128
|
+
}
|
|
129
|
+
if (current === content) continue
|
|
130
|
+
writeFileSync(file.target, content)
|
|
131
|
+
}
|
|
132
|
+
ensureTuiPluginEntry()
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// spec 12 doctor: the installed state of every loader file, by version
|
|
136
|
+
// comment — a stale core silently no-ops, so drift is worth naming
|
|
137
|
+
export interface LoaderFileStatus {
|
|
138
|
+
file: string
|
|
139
|
+
state: "current" | "stale" | "missing"
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function loaderStatus(): LoaderFileStatus[] {
|
|
143
|
+
const sourceDir = loaderSourceDir()
|
|
144
|
+
return loaderFiles(sourceDir).map((file) => {
|
|
145
|
+
let state: LoaderFileStatus["state"]
|
|
146
|
+
try {
|
|
147
|
+
state = readFileSync(file.target, "utf8") === stamped(join(sourceDir, file.source)) ? "current" : "stale"
|
|
148
|
+
} catch {
|
|
149
|
+
state = "missing"
|
|
150
|
+
}
|
|
151
|
+
return { file: file.source, state }
|
|
152
|
+
})
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function migrateLegacyLayout(): void {
|
|
156
|
+
const triggered = ["ocm-core.js", "ocm-ui.js"].some((name) => existsSync(join(OPENCODE_PLUGINS_DIR, name)))
|
|
157
|
+
if (!triggered) return
|
|
158
|
+
mkdirSync(OCM_DIR, { recursive: true })
|
|
159
|
+
if (existsSync(OCM_LEGACY_REGISTRY_FILE)) {
|
|
160
|
+
renameSync(OCM_LEGACY_REGISTRY_FILE, join(OCM_DIR, "registry.json"))
|
|
161
|
+
}
|
|
162
|
+
for (const name of LEGACY_PLUGIN_FILES) {
|
|
163
|
+
rmSync(join(OPENCODE_PLUGINS_DIR, name), { force: true })
|
|
164
|
+
}
|
|
165
|
+
rewriteLegacyTuiPluginEntry()
|
|
166
|
+
installFiles(loaderSourceDir())
|
|
167
|
+
// spec 01 migration step 5 pins this line to stdout
|
|
168
|
+
console.log(`migrated ocm registry to ${join(OCM_DIR, "registry.json")}`)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function installLoader(): void {
|
|
172
|
+
migrateLegacyLayout()
|
|
173
|
+
installFiles(loaderSourceDir())
|
|
174
|
+
console.error(`installed auto-sync loader (${join(OPENCODE_PLUGINS_DIR, OCM_LOADER_NAME)})`)
|
|
175
|
+
console.error(`installed TUI plugin (/ocm in the opencode TUI, restart opencode to activate)`)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function uninstallLoader(): void {
|
|
179
|
+
for (const name of [OCM_LOADER_NAME, ...LEGACY_PLUGIN_FILES]) {
|
|
180
|
+
rmSync(join(OPENCODE_PLUGINS_DIR, name), { force: true })
|
|
181
|
+
}
|
|
182
|
+
rmSync(OCM_DIR, { recursive: true, force: true })
|
|
183
|
+
removeTuiPluginEntry()
|
|
184
|
+
console.log("removed auto-sync loader")
|
|
185
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
// Hand-rolled validator for marketplace.json / plugin.json / mcp.json,
|
|
2
|
+
// implementing the same rules as schema/*-v1.json (spec 12: no runtime
|
|
3
|
+
// dependency; the schema files exist for editors).
|
|
4
|
+
import { existsSync, readFileSync } from "node:fs"
|
|
5
|
+
import { join, relative } from "node:path"
|
|
6
|
+
import { readRegistry } from "../loader/core.js"
|
|
7
|
+
import { error, warning, type Finding } from "./findings"
|
|
8
|
+
|
|
9
|
+
export const NAME_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/
|
|
10
|
+
|
|
11
|
+
const STRING_FIELDS = ["description", "version", "category", "homepage", "repository", "license"]
|
|
12
|
+
const ARRAY_FIELDS = ["tags", "keywords"]
|
|
13
|
+
|
|
14
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
15
|
+
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// bun's JSON.parse errors carry line/column properties but no position in
|
|
19
|
+
// the message itself
|
|
20
|
+
function jsonPosition(err: unknown): string {
|
|
21
|
+
const { line, column } = err as { line?: unknown; column?: unknown }
|
|
22
|
+
return typeof line === "number" && typeof column === "number" ? `${line}:${column}` : "0"
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function parseJson(file: string, rel: string, findings: Finding[]): Record<string, unknown> | undefined {
|
|
26
|
+
let parsed: unknown
|
|
27
|
+
try {
|
|
28
|
+
parsed = JSON.parse(readFileSync(file, "utf8"))
|
|
29
|
+
} catch (err) {
|
|
30
|
+
findings.push(error(`${rel}: invalid JSON at position ${jsonPosition(err)}`))
|
|
31
|
+
return undefined
|
|
32
|
+
}
|
|
33
|
+
if (!isRecord(parsed)) {
|
|
34
|
+
findings.push(error(`${rel}: must be a JSON object`))
|
|
35
|
+
return undefined
|
|
36
|
+
}
|
|
37
|
+
return parsed
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function lintFields(rel: string, record: Record<string, unknown>, findings: Finding[]): void {
|
|
41
|
+
for (const key of STRING_FIELDS) {
|
|
42
|
+
if (record[key] !== undefined && typeof record[key] !== "string") {
|
|
43
|
+
findings.push(error(`${rel}: "${key}" must be a string`))
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
for (const key of ARRAY_FIELDS) {
|
|
47
|
+
const value = record[key]
|
|
48
|
+
if (value !== undefined && (!Array.isArray(value) || !value.every((item) => typeof item === "string"))) {
|
|
49
|
+
findings.push(error(`${rel}: "${key}" must be an array of strings`))
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function lintName(rel: string, name: string, pluginName: string | undefined, findings: Finding[]): void {
|
|
55
|
+
if (!NAME_RE.test(name)) {
|
|
56
|
+
findings.push(error(`${rel}: name "${name}" must match ${NAME_RE}`))
|
|
57
|
+
} else if (name.length > 64) {
|
|
58
|
+
findings.push(error(`${rel}: name "${name}" is longer than 64 characters`))
|
|
59
|
+
} else if (pluginName !== undefined && name !== pluginName) {
|
|
60
|
+
findings.push(error(`${rel}: name "${name}" disagrees with the directory name "${pluginName}"`))
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function lintSource(rel: string, entry: Record<string, unknown>, root: string, findings: Finding[]): void {
|
|
65
|
+
const name = typeof entry.name === "string" ? entry.name : "(unnamed)"
|
|
66
|
+
const source = entry.source
|
|
67
|
+
if (typeof source !== "string") {
|
|
68
|
+
findings.push(error(`${rel}: plugins[] entry "${name}" needs a "source"`))
|
|
69
|
+
return
|
|
70
|
+
}
|
|
71
|
+
if (source.split("/").includes("..")) {
|
|
72
|
+
findings.push(error(`${rel}: plugins[] entry "${name}" source "${source}" escapes the marketplace`))
|
|
73
|
+
} else if (!source.startsWith("./")) {
|
|
74
|
+
findings.push(error(`${rel}: plugins[] entry "${name}" source "${source}" must be ./-relative`))
|
|
75
|
+
} else if (!existsSync(join(root, source.slice(2)))) {
|
|
76
|
+
findings.push(error(`${rel}: plugins[] entry "${name}" source "${source}" does not resolve`))
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// the plugins[]-entry fields beyond the shared string/array ones, per
|
|
81
|
+
// schema/marketplace-v1.json
|
|
82
|
+
function lintEntryFields(rel: string, entry: Record<string, unknown>, findings: Finding[]): void {
|
|
83
|
+
if (entry.author !== undefined && !isRecord(entry.author)) {
|
|
84
|
+
findings.push(error(`${rel}: "author" must be an object`))
|
|
85
|
+
}
|
|
86
|
+
if (entry.defaultEnabled !== undefined && typeof entry.defaultEnabled !== "boolean") {
|
|
87
|
+
findings.push(error(`${rel}: "defaultEnabled" must be a boolean`))
|
|
88
|
+
}
|
|
89
|
+
if (entry.mcpServers !== undefined && (typeof entry.mcpServers !== "string" || !entry.mcpServers.startsWith("./"))) {
|
|
90
|
+
findings.push(error(`${rel}: "mcpServers" must be a ./-relative path`))
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function lintPlugins(
|
|
95
|
+
rel: string,
|
|
96
|
+
plugins: unknown,
|
|
97
|
+
root: string,
|
|
98
|
+
manifest: MarketplaceManifest,
|
|
99
|
+
findings: Finding[],
|
|
100
|
+
): void {
|
|
101
|
+
if (!Array.isArray(plugins)) {
|
|
102
|
+
findings.push(error(`${rel}: "plugins" must be an array`))
|
|
103
|
+
return
|
|
104
|
+
}
|
|
105
|
+
for (const entry of plugins) {
|
|
106
|
+
if (!isRecord(entry)) {
|
|
107
|
+
findings.push(error(`${rel}: every plugins[] entry must be an object`))
|
|
108
|
+
continue
|
|
109
|
+
}
|
|
110
|
+
lintFields(rel, entry, findings)
|
|
111
|
+
lintEntryFields(rel, entry, findings)
|
|
112
|
+
if (typeof entry.name !== "string") {
|
|
113
|
+
findings.push(error(`${rel}: plugins[] entry needs a "name"`))
|
|
114
|
+
} else {
|
|
115
|
+
lintName(rel, entry.name, undefined, findings)
|
|
116
|
+
manifest.entries.set(entry.name, entry)
|
|
117
|
+
}
|
|
118
|
+
lintSource(rel, entry, root, findings)
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export interface MarketplaceManifest {
|
|
123
|
+
name?: string
|
|
124
|
+
entries: Map<string, Record<string, unknown>>
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function lintMarketplaceJson(root: string, findings: Finding[]): MarketplaceManifest {
|
|
128
|
+
const manifest: MarketplaceManifest = { entries: new Map() }
|
|
129
|
+
const file = join(root, "marketplace.json")
|
|
130
|
+
if (!existsSync(file)) return manifest
|
|
131
|
+
const rel = relative(root, file)
|
|
132
|
+
const parsed = parseJson(file, rel, findings)
|
|
133
|
+
if (!parsed) return manifest
|
|
134
|
+
lintFields(rel, parsed, findings)
|
|
135
|
+
if (parsed.owner !== undefined && !isRecord(parsed.owner)) {
|
|
136
|
+
findings.push(error(`${rel}: "owner" must be an object`))
|
|
137
|
+
}
|
|
138
|
+
if (parsed.renames !== undefined &&
|
|
139
|
+
(!isRecord(parsed.renames) || !Object.values(parsed.renames).every((value) => typeof value === "string" || value === null))) {
|
|
140
|
+
findings.push(error(`${rel}: "renames" must be an object of string or null`))
|
|
141
|
+
}
|
|
142
|
+
if (parsed.name !== undefined && typeof parsed.name !== "string") {
|
|
143
|
+
findings.push(error(`${rel}: "name" must be a string`))
|
|
144
|
+
}
|
|
145
|
+
if (typeof parsed.name === "string") {
|
|
146
|
+
manifest.name = parsed.name
|
|
147
|
+
if (readRegistry().marketplaces[parsed.name]) {
|
|
148
|
+
findings.push(warning(`${rel}: name "${parsed.name}" is already added on this machine`))
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
if (parsed.plugins !== undefined) lintPlugins(rel, parsed.plugins, root, manifest, findings)
|
|
152
|
+
return manifest
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// returns the parsed record so the caller can compare versions across manifests
|
|
156
|
+
export function lintPluginJson(
|
|
157
|
+
root: string,
|
|
158
|
+
pluginDir: string,
|
|
159
|
+
pluginName: string,
|
|
160
|
+
findings: Finding[],
|
|
161
|
+
): Record<string, unknown> | undefined {
|
|
162
|
+
const file = join(pluginDir, "plugin.json")
|
|
163
|
+
if (!existsSync(file)) return undefined
|
|
164
|
+
const rel = relative(root, file)
|
|
165
|
+
const parsed = parseJson(file, rel, findings)
|
|
166
|
+
if (!parsed) return undefined
|
|
167
|
+
lintFields(rel, parsed, findings)
|
|
168
|
+
if (parsed.author !== undefined && !isRecord(parsed.author)) {
|
|
169
|
+
findings.push(error(`${rel}: "author" must be an object`))
|
|
170
|
+
}
|
|
171
|
+
if (parsed.name !== undefined && typeof parsed.name !== "string") {
|
|
172
|
+
findings.push(error(`${rel}: "name" must be a string`))
|
|
173
|
+
}
|
|
174
|
+
if (typeof parsed.name === "string") lintName(rel, parsed.name, pluginName, findings)
|
|
175
|
+
return parsed
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function lintMcpJson(root: string, pluginDir: string, findings: Finding[]): void {
|
|
179
|
+
const file = join(pluginDir, "mcp.json")
|
|
180
|
+
if (!existsSync(file)) return
|
|
181
|
+
const rel = relative(root, file)
|
|
182
|
+
const parsed = parseJson(file, rel, findings)
|
|
183
|
+
if (!parsed) return
|
|
184
|
+
for (const [server, value] of Object.entries(parsed)) {
|
|
185
|
+
if (!isRecord(value) || value.type === undefined) {
|
|
186
|
+
findings.push(error(`${rel}: entry "${server}" is missing "type"`))
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
package/src/migrate.ts
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// spec 13: the second half of the one-shot migration, run by every ocm command
|
|
2
|
+
// before dispatch. The file moves live in migrateLegacyLayout (spec 01) and
|
|
3
|
+
// stay byte-preserving so a programmatic installLoader keeps its spec 01
|
|
4
|
+
// semantics; this half upgrades what later specs changed in place — the
|
|
5
|
+
// registry schema (02), the sync stamp (08) and the skill link layout (03) —
|
|
6
|
+
// and is deliberately not called from installLoader.
|
|
7
|
+
import { existsSync, readFileSync, readdirSync, readlinkSync, rmSync } from "node:fs"
|
|
8
|
+
import { join } from "node:path"
|
|
9
|
+
import { componentRoot, enabledPlugins, materialize } from "../loader/core.js"
|
|
10
|
+
import { OCM_LINKS_DIR, OCM_REGISTRY_FILE, OCM_STAMP_FILE, OPENCODE_AGENTS_DIR, OPENCODE_COMMANDS_DIR } from "./paths"
|
|
11
|
+
import { loadRegistry, loadRegistryForWrite, saveRegistry } from "./registry"
|
|
12
|
+
import { reportUpgrade, reportWarnings } from "./report"
|
|
13
|
+
import type { Registry } from "./types"
|
|
14
|
+
|
|
15
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
16
|
+
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// registry version 1 → 2 on disk: normalization fills the v2 defaults and
|
|
20
|
+
// rewrites absolute sources marketplace-relative (spec 02)
|
|
21
|
+
function upgradeRegistry(): void {
|
|
22
|
+
const { registry, wasV1 } = loadRegistryForWrite()
|
|
23
|
+
if (!wasV1) return
|
|
24
|
+
saveRegistry(registry)
|
|
25
|
+
reportUpgrade(true)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// the pre-08 global throttle stamp folds into per-marketplace lastSync (spec 08)
|
|
29
|
+
function foldSyncStamp(): void {
|
|
30
|
+
let at: string | null = null
|
|
31
|
+
try {
|
|
32
|
+
const stamp: unknown = JSON.parse(readFileSync(OCM_STAMP_FILE, "utf8"))
|
|
33
|
+
if (isRecord(stamp) && typeof stamp.at === "string") at = stamp.at
|
|
34
|
+
} catch {
|
|
35
|
+
return
|
|
36
|
+
}
|
|
37
|
+
let raw: unknown
|
|
38
|
+
try {
|
|
39
|
+
raw = JSON.parse(readFileSync(OCM_REGISTRY_FILE, "utf8"))
|
|
40
|
+
} catch {
|
|
41
|
+
return
|
|
42
|
+
}
|
|
43
|
+
const entries = isRecord(raw) && isRecord(raw.marketplaces) ? Object.values(raw.marketplaces).filter(isRecord) : []
|
|
44
|
+
const open = entries.filter((entry) => entry.lastSync == null)
|
|
45
|
+
rmSync(OCM_STAMP_FILE, { force: true })
|
|
46
|
+
if (!at || !open.length) return
|
|
47
|
+
for (const entry of open) entry.lastSync = { at, ok: true, error: null }
|
|
48
|
+
saveRegistry(raw as Registry)
|
|
49
|
+
console.log(`folded ${OCM_STAMP_FILE} into per-marketplace lastSync`)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// a top-level symlink in the skills dir is the pre-spec-03 whole-dir form; it
|
|
53
|
+
// is ours iff it points into this marketplace (raw prefix compare — a realpath
|
|
54
|
+
// must never be compared against a non-canonical directory)
|
|
55
|
+
function legacySkillLinks(skillsDir: string, root: string): string[] {
|
|
56
|
+
let entries: string[]
|
|
57
|
+
try {
|
|
58
|
+
entries = readdirSync(skillsDir)
|
|
59
|
+
} catch {
|
|
60
|
+
return []
|
|
61
|
+
}
|
|
62
|
+
const legacy: string[] = []
|
|
63
|
+
for (const entry of entries) {
|
|
64
|
+
let target: string | undefined
|
|
65
|
+
try {
|
|
66
|
+
target = readlinkSync(join(skillsDir, entry))
|
|
67
|
+
} catch {
|
|
68
|
+
continue
|
|
69
|
+
}
|
|
70
|
+
if (target === root || target.startsWith(`${root}/`)) legacy.push(entry)
|
|
71
|
+
}
|
|
72
|
+
return legacy
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// the old → new names for one plugin's relinked skills, read back from the
|
|
76
|
+
// rendered mirrors materialize just wrote
|
|
77
|
+
function skillMapping(skillsDir: string, plugin: string): string[] {
|
|
78
|
+
let entries: string[]
|
|
79
|
+
try {
|
|
80
|
+
entries = readdirSync(skillsDir)
|
|
81
|
+
} catch {
|
|
82
|
+
return []
|
|
83
|
+
}
|
|
84
|
+
const lines: string[] = []
|
|
85
|
+
for (const entry of entries) {
|
|
86
|
+
if (!entry.startsWith(`${plugin}--`)) continue
|
|
87
|
+
let name: string | undefined
|
|
88
|
+
try {
|
|
89
|
+
name = readFileSync(join(skillsDir, entry, "SKILL.md"), "utf8").match(/^name: "(.+)"$/m)?.[1]
|
|
90
|
+
} catch {}
|
|
91
|
+
if (name) lines.push(`relinked skill ${name.slice(plugin.length + 1)} -> ${name}`)
|
|
92
|
+
}
|
|
93
|
+
return lines.sort()
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// whole-dir skill symlinks and ocm--<mp> containers both predate spec 03; one
|
|
97
|
+
// materialize pass — the same code path an ocm update runs — replaces them
|
|
98
|
+
// with the current layout
|
|
99
|
+
function relinkSkills(): void {
|
|
100
|
+
for (const [name, entry] of Object.entries(loadRegistry().marketplaces)) {
|
|
101
|
+
const root = componentRoot(entry)
|
|
102
|
+
const skillsDir = join(OCM_LINKS_DIR, name, "skills")
|
|
103
|
+
const legacy = legacySkillLinks(skillsDir, root)
|
|
104
|
+
const container =
|
|
105
|
+
existsSync(join(OPENCODE_COMMANDS_DIR, `ocm--${name}`)) || existsSync(join(OPENCODE_AGENTS_DIR, `ocm--${name}`))
|
|
106
|
+
if (!legacy.length && !container) continue
|
|
107
|
+
const links = materialize(name, root, { enabled: enabledPlugins(entry, root) })
|
|
108
|
+
reportWarnings(links.warnings.map((warning) => `${name}: ${warning}`))
|
|
109
|
+
for (const plugin of legacy) for (const line of skillMapping(skillsDir, plugin)) console.log(line)
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function migrateInstallation(): void {
|
|
114
|
+
upgradeRegistry()
|
|
115
|
+
foldSyncStamp()
|
|
116
|
+
relinkSkills()
|
|
117
|
+
}
|
package/src/paths.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { homedir } from "node:os"
|
|
2
|
+
import { join } from "node:path"
|
|
3
|
+
|
|
4
|
+
export const HOME = homedir()
|
|
5
|
+
|
|
6
|
+
export const OPENCODE_GLOBAL_DIR = join(HOME, ".config", "opencode")
|
|
7
|
+
export const OPENCODE_GLOBAL_CONFIG = join(OPENCODE_GLOBAL_DIR, "opencode.json")
|
|
8
|
+
export const OPENCODE_COMMANDS_DIR = join(OPENCODE_GLOBAL_DIR, "commands")
|
|
9
|
+
export const OPENCODE_AGENTS_DIR = join(OPENCODE_GLOBAL_DIR, "agents")
|
|
10
|
+
export const OPENCODE_PLUGINS_DIR = join(OPENCODE_GLOBAL_DIR, "plugins")
|
|
11
|
+
|
|
12
|
+
export const OCM_CACHE_DIR = join(HOME, ".cache", "ocm")
|
|
13
|
+
export const OCM_MARKETPLACES_DIR = join(OCM_CACHE_DIR, "marketplaces")
|
|
14
|
+
export const OCM_LINKS_DIR = join(OCM_CACHE_DIR, "links")
|
|
15
|
+
// pre-08 global sync stamp, folded into per-marketplace lastSync by the migration
|
|
16
|
+
export const OCM_STAMP_FILE = join(OCM_CACHE_DIR, "last-sync.json")
|
|
17
|
+
|
|
18
|
+
export const OCM_DIR = join(OPENCODE_GLOBAL_DIR, "ocm")
|
|
19
|
+
export const OCM_REGISTRY_FILE = join(OCM_DIR, "registry.json")
|
|
20
|
+
export const OCM_LEGACY_REGISTRY_FILE = join(OPENCODE_PLUGINS_DIR, "ocm-registry.json")
|
|
21
|
+
|
|
22
|
+
export const OCM_LOADER_NAME = "ocm-loader.js"
|
package/src/probe.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process"
|
|
2
|
+
import { cpSync, existsSync, mkdtempSync, rmSync } from "node:fs"
|
|
3
|
+
import { tmpdir } from "node:os"
|
|
4
|
+
import { join } from "node:path"
|
|
5
|
+
import { OPENCODE_GLOBAL_DIR } from "./paths"
|
|
6
|
+
|
|
7
|
+
// Ask the real opencode binary which of its plugins failed to load, keeping
|
|
8
|
+
// only errors attributable to ocm files. The probe runs against a throwaway
|
|
9
|
+
// copy of the config directory under a scratch HOME: opencode rewrites
|
|
10
|
+
// opencode.json on load (it adds $schema) and creates files under HOME, so
|
|
11
|
+
// probing the real directory would mutate the user's config. OCM_SYNC_DISABLE
|
|
12
|
+
// keeps the copied loader's startup sync inert as well. Never throws — a
|
|
13
|
+
// failed probe reports nothing, it does not take doctor down with it.
|
|
14
|
+
export function ocmPluginErrors(): string[] {
|
|
15
|
+
if (!existsSync(OPENCODE_GLOBAL_DIR)) return []
|
|
16
|
+
let scratch: string | undefined
|
|
17
|
+
try {
|
|
18
|
+
scratch = mkdtempSync(join(tmpdir(), "ocm-doctor-"))
|
|
19
|
+
const configDir = join(scratch, "config")
|
|
20
|
+
cpSync(OPENCODE_GLOBAL_DIR, configDir, { recursive: true })
|
|
21
|
+
const env: NodeJS.ProcessEnv = {
|
|
22
|
+
...process.env,
|
|
23
|
+
HOME: join(scratch, "home"),
|
|
24
|
+
OPENCODE_CONFIG_DIR: configDir,
|
|
25
|
+
OCM_SYNC_DISABLE: "1",
|
|
26
|
+
}
|
|
27
|
+
// an XDG override would point opencode back at the real global config
|
|
28
|
+
delete env.XDG_CONFIG_HOME
|
|
29
|
+
const run = spawnSync("opencode", ["debug", "skill", "--print-logs", "--log-level", "ERROR"], {
|
|
30
|
+
env,
|
|
31
|
+
encoding: "utf8",
|
|
32
|
+
timeout: 180_000,
|
|
33
|
+
})
|
|
34
|
+
if (run.status !== 0) return []
|
|
35
|
+
const output = `${run.stdout ?? ""}\n${run.stderr ?? ""}`
|
|
36
|
+
return output
|
|
37
|
+
.split("\n")
|
|
38
|
+
.filter((line) => /level=ERROR.*failed to load plugin/.test(line))
|
|
39
|
+
.filter((line) => line.includes("/ocm--") || line.includes("/ocm-loader.js"))
|
|
40
|
+
.map((line) => line.trim())
|
|
41
|
+
} catch {
|
|
42
|
+
return []
|
|
43
|
+
} finally {
|
|
44
|
+
if (scratch !== undefined) {
|
|
45
|
+
// opencode leaves background writers in the scratch dir; a leftover
|
|
46
|
+
// temp directory is harmless, a crashed doctor is not
|
|
47
|
+
try {
|
|
48
|
+
rmSync(scratch, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 })
|
|
49
|
+
} catch {}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
package/src/registry.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import {
|
|
2
|
+
loadRegistryForWrite as coreLoadRegistryForWrite,
|
|
3
|
+
normalizeRegistry as coreNormalizeRegistry,
|
|
4
|
+
readRegistry,
|
|
5
|
+
saveRegistry as coreSaveRegistry,
|
|
6
|
+
} from "../loader/core.js"
|
|
7
|
+
import type { Registry } from "./types"
|
|
8
|
+
|
|
9
|
+
// thin facade over the core registry: the CLI keeps its typed surface, the
|
|
10
|
+
// canonical atomic save lives in the core (spec 10a)
|
|
11
|
+
export function loadRegistry(): Registry {
|
|
12
|
+
return readRegistry()
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function loadRegistryForWrite(): { registry: Registry; wasV1: boolean } {
|
|
16
|
+
return coreLoadRegistryForWrite()
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function normalizeRegistry(raw: unknown): Registry {
|
|
20
|
+
return coreNormalizeRegistry(raw)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function saveRegistry(registry: Registry): void {
|
|
24
|
+
coreSaveRegistry(registry)
|
|
25
|
+
}
|
package/src/renames.ts
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { incumbentMarketplace } from "./install"
|
|
2
|
+
import type { DiscoveredPlugin, MarketplaceEntry, MarketplacePlugin, Registry } from "./types"
|
|
3
|
+
|
|
4
|
+
export interface RenameResult {
|
|
5
|
+
renamed: { from: string; to: string }[]
|
|
6
|
+
removed: string[]
|
|
7
|
+
refused: { from: string; to: string; incumbent: string }[]
|
|
8
|
+
// rename targets refused for colliding with another marketplace's plugin
|
|
9
|
+
// name: excluded from registration so the incumbent keeps the name
|
|
10
|
+
excluded: Set<string>
|
|
11
|
+
// records a refusal kept at their old name, re-added after registration
|
|
12
|
+
kept: Record<string, MarketplacePlugin>
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// spec 08 rename chains: walk each source while its target is itself a
|
|
16
|
+
// source. A walk that revisits a name is a cycle — reported by the caller
|
|
17
|
+
// and ignored, so its keys stay unresolved.
|
|
18
|
+
export function resolveChains(renames: Record<string, string | null>): {
|
|
19
|
+
resolved: Record<string, string | null>
|
|
20
|
+
cycles: string[][]
|
|
21
|
+
} {
|
|
22
|
+
const resolved: Record<string, string | null> = {}
|
|
23
|
+
const cycles: string[][] = []
|
|
24
|
+
const reported = new Set<string>()
|
|
25
|
+
for (const start of Object.keys(renames)) {
|
|
26
|
+
if (start in resolved) continue
|
|
27
|
+
const path: string[] = []
|
|
28
|
+
const visited = new Set<string>()
|
|
29
|
+
let current: string | null = start
|
|
30
|
+
let cyclic = false
|
|
31
|
+
while (current !== null && current in renames) {
|
|
32
|
+
if (visited.has(current)) {
|
|
33
|
+
const cycle = path.slice(path.indexOf(current))
|
|
34
|
+
const id = [...cycle].sort().join("\u0000")
|
|
35
|
+
if (!reported.has(id)) {
|
|
36
|
+
reported.add(id)
|
|
37
|
+
cycles.push(cycle)
|
|
38
|
+
}
|
|
39
|
+
cyclic = true
|
|
40
|
+
break
|
|
41
|
+
}
|
|
42
|
+
visited.add(current)
|
|
43
|
+
path.push(current)
|
|
44
|
+
current = renames[current] ?? null
|
|
45
|
+
}
|
|
46
|
+
if (!cyclic) for (const key of path) resolved[key] = current
|
|
47
|
+
}
|
|
48
|
+
return { resolved, cycles }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// applied after discovery, before registration: migrate records along the
|
|
52
|
+
// resolved renames, drop removals, refuse cross-marketplace collisions
|
|
53
|
+
export function applyRenames(
|
|
54
|
+
registry: Registry,
|
|
55
|
+
name: string,
|
|
56
|
+
entry: MarketplaceEntry,
|
|
57
|
+
discovered: Map<string, DiscoveredPlugin>,
|
|
58
|
+
resolved: Record<string, string | null>,
|
|
59
|
+
): RenameResult {
|
|
60
|
+
const result: RenameResult = { renamed: [], removed: [], refused: [], excluded: new Set(), kept: {} }
|
|
61
|
+
for (const [from, to] of Object.entries(resolved)) {
|
|
62
|
+
const record = entry.plugins[from]
|
|
63
|
+
if (!record) continue
|
|
64
|
+
if (to === null) {
|
|
65
|
+
delete entry.plugins[from]
|
|
66
|
+
result.removed.push(from)
|
|
67
|
+
continue
|
|
68
|
+
}
|
|
69
|
+
const incumbent = incumbentMarketplace(registry, name, to)
|
|
70
|
+
if (incumbent) {
|
|
71
|
+
result.refused.push({ from, to, incumbent })
|
|
72
|
+
result.excluded.add(to)
|
|
73
|
+
result.kept[from] = record
|
|
74
|
+
continue
|
|
75
|
+
}
|
|
76
|
+
// the target is not shipped (yet): the record dangles rather than moves
|
|
77
|
+
if (!discovered.has(to)) continue
|
|
78
|
+
entry.plugins[to] = record
|
|
79
|
+
delete entry.plugins[from]
|
|
80
|
+
result.renamed.push({ from, to })
|
|
81
|
+
}
|
|
82
|
+
return result
|
|
83
|
+
}
|