skillcaddy 0.27.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.
Files changed (99) hide show
  1. package/CHANGELOG.md +497 -0
  2. package/CONTEXT.md +149 -0
  3. package/LICENSE +21 -0
  4. package/README.md +499 -0
  5. package/README_CN.md +482 -0
  6. package/collection-metadata/github/mattpocock.json +16 -0
  7. package/lib/canonicalJson.js +11 -0
  8. package/lib/claudeStore.js +135 -0
  9. package/lib/collectionEnablement.js +115 -0
  10. package/lib/collectionSetup.js +199 -0
  11. package/lib/enablePlan.js +116 -0
  12. package/lib/enablementScope.js +8 -0
  13. package/lib/hermesStore.js +21 -0
  14. package/lib/libraryImage.js +1785 -0
  15. package/lib/managerInstall.js +109 -0
  16. package/lib/managerSourceWorkflow.js +234 -0
  17. package/lib/projectActions.js +24 -0
  18. package/lib/projectPath.js +51 -0
  19. package/lib/roots.js +99 -0
  20. package/lib/skillLinkStore.js +68 -0
  21. package/lib/skillMetadata.js +191 -0
  22. package/lib/skillStore.js +541 -0
  23. package/lib/sourceAcquisition.js +475 -0
  24. package/lib/sourceAcquisitionAdapter.js +47 -0
  25. package/lib/sourceAcquisitionError.js +18 -0
  26. package/lib/sourceAcquisitionPolicy.js +139 -0
  27. package/lib/sourceArchive.js +363 -0
  28. package/lib/sourceArchiveWorkspace.js +36 -0
  29. package/lib/sourceGit.js +95 -0
  30. package/lib/sourceGitAcquisitionAdapter.js +145 -0
  31. package/lib/sourceGitBatchUpdate.js +168 -0
  32. package/lib/sourceGitRepair.js +227 -0
  33. package/lib/sourceGitStaging.js +139 -0
  34. package/lib/sourceGitUpgradeAdapter.js +583 -0
  35. package/lib/sourceGitUrl.js +170 -0
  36. package/lib/sourceHttp.js +302 -0
  37. package/lib/sourceLocal.js +200 -0
  38. package/lib/sourceLocalAcquisitionAdapter.js +107 -0
  39. package/lib/sourceManager.js +242 -0
  40. package/lib/sourceMigration.js +172 -0
  41. package/lib/sourceMigrationIssue.js +6 -0
  42. package/lib/sourcePath.js +6 -0
  43. package/lib/sourcePolicy.js +53 -0
  44. package/lib/sourceRecord.js +295 -0
  45. package/lib/sourceRegistry.js +155 -0
  46. package/lib/sourceRemoteArchiveAcquisitionAdapter.js +147 -0
  47. package/lib/sourceRemoteFileAcquisitionAdapter.js +95 -0
  48. package/lib/sourceRemoteFilePolicy.js +62 -0
  49. package/lib/sourceRemoteFileWorkspace.js +55 -0
  50. package/lib/sourceSkillDiscovery.js +117 -0
  51. package/lib/sourceTree.js +185 -0
  52. package/lib/sourceUpgrade.js +596 -0
  53. package/lib/sourceUpgradeAdapter.js +41 -0
  54. package/lib/sourceUpgradePolicy.js +170 -0
  55. package/lib/sourceUpgradeTransaction.js +202 -0
  56. package/lib/sourceValidation.js +120 -0
  57. package/lib/sourceWorkspace.js +37 -0
  58. package/lib/specGate.js +235 -0
  59. package/lib/tuiActions.js +169 -0
  60. package/lib/tuiInstall.js +133 -0
  61. package/lib/tuiLayout.js +94 -0
  62. package/lib/version.js +17 -0
  63. package/lib/webManager.js +370 -0
  64. package/package.json +74 -0
  65. package/public/agentsUi.js +138 -0
  66. package/public/app.js +1431 -0
  67. package/public/claude.css +2 -0
  68. package/public/claudeUi.js +119 -0
  69. package/public/emptyState.js +12 -0
  70. package/public/enabled.css +303 -0
  71. package/public/favicon.svg +5 -0
  72. package/public/index.html +199 -0
  73. package/public/skillcaddy_CN.png +0 -0
  74. package/public/skillcaddy_EN.png +0 -0
  75. package/public/styles.css +1611 -0
  76. package/scripts/cli.js +647 -0
  77. package/scripts/cron-sync-update-git.sh +128 -0
  78. package/scripts/pull-github.sh +22 -0
  79. package/scripts/source.js +638 -0
  80. package/scripts/tui.js +1058 -0
  81. package/server.js +196 -0
  82. package/skills/skillcaddy-manager/SKILL.md +93 -0
  83. package/skills/skillcaddy-manager/agents/openai.yaml +7 -0
  84. package/skills/skillcaddy-manager/references/METADATA.md +75 -0
  85. package/skills/skillcaddy-manager/references/OPERATIONS.md +315 -0
  86. package/skills/skillcaddy-manager/references/RECOMMENDATION_GUIDE.md +51 -0
  87. package/skills/skillcaddy-manager/references/featured-skills.json +564 -0
  88. package/skills/skillcaddy-manager/references/skill-platforms.json +48 -0
  89. package/skills/skillcaddy-manager/scripts/check-conflicts.cjs +117 -0
  90. package/skills/skillcaddy-manager/scripts/check-global-skills.cjs +169 -0
  91. package/skills/skillcaddy-manager/scripts/translate-skill-notes.cjs +297 -0
  92. package/skills/skillcaddy-manager/scripts/version-manager.cjs +146 -0
  93. package/skills/skillcaddy-manager/scripts/view-recommendations.cjs +279 -0
  94. package/skills/skillcaddy-manager/skillcaddy.json +9 -0
  95. package/skills/spec-pipeline/spec-direction-gate/SKILL.md +119 -0
  96. package/skills/spec-pipeline/spec-direction-gate/check-approval.py +110 -0
  97. package/skills/spec-pipeline/spec-draft-intake/SKILL.md +116 -0
  98. package/skills/spec-pipeline/spec-role-contract/SKILL.md +113 -0
  99. package/skills/spec-pipeline/spec-role-contract/check-roles.py +108 -0
@@ -0,0 +1,596 @@
1
+ /**
2
+ * Source upgrade lifecycle
3
+ *
4
+ * This module owns planning, re-planning, authorization, execution,
5
+ * and result projection for all source upgrades.
6
+ *
7
+ * Architecture note: Preparation and execution logic is inlined as private
8
+ * helpers rather than separate modules, because they have only one caller.
9
+ * Local, Remote Archive, and Remote File preparation (38-44 lines each)
10
+ * and execution (197 lines) are now internal to this lifecycle module.
11
+ * The Git upgrade adapter remains separate (327 lines) due to its depth.
12
+ *
13
+ * See ADR-0004 for the centralized upgrade lifecycle decision.
14
+ */
15
+ import path from 'node:path';
16
+ import { SourceAcquisitionError } from './sourceAcquisitionError.js';
17
+ import { createGitUpgradeAdapter } from './sourceGitUpgradeAdapter.js';
18
+ import { isRemoteArchiveInput, parseRemoteArchiveInput, parseRemoteSkillFileInput } from './sourceHttp.js';
19
+ import { inspectLocalInput, stageLocalInput } from './sourceLocal.js';
20
+ import { inspectRemoteArchive, stageRemoteArchive } from './sourceArchiveWorkspace.js';
21
+ import { inspectRemoteSkillFile, stageRemoteSkillFile } from './sourceRemoteFileWorkspace.js';
22
+ import {
23
+ readSourceRecords,
24
+ validateSourceId
25
+ } from './sourceRegistry.js';
26
+ import {
27
+ cleanupDirectorySourceReplacement,
28
+ commitDirectorySourceReplacement,
29
+ commitGitSourceFastForward,
30
+ defaultSourceUpdateFileOperations,
31
+ discardDirectorySourceReplacement,
32
+ discardGitSourceFastForward,
33
+ prepareDirectorySourceReplacement,
34
+ prepareGitSourceFastForward,
35
+ rollbackDirectorySourceReplacement,
36
+ rollbackGitSourceFastForward
37
+ } from './sourceUpgradeTransaction.js';
38
+ import { defineSourceUpgradeAdapter } from './sourceUpgradeAdapter.js';
39
+ import {
40
+ assertActiveSourceMatchesRecord,
41
+ assertNoAffectedLinks,
42
+ assertSameSourceUpgradePlan,
43
+ classifySkillChanges,
44
+ findAffectedGlobalLinks,
45
+ findAffectedHermesLinks,
46
+ findAffectedProjectLinks,
47
+ fingerprintSourceUpgradePlan
48
+ } from './sourceUpgradePolicy.js';
49
+ import {
50
+ createManagedSourceWorkspace,
51
+ removeManagedSourceWorkspace
52
+ } from './sourceWorkspace.js';
53
+ const sourceUpgradePlans = new WeakMap();
54
+ const SOURCE_UPGRADE_AUTHORIZATIONS = new Map([
55
+ ['ordinary', Object.freeze({
56
+ enforce(plan) {
57
+ assertNoAffectedLinks(
58
+ plan.affectedProjectLinks,
59
+ plan.affectedGlobalLinks,
60
+ plan.affectedHermesLinks
61
+ );
62
+ }
63
+ })],
64
+ ['breaking', Object.freeze({
65
+ enforce() {}
66
+ })]
67
+ ]);
68
+
69
+ export async function planSourceUpgrade(context, command) {
70
+ const request = command?.request;
71
+ const authorizationPolicy = requireAuthorizationPolicy(
72
+ command?.authorization
73
+ );
74
+ const preparedPlan = await buildSourceUpgradePlan(context, request);
75
+ authorizationPolicy.enforce(preparedPlan.plan);
76
+ sourceUpgradePlans.set(preparedPlan.plan, {
77
+ authorizationPolicy,
78
+ originalFingerprint: fingerprintSourceUpgradePlan(preparedPlan.plan),
79
+ stalePlanMessage: preparedPlan.stalePlanMessage,
80
+ request: {
81
+ sourceId: preparedPlan.plan.sourceId,
82
+ input: request.input
83
+ }
84
+ });
85
+ return preparedPlan.plan;
86
+ }
87
+
88
+ export async function applySourceUpgrade(context, plan) {
89
+ const session = sourceUpgradePlans.get(plan);
90
+ if (!session) {
91
+ assertUpdatePlan(plan);
92
+ throw copiedPlanError(plan);
93
+ }
94
+ assertOriginalPlan(
95
+ session.originalFingerprint,
96
+ plan,
97
+ session.stalePlanMessage
98
+ );
99
+ assertUpdatePlan(plan);
100
+
101
+ const replannedUpgrade = await buildSourceUpgradePlan(
102
+ context,
103
+ session.request
104
+ );
105
+ session.authorizationPolicy.enforce(replannedUpgrade.plan);
106
+ assertOriginalPlan(
107
+ session.originalFingerprint,
108
+ replannedUpgrade.plan,
109
+ session.stalePlanMessage
110
+ );
111
+
112
+ await runSourceUpgrade(
113
+ context,
114
+ replannedUpgrade,
115
+ session.stalePlanMessage
116
+ );
117
+ return {
118
+ status: 'applied',
119
+ result: buildUpdateResult(replannedUpgrade.plan)
120
+ };
121
+ }
122
+
123
+ function requireAuthorizationPolicy(authorization) {
124
+ const policy = SOURCE_UPGRADE_AUTHORIZATIONS.get(authorization?.kind);
125
+ if (!policy) {
126
+ throw new Error('Source upgrade requires an authorization policy');
127
+ }
128
+ return policy;
129
+ }
130
+
131
+ async function buildSourceUpgradePlan(context, request) {
132
+ const sourceId = requireSourceId(request);
133
+ const record = await findSourceUpgradeRecord(context.rootDir, sourceId);
134
+ const adapter = selectUpgradeAdapter(context, request, record);
135
+ const candidate = await adapter.inspect();
136
+ const changes = classifySkillChanges(record.skills, candidate.skills);
137
+ const affectedProjectLinks = await findAffectedProjectLinks({
138
+ rootDir: context.rootDir,
139
+ projectPath: context.projectPath,
140
+ installPath: record.installPath,
141
+ removedSkillPaths: changes.removedOrRelocated
142
+ });
143
+ const affectedGlobalLinks = await findAffectedGlobalLinks({
144
+ rootDir: context.rootDir,
145
+ globalDir: context.globalDir,
146
+ installPath: record.installPath,
147
+ removedSkillPaths: changes.removedOrRelocated
148
+ });
149
+ const affectedHermesLinks = await findAffectedHermesLinks({
150
+ rootDir: context.rootDir,
151
+ hermesDir: context.hermesDir,
152
+ installPath: record.installPath,
153
+ removedSkillPaths: changes.removedOrRelocated
154
+ });
155
+
156
+ return {
157
+ adapter,
158
+ kind: adapter.kind,
159
+ record,
160
+ stalePlanMessage: adapter.stalePlanMessage,
161
+ plan: buildUpgradePlan({
162
+ sourceId,
163
+ record,
164
+ candidate,
165
+ changes,
166
+ affectedProjectLinks,
167
+ affectedGlobalLinks,
168
+ affectedHermesLinks
169
+ })
170
+ };
171
+ }
172
+
173
+ function selectUpgradeAdapter(context, request, record) {
174
+ if (record.type === 'git') {
175
+ return createGitUpgradeAdapter(context, request, record);
176
+ }
177
+
178
+ if (record.type === 'remote-file') {
179
+ return createInlineRemoteFileUpgradeAdapter(context, request, record);
180
+ }
181
+
182
+ if (isRemoteArchiveInput(request?.input)) {
183
+ if (record.type !== 'archive') {
184
+ throw new SourceAcquisitionError(
185
+ 'unsupported-source',
186
+ `Remote Archive update requires an Archive source: ${record.sourceId}`
187
+ );
188
+ }
189
+ return createInlineRemoteArchiveUpgradeAdapter(context, request);
190
+ }
191
+
192
+ if (!['local', 'archive'].includes(record.type)) {
193
+ throw new SourceAcquisitionError(
194
+ 'unsupported-source',
195
+ `Local replacement requires a Local or Archive source: ${record.sourceId}`
196
+ );
197
+ }
198
+ return createInlineLocalUpgradeAdapter(context, request);
199
+ }
200
+
201
+ function buildUpgradePlan({
202
+ sourceId,
203
+ record,
204
+ candidate,
205
+ changes,
206
+ affectedProjectLinks,
207
+ affectedGlobalLinks,
208
+ affectedHermesLinks
209
+ }) {
210
+ return {
211
+ operation: 'update-source',
212
+ status: candidate.status || 'ready',
213
+ sourceId,
214
+ installPath: record.installPath,
215
+ input: candidate.input,
216
+ ...(candidate.origin ? { origin: candidate.origin } : {}),
217
+ ...(candidate.integrity ? { integrity: candidate.integrity } : {}),
218
+ ...(candidate.currentCommit ? {
219
+ currentCommit: candidate.currentCommit,
220
+ incomingCommit: candidate.incomingCommit
221
+ } : {}),
222
+ skills: candidate.skills,
223
+ warnings: candidate.warnings,
224
+ ...(candidate.skillChanges ? { skillChanges: candidate.skillChanges } : {}),
225
+ changes,
226
+ affectedProjectLinks,
227
+ ...(affectedGlobalLinks.length > 0 ? { affectedGlobalLinks } : {}),
228
+ ...(affectedHermesLinks.length > 0 ? { affectedHermesLinks } : {})
229
+ };
230
+ }
231
+
232
+ async function findSourceUpgradeRecord(rootDir, sourceId) {
233
+ const records = await readSourceRecords(rootDir);
234
+ const record = records.find((candidate) => candidate.sourceId === sourceId);
235
+ if (!record) {
236
+ throw new SourceAcquisitionError(
237
+ 'unresolved-identity',
238
+ `Source is not registered: ${sourceId}`,
239
+ 3
240
+ );
241
+ }
242
+ return record;
243
+ }
244
+
245
+ function requireSourceId(request) {
246
+ if (!request || typeof request.sourceId !== 'string' || !request.sourceId.trim()) {
247
+ throw new Error('Source update requires request.sourceId');
248
+ }
249
+ validateSourceId(request.sourceId);
250
+ return request.sourceId;
251
+ }
252
+
253
+ function assertUpdatePlan(plan) {
254
+ const validStatus = plan?.input?.type === 'git'
255
+ ? ['ready', 'current', 'dirty'].includes(plan?.status)
256
+ : plan?.status === 'ready';
257
+ if (!plan || plan.operation !== 'update-source' || !validStatus) {
258
+ throw new Error('A valid update-source plan is required');
259
+ }
260
+ }
261
+
262
+ function copiedPlanError(plan) {
263
+ return new Error(
264
+ plan.input?.type === 'git'
265
+ ? 'The Git update-source plan must be applied by the process that created it'
266
+ : 'The update-source plan must be applied by the process that created it'
267
+ );
268
+ }
269
+
270
+ function assertOriginalPlan(originalFingerprint, plan, stalePlanMessage) {
271
+ assertSameSourceUpgradePlan(
272
+ originalFingerprint,
273
+ plan,
274
+ stalePlanMessage
275
+ );
276
+ }
277
+
278
+ function buildUpdateResult(plan, status = plan.status) {
279
+ if (plan.input.type === 'git') {
280
+ return {
281
+ status: status === 'ready' ? 'updated' : status,
282
+ sourceId: plan.sourceId,
283
+ installPath: plan.installPath,
284
+ commit: plan.incomingCommit,
285
+ skills: plan.skills,
286
+ warnings: plan.warnings,
287
+ ...(plan.skillChanges ? { skillChanges: plan.skillChanges } : {}),
288
+ changes: plan.changes
289
+ };
290
+ }
291
+ return {
292
+ status: status === 'ready' ? 'updated' : status,
293
+ sourceId: plan.sourceId,
294
+ installPath: plan.installPath,
295
+ input: plan.input,
296
+ ...(plan.origin ? { origin: plan.origin } : {}),
297
+ integrity: plan.integrity,
298
+ skills: plan.skills,
299
+ warnings: plan.warnings,
300
+ changes: plan.changes
301
+ };
302
+ }
303
+
304
+ // === Private: Inline upgrade adapters ===
305
+
306
+ function createInlineLocalUpgradeAdapter(context, request) {
307
+ const inputPath = requireUpdateInput(request);
308
+
309
+ return defineSourceUpgradeAdapter({
310
+ stalePlanMessage: 'Local source or replacement content changed since the update plan',
311
+ kind: 'directory',
312
+ inspect: async () => projectLocalCandidate(
313
+ await inspectLocalInput(inputPath, { archiveLimits: context?.archiveLimits })
314
+ ),
315
+ prepare: async (workspaceRoot) => projectLocalCandidate(
316
+ await stageLocalInput(inputPath, workspaceRoot, { archiveLimits: context?.archiveLimits })
317
+ )
318
+ });
319
+ }
320
+
321
+ function createInlineRemoteArchiveUpgradeAdapter(context, request) {
322
+ const parsed = parseRemoteArchiveInput(request?.input);
323
+
324
+ return defineSourceUpgradeAdapter({
325
+ stalePlanMessage: 'Remote Archive changed since the update plan',
326
+ kind: 'directory',
327
+ async inspect() {
328
+ const candidate = await inspectRemoteArchive(
329
+ context,
330
+ parsed,
331
+ 'skillcaddy-archive-update-plan-'
332
+ );
333
+ return projectRemoteArchiveCandidate(parsed, candidate);
334
+ },
335
+ async prepare(stagingRoot) {
336
+ const candidate = await stageRemoteArchive(
337
+ context,
338
+ parsed,
339
+ stagingRoot
340
+ );
341
+ return projectRemoteArchiveCandidate(parsed, candidate);
342
+ }
343
+ });
344
+ }
345
+
346
+ function createInlineRemoteFileUpgradeAdapter(context, request, record) {
347
+ const parsed = parseRemoteSkillFileInput(
348
+ request?.input ?? record.origin.display
349
+ );
350
+
351
+ return defineSourceUpgradeAdapter({
352
+ stalePlanMessage: 'Remote SKILL.md changed since the update plan',
353
+ kind: 'directory',
354
+ async inspect() {
355
+ const candidate = await inspectRemoteSkillFile(
356
+ context,
357
+ parsed,
358
+ 'skillcaddy-remote-file-update-plan-'
359
+ );
360
+ return projectRemoteFileCandidate(parsed, candidate);
361
+ },
362
+ async prepare(stagingRoot) {
363
+ const candidate = await stageRemoteSkillFile(
364
+ context,
365
+ parsed,
366
+ stagingRoot
367
+ );
368
+ return projectRemoteFileCandidate(parsed, candidate);
369
+ }
370
+ });
371
+ }
372
+
373
+ // === Private: Projection helpers ===
374
+
375
+ function requireUpdateInput(request) {
376
+ if (typeof request?.input !== 'string' || !request.input.trim()) {
377
+ throw new Error('Source update requires request.input');
378
+ }
379
+ return path.resolve(request.input);
380
+ }
381
+
382
+ function projectLocalCandidate(prepared) {
383
+ return {
384
+ ...(prepared.contentRoot ? { contentRoot: prepared.contentRoot } : {}),
385
+ input: {
386
+ type: prepared.type,
387
+ name: prepared.basename
388
+ },
389
+ integrity: prepared.integrity,
390
+ skills: prepared.skills,
391
+ warnings: prepared.warnings
392
+ };
393
+ }
394
+
395
+ function projectRemoteArchiveCandidate(parsed, candidate) {
396
+ return {
397
+ ...candidate,
398
+ input: {
399
+ type: 'remote-zip',
400
+ display: parsed.display
401
+ },
402
+ origin: {
403
+ kind: parsed.protocol,
404
+ display: parsed.display
405
+ }
406
+ };
407
+ }
408
+
409
+ function projectRemoteFileCandidate(parsed, candidate) {
410
+ return {
411
+ ...candidate,
412
+ input: {
413
+ type: 'remote-file',
414
+ display: parsed.display
415
+ },
416
+ origin: {
417
+ kind: parsed.protocol,
418
+ display: parsed.display
419
+ }
420
+ };
421
+ }
422
+
423
+ // === Private: Execution phase ===
424
+
425
+ async function runSourceUpgrade(
426
+ context,
427
+ upgrade,
428
+ stalePlanMessage
429
+ ) {
430
+ if (upgrade.kind === 'git') {
431
+ return executeGitFastForward(context, upgrade, stalePlanMessage);
432
+ }
433
+ return executeDirectoryReplacement(context, upgrade, stalePlanMessage);
434
+ }
435
+
436
+ async function executeDirectoryReplacement(
437
+ context,
438
+ upgrade,
439
+ stalePlanMessage
440
+ ) {
441
+ const destination = path.resolve(
442
+ context.rootDir,
443
+ upgrade.record.installPath
444
+ );
445
+ await assertActiveSourceMatchesRecord(destination, upgrade.record);
446
+ const workspace = await createManagedSourceWorkspace(
447
+ context.rootDir,
448
+ 'update-'
449
+ );
450
+
451
+ try {
452
+ const candidate = await upgrade.adapter.prepare(workspace.root);
453
+ assertPreparedCandidateMatchesPlan(
454
+ candidate,
455
+ upgrade.plan,
456
+ stalePlanMessage
457
+ );
458
+ await assertActiveSourceMatchesRecord(destination, upgrade.record);
459
+ const publication = await prepareDirectorySourceReplacement({
460
+ rootDir: context.rootDir,
461
+ destination,
462
+ stagingRoot: workspace.root,
463
+ preparedContentRoot: candidate.contentRoot,
464
+ nextRecord: buildUpdatedSourceRecord(upgrade.record, upgrade.plan),
465
+ fileOperations: context.fileOperations ||
466
+ defaultSourceUpdateFileOperations
467
+ });
468
+ await runDirectoryReplacement(publication);
469
+ } finally {
470
+ await removeManagedSourceWorkspace(workspace);
471
+ }
472
+ }
473
+
474
+ async function executeGitFastForward(context, upgrade, stalePlanMessage) {
475
+ const candidate = await upgrade.adapter.prepare();
476
+ assertPreparedCandidateMatchesPlan(
477
+ candidate,
478
+ upgrade.plan,
479
+ stalePlanMessage
480
+ );
481
+ if (upgrade.plan.status !== 'ready') return;
482
+ const publication = await prepareGitSourceFastForward({
483
+ rootDir: context.rootDir,
484
+ record: upgrade.record,
485
+ nextRecord: buildUpdatedSourceRecord(upgrade.record, upgrade.plan),
486
+ incomingCommit: upgrade.plan.incomingCommit,
487
+ publishRecord: context.publishRecord
488
+ });
489
+ await runGitFastForward(publication);
490
+ }
491
+
492
+ function assertPreparedCandidateMatchesPlan(
493
+ candidate,
494
+ plan,
495
+ stalePlanMessage
496
+ ) {
497
+ assertSameSourceUpgradePlan(
498
+ fingerprintSourceUpgradePlan(plan),
499
+ {
500
+ ...plan,
501
+ status: candidate.status || 'ready',
502
+ input: candidate.input,
503
+ ...(candidate.origin ? { origin: candidate.origin } : {}),
504
+ ...(candidate.integrity ? { integrity: candidate.integrity } : {}),
505
+ ...(candidate.currentCommit ? {
506
+ currentCommit: candidate.currentCommit,
507
+ incomingCommit: candidate.incomingCommit
508
+ } : {}),
509
+ skills: candidate.skills,
510
+ warnings: candidate.warnings
511
+ },
512
+ stalePlanMessage
513
+ );
514
+ }
515
+
516
+ function buildUpdatedSourceRecord(record, plan) {
517
+ if (record.type === 'git') {
518
+ return {
519
+ ...record,
520
+ origin: {
521
+ ...record.origin,
522
+ commit: plan.incomingCommit
523
+ },
524
+ skills: plan.skills
525
+ };
526
+ }
527
+ return {
528
+ ...record,
529
+ origin: plan.origin || (
530
+ record.type === 'archive'
531
+ ? record.origin
532
+ : {
533
+ kind: 'local',
534
+ name: plan.input.name
535
+ }
536
+ ),
537
+ integrity: plan.integrity,
538
+ skills: plan.skills
539
+ };
540
+ }
541
+
542
+ async function runDirectoryReplacement(publication) {
543
+ let phase = 'prepared';
544
+
545
+ try {
546
+ phase = 'committing';
547
+ await commitDirectorySourceReplacement(publication);
548
+ phase = 'committed';
549
+ phase = 'cleanup';
550
+ await cleanupDirectorySourceReplacement(publication);
551
+ } catch (error) {
552
+ if (phase === 'committing') {
553
+ await rollbackBeforeCommit(
554
+ publication,
555
+ error,
556
+ rollbackDirectorySourceReplacement
557
+ );
558
+ }
559
+ throw error;
560
+ } finally {
561
+ await discardDirectorySourceReplacement(publication);
562
+ }
563
+ }
564
+
565
+ async function runGitFastForward(publication) {
566
+ let phase = 'prepared';
567
+
568
+ try {
569
+ phase = 'committing';
570
+ await commitGitSourceFastForward(publication);
571
+ phase = 'committed';
572
+ phase = 'cleanup';
573
+ } catch (error) {
574
+ if (phase === 'committing') {
575
+ await rollbackBeforeCommit(
576
+ publication,
577
+ error,
578
+ rollbackGitSourceFastForward
579
+ );
580
+ }
581
+ throw error;
582
+ } finally {
583
+ await discardGitSourceFastForward(publication);
584
+ }
585
+ }
586
+
587
+ async function rollbackBeforeCommit(publication, originalError, rollback) {
588
+ try {
589
+ await rollback(publication);
590
+ } catch (rollbackError) {
591
+ throw new AggregateError(
592
+ [originalError, rollbackError],
593
+ 'Source update failed and rollback could not restore the previous source'
594
+ );
595
+ }
596
+ }
@@ -0,0 +1,41 @@
1
+ const REQUIRED_ADAPTER_METHODS = ['inspect', 'prepare'];
2
+ const VALID_KINDS = ['git', 'directory'];
3
+
4
+ /**
5
+ * @typedef {object} SourceUpgradeAdapter
6
+ * @property {string} stalePlanMessage
7
+ * @property {'git' | 'directory'} kind
8
+ * @property {() => Promise<object>} inspect
9
+ * @property {(workspaceRoot?: string) => Promise<object>} prepare
10
+ */
11
+
12
+ /**
13
+ * @param {SourceUpgradeAdapter} adapter
14
+ * @returns {Readonly<SourceUpgradeAdapter>}
15
+ */
16
+ export function defineSourceUpgradeAdapter(adapter) {
17
+ if (!adapter || typeof adapter !== 'object' || Array.isArray(adapter)) {
18
+ throw new TypeError('Source upgrade adapter must be an object');
19
+ }
20
+ if (
21
+ typeof adapter.stalePlanMessage !== 'string' ||
22
+ !adapter.stalePlanMessage.trim()
23
+ ) {
24
+ throw new TypeError(
25
+ 'Source upgrade adapter requires stalePlanMessage'
26
+ );
27
+ }
28
+ if (!VALID_KINDS.includes(adapter.kind)) {
29
+ throw new TypeError(
30
+ `Source upgrade adapter requires kind ('git' or 'directory')`
31
+ );
32
+ }
33
+ for (const method of REQUIRED_ADAPTER_METHODS) {
34
+ if (typeof adapter[method] !== 'function') {
35
+ throw new TypeError(
36
+ `Source upgrade adapter requires ${method}()`
37
+ );
38
+ }
39
+ }
40
+ return Object.freeze(adapter);
41
+ }