Skip to content

Commit 05c4b8b

Browse files
oratisclaude
andauthored
feat(core): M5.2 — marketplace install (gh/npm) + ed25519 + revoked.json (#29)
· packages/core/src/plugins/install.ts (NEW) - installFromGithub('gh:owner/repo[@ref]') — git clone --depth 1 → installLocal → cleanup staging. Optional @ref for version pin. - installFromNpm('<pkg>@npm') — `npm pack` → extract tarball → installLocal. Doesn't write to global npm registry. - installFromSpec(spec) — polymorphic dispatch (gh: / @npm / local path). - uninstallPlugin(name) — removes ~/.deepcode/plugins/<name>/ + trust manifest entry. Idempotent. · packages/core/src/plugins/marketplace.ts (NEW, ~190 lines) - verifyEntrySignature(entry) — ed25519 verify of `${name}|${version}|${sourceHash}` against publisherPubKey (DER SPKI). - isRevoked(entry, list) — exact match on name+version+sourceHash. - fetchIndex(url) / fetchRevoked(baseUrl) — HTTP GET + JSON parse; 404 on revoked.json silently → empty list. - resolveEntry({ marketplaceUrl, name, version? }) — picks highest matching version, verifies sig, refuses revoked. - loadMarketplaceConfig / saveMarketplaceConfig / addMarketplace operate on ~/.deepcode/marketplaces.json. Tests: +15 marketplace + 0 install (install is integration-only — see BEHAVIOR_PARITY note). core 401 → 416; cli 47 unchanged. Total 448 → 463. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent b4a344a commit 05c4b8b

5 files changed

Lines changed: 602 additions & 0 deletions

File tree

packages/core/src/index.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,19 @@ export {
206206
generatePluginToken,
207207
wirePlugins,
208208
hasInstalledPlugins,
209+
installFromGithub,
210+
installFromNpm,
211+
installFromSpec,
212+
uninstallPlugin,
213+
verifyEntrySignature,
214+
isRevoked,
215+
fetchIndex,
216+
fetchRevoked,
217+
resolveEntry,
218+
loadMarketplaceConfig,
219+
saveMarketplaceConfig,
220+
addMarketplace,
221+
marketplacesPath,
209222
type PluginManifest,
210223
type InstalledPlugin,
211224
type PluginTrust,
@@ -220,6 +233,12 @@ export {
220233
type WiredPlugin,
221234
type WireResult,
222235
type PluginCapabilityBridge,
236+
type RemoteInstallOpts,
237+
type MarketplaceEntry,
238+
type MarketplaceIndex,
239+
type RevokedEntry,
240+
type RevokedList,
241+
type MarketplaceConfig,
223242
} from './plugins/index.js';
224243

225244
// Auto-mode classifier (M3c-rest — LLM-judged tool gate when mode === 'auto')

packages/core/src/plugins/index.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,3 +56,28 @@ export {
5656
type WireResult,
5757
type PluginCapabilityBridge,
5858
} from './wireup.js';
59+
60+
export {
61+
installFromGithub,
62+
installFromNpm,
63+
installFromSpec,
64+
uninstallPlugin,
65+
type RemoteInstallOpts,
66+
} from './install.js';
67+
68+
export {
69+
verifyEntrySignature,
70+
isRevoked,
71+
fetchIndex,
72+
fetchRevoked,
73+
resolveEntry,
74+
loadMarketplaceConfig,
75+
saveMarketplaceConfig,
76+
addMarketplace,
77+
marketplacesPath,
78+
type MarketplaceEntry,
79+
type MarketplaceIndex,
80+
type RevokedEntry,
81+
type RevokedList,
82+
type MarketplaceConfig,
83+
} from './marketplace.js';
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
// Plugin install — git clone (gh:user/repo) + npm (pkg@npm) + marketplace install paths.
2+
// Spec: docs/DEVELOPMENT_PLAN.md §3.14 (M5.2)
3+
//
4+
// Three install sources:
5+
// 1. Local path (M5; see installLocal in manifest.ts)
6+
// 2. gh:user/repo (M5.2; git clone into staging + verify + move)
7+
// 3. <pkg>@npm (M5.2; `npm pack` + extract + verify)
8+
9+
import { spawn } from 'node:child_process';
10+
import { promises as fs } from 'node:fs';
11+
import { homedir, tmpdir } from 'node:os';
12+
import { join } from 'node:path';
13+
import { installLocal, pluginsDir, type InstalledPlugin } from './manifest.js';
14+
15+
export interface RemoteInstallOpts {
16+
/** Override HOME for tests. */
17+
home?: string;
18+
/** Override the parent dir for staging clones. */
19+
stagingDir?: string;
20+
/** Trust origin label — recorded in plugins-trust.json. */
21+
trustedBy?: 'user' | 'marketplace' | 'official';
22+
}
23+
24+
/**
25+
* Install from a GitHub repo (`gh:owner/repo` or `gh:owner/repo@ref`).
26+
* Steps:
27+
* 1. git clone --depth 1 [--branch <ref>] into staging dir
28+
* 2. installLocal(staging) → copies to ~/.deepcode/plugins/<name>/
29+
* 3. Remove staging dir
30+
*/
31+
export async function installFromGithub(
32+
spec: string,
33+
opts: RemoteInstallOpts = {},
34+
): Promise<InstalledPlugin> {
35+
const m = /^gh:([\w-]+)\/([\w.-]+)(?:@(.+))?$/.exec(spec);
36+
if (!m) throw new Error(`Invalid GitHub spec: ${spec} (expected gh:owner/repo[@ref])`);
37+
const [, owner, repo, ref] = m;
38+
const url = `https://github.com/${owner}/${repo}.git`;
39+
const staging = await fs.mkdtemp(
40+
join(opts.stagingDir ?? tmpdir(), `dc-plug-staging-${repo}-`),
41+
);
42+
try {
43+
const args = ['clone', '--depth', '1'];
44+
if (ref) args.push('--branch', ref);
45+
args.push(url, staging);
46+
await runCommand('git', args);
47+
return await installLocal({
48+
sourcePath: staging,
49+
home: opts.home,
50+
trustedBy: opts.trustedBy ?? 'user',
51+
});
52+
} finally {
53+
await fs.rm(staging, { recursive: true, force: true });
54+
}
55+
}
56+
57+
/**
58+
* Install from an npm package (`<name>@npm` or `<name>@<version>@npm`).
59+
* Uses `npm pack <name>` to produce a tarball, extracts it, and runs the
60+
* local install flow. Doesn't write to the global npm registry.
61+
*/
62+
export async function installFromNpm(
63+
spec: string,
64+
opts: RemoteInstallOpts = {},
65+
): Promise<InstalledPlugin> {
66+
const m = /^(.+)@npm$/.exec(spec);
67+
if (!m) throw new Error(`Invalid npm spec: ${spec} (expected <name>@npm or <name>@<ver>@npm)`);
68+
const pkg = m[1];
69+
const staging = await fs.mkdtemp(
70+
join(opts.stagingDir ?? tmpdir(), `dc-plug-npm-${pkg.replace(/[@/]/g, '_')}-`),
71+
);
72+
try {
73+
// npm pack <pkg> --pack-destination=staging
74+
await runCommand('npm', ['pack', pkg, '--pack-destination', staging]);
75+
// Find the tarball (one .tgz in staging)
76+
const entries = await fs.readdir(staging);
77+
const tarball = entries.find((e) => e.endsWith('.tgz'));
78+
if (!tarball) throw new Error(`npm pack produced no tarball in ${staging}`);
79+
// Extract to staging/extracted/
80+
const extracted = join(staging, 'extracted');
81+
await fs.mkdir(extracted, { recursive: true });
82+
await runCommand('tar', ['-xzf', join(staging, tarball), '-C', extracted]);
83+
// tar yields `package/` inside extracted/
84+
const pkgRoot = join(extracted, 'package');
85+
return await installLocal({
86+
sourcePath: pkgRoot,
87+
home: opts.home,
88+
trustedBy: opts.trustedBy ?? 'user',
89+
});
90+
} finally {
91+
await fs.rm(staging, { recursive: true, force: true });
92+
}
93+
}
94+
95+
/**
96+
* Polymorphic entry point: detects spec format and dispatches.
97+
*/
98+
export async function installFromSpec(
99+
spec: string,
100+
opts: RemoteInstallOpts = {},
101+
): Promise<InstalledPlugin> {
102+
if (spec.startsWith('gh:')) return installFromGithub(spec, opts);
103+
if (spec.endsWith('@npm')) return installFromNpm(spec, opts);
104+
// Otherwise: treat as local path
105+
return installLocal({
106+
sourcePath: spec,
107+
home: opts.home,
108+
trustedBy: opts.trustedBy ?? 'user',
109+
});
110+
}
111+
112+
function runCommand(cmd: string, args: string[]): Promise<void> {
113+
return new Promise((resolve, reject) => {
114+
const p = spawn(cmd, args, { stdio: 'pipe' });
115+
let stderr = '';
116+
p.stderr.on('data', (c: Buffer) => (stderr += c.toString()));
117+
p.on('error', reject);
118+
p.on('close', (code) => {
119+
if (code === 0) resolve();
120+
else reject(new Error(`${cmd} ${args.join(' ')} exited ${code}: ${stderr}`));
121+
});
122+
});
123+
}
124+
125+
/**
126+
* Uninstall — remove the plugin dir from ~/.deepcode/plugins/<name>/
127+
* and the trust manifest entry. Idempotent.
128+
*/
129+
export async function uninstallPlugin(name: string, home: string = homedir()): Promise<boolean> {
130+
const dir = join(pluginsDir(home), name);
131+
let existed = false;
132+
try {
133+
await fs.access(dir);
134+
existed = true;
135+
} catch {
136+
/* nothing to remove */
137+
}
138+
if (existed) await fs.rm(dir, { recursive: true, force: true });
139+
// Trust state cleanup
140+
const { loadTrustState, saveTrustState } = await import('./manifest.js');
141+
const state = await loadTrustState(home);
142+
if (state.plugins[name]) {
143+
delete state.plugins[name];
144+
await saveTrustState(home, state);
145+
}
146+
return existed;
147+
}

0 commit comments

Comments
 (0)