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,475 @@
1
+ /**
2
+ * Source acquisition lifecycle
3
+ *
4
+ * This module owns selection, planning, re-planning, execution,
5
+ * and result projection for all source acquisitions.
6
+ *
7
+ * Architecture note: Execution logic is inlined as private helpers
8
+ * rather than a separate module (sourceAcquisitionExecution.js),
9
+ * because it has only one caller. Tests go through the sourceManager
10
+ * seam, not directly.
11
+ *
12
+ * See ADR-0005 for the centralized acquisition lifecycle decision.
13
+ */
14
+ import {
15
+ cp,
16
+ mkdir,
17
+ readdir,
18
+ realpath,
19
+ rename,
20
+ rm,
21
+ writeFile
22
+ } from 'node:fs/promises';
23
+ import path from 'node:path';
24
+ import { createGitAcquisitionAdapter } from './sourceGitAcquisitionAdapter.js';
25
+ import { readGitHead } from './sourceGit.js';
26
+ import {
27
+ isDefiniteGitSourceInput,
28
+ isGitSourceInput
29
+ } from './sourceGitUrl.js';
30
+ import {
31
+ isRemoteArchiveInput,
32
+ isRemoteSkillFileInput
33
+ } from './sourceHttp.js';
34
+ import {
35
+ createLocalAcquisitionAdapter
36
+ } from './sourceLocalAcquisitionAdapter.js';
37
+ import {
38
+ createRemoteArchiveAcquisitionAdapter
39
+ } from './sourceRemoteArchiveAcquisitionAdapter.js';
40
+ import {
41
+ createRemoteFileAcquisitionAdapter
42
+ } from './sourceRemoteFileAcquisitionAdapter.js';
43
+ import { validateSourceRecord } from './sourceRecord.js';
44
+ import { defineSourceAcquisitionAdapter } from './sourceAcquisitionAdapter.js';
45
+ import {
46
+ imageSourceMismatchError,
47
+ SourceAcquisitionError,
48
+ sourceCollisionError
49
+ } from './sourceAcquisitionError.js';
50
+ import {
51
+ assertOriginalSourceAcquisitionPlan,
52
+ assertSameSourceAcquisitionFacts,
53
+ assertSourceAcquisitionDestinationAvailable,
54
+ fingerprintSourceAcquisitionFacts,
55
+ fingerprintSourceAcquisitionPlan,
56
+ resolveSourceAcquisitionStatus
57
+ } from './sourceAcquisitionPolicy.js';
58
+ import {
59
+ SOURCE_INSTALLING_MARKER,
60
+ SOURCE_INSTALLING_MARKER_CONTENT
61
+ } from './sourcePolicy.js';
62
+ import { createSourceRecord, readSourceRecords } from './sourceRegistry.js';
63
+ import {
64
+ checksumDirectoryNormalized,
65
+ ensureSourceDirectory,
66
+ sourcePathExists
67
+ } from './sourceTree.js';
68
+ import { validateStagedSource } from './sourceValidation.js';
69
+ import {
70
+ createManagedSourceWorkspace,
71
+ removeManagedSourceWorkspace
72
+ } from './sourceWorkspace.js';
73
+
74
+ const sourceAcquisitionPlans = new WeakMap();
75
+ const SOURCE_INPUT_TYPES = new Set([
76
+ 'image',
77
+ 'git',
78
+ 'local-directory',
79
+ 'local-zip',
80
+ 'remote-file',
81
+ 'remote-zip'
82
+ ]);
83
+
84
+ export async function planSourceAcquisition(context, request) {
85
+ const acquisition = await buildSourceAcquisition(context, request);
86
+ sourceAcquisitionPlans.set(acquisition.plan, {
87
+ originalFingerprint: fingerprintSourceAcquisitionPlan(acquisition.plan),
88
+ factsFingerprint: acquisition.factsFingerprint,
89
+ request: snapshotRequest(request),
90
+ stalePlanMessage: acquisition.adapter.stalePlanMessage
91
+ });
92
+ return acquisition.plan;
93
+ }
94
+
95
+ export async function applySourceAcquisition(context, plan) {
96
+ const session = sourceAcquisitionPlans.get(plan);
97
+ if (!session) {
98
+ assertAcquisitionPlan(plan);
99
+ throw copiedPlanError(plan);
100
+ }
101
+ assertOriginalSourceAcquisitionPlan(
102
+ session.originalFingerprint,
103
+ plan,
104
+ session.stalePlanMessage
105
+ );
106
+ assertAcquisitionPlan(plan);
107
+
108
+ const acquisition = await buildSourceAcquisition(context, session.request);
109
+ assertSameSourceAcquisitionFacts(
110
+ session.factsFingerprint,
111
+ acquisition.plan,
112
+ session.stalePlanMessage
113
+ );
114
+ if (acquisition.plan.status === 'already-installed') {
115
+ return buildAcquisitionResult(acquisition.plan);
116
+ }
117
+
118
+ await runSourceAcquisition(context, acquisition);
119
+ return buildAcquisitionResult(acquisition.plan, 'added');
120
+ }
121
+
122
+ async function buildSourceAcquisition(context, request) {
123
+ if (request?.image) return inspectAcquisition(context, createImageAcquisitionAdapter(context, request));
124
+ if (isDefiniteGitSourceInput(request?.input)) {
125
+ return inspectAcquisition(
126
+ context,
127
+ createGitAcquisitionAdapter(context, request)
128
+ );
129
+ }
130
+ if (isRemoteSkillFileInput(request?.input)) {
131
+ return inspectAcquisition(
132
+ context,
133
+ createRemoteFileAcquisitionAdapter(context, request)
134
+ );
135
+ }
136
+ if (isRemoteArchiveInput(request?.input)) {
137
+ try {
138
+ return await inspectAcquisition(
139
+ context,
140
+ createRemoteArchiveAcquisitionAdapter(context, request)
141
+ );
142
+ } catch (error) {
143
+ if (!isAmbiguousHttpsSource(request.input)) throw error;
144
+ try {
145
+ return await inspectAcquisition(
146
+ context,
147
+ createGitAcquisitionAdapter(context, request)
148
+ );
149
+ } catch {
150
+ throw error;
151
+ }
152
+ }
153
+ }
154
+ return inspectAcquisition(
155
+ context,
156
+ createLocalAcquisitionAdapter(context, request)
157
+ );
158
+ }
159
+
160
+ async function inspectAcquisition(context, adapter) {
161
+ const candidate = await adapter.inspect();
162
+ const records = await readSourceRecords(context.rootDir);
163
+ const identity = await adapter.resolveIdentity(candidate, records);
164
+ const plan = buildSourceAcquisitionPlan(identity, candidate, 'ready');
165
+ plan.status = await resolveSourceAcquisitionStatus({
166
+ rootDir: context.rootDir,
167
+ plan,
168
+ adapter,
169
+ records
170
+ });
171
+ const factsFingerprint = fingerprintSourceAcquisitionFacts(plan);
172
+ return {
173
+ adapter,
174
+ plan,
175
+ factsFingerprint,
176
+ projectPlan: (prepared) => buildSourceAcquisitionPlan(
177
+ identity,
178
+ prepared,
179
+ plan.status
180
+ )
181
+ };
182
+ }
183
+
184
+ function buildSourceAcquisitionPlan(identity, candidate, status) {
185
+ return {
186
+ operation: 'add-source',
187
+ status,
188
+ sourceId: identity.sourceId,
189
+ installPath: identity.installPath,
190
+ input: candidate.input,
191
+ ...(candidate.origin ? { origin: candidate.origin } : {}),
192
+ ...(candidate.integrity ? { integrity: candidate.integrity } : {}),
193
+ ...(candidate.focus ? { focus: candidate.focus } : {}),
194
+ skills: candidate.skills,
195
+ warnings: candidate.warnings
196
+ };
197
+ }
198
+
199
+ function buildAcquisitionResult(plan, status = plan.status) {
200
+ return {
201
+ status,
202
+ sourceId: plan.sourceId,
203
+ installPath: plan.installPath,
204
+ input: plan.input,
205
+ ...(plan.origin ? { origin: plan.origin } : {}),
206
+ ...(plan.integrity ? { integrity: plan.integrity } : {}),
207
+ ...(plan.focus ? { focus: plan.focus } : {}),
208
+ skills: plan.skills,
209
+ warnings: plan.warnings
210
+ };
211
+ }
212
+
213
+ function assertAcquisitionPlan(plan) {
214
+ if (
215
+ !plan ||
216
+ plan.operation !== 'add-source' ||
217
+ !SOURCE_INPUT_TYPES.has(plan.input?.type) ||
218
+ !['ready', 'already-installed'].includes(plan.status)
219
+ ) {
220
+ throw new Error('A valid add-source plan is required');
221
+ }
222
+ }
223
+
224
+ function copiedPlanError(plan) {
225
+ return new Error(
226
+ plan.input?.type === 'git'
227
+ ? 'The Git add-source plan must be applied by the process that created it'
228
+ : 'The add-source plan must be applied by the process that created it'
229
+ );
230
+ }
231
+
232
+ function snapshotRequest(request) {
233
+ return {
234
+ input: request?.input,
235
+ ...(request?.image ? { image: structuredClone(request.image) } : {}),
236
+ ...(request?.name === undefined ? {} : { name: request.name }),
237
+ ...(request?.namespace === undefined
238
+ ? {}
239
+ : { namespace: request.namespace })
240
+ };
241
+ }
242
+
243
+ function isAmbiguousHttpsSource(input) {
244
+ if (!isGitSourceInput(input) || isDefiniteGitSourceInput(input)) return false;
245
+ let url;
246
+ try {
247
+ url = new URL(input);
248
+ } catch {
249
+ return false;
250
+ }
251
+ return !/\.zip$/i.test(url.pathname);
252
+ }
253
+
254
+ // === Private: Execution phase ===
255
+
256
+ const defaultSourceAcquisitionFileOperations = {
257
+ mkdir,
258
+ readdir,
259
+ rename,
260
+ rm,
261
+ writeFile
262
+ };
263
+
264
+ async function runSourceAcquisition(context, acquisition) {
265
+ const workspace = await createManagedSourceWorkspace(
266
+ context.rootDir,
267
+ 'add-'
268
+ );
269
+ const fileOperations = {
270
+ ...defaultSourceAcquisitionFileOperations,
271
+ ...context.acquisitionFileOperations
272
+ };
273
+ const publishRecord = context.createSourceRecord || createSourceRecord;
274
+ let destination;
275
+ let installed = false;
276
+
277
+ try {
278
+ const candidate = await acquisition.adapter.prepare(workspace.root);
279
+ assertSameSourceAcquisitionFacts(
280
+ acquisition.factsFingerprint,
281
+ acquisition.projectPlan(candidate),
282
+ acquisition.adapter.stalePlanMessage
283
+ );
284
+ await assertSourceAcquisitionDestinationAvailable({
285
+ rootDir: context.rootDir,
286
+ plan: acquisition.plan,
287
+ adapter: acquisition.adapter
288
+ });
289
+ await assertNoReservedPublicationMarker(candidate.contentRoot);
290
+ destination = await resolveSafeDestination(
291
+ context.rootDir,
292
+ acquisition.plan.installPath
293
+ );
294
+ await installPreparedDirectory({
295
+ preparedRoot: candidate.contentRoot,
296
+ destination,
297
+ fileOperations
298
+ });
299
+ installed = true;
300
+
301
+ try {
302
+ await publishRecord(
303
+ context.rootDir,
304
+ acquisition.adapter.buildRecord(acquisition.plan)
305
+ );
306
+ } catch (error) {
307
+ if (error.code === 'EEXIST') {
308
+ if (acquisition.plan.input?.type === 'image') {
309
+ // A missing receiver directory may still have its registry sidecar.
310
+ // The image restores bytes while the receiver keeps that sidecar.
311
+ return;
312
+ }
313
+ throw sourceCollisionError(
314
+ `Source identity was registered while applying: ${acquisition.plan.sourceId}`
315
+ );
316
+ }
317
+ throw error;
318
+ }
319
+ } catch (error) {
320
+ if (installed) {
321
+ await removeFailedPublication(fileOperations, destination, error);
322
+ }
323
+ throw error;
324
+ } finally {
325
+ await removeManagedSourceWorkspace(workspace);
326
+ }
327
+ }
328
+
329
+ async function installPreparedDirectory({
330
+ preparedRoot,
331
+ destination,
332
+ fileOperations
333
+ }) {
334
+ try {
335
+ await fileOperations.mkdir(destination);
336
+ } catch (error) {
337
+ if (error.code === 'EEXIST') {
338
+ throw sourceCollisionError(
339
+ `Source destination was occupied while applying: ${destination}`
340
+ );
341
+ }
342
+ throw error;
343
+ }
344
+
345
+ const markerPath = path.join(destination, SOURCE_INSTALLING_MARKER);
346
+ try {
347
+ await fileOperations.writeFile(
348
+ markerPath,
349
+ SOURCE_INSTALLING_MARKER_CONTENT,
350
+ { flag: 'wx' }
351
+ );
352
+ for (const entry of await fileOperations.readdir(
353
+ preparedRoot,
354
+ { withFileTypes: true }
355
+ )) {
356
+ await fileOperations.rename(
357
+ path.join(preparedRoot, entry.name),
358
+ path.join(destination, entry.name)
359
+ );
360
+ }
361
+ await fileOperations.rm(markerPath);
362
+ } catch (error) {
363
+ await removeFailedPublication(fileOperations, destination, error);
364
+ throw error;
365
+ }
366
+ }
367
+
368
+ async function removeFailedPublication(
369
+ fileOperations,
370
+ destination,
371
+ originalError
372
+ ) {
373
+ try {
374
+ await fileOperations.rm(destination, { recursive: true, force: true });
375
+ } catch (rollbackError) {
376
+ throw new AggregateError(
377
+ [originalError, rollbackError],
378
+ 'Source acquisition failed and rollback could not remove partial content'
379
+ );
380
+ }
381
+ }
382
+
383
+ async function resolveSafeDestination(rootDir, installPath) {
384
+ const segments = installPath.split('/');
385
+ const parentSegments = segments.slice(0, -1);
386
+ try {
387
+ const parent = await ensureSourceDirectory(rootDir, ...parentSegments);
388
+ return path.join(parent, segments.at(-1));
389
+ } catch (error) {
390
+ throw new SourceAcquisitionError('source-safety', error.message);
391
+ }
392
+ }
393
+
394
+ async function assertNoReservedPublicationMarker(contentRoot) {
395
+ if (!await sourcePathExists(
396
+ path.join(contentRoot, SOURCE_INSTALLING_MARKER)
397
+ )) {
398
+ return;
399
+ }
400
+ throw new SourceAcquisitionError(
401
+ 'source-validation',
402
+ `Source contains reserved publication marker: ${SOURCE_INSTALLING_MARKER}`
403
+ );
404
+ }
405
+
406
+ // Image input is internal to the library-image transaction. It preserves the
407
+ // original record rather than converting upstream-less content into Local input.
408
+ function createImageAcquisitionAdapter(context, request) {
409
+ const record = validateSourceRecord(context.rootDir, request.image.record);
410
+ const contentRoot = path.resolve(request.image.contentRoot);
411
+ const expectedHead = request.image.head || record.origin?.commit;
412
+ async function assertStagedContentRoot(root) {
413
+ const [rootReal, contentReal] = await Promise.all([
414
+ realpath(path.resolve(context.rootDir)),
415
+ realpath(root)
416
+ ]);
417
+ const relative = path.relative(rootReal, contentReal);
418
+ if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) {
419
+ throw new SourceAcquisitionError('source-safety', 'Image source content must be staged below the central-library root');
420
+ }
421
+ }
422
+ async function inspect(root = contentRoot) {
423
+ await assertStagedContentRoot(root);
424
+ const validation = await validateStagedSource(root);
425
+ const discoveredSkills = validation.skills
426
+ .map((skill) => path.posix.normalize(skill).normalize('NFC'))
427
+ .sort();
428
+ const registeredSkills = record.skills
429
+ .map((skill) => path.posix.normalize(skill).normalize('NFC'))
430
+ .sort();
431
+ if (JSON.stringify(discoveredSkills) !== JSON.stringify(registeredSkills)) {
432
+ throw new SourceAcquisitionError('source-validation', `Staged source skills differ from its registry record: ${record.sourceId}`);
433
+ }
434
+ const input = record.type === 'git'
435
+ ? { type: 'image', name: record.sourceId, head: expectedHead || await readGitHead(root) }
436
+ : { type: 'image', name: record.sourceId, digest: await checksumDirectoryNormalized(root) };
437
+ return {
438
+ input,
439
+ origin: record.origin, integrity: record.integrity, focus: record.focus,
440
+ skills: record.skills, warnings: validation.warnings, contentRoot: root
441
+ };
442
+ }
443
+ return defineSourceAcquisitionAdapter({
444
+ stalePlanMessage: 'Staged image source changed since planning; import the image again.',
445
+ inspect,
446
+ async prepare(workspaceRoot) {
447
+ const prepared = path.join(workspaceRoot, 'content');
448
+ await cp(contentRoot, prepared, { recursive: true, verbatimSymlinks: true });
449
+ return inspect(prepared);
450
+ },
451
+ resolveIdentity: () => ({ sourceId: record.sourceId, installPath: record.installPath }),
452
+ buildRecord: () => record,
453
+ matchesIdentity: (existing) => existing.installPath === record.installPath,
454
+ matchesContent: () => true,
455
+ async assertInstalled(rootDir, existing, plan) {
456
+ const installed = path.join(rootDir, existing.installPath.normalize('NFC'));
457
+ if (!await sourcePathExists(installed)) {
458
+ throw imageSourceMismatchError(`Existing source is missing: ${record.sourceId}.`);
459
+ }
460
+ if (record.type === 'git') {
461
+ let head;
462
+ try { head = await readGitHead(installed); } catch {
463
+ throw imageSourceMismatchError(`Existing Git source cannot report HEAD: ${record.sourceId}.`);
464
+ }
465
+ if (!plan.input.head || head !== plan.input.head) {
466
+ throw imageSourceMismatchError(`Existing Git source has a different HEAD: ${record.sourceId}. Import never replaces source bytes.`);
467
+ }
468
+ return;
469
+ }
470
+ if (await checksumDirectoryNormalized(installed) !== plan.input.digest) {
471
+ throw imageSourceMismatchError(`Existing source differs: ${record.sourceId}. Import never replaces source bytes.`);
472
+ }
473
+ }
474
+ });
475
+ }
@@ -0,0 +1,47 @@
1
+ const REQUIRED_ADAPTER_METHODS = [
2
+ 'inspect',
3
+ 'prepare',
4
+ 'resolveIdentity',
5
+ 'buildRecord',
6
+ 'matchesIdentity',
7
+ 'matchesContent',
8
+ 'assertInstalled'
9
+ ];
10
+
11
+ /**
12
+ * @typedef {object} SourceAcquisitionAdapter
13
+ * @property {string} stalePlanMessage
14
+ * @property {() => Promise<object>} inspect
15
+ * @property {(workspaceRoot: string) => Promise<object>} prepare
16
+ * @property {(candidate: object, records: object[]) => Promise<object>|object} resolveIdentity
17
+ * @property {(plan: object) => object} buildRecord
18
+ * @property {(record: object, plan: object) => boolean} matchesIdentity
19
+ * @property {(record: object, plan: object) => boolean} matchesContent
20
+ * @property {(rootDir: string, record: object, plan: object) => Promise<void>} assertInstalled
21
+ */
22
+
23
+ /**
24
+ * @param {SourceAcquisitionAdapter} adapter
25
+ * @returns {Readonly<SourceAcquisitionAdapter>}
26
+ */
27
+ export function defineSourceAcquisitionAdapter(adapter) {
28
+ if (!adapter || typeof adapter !== 'object' || Array.isArray(adapter)) {
29
+ throw new TypeError('Source acquisition adapter must be an object');
30
+ }
31
+ if (
32
+ typeof adapter.stalePlanMessage !== 'string' ||
33
+ !adapter.stalePlanMessage.trim()
34
+ ) {
35
+ throw new TypeError(
36
+ 'Source acquisition adapter requires stalePlanMessage'
37
+ );
38
+ }
39
+ for (const method of REQUIRED_ADAPTER_METHODS) {
40
+ if (typeof adapter[method] !== 'function') {
41
+ throw new TypeError(
42
+ `Source acquisition adapter requires ${method}()`
43
+ );
44
+ }
45
+ }
46
+ return Object.freeze(adapter);
47
+ }
@@ -0,0 +1,18 @@
1
+ export class SourceAcquisitionError extends Error {
2
+ constructor(category, message, exitCode = 1) {
3
+ super(message);
4
+ this.name = 'SourceAcquisitionError';
5
+ this.category = category;
6
+ this.exitCode = exitCode;
7
+ }
8
+ }
9
+
10
+ export function sourceCollisionError(message, registeredSourceId) {
11
+ const error = new SourceAcquisitionError('source-collision', message, 3);
12
+ if (registeredSourceId) error.registeredSourceId = registeredSourceId;
13
+ return error;
14
+ }
15
+
16
+ export function imageSourceMismatchError(message) {
17
+ return new SourceAcquisitionError('image-source-mismatch', message);
18
+ }
@@ -0,0 +1,139 @@
1
+ import path from 'node:path';
2
+ import { canonicalJson } from './canonicalJson.js';
3
+ import { SourceAcquisitionError, sourceCollisionError } from './sourceAcquisitionError.js';
4
+ import { readSourceRecords } from './sourceRegistry.js';
5
+ import {
6
+ checksumDirectory,
7
+ sourcePathExists
8
+ } from './sourceTree.js';
9
+
10
+ export function fingerprintSourceAcquisitionPlan(plan) {
11
+ return canonicalJson(projectPlanFacts(plan, true));
12
+ }
13
+
14
+ export function fingerprintSourceAcquisitionFacts(plan) {
15
+ return canonicalJson(projectPlanFacts(plan, false));
16
+ }
17
+
18
+ export function assertOriginalSourceAcquisitionPlan(fingerprint, plan, message) {
19
+ if (fingerprint !== fingerprintSourceAcquisitionPlan(plan)) {
20
+ throw new SourceAcquisitionError('stale-plan', message);
21
+ }
22
+ }
23
+
24
+ export function assertSameSourceAcquisitionFacts(fingerprint, plan, message) {
25
+ if (fingerprint !== fingerprintSourceAcquisitionFacts(plan)) {
26
+ throw new SourceAcquisitionError('stale-plan', message);
27
+ }
28
+ }
29
+
30
+ export async function resolveSourceAcquisitionStatus({
31
+ rootDir,
32
+ plan,
33
+ adapter,
34
+ records = null
35
+ }) {
36
+ const currentRecords = records || await readSourceRecords(rootDir);
37
+ const sameIdentity = currentRecords.find(
38
+ (record) => record.sourceId === plan.sourceId
39
+ );
40
+
41
+ if (sameIdentity) {
42
+ if (!adapter.matchesIdentity(sameIdentity, plan)) {
43
+ throw sourceCollisionError(
44
+ `Source identity is registered with incompatible source facts: ${plan.sourceId}.`
45
+ );
46
+ }
47
+ // A library image is allowed to repair a receiver whose registry record
48
+ // survived while its source directory did not. Keep the existing sidecar
49
+ // and restore only the missing bytes; ordinary acquisition inputs retain
50
+ // their existing collision semantics.
51
+ if (
52
+ plan.input?.type === 'image' &&
53
+ !await sourcePathExists(resolveSourceInstallDestination(rootDir, sameIdentity.installPath))
54
+ ) {
55
+ return 'ready';
56
+ }
57
+ if (!adapter.matchesContent(sameIdentity, plan)) {
58
+ throw sourceCollisionError(
59
+ `Source identity already exists with different content: ${plan.sourceId}. Use source update${
60
+ plan.input.type === 'local-directory' || plan.input.type === 'local-zip'
61
+ ? ' or another namespace'
62
+ : ''
63
+ }.`,
64
+ plan.sourceId
65
+ );
66
+ }
67
+ await adapter.assertInstalled(rootDir, sameIdentity, plan);
68
+ return 'already-installed';
69
+ }
70
+
71
+ const destination = resolveSourceInstallDestination(
72
+ rootDir,
73
+ plan.installPath
74
+ );
75
+ if (
76
+ currentRecords.some((record) => record.installPath === plan.installPath) ||
77
+ await sourcePathExists(destination)
78
+ ) {
79
+ throw sourceCollisionError(
80
+ `Source destination collision: ${plan.installPath}${
81
+ plan.input.type === 'local-directory' || plan.input.type === 'local-zip'
82
+ ? '. Use --namespace to choose another identity.'
83
+ : '.'
84
+ }`
85
+ );
86
+ }
87
+ return 'ready';
88
+ }
89
+
90
+ export async function assertSourceAcquisitionDestinationAvailable(options) {
91
+ if (await resolveSourceAcquisitionStatus(options) !== 'ready') {
92
+ throw sourceCollisionError(
93
+ `Source was installed while applying: ${options.plan.sourceId}`
94
+ );
95
+ }
96
+ }
97
+
98
+ export async function assertDirectorySourceMatchesRecord(
99
+ rootDir,
100
+ record
101
+ ) {
102
+ const destination = resolveSourceInstallDestination(
103
+ rootDir,
104
+ record.installPath
105
+ );
106
+ if (!(await sourcePathExists(destination))) {
107
+ throw sourceCollisionError(
108
+ `Registered source content is missing: ${record.sourceId}`
109
+ );
110
+ }
111
+ if (await checksumDirectory(destination) !== record.integrity?.value) {
112
+ throw sourceCollisionError(
113
+ `Registered source content no longer matches its integrity: ${record.sourceId}`
114
+ );
115
+ }
116
+ }
117
+
118
+ export function sameJson(left, right) {
119
+ return canonicalJson(left) === canonicalJson(right);
120
+ }
121
+
122
+ export function resolveSourceInstallDestination(rootDir, installPath) {
123
+ return path.join(rootDir, ...installPath.split('/'));
124
+ }
125
+
126
+ function projectPlanFacts(plan, includeStatus) {
127
+ return {
128
+ operation: plan?.operation,
129
+ ...(includeStatus ? { status: plan?.status } : {}),
130
+ sourceId: plan?.sourceId,
131
+ installPath: plan?.installPath,
132
+ input: plan?.input,
133
+ origin: plan?.origin,
134
+ integrity: plan?.integrity,
135
+ focus: plan?.focus,
136
+ skills: plan?.skills,
137
+ warnings: plan?.warnings
138
+ };
139
+ }