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