@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
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"
|
|
2
|
+
import { join } from "node:path"
|
|
3
|
+
import { LEGACY_REGISTRY_FILE, MARKETPLACES_DIR, OCM_DIR, REGISTRY_FILE } from "./paths.js"
|
|
4
|
+
|
|
5
|
+
export function isRecord(value) {
|
|
6
|
+
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function relativeSource(dir, source) {
|
|
10
|
+
if (typeof dir === "string" && typeof source === "string" && source.startsWith(dir + "/")) {
|
|
11
|
+
return source.slice(dir.length + 1)
|
|
12
|
+
}
|
|
13
|
+
return source
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// read-only mirror of normalizeRegistry in src/registry.ts, so a core that
|
|
17
|
+
// predates a schema change still reads v1 and v2 registries uniformly
|
|
18
|
+
export function normalizeRegistry(raw) {
|
|
19
|
+
if (!isRecord(raw) || !isRecord(raw.marketplaces)) return { version: 2, marketplaces: {} }
|
|
20
|
+
if (raw.version === 2) return raw
|
|
21
|
+
if (raw.version !== 1) return { version: 2, marketplaces: {} }
|
|
22
|
+
const marketplaces = {}
|
|
23
|
+
for (const [name, entry] of Object.entries(raw.marketplaces)) {
|
|
24
|
+
if (!isRecord(entry)) continue
|
|
25
|
+
const dir = typeof entry.dir === "string" ? entry.dir : ""
|
|
26
|
+
const addedAt = typeof entry.addedAt === "string" ? entry.addedAt : ""
|
|
27
|
+
const plugins = {}
|
|
28
|
+
if (isRecord(entry.plugins)) {
|
|
29
|
+
for (const [pluginName, plugin] of Object.entries(entry.plugins)) {
|
|
30
|
+
if (!isRecord(plugin)) continue
|
|
31
|
+
const source = typeof plugin.source === "string" ? plugin.source : ""
|
|
32
|
+
plugins[pluginName] = {
|
|
33
|
+
source: relativeSource(dir, source),
|
|
34
|
+
components: isRecord(plugin.components) ? plugin.components : {},
|
|
35
|
+
enabled: true,
|
|
36
|
+
installedAt: addedAt,
|
|
37
|
+
version: null,
|
|
38
|
+
manifest: {},
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
marketplaces[name] = {
|
|
43
|
+
url: typeof entry.url === "string" ? entry.url : "",
|
|
44
|
+
dir,
|
|
45
|
+
local: !dir.startsWith(MARKETPLACES_DIR),
|
|
46
|
+
addedAt,
|
|
47
|
+
mode: "auto",
|
|
48
|
+
ref: null,
|
|
49
|
+
revision: null,
|
|
50
|
+
syncIntervalMs: null,
|
|
51
|
+
trust: { code: "none" },
|
|
52
|
+
lastSync: null,
|
|
53
|
+
plugins,
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return { version: 2, marketplaces }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function readRegistry() {
|
|
60
|
+
if (existsSync(REGISTRY_FILE)) {
|
|
61
|
+
try {
|
|
62
|
+
return normalizeRegistry(JSON.parse(readFileSync(REGISTRY_FILE, "utf8")))
|
|
63
|
+
} catch {}
|
|
64
|
+
return { version: 2, marketplaces: {} }
|
|
65
|
+
}
|
|
66
|
+
try {
|
|
67
|
+
return normalizeRegistry(JSON.parse(readFileSync(LEGACY_REGISTRY_FILE, "utf8")))
|
|
68
|
+
} catch {}
|
|
69
|
+
return { version: 2, marketplaces: {} }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Mutating commands report the v1 → v2 upgrade when they save the migrated
|
|
73
|
+
// registry, so this load also says what version was on disk.
|
|
74
|
+
export function loadRegistryForWrite() {
|
|
75
|
+
// pre-02 layout; read as a fallback, never written
|
|
76
|
+
const file = existsSync(REGISTRY_FILE) ? REGISTRY_FILE : LEGACY_REGISTRY_FILE
|
|
77
|
+
try {
|
|
78
|
+
const raw = JSON.parse(readFileSync(file, "utf8"))
|
|
79
|
+
return { registry: normalizeRegistry(raw), wasV1: isRecord(raw) && raw.version === 1 }
|
|
80
|
+
} catch {
|
|
81
|
+
return { registry: { version: 2, marketplaces: {} }, wasV1: false }
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Canonical key order: a no-op save must be byte-identical, and unknown
|
|
86
|
+
// fields ride after the known ones so they survive round-trips.
|
|
87
|
+
const MARKETPLACE_KEYS = [
|
|
88
|
+
"url", "dir", "local", "addedAt", "mode", "ref", "subdir", "revision",
|
|
89
|
+
"syncIntervalMs", "trust", "lastSync", "plugins",
|
|
90
|
+
]
|
|
91
|
+
const PLUGIN_KEYS = ["source", "components", "enabled", "collision", "installedAt", "version", "manifest"]
|
|
92
|
+
|
|
93
|
+
function canonicalObject(source, keys) {
|
|
94
|
+
const out = {}
|
|
95
|
+
for (const key of keys) if (key in source) out[key] = source[key]
|
|
96
|
+
for (const key of Object.keys(source)) if (!keys.includes(key)) out[key] = source[key]
|
|
97
|
+
return out
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function serializeRegistry(registry) {
|
|
101
|
+
const marketplaces = {}
|
|
102
|
+
for (const [name, entry] of Object.entries(registry.marketplaces ?? {})) {
|
|
103
|
+
const plugins = {}
|
|
104
|
+
for (const [pluginName, plugin] of Object.entries(entry.plugins ?? {})) {
|
|
105
|
+
plugins[pluginName] = canonicalObject(plugin, PLUGIN_KEYS)
|
|
106
|
+
}
|
|
107
|
+
marketplaces[name] = canonicalObject({ ...entry, plugins }, MARKETPLACE_KEYS)
|
|
108
|
+
}
|
|
109
|
+
return `${JSON.stringify(canonicalObject({ ...registry, version: 2, marketplaces }, ["version", "marketplaces"]), null, 2)}\n`
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function saveRegistry(registry) {
|
|
113
|
+
mkdirSync(OCM_DIR, { recursive: true })
|
|
114
|
+
const tmp = `${REGISTRY_FILE}.tmp`
|
|
115
|
+
try {
|
|
116
|
+
writeFileSync(tmp, serializeRegistry(registry))
|
|
117
|
+
renameSync(tmp, REGISTRY_FILE)
|
|
118
|
+
} catch (err) {
|
|
119
|
+
throw new Error(`cannot write ${REGISTRY_FILE}: ${err instanceof Error ? err.message : String(err)}`)
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// spec 11: the variables the loader's shell.env hook exports. Every added
|
|
124
|
+
// marketplace root is available under both families — OCM_PLUGIN_ROOT for
|
|
125
|
+
// bodies authored for opencode, CLAUDE_PLUGIN_ROOT as an alias so a Claude
|
|
126
|
+
// Code command file runs unmodified. The flat pair exists only when one
|
|
127
|
+
// marketplace is added: a flat name cannot say which of several roots it
|
|
128
|
+
// means, and an unset variable fails louder than a silently wrong one.
|
|
129
|
+
export function pluginRootEnv() {
|
|
130
|
+
const entries = Object.entries(readRegistry().marketplaces ?? {}).filter(
|
|
131
|
+
([, entry]) => entry && typeof entry.dir === "string" && entry.dir,
|
|
132
|
+
)
|
|
133
|
+
const roots = entries.map(([name, entry]) => ({
|
|
134
|
+
suffix: name.replaceAll("-", "_").toUpperCase(),
|
|
135
|
+
root: entry.subdir ? join(entry.dir, entry.subdir) : entry.dir,
|
|
136
|
+
}))
|
|
137
|
+
const env = {}
|
|
138
|
+
for (const { suffix, root } of roots) {
|
|
139
|
+
env[`OCM_PLUGIN_ROOT_${suffix}`] = root
|
|
140
|
+
env[`CLAUDE_PLUGIN_ROOT_${suffix}`] = root
|
|
141
|
+
}
|
|
142
|
+
if (roots.length === 1) {
|
|
143
|
+
env.OCM_PLUGIN_ROOT = roots[0].root
|
|
144
|
+
env.CLAUDE_PLUGIN_ROOT = roots[0].root
|
|
145
|
+
}
|
|
146
|
+
return env
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// the loader's only registry write: flag that a marketplace's executable
|
|
150
|
+
// components changed since its grant. The raw file is preserved verbatim
|
|
151
|
+
// outside the flag — never migrated — and an existing flag is left alone.
|
|
152
|
+
export function markTrustPending(name) {
|
|
153
|
+
try {
|
|
154
|
+
const raw = JSON.parse(readFileSync(REGISTRY_FILE, "utf8"))
|
|
155
|
+
if (!isRecord(raw) || !isRecord(raw.marketplaces) || !isRecord(raw.marketplaces[name])) return
|
|
156
|
+
const entry = raw.marketplaces[name]
|
|
157
|
+
if (entry.trustPending === true) return
|
|
158
|
+
entry.trustPending = true
|
|
159
|
+
const tmp = `${REGISTRY_FILE}.tmp`
|
|
160
|
+
writeFileSync(tmp, JSON.stringify(raw, null, 2))
|
|
161
|
+
renameSync(tmp, REGISTRY_FILE)
|
|
162
|
+
} catch {}
|
|
163
|
+
}
|
package/loader/search.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { readRegistry } from "./registry.js"
|
|
2
|
+
|
|
3
|
+
// spec 09 ranking, first rule that matches wins; the marketplace name is a
|
|
4
|
+
// match surface ranked below every plugin-level rule
|
|
5
|
+
function rankOf(query, plugin, record, marketplace) {
|
|
6
|
+
const name = plugin.toLowerCase()
|
|
7
|
+
if (name === query) return { rank: 0, matched: [] }
|
|
8
|
+
if (name.startsWith(query)) return { rank: 1, matched: [] }
|
|
9
|
+
if (name.includes(query)) return { rank: 2, matched: [] }
|
|
10
|
+
const manifest = record.manifest
|
|
11
|
+
const words = [...(manifest.tags ?? []), ...(manifest.keywords ?? [])].map((word) => word.toLowerCase())
|
|
12
|
+
const category = manifest.category?.toLowerCase()
|
|
13
|
+
if (category === query || words.includes(query)) return { rank: 3, matched: [] }
|
|
14
|
+
if (
|
|
15
|
+
(manifest.description?.toLowerCase().includes(query) ?? false) ||
|
|
16
|
+
category?.includes(query) ||
|
|
17
|
+
words.some((word) => word.includes(query))
|
|
18
|
+
) {
|
|
19
|
+
return { rank: 4, matched: [] }
|
|
20
|
+
}
|
|
21
|
+
const matched = []
|
|
22
|
+
for (const file of record.components.command ?? []) {
|
|
23
|
+
const name = file.replace(/\.md$/, "")
|
|
24
|
+
if (name.toLowerCase().includes(query)) matched.push(`commands/${name}`)
|
|
25
|
+
}
|
|
26
|
+
for (const file of record.components.agent ?? []) {
|
|
27
|
+
const name = file.replace(/\.md$/, "")
|
|
28
|
+
if (name.toLowerCase().includes(query)) matched.push(`agents/${name}`)
|
|
29
|
+
}
|
|
30
|
+
for (const rel of record.components.skill ?? []) {
|
|
31
|
+
if (rel.toLowerCase().includes(query)) matched.push(`skills/${rel}`)
|
|
32
|
+
}
|
|
33
|
+
if (matched.length) return { rank: 5, matched }
|
|
34
|
+
if (marketplace.toLowerCase().includes(query)) return { rank: 6, matched: [] }
|
|
35
|
+
return null
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// spec 09: every match carries its registry records so the CLI renders
|
|
39
|
+
// without re-reading the registry; ties break alphabetically by
|
|
40
|
+
// plugin@marketplace so output is stable
|
|
41
|
+
export function searchPlugins(queryArg, options = {}) {
|
|
42
|
+
const query = queryArg.toLowerCase()
|
|
43
|
+
const registry = readRegistry()
|
|
44
|
+
const matches = []
|
|
45
|
+
for (const [marketplace, entry] of Object.entries(registry.marketplaces)) {
|
|
46
|
+
for (const [plugin, record] of Object.entries(entry.plugins)) {
|
|
47
|
+
if (options.enabledOnly && !record.enabled) continue
|
|
48
|
+
const found = rankOf(query, plugin, record, marketplace)
|
|
49
|
+
if (found) matches.push({ marketplace, plugin, entry, record, rank: found.rank, matched: found.matched })
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
const key = (match) => `${match.plugin}@${match.marketplace}`
|
|
53
|
+
matches.sort((a, b) => a.rank - b.rank || (key(a) < key(b) ? -1 : 1))
|
|
54
|
+
return matches
|
|
55
|
+
}
|
package/loader/source.js
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, renameSync, rmSync } from "node:fs"
|
|
2
|
+
import { join } from "node:path"
|
|
3
|
+
import { readManifest } from "./manifest.js"
|
|
4
|
+
import { HOME, MARKETPLACES_DIR } from "./paths.js"
|
|
5
|
+
import { git } from "./sync.js"
|
|
6
|
+
|
|
7
|
+
const GITHUB_TREE_RE = /^https:\/\/github\.com\/([^/]+)\/([^/]+)\/tree\/([^/]+)(\/.*)?$/
|
|
8
|
+
const NAME_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/
|
|
9
|
+
|
|
10
|
+
export function isGitUrl(source) {
|
|
11
|
+
return /^https?:|^git@|^file:\/\//.test(source)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function expandPath(source) {
|
|
15
|
+
return source.startsWith("~") ? join(HOME, source.replace(/^~\/?/, "")) : source
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function basename(p) {
|
|
19
|
+
return p.replace(/\/+$/, "").split("/").pop() ?? p
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function normaliseMarketplaceName(name) {
|
|
23
|
+
return (
|
|
24
|
+
name
|
|
25
|
+
.toLowerCase()
|
|
26
|
+
.replace(/[^a-z0-9-]+/g, "-")
|
|
27
|
+
.replace(/-+/g, "-")
|
|
28
|
+
.replace(/^-|-$/g, "") || "marketplace"
|
|
29
|
+
)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function marketplaceNameFromUrl(url) {
|
|
33
|
+
const cleaned = url
|
|
34
|
+
.replace(/\.git$/, "")
|
|
35
|
+
.replace(/\/+$/, "")
|
|
36
|
+
.replace(/^https?:\/\/[^/]+\//, "")
|
|
37
|
+
.replace(/^git@[^:]+:/, "")
|
|
38
|
+
return normaliseMarketplaceName(cleaned.split("/").filter(Boolean).slice(-2).join("--"))
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function marketplaceDir(name) {
|
|
42
|
+
return join(MARKETPLACES_DIR, name)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// spec 05 add step 1: git urls, github tree urls (repo + ref + subdir) and
|
|
46
|
+
// local paths (absolute, relative or ~/)
|
|
47
|
+
export function parseSource(source) {
|
|
48
|
+
if (isGitUrl(source)) {
|
|
49
|
+
const tree = source.match(GITHUB_TREE_RE)
|
|
50
|
+
if (tree) {
|
|
51
|
+
const url = `https://github.com/${tree[1]}/${tree[2]}`
|
|
52
|
+
return {
|
|
53
|
+
url,
|
|
54
|
+
name: marketplaceNameFromUrl(url),
|
|
55
|
+
subdir: tree[4] ? tree[4].replace(/^\/+|\/+$/g, "") : null,
|
|
56
|
+
isGit: true,
|
|
57
|
+
ref: tree[3],
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return { url: source, name: marketplaceNameFromUrl(source), subdir: null, isGit: true, ref: null }
|
|
61
|
+
}
|
|
62
|
+
const absolute = expandPath(source)
|
|
63
|
+
if (!existsSync(absolute)) {
|
|
64
|
+
throw new Error(`path does not exist: ${source}`)
|
|
65
|
+
}
|
|
66
|
+
return { url: absolute, name: normaliseMarketplaceName(basename(absolute)), subdir: null, isGit: false, ref: null }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// a marketplace.json name is honoured only when it is already a valid
|
|
70
|
+
// marketplace name (spec 05 add step 2)
|
|
71
|
+
export function manifestName(name) {
|
|
72
|
+
return name && NAME_RE.test(name) ? name : undefined
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function clone(url, dir, ref) {
|
|
76
|
+
const args = ["clone", "--depth", "1"]
|
|
77
|
+
if (ref) args.push("--branch", ref)
|
|
78
|
+
args.push(url, dir)
|
|
79
|
+
const result = await git(args)
|
|
80
|
+
if (!result.ok) {
|
|
81
|
+
throw new Error(`git clone failed: ${result.stderr || result.stdout}`)
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// clone under the url-derived name; a valid marketplace.json name renames
|
|
86
|
+
// the clone after the fact unless --name already decided it (spec 05 add
|
|
87
|
+
// step 2). The clone progress line is the CLI's — the core never prints.
|
|
88
|
+
export async function placeClone(parsed, name, ref, registry, named) {
|
|
89
|
+
const dir = marketplaceDir(name)
|
|
90
|
+
mkdirSync(MARKETPLACES_DIR, { recursive: true })
|
|
91
|
+
await clone(parsed.url, dir, ref)
|
|
92
|
+
const declared = named ? undefined : manifestName(readManifest(parsed.subdir ? join(dir, parsed.subdir) : dir).name)
|
|
93
|
+
if (!declared || declared === name) return { name, dir }
|
|
94
|
+
if (registry.marketplaces[declared]) {
|
|
95
|
+
rmSync(dir, { recursive: true, force: true })
|
|
96
|
+
throw new Error(`marketplace "${declared}" already added (use "ocm update ${declared}")`)
|
|
97
|
+
}
|
|
98
|
+
renameSync(dir, marketplaceDir(declared))
|
|
99
|
+
return { name: declared, dir: marketplaceDir(declared) }
|
|
100
|
+
}
|
package/loader/sync.js
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { spawn } from "node:child_process"
|
|
2
|
+
import { existsSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"
|
|
3
|
+
import { join } from "node:path"
|
|
4
|
+
import { DEFAULT_SYNC_INTERVAL_MS, REGISTRY_FILE, STAMP_FILE } from "./paths.js"
|
|
5
|
+
import { enabledPlugins, materialize } from "./materialize.js"
|
|
6
|
+
import { isRecord, markTrustPending, readRegistry } from "./registry.js"
|
|
7
|
+
import { executableComponents, trustFingerprint } from "./trust.js"
|
|
8
|
+
|
|
9
|
+
export function isGitRepo(dir) {
|
|
10
|
+
return existsSync(join(dir, ".git"))
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function git(args, cwd) {
|
|
14
|
+
return new Promise((resolve) => {
|
|
15
|
+
const child = spawn("git", args, { cwd, stdio: ["ignore", "pipe", "pipe"] })
|
|
16
|
+
let stdout = ""
|
|
17
|
+
let stderr = ""
|
|
18
|
+
let settled = false
|
|
19
|
+
const finish = (result) => {
|
|
20
|
+
if (settled) return
|
|
21
|
+
settled = true
|
|
22
|
+
clearTimeout(timer)
|
|
23
|
+
resolve(result)
|
|
24
|
+
}
|
|
25
|
+
const timer = setTimeout(() => {
|
|
26
|
+
child.kill()
|
|
27
|
+
finish({ ok: false, stdout: "", stderr: "git timed out" })
|
|
28
|
+
}, 120_000)
|
|
29
|
+
child.stdout.on("data", (chunk) => (stdout += chunk))
|
|
30
|
+
child.stderr.on("data", (chunk) => (stderr += chunk))
|
|
31
|
+
child.on("error", (err) => finish({ ok: false, stdout: "", stderr: String(err) }))
|
|
32
|
+
child.on("close", (code) => finish({ ok: code === 0, stdout: stdout.trim(), stderr: stderr.trim() }))
|
|
33
|
+
})
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// spec 08: fetch the pinned ref when there is one, else the remote's HEAD —
|
|
37
|
+
// a --branch clone is single-branch, so a bare fetch would follow the
|
|
38
|
+
// cloned branch rather than the default. Then hard-reset to FETCH_HEAD,
|
|
39
|
+
// with @{u} as the fallback.
|
|
40
|
+
export async function pullRepo(dir, ref) {
|
|
41
|
+
const before = (await git(["rev-parse", "HEAD"], dir)).stdout
|
|
42
|
+
const dirty = (await git(["status", "--porcelain"], dir)).stdout !== ""
|
|
43
|
+
const fetch = await git(["fetch", "--depth", "1", "origin", ref || "HEAD"], dir)
|
|
44
|
+
if (!fetch.ok) {
|
|
45
|
+
const detail = fetch.stderr || fetch.stdout
|
|
46
|
+
return {
|
|
47
|
+
ok: false, changed: false, before, after: before, dirty,
|
|
48
|
+
output: ref ? `cannot fetch ref "${ref}": ${detail}` : detail,
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
let reset = await git(["reset", "--hard", "FETCH_HEAD"], dir)
|
|
52
|
+
if (!reset.ok) reset = await git(["reset", "--hard", "@{u}"], dir)
|
|
53
|
+
if (!reset.ok) return { ok: false, changed: false, before, after: before, dirty, output: reset.stderr || reset.stdout }
|
|
54
|
+
const after = (await git(["rev-parse", "HEAD"], dir)).stdout
|
|
55
|
+
return { ok: true, changed: before !== after, before, after, dirty, output: after }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// the loader never prompts: an unanswered or drifted grant is recorded as
|
|
59
|
+
// pending and left for the CLI to surface (spec 07)
|
|
60
|
+
function markDriftedTrust(name, entry, root) {
|
|
61
|
+
const trust = entry?.trust
|
|
62
|
+
if (!isRecord(trust) || trust.code === "denied") return
|
|
63
|
+
const components = executableComponents(root, entry)
|
|
64
|
+
if (components.length && (trust.code === "none" || trust.fingerprint !== trustFingerprint(components))) {
|
|
65
|
+
markTrustPending(name)
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// the loader's only other registry write: lastSync per marketplace, plus the
|
|
70
|
+
// revision after a successful pull (spec 02). The raw file is mutated in
|
|
71
|
+
// place, never migrated, so unknown fields survive.
|
|
72
|
+
function recordSync(name, value, revision) {
|
|
73
|
+
try {
|
|
74
|
+
const raw = JSON.parse(readFileSync(REGISTRY_FILE, "utf8"))
|
|
75
|
+
if (!isRecord(raw) || !isRecord(raw.marketplaces) || !isRecord(raw.marketplaces[name])) return
|
|
76
|
+
raw.marketplaces[name].lastSync = value
|
|
77
|
+
if (typeof revision === "string") raw.marketplaces[name].revision = revision
|
|
78
|
+
const tmp = `${REGISTRY_FILE}.tmp`
|
|
79
|
+
writeFileSync(tmp, `${JSON.stringify(raw, null, 2)}\n`)
|
|
80
|
+
renameSync(tmp, REGISTRY_FILE)
|
|
81
|
+
} catch {}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// spec 08: syncIntervalMs beats OCM_SYNC_INTERVAL_MS beats the 1h default;
|
|
85
|
+
// 0 means due on every start
|
|
86
|
+
function intervalFor(entry) {
|
|
87
|
+
if (typeof entry?.syncIntervalMs === "number") return entry.syncIntervalMs
|
|
88
|
+
const raw = process.env.OCM_SYNC_INTERVAL_MS
|
|
89
|
+
if (raw) {
|
|
90
|
+
const env = Number(raw)
|
|
91
|
+
if (Number.isFinite(env) && env >= 0) return env
|
|
92
|
+
}
|
|
93
|
+
return DEFAULT_SYNC_INTERVAL_MS
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function due(entry, now) {
|
|
97
|
+
const at = Date.parse(entry?.lastSync?.at ?? "")
|
|
98
|
+
if (Number.isNaN(at)) return true
|
|
99
|
+
return now - at >= intervalFor(entry)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export async function syncAll(options = {}) {
|
|
103
|
+
const result = { ran: false, changed: false, updated: [], unchanged: [], failed: [], errors: {} }
|
|
104
|
+
if (process.env.OCM_SYNC_DISABLE === "1") return result
|
|
105
|
+
const registry = readRegistry()
|
|
106
|
+
const entries = Object.entries(registry.marketplaces ?? {})
|
|
107
|
+
if (!entries.length) return result
|
|
108
|
+
const now = Date.now()
|
|
109
|
+
for (const [name, entry] of entries) {
|
|
110
|
+
// the throttle is per marketplace: one marketplace's sync never
|
|
111
|
+
// suppresses another's (spec 08)
|
|
112
|
+
if (!options.force && !due(entry, now)) continue
|
|
113
|
+
if (!entry || typeof entry.dir !== "string" || !existsSync(entry.dir)) continue
|
|
114
|
+
result.ran = true
|
|
115
|
+
let changed = false
|
|
116
|
+
let revision
|
|
117
|
+
// `local === false` rather than `!entry.local`: an entry missing the
|
|
118
|
+
// field must never be treated as an ocm-managed clone
|
|
119
|
+
if (entry.local === false && isGitRepo(entry.dir)) {
|
|
120
|
+
const pull = await pullRepo(entry.dir, typeof entry.ref === "string" ? entry.ref : null)
|
|
121
|
+
if (!pull.ok) {
|
|
122
|
+
result.failed.push(name)
|
|
123
|
+
result.errors[name] = pull.output
|
|
124
|
+
recordSync(name, { at: new Date().toISOString(), ok: false, error: pull.output })
|
|
125
|
+
continue
|
|
126
|
+
}
|
|
127
|
+
changed = pull.changed
|
|
128
|
+
revision = pull.after
|
|
129
|
+
if (changed) result.updated.push(name)
|
|
130
|
+
else result.unchanged.push(name)
|
|
131
|
+
} else {
|
|
132
|
+
// a local directory never pulls; its sync is unchanged by definition
|
|
133
|
+
result.unchanged.push(name)
|
|
134
|
+
}
|
|
135
|
+
// discovery roots at the subdir when the source was a tree url (spec 05);
|
|
136
|
+
// git operations above ran against the clone root
|
|
137
|
+
const root = entry.subdir ? join(entry.dir, entry.subdir) : entry.dir
|
|
138
|
+
const links = materialize(name, root, { enabled: enabledPlugins(entry, root) })
|
|
139
|
+
if (links.warnings.length) result.warnings = [...(result.warnings ?? []), ...links.warnings.map((w) => `${name}: ${w}`)]
|
|
140
|
+
if (changed || links.created > 0) result.changed = true
|
|
141
|
+
markDriftedTrust(name, entry, root)
|
|
142
|
+
recordSync(name, { at: new Date().toISOString(), ok: true, error: null }, revision)
|
|
143
|
+
}
|
|
144
|
+
// the pre-08 global stamp is obsolete: the throttle lives in lastSync.at
|
|
145
|
+
if (result.ran) {
|
|
146
|
+
try {
|
|
147
|
+
rmSync(STAMP_FILE, { force: true })
|
|
148
|
+
} catch {}
|
|
149
|
+
}
|
|
150
|
+
return result
|
|
151
|
+
}
|
package/loader/trust.js
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { createHash } from "node:crypto"
|
|
2
|
+
import { readFileSync } from "node:fs"
|
|
3
|
+
import { join, relative } from "node:path"
|
|
4
|
+
import { discoverPlugins, mcpSourceFile } from "./discovery.js"
|
|
5
|
+
import { isRecord } from "./registry.js"
|
|
6
|
+
|
|
7
|
+
// canonical JSON: keys sorted at every level, so reordering mcp.json leaves a
|
|
8
|
+
// server entry's hash alone while a command change does not (spec 07)
|
|
9
|
+
function canonicalJson(value) {
|
|
10
|
+
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`
|
|
11
|
+
if (isRecord(value)) {
|
|
12
|
+
const keys = Object.keys(value).sort()
|
|
13
|
+
return `{${keys.map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`).join(",")}}`
|
|
14
|
+
}
|
|
15
|
+
return JSON.stringify(value)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function sha256(data) {
|
|
19
|
+
return createHash("sha256").update(data).digest("hex")
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// `plugin` and `plugins` are both valid source directories (spec 06)
|
|
23
|
+
function pluginFile(pluginDir, file) {
|
|
24
|
+
for (const dir of ["plugin", "plugins"]) {
|
|
25
|
+
const path = join(pluginDir, dir, file)
|
|
26
|
+
try {
|
|
27
|
+
return { path, content: readFileSync(path) }
|
|
28
|
+
} catch {}
|
|
29
|
+
}
|
|
30
|
+
return null
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// every executable component of every discovered plugin, sorted by its
|
|
34
|
+
// marketplace-relative path: plugins/<p>/plugin/*.{js,ts} files and every
|
|
35
|
+
// server entry in a plugin's mcp.json (spec 07)
|
|
36
|
+
export function executableComponents(dir, entry) {
|
|
37
|
+
const components = []
|
|
38
|
+
for (const plugin of discoverPlugins(dir)) {
|
|
39
|
+
for (const file of plugin.components.plugin ?? []) {
|
|
40
|
+
const source = pluginFile(plugin.dir, file)
|
|
41
|
+
if (!source) continue
|
|
42
|
+
components.push({
|
|
43
|
+
rel: relative(dir, source.path),
|
|
44
|
+
hash: sha256(source.content),
|
|
45
|
+
kind: "plugin",
|
|
46
|
+
plugin: plugin.name,
|
|
47
|
+
name: file,
|
|
48
|
+
})
|
|
49
|
+
}
|
|
50
|
+
const mcpFile = mcpSourceFile(dir, entry, plugin)
|
|
51
|
+
let servers = null
|
|
52
|
+
try {
|
|
53
|
+
const parsed = JSON.parse(readFileSync(mcpFile, "utf8"))
|
|
54
|
+
if (isRecord(parsed)) servers = parsed
|
|
55
|
+
} catch {}
|
|
56
|
+
for (const [server, value] of Object.entries(servers ?? {})) {
|
|
57
|
+
components.push({
|
|
58
|
+
rel: `${relative(dir, mcpFile)}:${server}`,
|
|
59
|
+
hash: sha256(canonicalJson(value)),
|
|
60
|
+
kind: "mcp",
|
|
61
|
+
plugin: plugin.name,
|
|
62
|
+
name: server,
|
|
63
|
+
value,
|
|
64
|
+
})
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return components.sort((a, b) => (a.rel < b.rel ? -1 : a.rel > b.rel ? 1 : 0))
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// sha256 over "<relative path>\n<sha256 of contents>\n" per component, in
|
|
71
|
+
// path order — the fingerprint recorded at grant time (spec 07)
|
|
72
|
+
export function trustFingerprint(components) {
|
|
73
|
+
const hash = createHash("sha256")
|
|
74
|
+
for (const component of components) {
|
|
75
|
+
hash.update(`${component.rel}\n${component.hash}\n`)
|
|
76
|
+
}
|
|
77
|
+
return hash.digest("hex")
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function componentKey(kind, plugin, name) {
|
|
81
|
+
return `${kind}\n${plugin}\n${name}`
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// the in-memory halves of a trust decision: applied to a loaded entry and
|
|
85
|
+
// saved by the caller (spec 07)
|
|
86
|
+
export function grantEntry(entry, components) {
|
|
87
|
+
entry.trust = {
|
|
88
|
+
code: "granted",
|
|
89
|
+
grantedAt: new Date().toISOString(),
|
|
90
|
+
fingerprint: trustFingerprint(components),
|
|
91
|
+
components: Object.fromEntries(components.map((c) => [c.rel, c.hash])),
|
|
92
|
+
}
|
|
93
|
+
delete entry.trustPending
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function denyEntry(entry) {
|
|
97
|
+
entry.trust = { code: "denied" }
|
|
98
|
+
delete entry.trustPending
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// the materializer's per-component gate: approved iff the marketplace is
|
|
102
|
+
// granted and the component's hash matches the grant record. A grant without
|
|
103
|
+
// a record (written by hand before spec 07) approves everything.
|
|
104
|
+
export function approvedComponents(dir, entry) {
|
|
105
|
+
const trust = entry?.trust
|
|
106
|
+
const granted = trust?.code === "granted"
|
|
107
|
+
const recorded = granted && isRecord(trust.components) ? trust.components : null
|
|
108
|
+
const approved = new Map()
|
|
109
|
+
for (const component of executableComponents(dir, entry)) {
|
|
110
|
+
const ok = granted && (recorded === null || recorded[component.rel] === component.hash)
|
|
111
|
+
approved.set(componentKey(component.kind, component.plugin, component.name), ok)
|
|
112
|
+
}
|
|
113
|
+
return approved
|
|
114
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// Shared view primitives for the /ocm TUI dialog (spec 10b): thin wrappers
|
|
2
|
+
// over api.ui so every flow renders through the same dialogs. Nothing
|
|
3
|
+
// reloads in-session, so every mutation toast ends with the restart notice.
|
|
4
|
+
const NOTICE = "restart opencode to activate"
|
|
5
|
+
|
|
6
|
+
const message = (err) => (err instanceof Error ? err.message : String(err))
|
|
7
|
+
|
|
8
|
+
function componentSummary(record) {
|
|
9
|
+
const parts = []
|
|
10
|
+
for (const [type, files] of Object.entries(record.components ?? {})) {
|
|
11
|
+
if (files?.length) parts.push(`${files.length} ${type}${files.length === 1 ? "" : "s"}`)
|
|
12
|
+
}
|
|
13
|
+
return parts.join(", ") || "no components"
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function select(api, props) {
|
|
17
|
+
api.ui.dialog.replace(() => api.ui.DialogSelect(props))
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function alert(api, title, text, onConfirm) {
|
|
21
|
+
api.ui.dialog.replace(() =>
|
|
22
|
+
api.ui.DialogAlert({ title, message: text, onConfirm: () => (onConfirm ? onConfirm() : api.ui.dialog.clear()) }),
|
|
23
|
+
)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function confirm(api, title, text) {
|
|
27
|
+
return new Promise((resolve) => {
|
|
28
|
+
api.ui.dialog.replace(() =>
|
|
29
|
+
api.ui.DialogConfirm({ title, message: text, onConfirm: () => resolve(true), onCancel: () => resolve(false) }),
|
|
30
|
+
)
|
|
31
|
+
})
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function prompt(api, title, text) {
|
|
35
|
+
return new Promise((resolve) => {
|
|
36
|
+
api.ui.dialog.replace(() =>
|
|
37
|
+
api.ui.DialogPrompt({ title, message: text, onConfirm: (value) => resolve(value), onCancel: () => resolve(null) }),
|
|
38
|
+
)
|
|
39
|
+
})
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function toast(api, variant, text) {
|
|
43
|
+
api.ui.toast({ variant, message: text })
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export { NOTICE, componentSummary, message }
|