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.
- package/CHANGELOG.md +497 -0
- package/CONTEXT.md +149 -0
- package/LICENSE +21 -0
- package/README.md +499 -0
- package/README_CN.md +482 -0
- package/collection-metadata/github/mattpocock.json +16 -0
- package/lib/canonicalJson.js +11 -0
- package/lib/claudeStore.js +135 -0
- package/lib/collectionEnablement.js +115 -0
- package/lib/collectionSetup.js +199 -0
- package/lib/enablePlan.js +116 -0
- package/lib/enablementScope.js +8 -0
- package/lib/hermesStore.js +21 -0
- package/lib/libraryImage.js +1785 -0
- package/lib/managerInstall.js +109 -0
- package/lib/managerSourceWorkflow.js +234 -0
- package/lib/projectActions.js +24 -0
- package/lib/projectPath.js +51 -0
- package/lib/roots.js +99 -0
- package/lib/skillLinkStore.js +68 -0
- package/lib/skillMetadata.js +191 -0
- package/lib/skillStore.js +541 -0
- package/lib/sourceAcquisition.js +475 -0
- package/lib/sourceAcquisitionAdapter.js +47 -0
- package/lib/sourceAcquisitionError.js +18 -0
- package/lib/sourceAcquisitionPolicy.js +139 -0
- package/lib/sourceArchive.js +363 -0
- package/lib/sourceArchiveWorkspace.js +36 -0
- package/lib/sourceGit.js +95 -0
- package/lib/sourceGitAcquisitionAdapter.js +145 -0
- package/lib/sourceGitBatchUpdate.js +168 -0
- package/lib/sourceGitRepair.js +227 -0
- package/lib/sourceGitStaging.js +139 -0
- package/lib/sourceGitUpgradeAdapter.js +583 -0
- package/lib/sourceGitUrl.js +170 -0
- package/lib/sourceHttp.js +302 -0
- package/lib/sourceLocal.js +200 -0
- package/lib/sourceLocalAcquisitionAdapter.js +107 -0
- package/lib/sourceManager.js +242 -0
- package/lib/sourceMigration.js +172 -0
- package/lib/sourceMigrationIssue.js +6 -0
- package/lib/sourcePath.js +6 -0
- package/lib/sourcePolicy.js +53 -0
- package/lib/sourceRecord.js +295 -0
- package/lib/sourceRegistry.js +155 -0
- package/lib/sourceRemoteArchiveAcquisitionAdapter.js +147 -0
- package/lib/sourceRemoteFileAcquisitionAdapter.js +95 -0
- package/lib/sourceRemoteFilePolicy.js +62 -0
- package/lib/sourceRemoteFileWorkspace.js +55 -0
- package/lib/sourceSkillDiscovery.js +117 -0
- package/lib/sourceTree.js +185 -0
- package/lib/sourceUpgrade.js +596 -0
- package/lib/sourceUpgradeAdapter.js +41 -0
- package/lib/sourceUpgradePolicy.js +170 -0
- package/lib/sourceUpgradeTransaction.js +202 -0
- package/lib/sourceValidation.js +120 -0
- package/lib/sourceWorkspace.js +37 -0
- package/lib/specGate.js +235 -0
- package/lib/tuiActions.js +169 -0
- package/lib/tuiInstall.js +133 -0
- package/lib/tuiLayout.js +94 -0
- package/lib/version.js +17 -0
- package/lib/webManager.js +370 -0
- package/package.json +74 -0
- package/public/agentsUi.js +138 -0
- package/public/app.js +1431 -0
- package/public/claude.css +2 -0
- package/public/claudeUi.js +119 -0
- package/public/emptyState.js +12 -0
- package/public/enabled.css +303 -0
- package/public/favicon.svg +5 -0
- package/public/index.html +199 -0
- package/public/skillcaddy_CN.png +0 -0
- package/public/skillcaddy_EN.png +0 -0
- package/public/styles.css +1611 -0
- package/scripts/cli.js +647 -0
- package/scripts/cron-sync-update-git.sh +128 -0
- package/scripts/pull-github.sh +22 -0
- package/scripts/source.js +638 -0
- package/scripts/tui.js +1058 -0
- package/server.js +196 -0
- package/skills/skillcaddy-manager/SKILL.md +93 -0
- package/skills/skillcaddy-manager/agents/openai.yaml +7 -0
- package/skills/skillcaddy-manager/references/METADATA.md +75 -0
- package/skills/skillcaddy-manager/references/OPERATIONS.md +315 -0
- package/skills/skillcaddy-manager/references/RECOMMENDATION_GUIDE.md +51 -0
- package/skills/skillcaddy-manager/references/featured-skills.json +564 -0
- package/skills/skillcaddy-manager/references/skill-platforms.json +48 -0
- package/skills/skillcaddy-manager/scripts/check-conflicts.cjs +117 -0
- package/skills/skillcaddy-manager/scripts/check-global-skills.cjs +169 -0
- package/skills/skillcaddy-manager/scripts/translate-skill-notes.cjs +297 -0
- package/skills/skillcaddy-manager/scripts/version-manager.cjs +146 -0
- package/skills/skillcaddy-manager/scripts/view-recommendations.cjs +279 -0
- package/skills/skillcaddy-manager/skillcaddy.json +9 -0
- package/skills/spec-pipeline/spec-direction-gate/SKILL.md +119 -0
- package/skills/spec-pipeline/spec-direction-gate/check-approval.py +110 -0
- package/skills/spec-pipeline/spec-draft-intake/SKILL.md +116 -0
- package/skills/spec-pipeline/spec-role-contract/SKILL.md +113 -0
- 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
|
+
}
|