Skip to content

Commit 92289e7

Browse files
committed
chore(sync): cascade fleet template@1e967b5
Auto-applied by socket-wheelhouse sync-scaffolding into vscode-socket-security. 1 file(s) touched: - scripts/install-claude-plugins.mts
1 parent 130f424 commit 92289e7

1 file changed

Lines changed: 192 additions & 27 deletions

File tree

scripts/install-claude-plugins.mts

Lines changed: 192 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,26 @@
33
* @file Reconcile the local machine's Claude Code plugin state to the
44
* wheelhouse-canonical SHA-pinned set.
55
*
6-
* - Ensures the `socket-wheelhouse` marketplace is added to Claude
7-
* Code (`~/.claude/plugins/known_marketplaces.json`).
8-
* - For each plugin in the wheelhouse marketplace's
9-
* `.claude-plugin/marketplace.json`, ensures it's installed at the
10-
* pinned SHA.
6+
* What the reconciler does:
117
*
12-
* Idempotent — running twice is a no-op. Designed for `pnpm setup`
13-
* wiring in every fleet repo.
8+
* 1. Ensures the `socket-wheelhouse` marketplace is added to Claude
9+
* Code (`~/.claude/plugins/known_marketplaces.json`).
10+
* 2. For each plugin in the wheelhouse marketplace's
11+
* `.claude-plugin/marketplace.json`:
12+
* - If installed under a *different* marketplace (foreign source) —
13+
* uninstalls it, then installs ours. Wheelhouse is the pin
14+
* authority; foreign installs are silently overriding our pin.
15+
* - If installed under our marketplace at the right SHA — no-op.
16+
* - If installed under our marketplace at a stale SHA — uninstalls
17+
* + reinstalls to bump.
18+
* - If not installed at all — installs.
19+
* 3. Warns (does NOT auto-remove) about marketplaces that exist
20+
* locally + only serve plugins we now serve canonically. The
21+
* user might intentionally keep a dev-source override; let them
22+
* remove it explicitly.
23+
*
24+
* Idempotent — running twice in a row is a no-op. Designed for
25+
* `pnpm setup` wiring in every fleet repo.
1426
*
1527
* Pin discipline is enforced by `.claude/hooks/marketplace-comment-guard/`:
1628
* every `plugins[].source.sha` in `marketplace.json` must have a row
@@ -34,21 +46,26 @@ const logger = getDefaultLogger()
3446
const MARKETPLACE_NAME = 'socket-wheelhouse'
3547
const MARKETPLACE_URL = 'https://github.com/SocketDev/socket-wheelhouse'
3648

37-
interface MarketplaceListEntry {
49+
// Claude Code stores SHA-pinned plugin installs at a cache directory
50+
// whose name is `<sha-12-chars>-<content-hash-8-chars>`. We parse the
51+
// first segment to extract the pinned SHA for drift comparison.
52+
const SHA_PINNED_DIR_NAME = /^([0-9a-f]{12})-[0-9a-f]{8,}$/
53+
54+
export interface MarketplaceListEntry {
3855
name: string
3956
source: string
4057
installLocation?: string
4158
}
4259

43-
interface PluginListEntry {
60+
export interface PluginListEntry {
4461
id: string
4562
version?: string
4663
scope?: string
4764
enabled?: boolean
4865
installPath?: string
4966
}
5067

51-
interface MarketplacePluginSource {
68+
export interface MarketplacePluginSource {
5269
source: string
5370
url?: string
5471
path?: string
@@ -57,16 +74,82 @@ interface MarketplacePluginSource {
5774
commit?: string
5875
}
5976

60-
interface MarketplacePlugin {
77+
export interface MarketplacePlugin {
6178
name: string
6279
source: MarketplacePluginSource
6380
}
6481

65-
interface MarketplaceManifest {
82+
export interface MarketplaceManifest {
6683
name?: string
6784
plugins?: MarketplacePlugin[]
6885
}
6986

87+
/**
88+
* Parse the plugin's `installPath` to extract the SHA prefix it was
89+
* pinned to (12 chars). Returns `null` for directory installs,
90+
* version-tagged installs, or any path shape we don't recognize as
91+
* SHA-pinned. Used to detect drift between manifest pin and on-disk
92+
* install.
93+
*/
94+
export function extractInstalledSha(
95+
installPath: string | undefined,
96+
): string | null {
97+
if (!installPath) return null
98+
const dirName = path.basename(installPath)
99+
const m = SHA_PINNED_DIR_NAME.exec(dirName)
100+
return m ? m[1] ?? null : null
101+
}
102+
103+
/**
104+
* Find an existing install of `pluginName` that came from a marketplace
105+
* *other than* ours. Plugin ids have the shape `<name>@<marketplace>`.
106+
* Returns the foreign install entry, or `undefined` if none.
107+
*/
108+
export function findForeignInstall(
109+
pluginName: string,
110+
plugins: PluginListEntry[],
111+
ourMarketplace: string,
112+
): PluginListEntry | undefined {
113+
const ourId = `${pluginName}@${ourMarketplace}`
114+
for (const p of plugins) {
115+
if (!p.id.startsWith(`${pluginName}@`)) continue
116+
if (p.id === ourId) continue
117+
return p
118+
}
119+
return undefined
120+
}
121+
122+
/**
123+
* Identify marketplaces that look orphaned — exist locally, aren't
124+
* ours, and only serve plugins our marketplace now serves canonically.
125+
* Returns the marketplace names; we warn the user rather than
126+
* auto-remove (a dev-source override is a legitimate deliberate state).
127+
*/
128+
export function findOrphanMarketplaces(
129+
marketplaces: MarketplaceListEntry[],
130+
ourMarketplace: string,
131+
ourPluginNames: Set<string>,
132+
plugins: PluginListEntry[],
133+
): string[] {
134+
const orphans: string[] = []
135+
for (const mkt of marketplaces) {
136+
if (mkt.name === ourMarketplace) continue
137+
// Find every plugin installed from this marketplace.
138+
const installedFromHere = plugins
139+
.filter(p => p.id.endsWith(`@${mkt.name}`))
140+
.map(p => p.id.slice(0, -`@${mkt.name}`.length))
141+
if (installedFromHere.length === 0) {
142+
// No installs from this marketplace — leave it alone. The user
143+
// added it for a reason we can't see.
144+
continue
145+
}
146+
if (installedFromHere.every(name => ourPluginNames.has(name))) {
147+
orphans.push(mkt.name)
148+
}
149+
}
150+
return orphans
151+
}
152+
70153
/**
71154
* Run `claude` CLI synchronously; return stdout + exit code. Stderr
72155
* goes through to our own stderr so the user sees CLI errors in real
@@ -162,24 +245,97 @@ function loadMarketplaceManifest(
162245
return JSON.parse(raw) as MarketplaceManifest
163246
}
164247

165-
function ensurePluginInstalled(plugin: MarketplacePlugin): void {
166-
const installId = `${plugin.name}@${MARKETPLACE_NAME}`
167-
const installed = listPlugins().find(p => p.id === installId)
168-
if (installed) {
169-
logger.log(`Plugin ${installId} already installed (scope: ${installed.scope ?? 'unknown'}).`)
248+
function uninstallPlugin(installId: string): void {
249+
logger.log(`Uninstalling ${installId}…`)
250+
runClaudeCli(['plugin', 'uninstall', installId, '--scope', 'user'])
251+
}
252+
253+
function installPlugin(installId: string, pinDescription: string): void {
254+
logger.log(`Installing ${installId} pinned to ${pinDescription}…`)
255+
runClaudeCli(['plugin', 'install', installId, '--scope', 'user'])
256+
}
257+
258+
/**
259+
* Reconcile a single plugin to the wheelhouse pin. Handles four cases:
260+
* foreign install (uninstall + install), missing (install), stale SHA
261+
* (uninstall + reinstall), and correct (no-op).
262+
*/
263+
function reconcilePlugin(plugin: MarketplacePlugin): void {
264+
const ourInstallId = `${plugin.name}@${MARKETPLACE_NAME}`
265+
const expectedShaPrefix = plugin.source.sha?.slice(0, 12) ?? null
266+
const pinDescription =
267+
plugin.source.sha ?? plugin.source.ref ?? '<no ref>'
268+
269+
let plugins = listPlugins()
270+
271+
// (1) Foreign install: same plugin name, different marketplace. Wheelhouse
272+
// is the pin authority; uninstall the foreign install so our pin can
273+
// take effect. The user's enabledPlugins entry under the foreign id
274+
// disappears as a side effect of the CLI uninstall.
275+
const foreign = findForeignInstall(plugin.name, plugins, MARKETPLACE_NAME)
276+
if (foreign) {
277+
logger.log(
278+
`Found foreign install ${foreign.id} (path: ${foreign.installPath ?? '<unknown>'}); rewiring to ${ourInstallId}.`,
279+
)
280+
uninstallPlugin(foreign.id)
281+
plugins = listPlugins()
282+
}
283+
284+
// (2) Our install present? Check SHA.
285+
const ours = plugins.find(p => p.id === ourInstallId)
286+
if (ours) {
287+
const installedShaPrefix = extractInstalledSha(ours.installPath)
288+
if (!expectedShaPrefix) {
289+
// Manifest pin has no SHA — we can't drift-compare. Trust the
290+
// existing install.
291+
logger.log(`Plugin ${ourInstallId} already installed (manifest has no SHA to compare).`)
292+
return
293+
}
294+
if (installedShaPrefix === expectedShaPrefix) {
295+
logger.log(`Plugin ${ourInstallId} already installed at pinned SHA ${expectedShaPrefix}.`)
296+
return
297+
}
298+
// Drift: our install is at a different SHA. Reinstall.
299+
logger.log(
300+
`Plugin ${ourInstallId} drift: installed at ${installedShaPrefix ?? '<unknown>'}, manifest pins ${expectedShaPrefix}. Reinstalling.`,
301+
)
302+
uninstallPlugin(ourInstallId)
303+
installPlugin(ourInstallId, pinDescription)
170304
return
171305
}
172-
logger.log(`Installing ${installId} pinned to ${plugin.source.sha ?? plugin.source.ref ?? '<no ref>'}`)
173-
runClaudeCli(['plugin', 'install', installId, '--scope', 'user'])
174-
const after = listPlugins().find(p => p.id === installId)
306+
307+
// (3) Not installed at all (or we just uninstalled a foreign copy).
308+
installPlugin(ourInstallId, pinDescription)
309+
const after = listPlugins().find(p => p.id === ourInstallId)
175310
if (!after) {
176311
throw new Error(
177-
`plugin ${installId} did not appear in plugin list after install ` +
312+
`plugin ${ourInstallId} did not appear in plugin list after install ` +
178313
'— check the CLI output above.',
179314
)
180315
}
181316
}
182317
318+
function warnOrphanMarketplaces(
319+
marketplaces: MarketplaceListEntry[],
320+
ourPluginNames: Set<string>,
321+
plugins: PluginListEntry[],
322+
): void {
323+
const orphans = findOrphanMarketplaces(
324+
marketplaces,
325+
MARKETPLACE_NAME,
326+
ourPluginNames,
327+
plugins,
328+
)
329+
for (const name of orphans) {
330+
logger.warn(
331+
`Marketplace "${name}" appears to only serve plugins we now pin via ` +
332+
`"${MARKETPLACE_NAME}". Consider \`claude plugin marketplace remove ${name}\` ` +
333+
`to keep your config tidy. (Not auto-removed — a deliberate dev-source ` +
334+
`override is a legitimate state we won't silently undo.)`,
335+
)
336+
}
337+
}
338+
183339
function main(): void {
184340
logger.log(`Reconciling Claude Code plugins to ${MARKETPLACE_NAME}…`)
185341
const marketplace = ensureMarketplace()
@@ -191,14 +347,23 @@ function main(): void {
191347
)
192348
}
193349
for (const plugin of plugins) {
194-
ensurePluginInstalled(plugin)
350+
reconcilePlugin(plugin)
195351
}
352+
353+
// Post-pass: warn about marketplaces that now look redundant.
354+
const ourPluginNames = new Set(plugins.map(p => p.name))
355+
warnOrphanMarketplaces(listMarketplaces(), ourPluginNames, listPlugins())
356+
196357
logger.log('Done.')
197358
}
198359

199-
try {
200-
main()
201-
} catch (e) {
202-
logger.fail(errorMessage(e))
203-
process.exit(1)
360+
// Skip execution when imported (for tests). The CLI entry is direct
361+
// `node scripts/install-claude-plugins.mts` invocation.
362+
if (import.meta.url === `file://${process.argv[1]}`) {
363+
try {
364+
main()
365+
} catch (e) {
366+
logger.fail(errorMessage(e))
367+
process.exit(1)
368+
}
204369
}

0 commit comments

Comments
 (0)