@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,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
|
+
}
|