/ utils / plugins / reconciler.ts
reconciler.ts
  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  
 10  import isEqual from 'lodash-es/isEqual.js'
 11  import { isAbsolute, resolve } from 'path'
 12  import { getOriginalCwd } from '../../bootstrap/state.js'
 13  import { logForDebugging } from '../debug.js'
 14  import { errorMessage } from '../errors.js'
 15  import { pathExists } from '../file.js'
 16  import { findCanonicalGitRoot } from '../git.js'
 17  import { logError } from '../log.js'
 18  import {
 19    addMarketplaceSource,
 20    type DeclaredMarketplace,
 21    getDeclaredMarketplaces,
 22    loadKnownMarketplacesConfig,
 23  } from './marketplaceManager.js'
 24  import {
 25    isLocalMarketplaceSource,
 26    type KnownMarketplacesFile,
 27    type MarketplaceSource,
 28  } from './schemas.js'
 29  
 30  export 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   */
 50  export 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  
 85  export 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  
 91  export 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  
102  export 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   */
114  export 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   */
249  function 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  }