@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,127 @@
1
+ import { existsSync, mkdtempSync, readlinkSync, rmSync } from "node:fs"
2
+ import { tmpdir } from "node:os"
3
+ import { join } from "node:path"
4
+ import { isGitUrl, parseSource, resolvePlugin, setEnabled } from "../../loader/core.js"
5
+ import type { CorePluginComponents } from "../../loader/core.js"
6
+ import { OPENCODE_AGENTS_DIR, OPENCODE_COMMANDS_DIR, OPENCODE_GLOBAL_CONFIG, OPENCODE_PLUGINS_DIR } from "../paths"
7
+ import { loadRegistry, loadRegistryForWrite, saveRegistry } from "../registry"
8
+ import { discoverMarketplace } from "../discovery"
9
+ import { clone } from "../git"
10
+ import { reportRestart, reportUpgrade, reportWarnings } from "../report"
11
+
12
+ function componentSummary(components: CorePluginComponents): string {
13
+ const parts: string[] = []
14
+ for (const [type, files] of Object.entries(components)) {
15
+ if (files?.length) parts.push(`${files.length} ${type}${files.length === 1 ? "" : "s"}`)
16
+ }
17
+ return parts.join(", ")
18
+ }
19
+
20
+ // spec 05 install: the core flips the record, saves and materializes; the
21
+ // CLI renders — disagreement first, then the upgrade, then the links report
22
+ export function install(arg: string, force = false): void {
23
+ const result = setEnabled(arg, true, { force })
24
+ if (result.disagreement) reportWarnings([result.disagreement])
25
+ reportUpgrade(result.wasV1)
26
+ reportWarnings(result.report.warnings)
27
+ console.log(`installed ${result.plugin}@${result.marketplace} (${componentSummary(result.components)})`)
28
+ reportRestart(result.report.created)
29
+ }
30
+
31
+ export function uninstall(arg: string): void {
32
+ const result = setEnabled(arg, false)
33
+ reportUpgrade(result.wasV1)
34
+ reportWarnings(result.report.warnings)
35
+ reportRestart(result.report.removed)
36
+ console.log(`uninstalled ${result.plugin}@${result.marketplace}`)
37
+ }
38
+
39
+ export function setMode(name: string, mode: string): void {
40
+ if (mode !== "auto" && mode !== "explicit") {
41
+ throw new Error(`unknown mode "${mode}" (expected "auto" or "explicit")`)
42
+ }
43
+ const { registry, wasV1 } = loadRegistryForWrite()
44
+ const entry = registry.marketplaces[name]
45
+ if (!entry) throw new Error(`marketplace "${name}" not found (ocm list)`)
46
+ entry.mode = mode
47
+ saveRegistry(registry)
48
+ reportUpgrade(wasV1)
49
+ console.log(`marketplace "${name}" mode: ${mode}`)
50
+ }
51
+
52
+ // a plugin arg is a bare name or name@marketplace; anything with a url
53
+ // scheme or a slash is a source
54
+ function isPluginArg(source: string): boolean {
55
+ if (isGitUrl(source) || source.includes("/")) return false
56
+ return /^[a-z0-9]+(-[a-z0-9]+)*(@[a-z0-9]+(-[a-z0-9]+)*)?$/.test(source)
57
+ }
58
+
59
+ export function scan(source: string): void {
60
+ if (isPluginArg(source) && !existsSync(source)) {
61
+ scanPlugin(source)
62
+ return
63
+ }
64
+ const parsed = parseSource(source)
65
+ let dir = parsed.url
66
+ let temp: string | null = null
67
+ if (parsed.isGit) {
68
+ // scan never writes outside its temp directory (spec 05): the clone
69
+ // lives in the os temp dir, not the marketplaces store
70
+ temp = mkdtempSync(join(tmpdir(), "ocm-scan-"))
71
+ console.log(`cloning ${parsed.url}...`)
72
+ try {
73
+ clone(parsed.url, temp, parsed.ref)
74
+ } catch (err) {
75
+ rmSync(temp, { recursive: true, force: true })
76
+ throw err
77
+ }
78
+ dir = parsed.subdir ? join(temp, parsed.subdir) : temp
79
+ }
80
+ try {
81
+ const discovered = discoverMarketplace(dir)
82
+ reportWarnings(discovered.warnings)
83
+ const plugins = [...discovered.plugins.values()]
84
+ if (!plugins.length) {
85
+ console.log(`no plugins found in ${source}`)
86
+ return
87
+ }
88
+ console.log(`${plugins.length} plugin(s) would be installed from ${parsed.url}:`)
89
+ for (const plugin of plugins) {
90
+ console.log(` ${plugin.name} (${componentSummary(plugin.components)})`)
91
+ }
92
+ } finally {
93
+ if (temp) rmSync(temp, { recursive: true, force: true })
94
+ }
95
+ }
96
+
97
+ function scanPlugin(arg: string): void {
98
+ const { marketplace, plugin, entry } = resolvePlugin(loadRegistry(), arg)
99
+ const record = entry.plugins[plugin]!
100
+ console.log(`installing ${plugin}@${marketplace} would materialize:`)
101
+ for (const file of record.components.command ?? []) {
102
+ reportScanDest(join(OPENCODE_COMMANDS_DIR, `${plugin}:${file}`), entry.dir)
103
+ }
104
+ for (const file of record.components.agent ?? []) {
105
+ reportScanDest(join(OPENCODE_AGENTS_DIR, `${plugin}:${file}`), entry.dir)
106
+ }
107
+ for (const rel of record.components.skill ?? []) {
108
+ console.log(` skill ${plugin}:${rel}`)
109
+ }
110
+ for (const file of record.components.plugin ?? []) {
111
+ console.log(` plugin ${join(OPENCODE_PLUGINS_DIR, `ocm--${plugin}--${file}`)}`)
112
+ }
113
+ for (const server of record.components.mcp ?? []) {
114
+ console.log(` mcp ocm--${plugin}--${server} (${OPENCODE_GLOBAL_CONFIG})`)
115
+ }
116
+ }
117
+
118
+ // a dest that exists but is not a symlink into this marketplace is a
119
+ // collision install would refuse (or --force displace)
120
+ function reportScanDest(dest: string, marketplaceDir: string): void {
121
+ let target
122
+ try {
123
+ target = readlinkSync(dest)
124
+ } catch {}
125
+ const owned = target !== undefined && (target === marketplaceDir || target.startsWith(`${marketplaceDir}/`))
126
+ console.log(` ${dest}${owned ? "" : " (collision: install would refuse without --force)"}`)
127
+ }
@@ -0,0 +1,86 @@
1
+ import { DEFAULT_SYNC_INTERVAL_MS, searchPlugins } from "../../loader/core.js"
2
+ import type { CoreSearchMatch } from "../../loader/core.js"
3
+ import { loadRegistry } from "../registry"
4
+ import type { MarketplaceEntry, MarketplacePlugin } from "../types"
5
+
6
+ export interface SearchOptions {
7
+ enabledOnly?: boolean
8
+ json?: boolean
9
+ }
10
+
11
+ // blocked is a trust state, not an enabled state: executable components of
12
+ // an untrusted marketplace stay listed, just not linked (spec 07)
13
+ function isBlocked(entry: MarketplaceEntry, record: MarketplacePlugin): boolean {
14
+ if (entry.trust.code === "granted") return false
15
+ return Boolean(record.components.plugin?.length || record.components.mcp?.length)
16
+ }
17
+
18
+ // the names the summary line prints: commands and agents drop their .md
19
+ function bareComponents(components: Partial<Record<string, string[]>>): Partial<Record<string, string[]>> {
20
+ const out: Partial<Record<string, string[]>> = {}
21
+ for (const type of ["command", "agent"] as const) {
22
+ if (components[type]?.length) out[type] = components[type]!.map((file) => file.replace(/\.md$/, ""))
23
+ }
24
+ for (const type of ["skill", "plugin", "mcp"] as const) {
25
+ if (components[type]?.length) out[type] = [...components[type]!]
26
+ }
27
+ return out
28
+ }
29
+
30
+ const LABELS: Record<string, string> = { command: "commands", agent: "agents", skill: "skills", plugin: "plugins", mcp: "mcp" }
31
+
32
+ function componentSummary(components: Partial<Record<string, string[]>>): string {
33
+ return Object.entries(bareComponents(components))
34
+ .map(([type, names]) => `${LABELS[type]}: ${names!.join(", ")}`)
35
+ .join(" · ")
36
+ }
37
+
38
+ // a stale or failed sync is a common cause of a plugin appearing not to exist
39
+ function staleSync(entry: MarketplaceEntry): boolean {
40
+ const sync = entry.lastSync
41
+ if (!sync || !sync.ok) return true
42
+ const at = Date.parse(sync.at)
43
+ if (Number.isNaN(at)) return true
44
+ return Date.now() - at >= (entry.syncIntervalMs ?? DEFAULT_SYNC_INTERVAL_MS)
45
+ }
46
+
47
+ export function search(queryArg: string, options: SearchOptions = {}): void {
48
+ const matches = searchPlugins(queryArg, { enabledOnly: options.enabledOnly })
49
+ if (!matches.length) {
50
+ const registry = loadRegistry()
51
+ const hint = Object.values(registry.marketplaces).some(staleSync)
52
+ ? "\n a marketplace sync is stale or failed; run ocm update"
53
+ : ""
54
+ throw new Error(`no matches for "${queryArg}"${hint}`)
55
+ }
56
+ if (options.json) {
57
+ console.log(JSON.stringify(matches.map((match: CoreSearchMatch) => ({
58
+ plugin: match.plugin,
59
+ marketplace: match.marketplace,
60
+ version: match.record.version,
61
+ category: match.record.manifest.category ?? null,
62
+ description: match.record.manifest.description ?? null,
63
+ enabled: match.record.enabled,
64
+ blocked: isBlocked(match.entry, match.record),
65
+ matched: match.matched.length ? match.matched : null,
66
+ components: bareComponents(match.record.components),
67
+ })), null, 2))
68
+ return
69
+ }
70
+ for (const match of matches) {
71
+ const head = [
72
+ `${match.plugin}@${match.marketplace}`,
73
+ match.record.version,
74
+ match.record.manifest.category,
75
+ match.record.manifest.description,
76
+ ].filter(Boolean).join(" ")
77
+ const markers = `${!match.record.enabled ? " (disabled)" : ""}${isBlocked(match.entry, match.record) ? " (blocked)" : ""}`
78
+ console.log(`${head}${markers}`)
79
+ if (match.matched.length) {
80
+ for (const component of match.matched) console.log(` matched: ${component}`)
81
+ } else {
82
+ const summary = componentSummary(match.record.components)
83
+ if (summary) console.log(` ${summary}`)
84
+ }
85
+ }
86
+ }
@@ -0,0 +1,146 @@
1
+ import { denyEntry, denyTrust, executableComponents, grantEntry, grantTrust, trustFingerprint } from "../../loader/core.js"
2
+ import type { CoreExecutableComponent } from "../../loader/core.js"
3
+ import { componentRoot, materializeLinks } from "../install"
4
+ import { loadRegistryForWrite, saveRegistry } from "../registry"
5
+ import { reportRestart, reportUpgrade, reportWarnings } from "../report"
6
+ import type { MarketplaceEntry } from "../types"
7
+
8
+ // the block every trust decision prints before asking: what runs, where it
9
+ // lives, and what it can do (spec 07)
10
+ function printTrustBlock(name: string, dir: string, components: CoreExecutableComponent[]): void {
11
+ console.error(`marketplace "${name}" ships code that opencode will execute:`)
12
+ for (const component of components) {
13
+ if (component.kind === "plugin") {
14
+ console.error(` plugin ${component.plugin}/${component.name.replace(/\.[jt]s$/, "")} (${component.rel})`)
15
+ } else {
16
+ const value = component.value as Record<string, unknown> | undefined
17
+ const detail =
18
+ value && typeof value.url === "string"
19
+ ? `remote server: ${value.url}`
20
+ : `local server: ${Array.isArray(value?.command) ? (value.command as string[]).join(" ") : ""}`
21
+ console.error(` mcp ${component.plugin}/${component.name} (${detail})`)
22
+ }
23
+ }
24
+ console.error("this code runs with your shell's permissions on every opencode start.")
25
+ console.error(`review it at ${dir}`)
26
+ console.error("trust this marketplace to run code? [y/N/skip]")
27
+ }
28
+
29
+ async function readAnswer(): Promise<string> {
30
+ const { createInterface } = await import("node:readline/promises")
31
+ const rl = createInterface({ input: process.stdin, output: process.stdout })
32
+ try {
33
+ return (await rl.question("")).trim().toLowerCase()
34
+ } finally {
35
+ rl.close()
36
+ }
37
+ }
38
+
39
+ // the prompt half of a trust decision: renders the block and reads the
40
+ // answer. The mutation is the caller's — the core never prompts (spec 10a)
41
+ export async function promptTrust(
42
+ name: string,
43
+ dir: string,
44
+ components: CoreExecutableComponent[],
45
+ ): Promise<"granted" | "denied" | "skipped"> {
46
+ printTrustBlock(name, dir, components)
47
+ if (!process.stdin.isTTY) return "skipped"
48
+ const answer = await readAnswer()
49
+ if (answer === "y" || answer === "yes") return "granted"
50
+ if (answer === "skip") return "skipped"
51
+ return "denied"
52
+ }
53
+
54
+ // the update-time decision, applied in memory: the update engine saves the
55
+ // registry after reconcile, so a mid-update core call would be overwritten
56
+ // by that later save
57
+ async function decideTrust(name: string, entry: MarketplaceEntry, root: string, flag?: boolean): Promise<boolean> {
58
+ const components = executableComponents(root, entry)
59
+ if (!components.length) return false
60
+ if (flag === true) {
61
+ grantEntry(entry, components)
62
+ return true
63
+ }
64
+ if (flag === false) {
65
+ denyEntry(entry)
66
+ return false
67
+ }
68
+ const decision = await promptTrust(name, entry.dir, components)
69
+ if (decision === "granted") {
70
+ grantEntry(entry, components)
71
+ return true
72
+ }
73
+ if (decision === "denied") denyEntry(entry)
74
+ return false
75
+ }
76
+
77
+ function reportChanged(name: string, entry: MarketplaceEntry, components: CoreExecutableComponent[]): void {
78
+ const recorded = entry.trust.components ?? {}
79
+ const current = new Map(components.map((c) => [c.rel, c.hash]))
80
+ const added = components.filter((c) => !(c.rel in recorded)).map((c) => c.rel)
81
+ const removed = Object.keys(recorded).filter((rel) => !current.has(rel))
82
+ const modified = components.filter((c) => c.rel in recorded && recorded[c.rel] !== c.hash).map((c) => c.rel)
83
+ console.error(`marketplace "${name}" shipped code that changed since you trusted it:`)
84
+ for (const rel of added) console.error(` added: ${rel}`)
85
+ for (const rel of removed) console.error(` removed: ${rel}`)
86
+ for (const rel of modified) console.error(` modified: ${rel}`)
87
+ }
88
+
89
+ // the update-time decision: an unchanged or denied grant stands; a drifted
90
+ // one reports the diff and re-prompts (spec 07)
91
+ export async function decideUpdateTrust(
92
+ name: string,
93
+ entry: MarketplaceEntry,
94
+ root: string,
95
+ flag?: boolean,
96
+ ): Promise<boolean> {
97
+ const components = executableComponents(root, entry)
98
+ if (!components.length || entry.trust.code === "denied") return false
99
+ if (entry.trust.code === "granted" && entry.trust.fingerprint === trustFingerprint(components)) return false
100
+ if (entry.trust.code === "granted") reportChanged(name, entry, components)
101
+ return decideTrust(name, entry, root, flag)
102
+ }
103
+
104
+ export async function trust(name: string): Promise<void> {
105
+ const { registry, wasV1 } = loadRegistryForWrite()
106
+ const entry = registry.marketplaces[name]
107
+ if (!entry) throw new Error(`marketplace "${name}" not found (ocm list)`)
108
+ const root = componentRoot(entry)
109
+ const components = executableComponents(root, entry)
110
+ if (!components.length) {
111
+ console.log(`marketplace "${name}" ships no code; nothing to trust`)
112
+ return
113
+ }
114
+ const decision = await promptTrust(name, entry.dir, components)
115
+ if (decision === "granted") {
116
+ const result = grantTrust(name)
117
+ if (result.report) {
118
+ reportWarnings(result.report.warnings)
119
+ reportRestart(result.report.created)
120
+ }
121
+ console.log(`marketplace "${name}" trusted to run code`)
122
+ reportUpgrade(result.wasV1)
123
+ return
124
+ }
125
+ if (decision === "denied") {
126
+ const result = denyTrust(name)
127
+ reportWarnings(result.report.warnings)
128
+ reportRestart(result.report.created)
129
+ reportUpgrade(result.wasV1)
130
+ return
131
+ }
132
+ // skipped: the registry is still saved (a v1 migration) and materialized
133
+ saveRegistry(registry)
134
+ const links = materializeLinks(name, entry)
135
+ reportWarnings(links.warnings)
136
+ reportRestart(links.created)
137
+ reportUpgrade(wasV1)
138
+ }
139
+
140
+ export async function untrust(name: string): Promise<void> {
141
+ const result = denyTrust(name)
142
+ reportWarnings(result.report.warnings)
143
+ reportRestart(result.report.removed)
144
+ console.log(`marketplace "${name}" no longer trusted; executable components removed`)
145
+ reportUpgrade(result.wasV1)
146
+ }
@@ -0,0 +1,123 @@
1
+ import { relative } from "node:path"
2
+ import { git } from "../git"
3
+ import type { DiscoveredPlugin, MarketplaceEntry, MarketplacePlugin } from "../types"
4
+
5
+ export interface FileChange {
6
+ mark: "+" | "~" | "-"
7
+ path: string
8
+ }
9
+
10
+ export interface PluginReport {
11
+ name: string
12
+ fresh: boolean
13
+ from: string | null
14
+ to: string | null
15
+ files: FileChange[]
16
+ note: string | null
17
+ }
18
+
19
+ export interface MarketplaceReport {
20
+ name: string
21
+ ok: boolean
22
+ error: string | null
23
+ note: string | null
24
+ before: string | null
25
+ after: string | null
26
+ changed: boolean
27
+ renamed: { from: string; to: string }[]
28
+ removed: string[]
29
+ pruned: string[]
30
+ refused: { from: string; to: string; incumbent: string }[]
31
+ plugins: PluginReport[]
32
+ warnings: string[]
33
+ materialized: { created: number; removed: number; skipped: number } | null
34
+ }
35
+
36
+ // spec 08: the per-plugin file list is git's own answer — diff the revision
37
+ // pair and filter to the plugin's source prefix
38
+ export function pluginFileChanges(
39
+ entry: MarketplaceEntry,
40
+ before: string | null,
41
+ after: string | null,
42
+ plugins: DiscoveredPlugin[],
43
+ ): Map<string, FileChange[]> {
44
+ const changes = new Map<string, FileChange[]>()
45
+ if (!before || !after || before === after) return changes
46
+ const diff = git(["diff", "--name-status", before, after], entry.dir)
47
+ if (!diff.ok) return changes
48
+ for (const line of diff.stdout.split("\n")) {
49
+ const [status, ...paths] = line.split("\t")
50
+ if (!status || !paths.length) continue
51
+ const mark = status.startsWith("A") ? "+" : status.startsWith("D") ? "-" : "~"
52
+ // R lines carry old and new; the new path is what ships now
53
+ const path = paths[paths.length - 1]!
54
+ for (const plugin of plugins) {
55
+ const prefix = `${relative(entry.dir, plugin.dir)}/`
56
+ if (path.startsWith(prefix)) {
57
+ const list = changes.get(plugin.name) ?? []
58
+ list.push({ mark, path: path.slice(prefix.length) })
59
+ changes.set(plugin.name, list)
60
+ }
61
+ }
62
+ }
63
+ return changes
64
+ }
65
+
66
+ export function pluginReports(
67
+ plugins: DiscoveredPlugin[],
68
+ records: Record<string, MarketplacePlugin>,
69
+ versions: Map<string, string | null>,
70
+ known: Set<string>,
71
+ files: Map<string, FileChange[]>,
72
+ ): PluginReport[] {
73
+ const reports: PluginReport[] = []
74
+ for (const plugin of plugins) {
75
+ const record = records[plugin.name]
76
+ if (!record) continue
77
+ const from = versions.get(plugin.name) ?? null
78
+ const fresh = !known.has(plugin.name)
79
+ const pluginFiles = files.get(plugin.name) ?? []
80
+ if (!fresh && from === record.version && !pluginFiles.length) continue
81
+ const note = record.collision
82
+ ? `not installed (name provided by ${record.collision})`
83
+ : fresh
84
+ ? "installed (auto)"
85
+ : null
86
+ reports.push({ name: plugin.name, fresh, from, to: record.version, files: pluginFiles, note })
87
+ }
88
+ return reports
89
+ }
90
+
91
+ export function renderMarketplace(report: MarketplaceReport, quiet: boolean): void {
92
+ if (quiet && report.ok && !report.changed) return
93
+ console.log(`updating ${report.name}...`)
94
+ for (const warning of report.warnings) console.error(` warning: ${warning}`)
95
+ if (report.note) {
96
+ console.error(`marketplace "${report.name}" ${report.note}`)
97
+ return
98
+ }
99
+ if (!report.ok) {
100
+ console.error(` failed: ${report.error}`)
101
+ return
102
+ }
103
+ if (report.before && report.after) {
104
+ console.log(report.before === report.after ? " already up to date" : ` ${report.before.slice(0, 7)} → ${report.after.slice(0, 7)}`)
105
+ } else if (report.materialized) {
106
+ // no revision pair (local marketplace, re-clone): the materializer's own
107
+ // counts are the report (spec 08)
108
+ const m = report.materialized
109
+ console.log(` ${m.created} created, ${m.removed} removed, ${m.skipped} skipped`)
110
+ }
111
+ for (const rename of report.renamed) console.log(` renamed ${rename.from} → ${rename.to}`)
112
+ for (const name of report.removed) console.log(` removed ${name}`)
113
+ for (const name of report.pruned) console.log(` ${name} removed (no longer in the marketplace)`)
114
+ for (const refusal of report.refused) {
115
+ console.log(` refused rename ${refusal.from} → ${refusal.to}: "${refusal.to}" is already provided by marketplace "${refusal.incumbent}"`)
116
+ }
117
+ for (const plugin of report.plugins) {
118
+ const detail = plugin.note ?? (plugin.from === plugin.to ? null : `${plugin.from ?? "?"} → ${plugin.to ?? "?"}`)
119
+ console.log(` ${plugin.name}${detail ? ` ${detail}` : ""}`)
120
+ for (const file of plugin.files) console.log(` ${file.mark} ${file.path}`)
121
+ }
122
+ if (report.materialized && report.materialized.created > 0) console.log(" restart opencode to activate")
123
+ }
@@ -0,0 +1,160 @@
1
+ import { existsSync, mkdirSync } from "node:fs"
2
+ import { dirname } from "node:path"
3
+ import { pullRepo } from "../../loader/core.js"
4
+ import { componentRoot, materializeLinks, registerPlugins, removeMcpKeys } from "../install"
5
+ import { discoverMarketplace, readRenames } from "../discovery"
6
+ import { applyRenames, resolveChains } from "../renames"
7
+ import { clone, git } from "../git"
8
+ import { loadRegistryForWrite, saveRegistry } from "../registry"
9
+ import { installLoader } from "../loader"
10
+ import { reportUpgrade } from "../report"
11
+ import type { MarketplaceEntry, Registry } from "../types"
12
+ import { decideUpdateTrust } from "./trust"
13
+ import { pluginFileChanges, pluginReports, renderMarketplace } from "./update-report"
14
+ import type { MarketplaceReport } from "./update-report"
15
+
16
+ export interface UpdateOptions {
17
+ quiet?: boolean
18
+ json?: boolean
19
+ trust?: boolean
20
+ }
21
+
22
+ // `ocm update` takes a marketplace, a plugin@marketplace, or nothing
23
+ function resolveTarget(registry: Registry, target?: string): { names: string[]; plugin?: string } {
24
+ if (!target) return { names: Object.keys(registry.marketplaces) }
25
+ const at = target.indexOf("@")
26
+ if (at === -1) {
27
+ if (!registry.marketplaces[target]) throw new Error(`marketplace "${target}" not found (ocm list)`)
28
+ return { names: [target] }
29
+ }
30
+ const plugin = target.slice(0, at)
31
+ const name = target.slice(at + 1)
32
+ const entry = registry.marketplaces[name]
33
+ if (!entry) throw new Error(`marketplace "${name}" not found (ocm list)`)
34
+ if (!entry.plugins[plugin]) {
35
+ throw new Error(`plugin "${plugin}" not found in marketplace "${name}" (ocm list --all)`)
36
+ }
37
+ return { names: [name], plugin }
38
+ }
39
+
40
+ export async function update(target?: string, options: UpdateOptions = {}): Promise<void> {
41
+ const { registry, wasV1 } = loadRegistryForWrite()
42
+ const { names, plugin } = resolveTarget(registry, target)
43
+ if (!names.length) {
44
+ if (options.json) console.log(JSON.stringify({ marketplaces: [] }, null, 2))
45
+ else console.log("no marketplaces added yet (ocm add <url|path>)")
46
+ return
47
+ }
48
+ const reports: MarketplaceReport[] = []
49
+ let installedLoader = false
50
+ for (const name of names) {
51
+ const report = await updateOne(registry, name, options.trust, plugin)
52
+ reports.push(report)
53
+ if (!options.json) renderMarketplace(report, options.quiet === true)
54
+ if (report.ok && !installedLoader) {
55
+ installLoader()
56
+ installedLoader = true
57
+ }
58
+ }
59
+ if (options.json) console.log(JSON.stringify({ marketplaces: reports }, null, 2))
60
+ reportUpgrade(wasV1)
61
+ const failed = reports.filter((report) => !report.ok).map((report) => report.name)
62
+ if (failed.length) {
63
+ throw new Error(`update failed for ${failed.length} marketplace(s): ${failed.join(", ")}`)
64
+ }
65
+ }
66
+
67
+ // pull, or re-clone a cleared cache (spec 08 edge cases); the revision
68
+ // pair and the re-clone note land on the report
69
+ async function pullMarketplace(entry: MarketplaceEntry, report: MarketplaceReport): Promise<void> {
70
+ if (entry.local) {
71
+ // a local directory is the user's: reported and skipped, never re-created
72
+ if (!existsSync(entry.dir)) report.note = `directory missing (${entry.dir}), skipping`
73
+ } else if (existsSync(entry.dir)) {
74
+ const pull = await pullRepo(entry.dir, entry.ref)
75
+ if (!pull.ok) throw new Error(pull.output)
76
+ report.before = pull.before
77
+ report.after = pull.after
78
+ if (pull.dirty) report.warnings.push(`${entry.dir} has local changes; discarded (the cache is not an editing surface)`)
79
+ } else {
80
+ if (!entry.url) throw new Error(`clone directory missing (${entry.dir}) and no url recorded; remove and re-add the marketplace`)
81
+ console.error(`clone directory missing, re-cloning ${entry.url}...`)
82
+ mkdirSync(dirname(entry.dir), { recursive: true })
83
+ clone(entry.url, entry.dir, entry.ref)
84
+ report.after = git(["rev-parse", "HEAD"], entry.dir).stdout
85
+ }
86
+ }
87
+
88
+ // one marketplace, wrapped: a failure records lastSync.ok = false and moves
89
+ // on, never touching this marketplace's links (spec 08)
90
+ async function updateOne(registry: Registry, name: string, trust?: boolean, plugin?: string): Promise<MarketplaceReport> {
91
+ const entry = registry.marketplaces[name]!
92
+ const report: MarketplaceReport = {
93
+ name, ok: true, error: null, note: null, before: null, after: null, changed: false,
94
+ renamed: [], removed: [], pruned: [], refused: [], plugins: [], warnings: [], materialized: null,
95
+ }
96
+ try {
97
+ await pullMarketplace(entry, report)
98
+ if (!report.note) {
99
+ reconcile(registry, name, report, plugin)
100
+ await decideUpdateTrust(name, entry, componentRoot(entry), trust)
101
+ if (report.after) entry.revision = report.after
102
+ entry.lastSync = { at: new Date().toISOString(), ok: true, error: null }
103
+ saveRegistry(registry)
104
+ const links = materializeLinks(name, entry, false, plugin)
105
+ report.warnings.push(...links.warnings)
106
+ report.materialized = { created: links.created, removed: links.removed, skipped: links.skipped }
107
+ report.changed =
108
+ report.before !== report.after ||
109
+ report.renamed.length > 0 ||
110
+ report.removed.length > 0 ||
111
+ report.pruned.length > 0 ||
112
+ report.refused.length > 0 ||
113
+ report.plugins.length > 0 ||
114
+ links.created > 0
115
+ }
116
+ } catch (err) {
117
+ report.ok = false
118
+ report.error = err instanceof Error ? err.message : String(err)
119
+ entry.lastSync = { at: new Date().toISOString(), ok: false, error: report.error }
120
+ try {
121
+ saveRegistry(registry)
122
+ } catch {}
123
+ }
124
+ return report
125
+ }
126
+
127
+ // discover, apply renames, reconcile against the registry (spec 08 step 4)
128
+ function reconcile(registry: Registry, name: string, report: MarketplaceReport, plugin?: string): void {
129
+ const entry = registry.marketplaces[name]!
130
+ const root = componentRoot(entry)
131
+ const discovered = discoverMarketplace(root)
132
+ report.warnings.push(...discovered.warnings)
133
+ const plugins = [...discovered.plugins.values()]
134
+ const renames = readRenames(root, plugins)
135
+ const { resolved, cycles } = resolveChains(renames)
136
+ for (const cycle of cycles) report.warnings.push(`rename cycle ignored: ${cycle.join(" → ")} → ${cycle[0]}`)
137
+ const applied = applyRenames(registry, name, entry, discovered.plugins, resolved)
138
+ const pruned: string[] = []
139
+ for (const pluginName of Object.keys(entry.plugins)) {
140
+ if (!discovered.plugins.has(pluginName) && !(pluginName in resolved)) {
141
+ delete entry.plugins[pluginName]
142
+ pruned.push(pluginName)
143
+ }
144
+ }
145
+ const mcpWarning = removeMcpKeys([...applied.removed, ...applied.renamed.map((rename) => rename.from), ...pruned])
146
+ if (mcpWarning) report.warnings.push(mcpWarning)
147
+ const versions = new Map(Object.entries(entry.plugins).map(([pluginName, plugin]) => [pluginName, plugin.version]))
148
+ const known = new Set(Object.keys(entry.plugins))
149
+ let registrable = plugins.filter((candidate) => !applied.excluded.has(candidate.name))
150
+ // a plugin-scoped update registers no newly shipped plugin: auto-install
151
+ // is the full pass's job, not this one's (spec 08)
152
+ if (plugin) registrable = registrable.filter((candidate) => candidate.name in entry.plugins)
153
+ registerPlugins(registry, name, registrable)
154
+ Object.assign(entry.plugins, applied.kept)
155
+ report.renamed = applied.renamed
156
+ report.removed = applied.removed
157
+ report.pruned = pruned
158
+ report.refused = applied.refused
159
+ report.plugins = pluginReports(plugins, entry.plugins, versions, known, pluginFileChanges(entry, report.before, report.after, plugins))
160
+ }