@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.
Files changed (64) hide show
  1. package/README.md +286 -0
  2. package/bin/ocm.ts +7 -0
  3. package/loader/config.js +47 -0
  4. package/loader/core.d.ts +256 -0
  5. package/loader/core.js +40 -0
  6. package/loader/discovery.js +162 -0
  7. package/loader/links.js +193 -0
  8. package/loader/lint.js +83 -0
  9. package/loader/manifest.js +158 -0
  10. package/loader/marketplace.js +199 -0
  11. package/loader/materialize.js +218 -0
  12. package/loader/mcp.js +124 -0
  13. package/loader/mutations.js +102 -0
  14. package/loader/ocm-loader.js +21 -0
  15. package/loader/paths.js +20 -0
  16. package/loader/registry.js +163 -0
  17. package/loader/search.js +55 -0
  18. package/loader/source.js +100 -0
  19. package/loader/sync.js +151 -0
  20. package/loader/trust.js +114 -0
  21. package/loader/ui-dialog.js +46 -0
  22. package/loader/ui-marketplaces.js +187 -0
  23. package/loader/ui-plugins.js +101 -0
  24. package/loader/ui-trust.js +68 -0
  25. package/loader/ui.js +109 -0
  26. package/package.json +32 -0
  27. package/src/commands/doctor-config.ts +79 -0
  28. package/src/commands/doctor-links.ts +170 -0
  29. package/src/commands/doctor.ts +144 -0
  30. package/src/commands/info.ts +159 -0
  31. package/src/commands/list.ts +44 -0
  32. package/src/commands/marketplace.ts +99 -0
  33. package/src/commands/plugins.ts +127 -0
  34. package/src/commands/search.ts +86 -0
  35. package/src/commands/trust.ts +146 -0
  36. package/src/commands/update-report.ts +123 -0
  37. package/src/commands/update.ts +160 -0
  38. package/src/commands/validate-files.ts +145 -0
  39. package/src/commands/validate.ts +77 -0
  40. package/src/discovery.ts +36 -0
  41. package/src/findings.ts +33 -0
  42. package/src/git.ts +24 -0
  43. package/src/index.ts +175 -0
  44. package/src/install.ts +15 -0
  45. package/src/loader.ts +185 -0
  46. package/src/manifest-lint.ts +189 -0
  47. package/src/migrate.ts +117 -0
  48. package/src/paths.ts +22 -0
  49. package/src/probe.ts +52 -0
  50. package/src/registry.ts +25 -0
  51. package/src/renames.ts +83 -0
  52. package/src/report.ts +13 -0
  53. package/src/types.ts +70 -0
  54. package/template/marketplace.json +17 -0
  55. package/template/plugins/demo-kit/agents/reviewer.md +20 -0
  56. package/template/plugins/demo-kit/commands/tdd.md +12 -0
  57. package/template/plugins/demo-kit/mcp.json +3 -0
  58. package/template/plugins/demo-kit/plugin/notify.js +4 -0
  59. package/template/plugins/demo-kit/plugin.json +6 -0
  60. package/template/plugins/demo-kit/skills/code-review/SKILL.md +21 -0
  61. package/template/plugins/release-kit/commands/ship.md +11 -0
  62. package/template/plugins/release-kit/commands.claude/ship.md +11 -0
  63. package/template/plugins/release-kit/plugin.json +6 -0
  64. 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
+ }
@@ -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
+ }
@@ -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
+ }
@@ -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 }