source dump of claude code
at main 265 lines 8.3 kB view raw
1/** 2 * Marketplace reconciler — makes known_marketplaces.json consistent with 3 * declared intent in settings. 4 * 5 * Two layers: 6 * - diffMarketplaces(): comparison (reads .git for worktree canonicalization, memoized) 7 * - reconcileMarketplaces(): bundled diff + install (I/O, idempotent, additive) 8 */ 9 10import isEqual from 'lodash-es/isEqual.js' 11import { isAbsolute, resolve } from 'path' 12import { getOriginalCwd } from '../../bootstrap/state.js' 13import { logForDebugging } from '../debug.js' 14import { errorMessage } from '../errors.js' 15import { pathExists } from '../file.js' 16import { findCanonicalGitRoot } from '../git.js' 17import { logError } from '../log.js' 18import { 19 addMarketplaceSource, 20 type DeclaredMarketplace, 21 getDeclaredMarketplaces, 22 loadKnownMarketplacesConfig, 23} from './marketplaceManager.js' 24import { 25 isLocalMarketplaceSource, 26 type KnownMarketplacesFile, 27 type MarketplaceSource, 28} from './schemas.js' 29 30export type MarketplaceDiff = { 31 /** Declared in settings, absent from known_marketplaces.json */ 32 missing: string[] 33 /** Present in both, but settings source ≠ JSON source (settings wins) */ 34 sourceChanged: Array<{ 35 name: string 36 declaredSource: MarketplaceSource 37 materializedSource: MarketplaceSource 38 }> 39 /** Present in both, sources match */ 40 upToDate: string[] 41} 42 43/** 44 * Compare declared intent (settings) against materialized state (JSON). 45 * 46 * Resolves relative directory/file paths in `declared` before comparing, 47 * so project settings with `./path` match JSON's absolute path. Path 48 * resolution reads `.git` to canonicalize worktree paths (memoized). 49 */ 50export function diffMarketplaces( 51 declared: Record<string, DeclaredMarketplace>, 52 materialized: KnownMarketplacesFile, 53 opts?: { projectRoot?: string }, 54): MarketplaceDiff { 55 const missing: string[] = [] 56 const sourceChanged: MarketplaceDiff['sourceChanged'] = [] 57 const upToDate: string[] = [] 58 59 for (const [name, intent] of Object.entries(declared)) { 60 const state = materialized[name] 61 const normalizedIntent = normalizeSource(intent.source, opts?.projectRoot) 62 63 if (!state) { 64 missing.push(name) 65 } else if (intent.sourceIsFallback) { 66 // Fallback: presence suffices. Don't compare sources — the declared source 67 // is only a default for the `missing` branch. If seed/prior-install/mirror 68 // materialized this marketplace under ANY source, leave it alone. Comparing 69 // would report sourceChanged → re-clone → stomp the materialized content. 70 upToDate.push(name) 71 } else if (!isEqual(normalizedIntent, state.source)) { 72 sourceChanged.push({ 73 name, 74 declaredSource: normalizedIntent, 75 materializedSource: state.source, 76 }) 77 } else { 78 upToDate.push(name) 79 } 80 } 81 82 return { missing, sourceChanged, upToDate } 83} 84 85export type ReconcileOptions = { 86 /** Skip a declared marketplace. Used by zip-cache mode for unsupported source types. */ 87 skip?: (name: string, source: MarketplaceSource) => boolean 88 onProgress?: (event: ReconcileProgressEvent) => void 89} 90 91export type ReconcileProgressEvent = 92 | { 93 type: 'installing' 94 name: string 95 action: 'install' | 'update' 96 index: number 97 total: number 98 } 99 | { type: 'installed'; name: string; alreadyMaterialized: boolean } 100 | { type: 'failed'; name: string; error: string } 101 102export type ReconcileResult = { 103 installed: string[] 104 updated: string[] 105 failed: Array<{ name: string; error: string }> 106 upToDate: string[] 107 skipped: string[] 108} 109 110/** 111 * Make known_marketplaces.json consistent with declared intent. 112 * Idempotent. Additive only (never deletes). Does not touch AppState. 113 */ 114export async function reconcileMarketplaces( 115 opts?: ReconcileOptions, 116): Promise<ReconcileResult> { 117 const declared = getDeclaredMarketplaces() 118 if (Object.keys(declared).length === 0) { 119 return { installed: [], updated: [], failed: [], upToDate: [], skipped: [] } 120 } 121 122 let materialized: KnownMarketplacesFile 123 try { 124 materialized = await loadKnownMarketplacesConfig() 125 } catch (e) { 126 logError(e) 127 materialized = {} 128 } 129 130 const diff = diffMarketplaces(declared, materialized, { 131 projectRoot: getOriginalCwd(), 132 }) 133 134 type WorkItem = { 135 name: string 136 source: MarketplaceSource 137 action: 'install' | 'update' 138 } 139 const work: WorkItem[] = [ 140 ...diff.missing.map( 141 (name): WorkItem => ({ 142 name, 143 source: normalizeSource(declared[name]!.source), 144 action: 'install', 145 }), 146 ), 147 ...diff.sourceChanged.map( 148 ({ name, declaredSource }): WorkItem => ({ 149 name, 150 source: declaredSource, 151 action: 'update', 152 }), 153 ), 154 ] 155 156 const skipped: string[] = [] 157 const toProcess: WorkItem[] = [] 158 for (const item of work) { 159 if (opts?.skip?.(item.name, item.source)) { 160 skipped.push(item.name) 161 continue 162 } 163 // For sourceChanged local-path entries, skip if the declared path doesn't 164 // exist. Guards multi-checkout scenarios where normalizeSource can't 165 // canonicalize and produces a dead path — the materialized entry may still 166 // be valid; addMarketplaceSource would fail anyway, so skipping avoids a 167 // noisy "failed" event and preserves the working entry. Missing entries 168 // are NOT skipped (nothing to preserve; the user should see the error). 169 if ( 170 item.action === 'update' && 171 isLocalMarketplaceSource(item.source) && 172 !(await pathExists(item.source.path)) 173 ) { 174 logForDebugging( 175 `[reconcile] '${item.name}' declared path does not exist; keeping materialized entry`, 176 ) 177 skipped.push(item.name) 178 continue 179 } 180 toProcess.push(item) 181 } 182 183 if (toProcess.length === 0) { 184 return { 185 installed: [], 186 updated: [], 187 failed: [], 188 upToDate: diff.upToDate, 189 skipped, 190 } 191 } 192 193 logForDebugging( 194 `[reconcile] ${toProcess.length} marketplace(s): ${toProcess.map(w => `${w.name}(${w.action})`).join(', ')}`, 195 ) 196 197 const installed: string[] = [] 198 const updated: string[] = [] 199 const failed: ReconcileResult['failed'] = [] 200 201 for (let i = 0; i < toProcess.length; i++) { 202 const { name, source, action } = toProcess[i]! 203 opts?.onProgress?.({ 204 type: 'installing', 205 name, 206 action, 207 index: i + 1, 208 total: toProcess.length, 209 }) 210 211 try { 212 // addMarketplaceSource is source-idempotent — same source returns 213 // alreadyMaterialized:true without cloning. For 'update' (source 214 // changed), the new source won't match existing → proceeds with clone 215 // and overwrites the old JSON entry. 216 const result = await addMarketplaceSource(source) 217 218 if (action === 'install') installed.push(name) 219 else updated.push(name) 220 opts?.onProgress?.({ 221 type: 'installed', 222 name, 223 alreadyMaterialized: result.alreadyMaterialized, 224 }) 225 } catch (e) { 226 const error = errorMessage(e) 227 failed.push({ name, error }) 228 opts?.onProgress?.({ type: 'failed', name, error }) 229 logError(e) 230 } 231 } 232 233 return { installed, updated, failed, upToDate: diff.upToDate, skipped } 234} 235 236/** 237 * Resolve relative directory/file paths for stable comparison. 238 * Settings declared at project scope may use project-relative paths; 239 * JSON stores absolute paths. 240 * 241 * For git worktrees, resolve against the main checkout (canonical root) 242 * instead of the worktree cwd. Project settings are checked into git, 243 * so `./foo` means "relative to this repo" — but known_marketplaces.json is 244 * user-global with one entry per marketplace name. Resolving against the 245 * worktree cwd means each worktree session overwrites the shared entry with 246 * its own absolute path, and deleting the worktree leaves a dead 247 * installLocation. The canonical root is stable across all worktrees. 248 */ 249function normalizeSource( 250 source: MarketplaceSource, 251 projectRoot?: string, 252): MarketplaceSource { 253 if ( 254 (source.source === 'directory' || source.source === 'file') && 255 !isAbsolute(source.path) 256 ) { 257 const base = projectRoot ?? getOriginalCwd() 258 const canonicalRoot = findCanonicalGitRoot(base) 259 return { 260 ...source, 261 path: resolve(canonicalRoot ?? base, source.path), 262 } 263 } 264 return source 265}