bearings 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,799 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ ADAPTER_KINDS,
4
+ SCAFFOLD,
5
+ canonicalAdapterEntries,
6
+ entriesMatch,
7
+ migrateV1,
8
+ renderUpdatePlan,
9
+ renderUpdateReport,
10
+ sha256,
11
+ templatesDir,
12
+ validateHarnesses
13
+ } from "./chunk-GUIHDRBB.js";
14
+
15
+ // src/semver.ts
16
+ function parseSemver(value) {
17
+ const [withoutBuild, ...build] = value.split("+");
18
+ if (build.length > 1 || build.some((part) => !/^[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*$/.test(part))) {
19
+ throw new Error(`Invalid semantic version: ${value}`);
20
+ }
21
+ const [coreText, ...preParts] = withoutBuild.split("-");
22
+ const core = coreText.split(".");
23
+ if (core.length !== 3 || core.some((part) => !/^(0|[1-9]\d*)$/.test(part))) {
24
+ throw new Error(`Invalid semantic version: ${value}`);
25
+ }
26
+ const prerelease = preParts.length ? preParts.join("-").split(".") : [];
27
+ if (prerelease.some((part) => !/^[0-9A-Za-z-]+$/.test(part) || /^\d+$/.test(part) && !/^(0|[1-9]\d*)$/.test(part))) {
28
+ throw new Error(`Invalid semantic version: ${value}`);
29
+ }
30
+ return { core, prerelease };
31
+ }
32
+ function compareNumeric(left, right) {
33
+ if (left.length !== right.length) return left.length < right.length ? -1 : 1;
34
+ return left === right ? 0 : left < right ? -1 : 1;
35
+ }
36
+ function compareIdentifier(left, right) {
37
+ const leftNumeric = /^\d+$/.test(left);
38
+ const rightNumeric = /^\d+$/.test(right);
39
+ if (leftNumeric && rightNumeric) return compareNumeric(left, right);
40
+ if (leftNumeric !== rightNumeric) return leftNumeric ? -1 : 1;
41
+ return left === right ? 0 : left < right ? -1 : 1;
42
+ }
43
+ function compareSemver(left, right) {
44
+ const a = parseSemver(left);
45
+ const b = parseSemver(right);
46
+ for (let index = 0; index < 3; index++) {
47
+ const compared = compareNumeric(a.core[index], b.core[index]);
48
+ if (compared) return compared;
49
+ }
50
+ if (!a.prerelease.length || !b.prerelease.length) {
51
+ return a.prerelease.length === b.prerelease.length ? 0 : a.prerelease.length ? -1 : 1;
52
+ }
53
+ for (let index = 0; index < Math.max(a.prerelease.length, b.prerelease.length); index++) {
54
+ if (a.prerelease[index] === void 0) return -1;
55
+ if (b.prerelease[index] === void 0) return 1;
56
+ const compared = compareIdentifier(a.prerelease[index], b.prerelease[index]);
57
+ if (compared) return compared;
58
+ }
59
+ return 0;
60
+ }
61
+
62
+ // src/update/planner.ts
63
+ import { access, readFile } from "fs/promises";
64
+ import { join } from "path";
65
+ var SETUP_COMMAND_PATH = ".agents/commands/setup-repo.md";
66
+ async function exists(path) {
67
+ try {
68
+ await access(path);
69
+ return true;
70
+ } catch {
71
+ return false;
72
+ }
73
+ }
74
+ async function freeBackupPath(repoDir, target) {
75
+ let candidate = `${target}.bkp`;
76
+ for (let i = 1; await exists(join(repoDir, candidate)); i++) candidate = `${target}.bkp.${i}`;
77
+ return candidate;
78
+ }
79
+ function lineDiffStats(current, incoming) {
80
+ const left = current.split("\n");
81
+ const right = incoming.split("\n");
82
+ let previous = new Uint32Array(right.length + 1);
83
+ for (const line of left) {
84
+ const next = new Uint32Array(right.length + 1);
85
+ for (let index = 1; index <= right.length; index++) {
86
+ next[index] = line === right[index - 1] ? previous[index - 1] + 1 : Math.max(previous[index], next[index - 1]);
87
+ }
88
+ previous = next;
89
+ }
90
+ const common = previous[right.length];
91
+ return { added: right.length - common, removed: left.length - common };
92
+ }
93
+ async function planFiles(input) {
94
+ const { repoDir, previous, source, reconstruction } = input;
95
+ const actions = [];
96
+ const consumedPaths = /* @__PURE__ */ new Set();
97
+ for (const entry of source.scaffold) {
98
+ consumedPaths.add(entry.target);
99
+ const abs = join(repoDir, entry.target);
100
+ const currentExists = await exists(abs);
101
+ const currentContent = currentExists ? await readFile(abs, "utf8") : void 0;
102
+ const currentHash = currentContent !== void 0 ? sha256(currentContent) : void 0;
103
+ const templateContent = await readFile(join(source.templatesDir, entry.template), "utf8");
104
+ const incomingHash = sha256(templateContent);
105
+ const incomingRecord = {
106
+ path: entry.target,
107
+ template: entry.template,
108
+ templateVersion: source.bearingsVersion,
109
+ hash: incomingHash,
110
+ owner: entry.owner
111
+ };
112
+ const prevRecord = previous?.files.find((file) => file.path === entry.target);
113
+ const canSkip = entry.target !== SETUP_COMMAND_PATH;
114
+ if (!currentExists) {
115
+ actions.push({
116
+ kind: "write",
117
+ reason: prevRecord ? "restore" : "add",
118
+ path: entry.target,
119
+ content: templateContent,
120
+ record: incomingRecord
121
+ });
122
+ continue;
123
+ }
124
+ if (!prevRecord && reconstruction && currentHash === incomingHash) {
125
+ actions.push({ kind: "keep", record: incomingRecord });
126
+ continue;
127
+ }
128
+ if (!prevRecord) {
129
+ actions.push({
130
+ kind: "conflict",
131
+ reason: "new-path",
132
+ path: entry.target,
133
+ content: templateContent,
134
+ currentHash,
135
+ record: incomingRecord,
136
+ stats: lineDiffStats(currentContent, templateContent),
137
+ canSkip
138
+ });
139
+ continue;
140
+ }
141
+ if (prevRecord.skippedTemplate?.hash === incomingHash) {
142
+ actions.push({ kind: "keep", record: prevRecord });
143
+ continue;
144
+ }
145
+ if (incomingHash === prevRecord.hash) {
146
+ actions.push({ kind: "keep", record: prevRecord });
147
+ continue;
148
+ }
149
+ if (currentHash === prevRecord.hash) {
150
+ actions.push({
151
+ kind: "write",
152
+ reason: "replace",
153
+ path: entry.target,
154
+ content: templateContent,
155
+ record: incomingRecord
156
+ });
157
+ continue;
158
+ }
159
+ actions.push({
160
+ kind: "conflict",
161
+ reason: "changed",
162
+ path: entry.target,
163
+ content: templateContent,
164
+ currentHash,
165
+ record: incomingRecord,
166
+ previous: prevRecord,
167
+ stats: lineDiffStats(currentContent, templateContent),
168
+ canSkip
169
+ });
170
+ }
171
+ if (previous) {
172
+ for (const prevRecord of previous.files) {
173
+ if (consumedPaths.has(prevRecord.path)) continue;
174
+ const abs = join(repoDir, prevRecord.path);
175
+ if (!await exists(abs)) {
176
+ actions.push({ kind: "untrack-missing", path: prevRecord.path, previous: prevRecord });
177
+ continue;
178
+ }
179
+ const currentContent = await readFile(abs, "utf8");
180
+ const currentHash = sha256(currentContent);
181
+ if (currentHash === prevRecord.hash) {
182
+ actions.push({ kind: "delete", path: prevRecord.path, previous: prevRecord });
183
+ } else {
184
+ actions.push({ kind: "removed-conflict", path: prevRecord.path, previous: prevRecord });
185
+ }
186
+ }
187
+ }
188
+ return {
189
+ fromVersion: previous?.bearingsVersion,
190
+ toVersion: source.bearingsVersion,
191
+ reconstruction,
192
+ actions
193
+ };
194
+ }
195
+ function fail(message) {
196
+ throw new Error(message);
197
+ }
198
+ function resolveConflict(action, decision) {
199
+ if (decision.action !== "replace" && decision.action !== "skip") {
200
+ fail(`Invalid decision for ${action.path}: ${decision.action}`);
201
+ }
202
+ if (decision.action === "skip" && !action.canSkip) {
203
+ fail(`Cannot skip required path: ${action.path}`);
204
+ }
205
+ if (decision.action === "replace") {
206
+ return {
207
+ resolved: { kind: "write", reason: "replace", path: action.path, content: action.content, record: action.record },
208
+ record: action.record
209
+ };
210
+ }
211
+ const baseline = action.previous ?? { ...action.record, hash: action.currentHash };
212
+ const record = {
213
+ ...baseline,
214
+ skippedTemplate: { templateVersion: action.record.templateVersion, hash: action.record.hash }
215
+ };
216
+ return { resolved: { kind: "skip", path: action.path, record }, record };
217
+ }
218
+ async function resolveMerge(repoDir, action) {
219
+ const backup = await freeBackupPath(repoDir, action.path);
220
+ const reconciliation = {
221
+ backup,
222
+ reason: "update-merge",
223
+ sourceHash: action.currentHash,
224
+ incomingTemplateVersion: action.record.templateVersion,
225
+ incomingHash: action.record.hash
226
+ };
227
+ const record = {
228
+ ...action.record,
229
+ reconciliations: [...action.previous?.reconciliations ?? [], reconciliation]
230
+ };
231
+ return { resolved: { kind: "merge", path: action.path, content: action.content, backup, record }, record };
232
+ }
233
+ function resolveRemovedConflict(action, decision) {
234
+ if (decision.action !== "remove" && decision.action !== "keep-untracked") {
235
+ fail(`Invalid decision for ${action.path}: ${decision.action}`);
236
+ }
237
+ if (decision.action === "remove") {
238
+ return { resolved: { kind: "delete", path: action.path, previous: action.previous } };
239
+ }
240
+ const resolved = { kind: "keep-untracked", path: action.path, previous: action.previous };
241
+ if (action.previous.reconciliations?.length) {
242
+ return { resolved, record: { ...action.previous, retired: true } };
243
+ }
244
+ return { resolved };
245
+ }
246
+ async function resolveFilePlan(repoDir, draft, decisions) {
247
+ const decisionsByPath = /* @__PURE__ */ new Map();
248
+ for (const decision of decisions) {
249
+ if (decisionsByPath.has(decision.path)) fail(`Duplicate decision for path: ${decision.path}`);
250
+ decisionsByPath.set(decision.path, decision);
251
+ }
252
+ const actions = [];
253
+ const files = [];
254
+ let setupRequired = draft.reconstruction;
255
+ for (const action of draft.actions) {
256
+ if (action.kind === "keep") {
257
+ actions.push(action);
258
+ files.push(action.record);
259
+ continue;
260
+ }
261
+ if (action.kind === "write") {
262
+ actions.push(action);
263
+ files.push(action.record);
264
+ setupRequired = true;
265
+ continue;
266
+ }
267
+ if (action.kind === "delete") {
268
+ actions.push(action);
269
+ setupRequired = true;
270
+ continue;
271
+ }
272
+ if (action.kind === "untrack-missing") {
273
+ actions.push(action);
274
+ if (action.previous.reconciliations?.length) {
275
+ files.push({ ...action.previous, retired: true });
276
+ setupRequired = true;
277
+ }
278
+ continue;
279
+ }
280
+ if (action.kind === "conflict") {
281
+ const decision2 = decisionsByPath.get(action.path);
282
+ if (!decision2) fail(`Missing decision for path: ${action.path}`);
283
+ decisionsByPath.delete(action.path);
284
+ if (decision2.action === "merge") {
285
+ const { resolved: resolved2, record: record2 } = await resolveMerge(repoDir, action);
286
+ actions.push(resolved2);
287
+ files.push(record2);
288
+ } else {
289
+ const { resolved: resolved2, record: record2 } = resolveConflict(action, decision2);
290
+ actions.push(resolved2);
291
+ if (record2) files.push(record2);
292
+ }
293
+ setupRequired = true;
294
+ continue;
295
+ }
296
+ const decision = decisionsByPath.get(action.path);
297
+ if (!decision) fail(`Missing decision for path: ${action.path}`);
298
+ decisionsByPath.delete(action.path);
299
+ const { resolved, record } = resolveRemovedConflict(action, decision);
300
+ actions.push(resolved);
301
+ if (record) files.push(record);
302
+ setupRequired = true;
303
+ }
304
+ if (decisionsByPath.size > 0) {
305
+ const [leftoverPath] = decisionsByPath.keys();
306
+ fail(`Unexpected decision for path: ${leftoverPath}`);
307
+ }
308
+ return { actions, files, setupRequired };
309
+ }
310
+
311
+ // src/update/adapter-plan.ts
312
+ import { lstat, readdir, readFile as readFile2, readlink } from "fs/promises";
313
+ import { join as join2 } from "path";
314
+ var HARNESSES = ["claude", "opencode"];
315
+ function driftError(adapterPath) {
316
+ throw new Error(`Adapter drift must be resolved before update: ${adapterPath}`);
317
+ }
318
+ function parseAdapterRoot(path) {
319
+ for (const kind of ADAPTER_KINDS) {
320
+ const prefix = `.agents/${kind}/`;
321
+ if (!path.startsWith(prefix)) continue;
322
+ const rest = path.slice(prefix.length);
323
+ const name = kind === "skills" ? rest.split("/")[0] : rest;
324
+ if (!name) continue;
325
+ return `${kind}/${name}`;
326
+ }
327
+ return void 0;
328
+ }
329
+ function splitRoot(root) {
330
+ const index = root.indexOf("/");
331
+ return [root.slice(0, index), root.slice(index + 1)];
332
+ }
333
+ function filesUnderRoot(files, kind, name) {
334
+ if (kind === "commands") {
335
+ return files.filter((file) => file.path === `.agents/commands/${name}`);
336
+ }
337
+ const prefix = `.agents/${kind}/${name}/`;
338
+ return files.filter((file) => file.path.startsWith(prefix));
339
+ }
340
+ async function listFilesRecursive(dir, prefix = "") {
341
+ const entries = await readdir(dir).catch(() => []);
342
+ const files = [];
343
+ for (const entry of entries) {
344
+ const abs = join2(dir, entry);
345
+ const rel = prefix ? `${prefix}/${entry}` : entry;
346
+ const stat = await lstat(abs);
347
+ if (stat.isDirectory()) {
348
+ files.push(...await listFilesRecursive(abs, rel));
349
+ } else {
350
+ files.push(rel);
351
+ }
352
+ }
353
+ return files;
354
+ }
355
+ async function detectHarnessExposure(repoDir, harness) {
356
+ for (const kind of ADAPTER_KINDS) {
357
+ const dir = join2(repoDir, `.${harness}`, kind);
358
+ for (const name of await readdir(dir).catch(() => [])) {
359
+ const stat = await lstat(join2(dir, name)).catch(() => null);
360
+ if (stat) return stat.isSymbolicLink() ? "symlink" : "copy";
361
+ }
362
+ }
363
+ return void 0;
364
+ }
365
+ async function detectAdapterConfig(repoDir) {
366
+ const detected = [];
367
+ for (const harness of HARNESSES) {
368
+ const exposure = await detectHarnessExposure(repoDir, harness);
369
+ if (exposure) detected.push({ harness, exposure });
370
+ }
371
+ if (detected.length === 0) {
372
+ return { harnesses: [...HARNESSES], exposure: "symlink", mixed: false };
373
+ }
374
+ const exposures = new Set(detected.map((entry) => entry.exposure));
375
+ const mixed = exposures.size > 1;
376
+ return {
377
+ harnesses: detected.map((entry) => entry.harness),
378
+ exposure: mixed ? "copy" : detected[0].exposure,
379
+ mixed
380
+ };
381
+ }
382
+ async function preflightAdapters(repoDir, manifest) {
383
+ const rootKeys = /* @__PURE__ */ new Set();
384
+ for (const file of manifest.files) {
385
+ const root = parseAdapterRoot(file.path);
386
+ if (root) rootKeys.add(root);
387
+ }
388
+ for (const harness of manifest.harnesses) {
389
+ for (const root of rootKeys) {
390
+ const [kind, name] = splitRoot(root);
391
+ const canonicalAbs = join2(repoDir, ".agents", kind, name);
392
+ const adapterRel = join2(`.${harness}`, kind, name);
393
+ const adapterAbs = join2(repoDir, adapterRel);
394
+ const adapterStat = await lstat(adapterAbs).catch(() => null);
395
+ if (!adapterStat) continue;
396
+ const canonicalStat = await lstat(canonicalAbs).catch(() => null);
397
+ if (canonicalStat) {
398
+ if (manifest.exposure === "symlink") {
399
+ const relTarget = join2("..", "..", ".agents", kind, name);
400
+ if (!adapterStat.isSymbolicLink() || await readlink(adapterAbs) !== relTarget) driftError(adapterRel);
401
+ } else if (adapterStat.isSymbolicLink() || !await entriesMatch(canonicalAbs, adapterAbs)) {
402
+ driftError(adapterRel);
403
+ }
404
+ continue;
405
+ }
406
+ if (manifest.exposure === "symlink" || adapterStat.isSymbolicLink()) driftError(adapterRel);
407
+ if (kind === "commands") {
408
+ if (adapterStat.isDirectory()) driftError(adapterRel);
409
+ const record = manifest.files.find((file) => file.path === `.agents/commands/${name}`);
410
+ const content = await readFile2(adapterAbs, "utf8");
411
+ if (!record || sha256(content) !== record.hash) driftError(adapterRel);
412
+ continue;
413
+ }
414
+ if (!adapterStat.isDirectory()) driftError(adapterRel);
415
+ const expected = filesUnderRoot(manifest.files, kind, name);
416
+ const actualRelFiles = await listFilesRecursive(adapterAbs);
417
+ if (actualRelFiles.length !== expected.length) driftError(adapterRel);
418
+ for (const relFile of actualRelFiles) {
419
+ const record = expected.find((file) => file.path === `.agents/${kind}/${name}/${relFile}`);
420
+ if (!record) driftError(adapterRel);
421
+ const content = await readFile2(join2(adapterAbs, relFile), "utf8");
422
+ if (sha256(content) !== record.hash) driftError(adapterRel);
423
+ }
424
+ }
425
+ }
426
+ }
427
+ async function preflightAdapterCollisions(input) {
428
+ const { repoDir, currentHarnesses, desiredHarnesses, scaffold } = input;
429
+ const previousRootKeys = new Set((await canonicalAdapterEntries(repoDir)).keys());
430
+ const candidateRoots = new Set(previousRootKeys);
431
+ for (const entry of scaffold) {
432
+ const root = parseAdapterRoot(entry.target);
433
+ if (root) candidateRoots.add(root);
434
+ }
435
+ const currentHarnessSet = new Set(currentHarnesses);
436
+ for (const harness of desiredHarnesses) {
437
+ for (const root of candidateRoots) {
438
+ if (currentHarnessSet.has(harness) && previousRootKeys.has(root)) continue;
439
+ const [kind, name] = splitRoot(root);
440
+ const adapterRel = join2(`.${harness}`, kind, name);
441
+ if (await lstat(join2(repoDir, adapterRel)).catch(() => null)) {
442
+ throw new Error(`Adapter collision: ${adapterRel} already exists and is not managed by bearings`);
443
+ }
444
+ }
445
+ }
446
+ }
447
+ function actionPath(action) {
448
+ return action.kind === "keep" ? action.record.path : action.path;
449
+ }
450
+ async function planAdapterActions(input) {
451
+ const { repoDir, currentHarnesses, currentExposure, desiredHarnesses, desiredExposure, fileActions } = input;
452
+ const previousRootKeys = new Set((await canonicalAdapterEntries(repoDir)).keys());
453
+ const currentHarnessSet = new Set(currentHarnesses);
454
+ const desiredHarnessSet = new Set(desiredHarnesses);
455
+ const exposureChanged = currentExposure !== desiredExposure;
456
+ const desiredRootState = /* @__PURE__ */ new Map();
457
+ const affectedRootKeys = /* @__PURE__ */ new Set();
458
+ for (const action of fileActions) {
459
+ const root = parseAdapterRoot(actionPath(action));
460
+ if (!root) continue;
461
+ if (action.kind === "delete") {
462
+ if (!desiredRootState.has(root)) desiredRootState.set(root, false);
463
+ } else {
464
+ desiredRootState.set(root, true);
465
+ }
466
+ if (action.kind === "write" || action.kind === "merge" || action.kind === "delete") {
467
+ affectedRootKeys.add(root);
468
+ }
469
+ }
470
+ const desiredRootKeys = /* @__PURE__ */ new Set();
471
+ for (const [root, present] of desiredRootState) {
472
+ if (present) desiredRootKeys.add(root);
473
+ }
474
+ for (const root of previousRootKeys) {
475
+ if (desiredRootState.get(root) !== false) desiredRootKeys.add(root);
476
+ }
477
+ const actions = [];
478
+ for (const harness of currentHarnesses) {
479
+ if (desiredHarnessSet.has(harness)) continue;
480
+ for (const root of previousRootKeys) {
481
+ const [kind, name] = splitRoot(root);
482
+ const adapterRel = join2(`.${harness}`, kind, name);
483
+ if (await lstat(join2(repoDir, adapterRel)).catch(() => null)) {
484
+ actions.push({ kind: "remove", path: adapterRel });
485
+ }
486
+ }
487
+ }
488
+ for (const root of previousRootKeys) {
489
+ if (desiredRootKeys.has(root)) continue;
490
+ const [kind, name] = splitRoot(root);
491
+ for (const harness of currentHarnesses) {
492
+ if (!desiredHarnessSet.has(harness)) continue;
493
+ const adapterRel = join2(`.${harness}`, kind, name);
494
+ if (await lstat(join2(repoDir, adapterRel)).catch(() => null)) {
495
+ actions.push({ kind: "remove", path: adapterRel });
496
+ }
497
+ }
498
+ }
499
+ for (const root of desiredRootKeys) {
500
+ const [kind, name] = splitRoot(root);
501
+ const canonicalAbs = join2(repoDir, ".agents", kind, name);
502
+ const relTarget = join2("..", "..", ".agents", kind, name);
503
+ for (const harness of desiredHarnessSet) {
504
+ const adapterRel = join2(`.${harness}`, kind, name);
505
+ const adapterAbs = join2(repoDir, adapterRel);
506
+ const wasManagedBefore = currentHarnessSet.has(harness) && previousRootKeys.has(root);
507
+ const needsWrite = !wasManagedBefore || affectedRootKeys.has(root) || exposureChanged;
508
+ if (!needsWrite) continue;
509
+ if (!wasManagedBefore && await lstat(adapterAbs).catch(() => null)) {
510
+ throw new Error(`Adapter collision: ${adapterRel} already exists and is not managed by bearings`);
511
+ }
512
+ actions.push(
513
+ desiredExposure === "symlink" ? { kind: "write-symlink", path: adapterRel, target: relTarget } : { kind: "write-copy", path: adapterRel, source: canonicalAbs }
514
+ );
515
+ }
516
+ }
517
+ return actions;
518
+ }
519
+
520
+ // src/update/transaction.ts
521
+ import { randomUUID } from "crypto";
522
+ import { cp, lstat as lstat2, mkdir, readdir as readdir2, rename, rm, rmdir, symlink, writeFile } from "fs/promises";
523
+ import { dirname, join as join3, relative, sep } from "path";
524
+ var MANIFEST_RELATIVE = join3(".agents", "bearings.json");
525
+ async function capture(repoDir, txDir, relativePath, snapshots) {
526
+ const target = join3(repoDir, relativePath);
527
+ const stat = await lstat2(target).catch(() => null);
528
+ if (!stat) {
529
+ snapshots.push({ target });
530
+ return;
531
+ }
532
+ const stored = join3(txDir, "snapshots", String(snapshots.length));
533
+ await mkdir(dirname(stored), { recursive: true });
534
+ await rename(target, stored);
535
+ snapshots.push({ target, stored });
536
+ }
537
+ async function captureToBackup(repoDir, relativePath, backupRelativePath, snapshots) {
538
+ const target = join3(repoDir, relativePath);
539
+ const stored = join3(repoDir, backupRelativePath);
540
+ await mkdir(dirname(stored), { recursive: true });
541
+ await rename(target, stored);
542
+ snapshots.push({ target, stored });
543
+ }
544
+ async function ensureDir(repoDir, absDir, createdDirs) {
545
+ const rel = relative(repoDir, absDir);
546
+ if (!rel || rel.startsWith("..")) return;
547
+ let current = repoDir;
548
+ for (const part of rel.split(sep)) {
549
+ current = join3(current, part);
550
+ const stat = await lstat2(current).catch(() => null);
551
+ if (!stat) {
552
+ await mkdir(current);
553
+ createdDirs.push(current);
554
+ }
555
+ }
556
+ }
557
+ async function restore(snapshots, hooks, txDir) {
558
+ for (const snapshot of [...snapshots].reverse()) {
559
+ try {
560
+ await hooks.beforeRollbackRestore?.(snapshot.target);
561
+ await rm(snapshot.target, { recursive: true, force: true });
562
+ if (snapshot.stored) {
563
+ await mkdir(dirname(snapshot.target), { recursive: true });
564
+ await rename(snapshot.stored, snapshot.target);
565
+ }
566
+ } catch (error) {
567
+ throw new Error(
568
+ `Rollback failed while restoring ${snapshot.target}; transaction data retained at ${txDir}. Underlying error: ${error.message}`
569
+ );
570
+ }
571
+ }
572
+ }
573
+ async function removeEmptyDirs(createdDirs) {
574
+ for (const dir of [...createdDirs].reverse()) {
575
+ try {
576
+ const entries = await readdir2(dir);
577
+ if (entries.length === 0) await rmdir(dir);
578
+ } catch {
579
+ }
580
+ }
581
+ }
582
+ async function applyUpdate(repoDir, plan, hooks = {}) {
583
+ const txDir = join3(repoDir, ".agents", `.bearings-txn-${randomUUID()}`);
584
+ const snapshots = [];
585
+ const createdDirs = [];
586
+ const abs = (relativePath) => join3(repoDir, relativePath);
587
+ let opIndex = 0;
588
+ const afterOp = async () => {
589
+ const current = opIndex;
590
+ opIndex += 1;
591
+ await hooks.afterOperation?.(current);
592
+ };
593
+ try {
594
+ for (const action of plan.files) {
595
+ if (action.kind === "write") {
596
+ await capture(repoDir, txDir, action.path, snapshots);
597
+ await ensureDir(repoDir, dirname(abs(action.path)), createdDirs);
598
+ await writeFile(abs(action.path), action.content);
599
+ await afterOp();
600
+ } else if (action.kind === "merge") {
601
+ await captureToBackup(repoDir, action.path, action.backup, snapshots);
602
+ await writeFile(abs(action.path), action.content);
603
+ await afterOp();
604
+ } else if (action.kind === "delete") {
605
+ await capture(repoDir, txDir, action.path, snapshots);
606
+ await afterOp();
607
+ }
608
+ }
609
+ for (const action of plan.adapters) {
610
+ if (action.kind === "write-symlink") {
611
+ await capture(repoDir, txDir, action.path, snapshots);
612
+ await ensureDir(repoDir, dirname(abs(action.path)), createdDirs);
613
+ await symlink(action.target, abs(action.path));
614
+ await afterOp();
615
+ } else if (action.kind === "write-copy") {
616
+ await capture(repoDir, txDir, action.path, snapshots);
617
+ await ensureDir(repoDir, dirname(abs(action.path)), createdDirs);
618
+ await cp(action.source, abs(action.path), { recursive: true });
619
+ await afterOp();
620
+ } else {
621
+ await capture(repoDir, txDir, action.path, snapshots);
622
+ await afterOp();
623
+ }
624
+ }
625
+ await capture(repoDir, txDir, MANIFEST_RELATIVE, snapshots);
626
+ await ensureDir(repoDir, dirname(abs(MANIFEST_RELATIVE)), createdDirs);
627
+ await writeFile(abs(MANIFEST_RELATIVE), JSON.stringify(plan.manifest, null, 2) + "\n");
628
+ await afterOp();
629
+ } catch (error) {
630
+ await restore(snapshots, hooks, txDir);
631
+ await removeEmptyDirs(createdDirs);
632
+ await rm(txDir, { recursive: true, force: true }).catch(() => {
633
+ });
634
+ throw error;
635
+ }
636
+ try {
637
+ await hooks.beforeCleanup?.();
638
+ await rm(txDir, { recursive: true, force: true });
639
+ } catch {
640
+ }
641
+ }
642
+
643
+ // src/update/prompts.ts
644
+ function cancelUpdate(p) {
645
+ p.cancel("Init cancelled.");
646
+ throw new Error("Init cancelled.");
647
+ }
648
+ async function chooseConflict(p, action) {
649
+ const options = [
650
+ { value: "replace", label: "Replace", hint: "discard current content" },
651
+ { value: "merge", label: "Merge", hint: "back up current content for /setup-repo" },
652
+ ...action.canSkip ? [{ value: "skip", label: "Skip", hint: "decline this template revision" }] : []
653
+ ];
654
+ const selected = await p.select({
655
+ message: `${action.path}: incoming +${action.stats.added} -${action.stats.removed}`,
656
+ options
657
+ });
658
+ if (p.isCancel(selected)) cancelUpdate(p);
659
+ return { path: action.path, action: selected };
660
+ }
661
+ async function chooseRemovedConflict(p, action) {
662
+ const selected = await p.select({
663
+ message: `${action.path}: removed from the package but locally modified`,
664
+ options: [
665
+ { value: "remove", label: "Remove", hint: "delete the file" },
666
+ { value: "keep-untracked", label: "Keep and untrack", hint: "preserve the file, stop managing it" }
667
+ ]
668
+ });
669
+ if (p.isCancel(selected)) cancelUpdate(p);
670
+ return { path: action.path, action: selected };
671
+ }
672
+ async function confirmReconstructionConfig(p, detected, flags) {
673
+ let harnesses = flags.harnesses;
674
+ if (!harnesses) {
675
+ const selected = await p.multiselect({
676
+ message: `Detected harnesses [${detected.harnesses.join(", ") || "none"}] \u2014 confirm or correct.`,
677
+ options: [
678
+ { value: "claude", label: "Claude Code (.claude/)" },
679
+ { value: "opencode", label: "OpenCode (.opencode/)" }
680
+ ],
681
+ initialValues: detected.harnesses
682
+ });
683
+ if (p.isCancel(selected)) cancelUpdate(p);
684
+ harnesses = selected;
685
+ }
686
+ let exposure = flags.exposure;
687
+ if (!exposure) {
688
+ const selected = await p.select({
689
+ message: detected.mixed ? "Detected mixed exposure \u2014 choose one consistent mode." : `Detected ${detected.exposure} exposure \u2014 confirm or correct.`,
690
+ options: [
691
+ { value: "symlink", label: "Symlinks (recommended)" },
692
+ { value: "copy", label: "Copies (verify checks drift)" }
693
+ ],
694
+ initialValue: detected.exposure
695
+ });
696
+ if (p.isCancel(selected)) cancelUpdate(p);
697
+ exposure = selected;
698
+ }
699
+ return { harnesses, exposure };
700
+ }
701
+ async function confirmUpdatePlan(p, message) {
702
+ const selected = await p.confirm({ message });
703
+ if (p.isCancel(selected) || !selected) cancelUpdate(p);
704
+ }
705
+
706
+ // src/commands/update.ts
707
+ async function runUpdate(repoDir, flags, version, state) {
708
+ if (!process.stdin.isTTY) {
709
+ throw new Error("Updating an initialized repository requires an interactive terminal.");
710
+ }
711
+ const migratedFromV1 = state.kind === "valid" && state.manifest.version === 1;
712
+ const current = state.kind === "valid" ? migratedFromV1 ? await migrateV1(repoDir, state.manifest) : state.manifest : null;
713
+ if (!migratedFromV1 && (current?.setupPending || current?.files.some((file) => file.reconciliations?.length))) {
714
+ throw new Error("Finish the pending /setup-repo handoff before running bearings init again.");
715
+ }
716
+ if (current && compareSemver(version, current.bearingsVersion) < 0) {
717
+ throw new Error(`Refusing to downgrade bearings from ${current.bearingsVersion} to ${version}.`);
718
+ }
719
+ const reconstruction = state.kind === "invalid";
720
+ const p = await import("@clack/prompts");
721
+ let harnesses;
722
+ let exposure;
723
+ let currentHarnesses;
724
+ let currentExposure;
725
+ if (reconstruction) {
726
+ const detected = await detectAdapterConfig(repoDir);
727
+ ({ harnesses, exposure } = await confirmReconstructionConfig(p, detected, {
728
+ harnesses: validateHarnesses(flags.harnesses),
729
+ exposure: flags.exposure
730
+ }));
731
+ currentHarnesses = detected.harnesses;
732
+ currentExposure = detected.exposure;
733
+ } else {
734
+ await preflightAdapters(repoDir, current);
735
+ harnesses = validateHarnesses(flags.harnesses) ?? current.harnesses;
736
+ exposure = flags.exposure === "copy" ? "copy" : current.exposure;
737
+ currentHarnesses = current.harnesses;
738
+ currentExposure = current.exposure;
739
+ }
740
+ await preflightAdapterCollisions({ repoDir, currentHarnesses, desiredHarnesses: harnesses, scaffold: SCAFFOLD });
741
+ const draft = await planFiles({
742
+ repoDir,
743
+ previous: current,
744
+ source: { bearingsVersion: version, scaffold: SCAFFOLD, templatesDir: templatesDir() },
745
+ reconstruction
746
+ });
747
+ const decisions = [];
748
+ for (const action of draft.actions) {
749
+ if (action.kind === "conflict") decisions.push(await chooseConflict(p, action));
750
+ else if (action.kind === "removed-conflict") decisions.push(await chooseRemovedConflict(p, action));
751
+ }
752
+ const resolved = await resolveFilePlan(repoDir, draft, decisions);
753
+ const adapterActions = await planAdapterActions({
754
+ repoDir,
755
+ currentHarnesses,
756
+ currentExposure,
757
+ desiredHarnesses: harnesses,
758
+ desiredExposure: exposure,
759
+ fileActions: resolved.actions
760
+ });
761
+ const harnessesUnchanged = current ? current.harnesses.length === harnesses.length && current.harnesses.every((h) => harnesses.includes(h)) : false;
762
+ const exposureUnchanged = current ? current.exposure === exposure : false;
763
+ const noArtifactChanges = resolved.actions.every((a) => a.kind === "keep") && adapterActions.length === 0;
764
+ const versionUnchanged = !!current && version === current.bearingsVersion;
765
+ if (!reconstruction && !migratedFromV1 && noArtifactChanges && versionUnchanged && harnessesUnchanged && exposureUnchanged) {
766
+ return { manifest: current, report: "bearings init: already up to date." };
767
+ }
768
+ const migratedAbsorbedBackup = migratedFromV1 && (current?.files.some((file) => file.reconciliations?.length) ?? false);
769
+ const setupRequired = resolved.setupRequired || adapterActions.length > 0 || migratedAbsorbedBackup;
770
+ const finalManifest = {
771
+ version: 2,
772
+ bearingsVersion: version,
773
+ harnesses,
774
+ exposure,
775
+ ...setupRequired ? {
776
+ setupPending: {
777
+ kind: reconstruction ? "reconstruction" : "update",
778
+ ...current ? { fromVersion: current.bearingsVersion } : {},
779
+ toVersion: version
780
+ }
781
+ } : {},
782
+ files: [...resolved.files]
783
+ };
784
+ const summaryInput = {
785
+ actions: resolved.actions,
786
+ adapterActions,
787
+ fromVersion: current?.bearingsVersion,
788
+ toVersion: version,
789
+ fromSchema: state.kind === "valid" ? state.manifest.version : void 0,
790
+ toSchema: 2,
791
+ reconstruction
792
+ };
793
+ await confirmUpdatePlan(p, renderUpdatePlan(summaryInput));
794
+ await applyUpdate(repoDir, { files: resolved.actions, adapters: adapterActions, manifest: finalManifest });
795
+ return { manifest: finalManifest, report: renderUpdateReport({ ...summaryInput, setupRequired }) };
796
+ }
797
+ export {
798
+ runUpdate
799
+ };