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,583 @@
|
|
|
1
|
+
import { execFile as execFileCallback } from 'node:child_process';
|
|
2
|
+
import {
|
|
3
|
+
mkdtemp,
|
|
4
|
+
rm
|
|
5
|
+
} from 'node:fs/promises';
|
|
6
|
+
import { tmpdir } from 'node:os';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { setTimeout as wait } from 'node:timers/promises';
|
|
9
|
+
import { promisify } from 'node:util';
|
|
10
|
+
import { SourceAcquisitionError } from './sourceAcquisitionError.js';
|
|
11
|
+
import { canonicalGitRepositoryLocation } from './sourceGitUrl.js';
|
|
12
|
+
import { SOURCE_INSTALLING_MARKER } from './sourcePolicy.js';
|
|
13
|
+
import { sanitizeGitRemote } from './sourceRecord.js';
|
|
14
|
+
import { sourcePathExists, validateSourceTree } from './sourceTree.js';
|
|
15
|
+
import { validateStagedSource } from './sourceValidation.js';
|
|
16
|
+
import { defineSourceUpgradeAdapter } from './sourceUpgradeAdapter.js';
|
|
17
|
+
|
|
18
|
+
const execFile = promisify(execFileCallback);
|
|
19
|
+
const DEFAULT_FETCH_RETRY_DELAYS_MS = Object.freeze([2000, 5000]);
|
|
20
|
+
|
|
21
|
+
export function createGitUpgradeAdapter(context, request, record) {
|
|
22
|
+
if (request?.input !== undefined) {
|
|
23
|
+
throw new Error('Git source update does not accept replacement input');
|
|
24
|
+
}
|
|
25
|
+
assertGitSourceRecord(record);
|
|
26
|
+
|
|
27
|
+
return defineSourceUpgradeAdapter({
|
|
28
|
+
stalePlanMessage: 'Git source changed since the update plan',
|
|
29
|
+
kind: 'git',
|
|
30
|
+
inspect: () => inspectGitCandidate(context, record),
|
|
31
|
+
prepare: () => inspectGitCandidate(context, record)
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function inspectGitRegistryRepair(context, record) {
|
|
36
|
+
const { rootDir } = context;
|
|
37
|
+
const destination = path.resolve(rootDir, record.installPath);
|
|
38
|
+
if (!(await sourcePathExists(destination))) {
|
|
39
|
+
throw gitUpgradeError(
|
|
40
|
+
'source-collision',
|
|
41
|
+
`Registered source content is missing: ${record.sourceId}`,
|
|
42
|
+
3
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
if (!record.origin.commit) {
|
|
46
|
+
throw gitUpgradeError(
|
|
47
|
+
'unresolved-identity',
|
|
48
|
+
`Git source registry repair requires a registered commit: ${record.sourceId}`,
|
|
49
|
+
3
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
if (!record.origin.ref) {
|
|
53
|
+
throw gitUpgradeError(
|
|
54
|
+
'unresolved-identity',
|
|
55
|
+
`Git source registry repair requires a tracked ref: ${record.sourceId}`,
|
|
56
|
+
3
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const currentCommit = await readGitOutput(
|
|
61
|
+
destination,
|
|
62
|
+
['rev-parse', 'HEAD'],
|
|
63
|
+
record.sourceId
|
|
64
|
+
);
|
|
65
|
+
await assertTrackedRef(destination, record);
|
|
66
|
+
await assertRegisteredRemote(destination, record);
|
|
67
|
+
|
|
68
|
+
const status = await readGitOutput(
|
|
69
|
+
destination,
|
|
70
|
+
['status', '--porcelain'],
|
|
71
|
+
record.sourceId
|
|
72
|
+
);
|
|
73
|
+
if (status) {
|
|
74
|
+
return {
|
|
75
|
+
status: 'dirty',
|
|
76
|
+
currentCommit,
|
|
77
|
+
skills: record.skills,
|
|
78
|
+
warnings: []
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
await validateSourceTree(rootDir, destination);
|
|
83
|
+
const validated = await validateStagedSource(destination);
|
|
84
|
+
assertFocusContainsSkill(validated.skills, record.focus);
|
|
85
|
+
|
|
86
|
+
if (currentCommit === record.origin.commit) {
|
|
87
|
+
if (JSON.stringify(record.skills) !== JSON.stringify(validated.skills)) {
|
|
88
|
+
throw gitUpgradeError(
|
|
89
|
+
'source-collision',
|
|
90
|
+
`Registered Git source skills do not match its source record: ${record.sourceId}`,
|
|
91
|
+
3
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
return {
|
|
95
|
+
status: 'current',
|
|
96
|
+
currentCommit,
|
|
97
|
+
skills: validated.skills,
|
|
98
|
+
warnings: validated.warnings
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
await fetchGit(
|
|
103
|
+
context,
|
|
104
|
+
destination,
|
|
105
|
+
['fetch', '--no-tags', 'origin', `refs/heads/${record.origin.ref}`],
|
|
106
|
+
record.sourceId,
|
|
107
|
+
'Could not fetch Git source for registry repair'
|
|
108
|
+
);
|
|
109
|
+
const remoteCommit = await readGitOutput(
|
|
110
|
+
destination,
|
|
111
|
+
['rev-parse', 'FETCH_HEAD'],
|
|
112
|
+
record.sourceId
|
|
113
|
+
);
|
|
114
|
+
if (currentCommit !== remoteCommit) {
|
|
115
|
+
throw gitUpgradeError(
|
|
116
|
+
'source-not-synchronized',
|
|
117
|
+
`Git source checkout is not at origin/${record.origin.ref}; run git pull before registry repair: ${record.sourceId}`
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
if (!await isAncestor(destination, record.origin.commit, currentCommit, record.sourceId)) {
|
|
121
|
+
throw gitUpgradeError(
|
|
122
|
+
'non-fast-forward',
|
|
123
|
+
`Git source registry cannot adopt a non-fast-forward checkout: ${record.sourceId}`
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return {
|
|
128
|
+
status: 'ready',
|
|
129
|
+
currentCommit,
|
|
130
|
+
skills: validated.skills,
|
|
131
|
+
warnings: validated.warnings
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function inspectGitCandidate(context, record) {
|
|
136
|
+
const { rootDir } = context;
|
|
137
|
+
const destination = path.resolve(rootDir, record.installPath);
|
|
138
|
+
if (!(await sourcePathExists(destination))) {
|
|
139
|
+
throw gitUpgradeError(
|
|
140
|
+
'source-collision',
|
|
141
|
+
`Registered source content is missing: ${record.sourceId}`,
|
|
142
|
+
3
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const currentCommit = await readGitOutput(
|
|
147
|
+
destination,
|
|
148
|
+
['rev-parse', 'HEAD'],
|
|
149
|
+
record.sourceId
|
|
150
|
+
);
|
|
151
|
+
await assertTrackedRef(destination, record);
|
|
152
|
+
await assertRegisteredRemote(destination, record);
|
|
153
|
+
|
|
154
|
+
const status = await readGitOutput(
|
|
155
|
+
destination,
|
|
156
|
+
['status', '--porcelain'],
|
|
157
|
+
record.sourceId
|
|
158
|
+
);
|
|
159
|
+
if (status) {
|
|
160
|
+
return buildCandidate({
|
|
161
|
+
record,
|
|
162
|
+
status: 'dirty',
|
|
163
|
+
currentCommit,
|
|
164
|
+
incomingCommit: currentCommit,
|
|
165
|
+
skills: record.skills,
|
|
166
|
+
warnings: []
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const fetchContext = record.origin.commit === currentCommit
|
|
171
|
+
? context
|
|
172
|
+
: { ...context, gitFetchRetryDelaysMs: [] };
|
|
173
|
+
try {
|
|
174
|
+
await fetchGit(
|
|
175
|
+
fetchContext,
|
|
176
|
+
destination,
|
|
177
|
+
['fetch', '--no-tags', 'origin', `refs/heads/${record.origin.ref}`],
|
|
178
|
+
record.sourceId,
|
|
179
|
+
'Could not fetch Git source'
|
|
180
|
+
);
|
|
181
|
+
} catch (error) {
|
|
182
|
+
if (record.origin.commit !== currentCommit) {
|
|
183
|
+
assertRecordedCommit(record, currentCommit);
|
|
184
|
+
}
|
|
185
|
+
throw error;
|
|
186
|
+
}
|
|
187
|
+
const incomingCommit = await readGitOutput(
|
|
188
|
+
destination,
|
|
189
|
+
['rev-parse', 'FETCH_HEAD'],
|
|
190
|
+
record.sourceId
|
|
191
|
+
);
|
|
192
|
+
if (incomingCommit === currentCommit) {
|
|
193
|
+
assertRecordedCommit(record, currentCommit);
|
|
194
|
+
return buildCandidate({
|
|
195
|
+
record,
|
|
196
|
+
status: 'current',
|
|
197
|
+
currentCommit,
|
|
198
|
+
incomingCommit,
|
|
199
|
+
skills: record.skills,
|
|
200
|
+
warnings: []
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (!await isAncestor(
|
|
205
|
+
destination,
|
|
206
|
+
currentCommit,
|
|
207
|
+
incomingCommit,
|
|
208
|
+
record.sourceId
|
|
209
|
+
)) {
|
|
210
|
+
throw gitUpgradeError(
|
|
211
|
+
'non-fast-forward',
|
|
212
|
+
`Git source cannot advance by fast-forward: ${record.sourceId}`
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
assertRecordedCommit(record, currentCommit);
|
|
216
|
+
const prepared = await inspectGitTrees(
|
|
217
|
+
destination,
|
|
218
|
+
currentCommit,
|
|
219
|
+
incomingCommit,
|
|
220
|
+
record
|
|
221
|
+
);
|
|
222
|
+
if (JSON.stringify(prepared.current.skills) !== JSON.stringify(record.skills)) {
|
|
223
|
+
throw gitUpgradeError(
|
|
224
|
+
'source-collision',
|
|
225
|
+
`Registered Git source skills do not match its source record: ${
|
|
226
|
+
record.sourceId
|
|
227
|
+
}`,
|
|
228
|
+
3
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
const changedFiles = await readChangedFiles(
|
|
232
|
+
destination,
|
|
233
|
+
currentCommit,
|
|
234
|
+
incomingCommit,
|
|
235
|
+
record.sourceId
|
|
236
|
+
);
|
|
237
|
+
return buildCandidate({
|
|
238
|
+
record,
|
|
239
|
+
status: 'ready',
|
|
240
|
+
currentCommit,
|
|
241
|
+
incomingCommit,
|
|
242
|
+
skills: prepared.incoming.skills,
|
|
243
|
+
warnings: prepared.incoming.warnings,
|
|
244
|
+
skillChanges: classifySkillChanges(
|
|
245
|
+
prepared.current.skills,
|
|
246
|
+
prepared.incoming.skills,
|
|
247
|
+
changedFiles
|
|
248
|
+
)
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
async function assertTrackedRef(destination, record) {
|
|
253
|
+
const branch = await readGitOutput(
|
|
254
|
+
destination,
|
|
255
|
+
['symbolic-ref', '--quiet', '--short', 'HEAD'],
|
|
256
|
+
record.sourceId
|
|
257
|
+
);
|
|
258
|
+
if (branch !== record.origin.ref) {
|
|
259
|
+
throw gitUpgradeError(
|
|
260
|
+
'source-collision',
|
|
261
|
+
`Registered Git source is not on its tracked ref: ${record.sourceId}`,
|
|
262
|
+
3
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
async function assertRegisteredRemote(destination, record) {
|
|
268
|
+
const configuredRemote = sanitizeGitRemote(
|
|
269
|
+
await readGitOutput(
|
|
270
|
+
destination,
|
|
271
|
+
['config', '--get', 'remote.origin.url'],
|
|
272
|
+
record.sourceId
|
|
273
|
+
)
|
|
274
|
+
);
|
|
275
|
+
if (
|
|
276
|
+
canonicalGitRepositoryLocation(configuredRemote) !==
|
|
277
|
+
canonicalGitRepositoryLocation(record.origin.remote)
|
|
278
|
+
) {
|
|
279
|
+
throw gitUpgradeError(
|
|
280
|
+
'source-collision',
|
|
281
|
+
`Registered Git source origin does not match its source record: ${
|
|
282
|
+
record.sourceId
|
|
283
|
+
}`,
|
|
284
|
+
3
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function assertRecordedCommit(record, currentCommit) {
|
|
290
|
+
if (record.origin.commit !== currentCommit) {
|
|
291
|
+
throw gitUpgradeError(
|
|
292
|
+
'source-collision',
|
|
293
|
+
`Registered Git source commit does not match its source record: ${
|
|
294
|
+
record.sourceId
|
|
295
|
+
}`,
|
|
296
|
+
3
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
async function inspectGitTrees(
|
|
302
|
+
destination,
|
|
303
|
+
currentCommit,
|
|
304
|
+
incomingCommit,
|
|
305
|
+
record
|
|
306
|
+
) {
|
|
307
|
+
const stagingRoot = await mkdtemp(
|
|
308
|
+
path.join(tmpdir(), 'skillcaddy-git-update-')
|
|
309
|
+
);
|
|
310
|
+
const contentRoot = path.join(stagingRoot, 'repository');
|
|
311
|
+
try {
|
|
312
|
+
await execGit(
|
|
313
|
+
stagingRoot,
|
|
314
|
+
['clone', '--quiet', '--no-checkout', '--', destination, contentRoot],
|
|
315
|
+
record.sourceId,
|
|
316
|
+
'Could not inspect incoming Git source'
|
|
317
|
+
);
|
|
318
|
+
const current = await checkoutAndValidateTree({
|
|
319
|
+
stagingRoot,
|
|
320
|
+
contentRoot,
|
|
321
|
+
commit: currentCommit,
|
|
322
|
+
record
|
|
323
|
+
});
|
|
324
|
+
const incoming = currentCommit === incomingCommit
|
|
325
|
+
? current
|
|
326
|
+
: await checkoutAndValidateTree({
|
|
327
|
+
stagingRoot,
|
|
328
|
+
contentRoot,
|
|
329
|
+
commit: incomingCommit,
|
|
330
|
+
record
|
|
331
|
+
});
|
|
332
|
+
return { current, incoming };
|
|
333
|
+
} finally {
|
|
334
|
+
await rm(stagingRoot, { recursive: true, force: true });
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
async function checkoutAndValidateTree({
|
|
339
|
+
stagingRoot,
|
|
340
|
+
contentRoot,
|
|
341
|
+
commit,
|
|
342
|
+
record
|
|
343
|
+
}) {
|
|
344
|
+
await execGit(
|
|
345
|
+
contentRoot,
|
|
346
|
+
['checkout', '--quiet', '--detach', commit],
|
|
347
|
+
record.sourceId,
|
|
348
|
+
'Could not inspect Git source tree'
|
|
349
|
+
);
|
|
350
|
+
if (await sourcePathExists(path.join(contentRoot, SOURCE_INSTALLING_MARKER))) {
|
|
351
|
+
throw gitUpgradeError(
|
|
352
|
+
'source-validation',
|
|
353
|
+
`Git source contains reserved publication marker: ${
|
|
354
|
+
SOURCE_INSTALLING_MARKER
|
|
355
|
+
}`
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
await validateSourceTree(stagingRoot, contentRoot);
|
|
359
|
+
const validated = await validateStagedSource(contentRoot);
|
|
360
|
+
assertFocusContainsSkill(validated.skills, record.focus);
|
|
361
|
+
return validated;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function buildCandidate({
|
|
365
|
+
record,
|
|
366
|
+
status,
|
|
367
|
+
currentCommit,
|
|
368
|
+
incomingCommit,
|
|
369
|
+
skills,
|
|
370
|
+
warnings,
|
|
371
|
+
skillChanges
|
|
372
|
+
}) {
|
|
373
|
+
return {
|
|
374
|
+
status,
|
|
375
|
+
input: {
|
|
376
|
+
type: 'git',
|
|
377
|
+
remote: record.origin.remote
|
|
378
|
+
},
|
|
379
|
+
currentCommit,
|
|
380
|
+
incomingCommit,
|
|
381
|
+
skills,
|
|
382
|
+
warnings,
|
|
383
|
+
...(skillChanges ? { skillChanges } : {})
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
async function readChangedFiles(
|
|
388
|
+
destination,
|
|
389
|
+
currentCommit,
|
|
390
|
+
incomingCommit,
|
|
391
|
+
sourceId
|
|
392
|
+
) {
|
|
393
|
+
const result = await execGit(
|
|
394
|
+
destination,
|
|
395
|
+
[
|
|
396
|
+
'diff',
|
|
397
|
+
'--name-only',
|
|
398
|
+
'--no-renames',
|
|
399
|
+
'-z',
|
|
400
|
+
currentCommit,
|
|
401
|
+
incomingCommit,
|
|
402
|
+
'--'
|
|
403
|
+
],
|
|
404
|
+
sourceId,
|
|
405
|
+
'Could not summarize Git source changes'
|
|
406
|
+
);
|
|
407
|
+
return result.stdout.split('\0').filter(Boolean);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function classifySkillChanges(previousSkills, nextSkills, changedFiles) {
|
|
411
|
+
const previous = new Set(previousSkills);
|
|
412
|
+
const next = new Set(nextSkills);
|
|
413
|
+
const changedSkill = (skillPath) => changedFiles.some((filePath) =>
|
|
414
|
+
skillPath === '.' || filePath === skillPath || filePath.startsWith(`${skillPath}/`)
|
|
415
|
+
);
|
|
416
|
+
|
|
417
|
+
return {
|
|
418
|
+
added: nextSkills.filter((skillPath) => !previous.has(skillPath)),
|
|
419
|
+
edited: nextSkills.filter((skillPath) =>
|
|
420
|
+
previous.has(skillPath) && changedSkill(skillPath)
|
|
421
|
+
),
|
|
422
|
+
deleted: previousSkills.filter((skillPath) => !next.has(skillPath))
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function assertGitSourceRecord(record) {
|
|
427
|
+
if (record.type !== 'git') {
|
|
428
|
+
throw gitUpgradeError(
|
|
429
|
+
'unsupported-source',
|
|
430
|
+
`Git update requires a Git source: ${record.sourceId}`
|
|
431
|
+
);
|
|
432
|
+
}
|
|
433
|
+
if (!record.origin.ref || !record.origin.commit) {
|
|
434
|
+
throw gitUpgradeError(
|
|
435
|
+
'unresolved-identity',
|
|
436
|
+
`Git source record requires a tracked ref and commit: ${record.sourceId}`,
|
|
437
|
+
3
|
|
438
|
+
);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
async function readGitOutput(directory, args, sourceId) {
|
|
443
|
+
const result = await execGit(
|
|
444
|
+
directory,
|
|
445
|
+
args,
|
|
446
|
+
sourceId,
|
|
447
|
+
'Could not inspect Git source'
|
|
448
|
+
);
|
|
449
|
+
return result.stdout.trim();
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
async function isAncestor(directory, ancestor, descendant, sourceId) {
|
|
453
|
+
try {
|
|
454
|
+
await execFile('git', [
|
|
455
|
+
'-C',
|
|
456
|
+
directory,
|
|
457
|
+
'merge-base',
|
|
458
|
+
'--is-ancestor',
|
|
459
|
+
ancestor,
|
|
460
|
+
descendant
|
|
461
|
+
]);
|
|
462
|
+
return true;
|
|
463
|
+
} catch (error) {
|
|
464
|
+
if (error.code === 1) return false;
|
|
465
|
+
throw gitUpgradeError(
|
|
466
|
+
'git-update',
|
|
467
|
+
`Could not compare Git source commits: ${sourceId}`
|
|
468
|
+
);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
async function execGit(directory, args, sourceId, action) {
|
|
473
|
+
try {
|
|
474
|
+
return await execFile('git', ['-C', directory, ...args]);
|
|
475
|
+
} catch (error) {
|
|
476
|
+
throw gitUpgradeError(
|
|
477
|
+
'git-update',
|
|
478
|
+
formatGitFailure(action, sourceId, error)
|
|
479
|
+
);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
async function fetchGit(context, directory, args, sourceId, action) {
|
|
484
|
+
const retryDelays = fetchRetryDelays(context);
|
|
485
|
+
let attempts = 0;
|
|
486
|
+
let lastError;
|
|
487
|
+
|
|
488
|
+
while (attempts <= retryDelays.length) {
|
|
489
|
+
attempts += 1;
|
|
490
|
+
try {
|
|
491
|
+
return await execFile('git', ['-C', directory, ...args]);
|
|
492
|
+
} catch (error) {
|
|
493
|
+
lastError = error;
|
|
494
|
+
if (!isTransientGitFailure(error) || attempts > retryDelays.length) break;
|
|
495
|
+
await wait(retryDelays[attempts - 1]);
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
const category = isTransientGitFailure(lastError)
|
|
500
|
+
? 'git-network'
|
|
501
|
+
: 'git-update';
|
|
502
|
+
throw gitUpgradeError(
|
|
503
|
+
category,
|
|
504
|
+
formatGitFailure(action, sourceId, lastError, attempts)
|
|
505
|
+
);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function fetchRetryDelays(context) {
|
|
509
|
+
const configured = context?.gitFetchRetryDelaysMs;
|
|
510
|
+
if (configured === undefined) return DEFAULT_FETCH_RETRY_DELAYS_MS;
|
|
511
|
+
if (
|
|
512
|
+
!Array.isArray(configured) ||
|
|
513
|
+
configured.some((delay) => !Number.isFinite(delay) || delay < 0)
|
|
514
|
+
) {
|
|
515
|
+
throw new TypeError('gitFetchRetryDelaysMs must contain non-negative numbers');
|
|
516
|
+
}
|
|
517
|
+
return configured;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function isTransientGitFailure(error) {
|
|
521
|
+
const detail = rawGitError(error);
|
|
522
|
+
return [
|
|
523
|
+
/could not resolve host/i,
|
|
524
|
+
/temporary failure in name resolution/i,
|
|
525
|
+
/network is unreachable/i,
|
|
526
|
+
/failed to connect/i,
|
|
527
|
+
/connection (?:reset|timed out|refused|closed)/i,
|
|
528
|
+
/operation timed out/i,
|
|
529
|
+
/recv failure|send failure/i,
|
|
530
|
+
/empty reply from server/i,
|
|
531
|
+
/(?:tls|gnutls|ssl).*(?:error|failed|terminated|timed out)/i,
|
|
532
|
+
/http\/2 stream.*(?:closed|reset|cancel)/i,
|
|
533
|
+
/rpc failed/i,
|
|
534
|
+
/unexpected disconnect|remote end hung up|early eof/i,
|
|
535
|
+
/(?:http |requested url returned error: )(?:429|500|502|503|504)/i
|
|
536
|
+
].some((pattern) => pattern.test(detail));
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
function formatGitFailure(action, sourceId, error, attempts) {
|
|
540
|
+
const detail = safeGitError(error);
|
|
541
|
+
const metadata = [
|
|
542
|
+
detail,
|
|
543
|
+
attempts === undefined ? '' : `attempts=${attempts}`
|
|
544
|
+
].filter(Boolean).join('; ');
|
|
545
|
+
return `${action}: ${sourceId}${metadata ? ` (${metadata})` : ''}`;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
function rawGitError(error) {
|
|
549
|
+
if (typeof error?.stderr === 'string' && error.stderr.trim()) {
|
|
550
|
+
return error.stderr.trim();
|
|
551
|
+
}
|
|
552
|
+
return String(error?.message || '');
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
function safeGitError(error) {
|
|
556
|
+
const lines = rawGitError(error)
|
|
557
|
+
.replace(/(?:https?|ssh|git):\/\/[^\s'"]+/gi, '[sanitized Git URL]')
|
|
558
|
+
.replace(/(?:[^\s@'"]+@)?[^\s:/'"]+:[^\s'"]+/g, '[sanitized Git URL]')
|
|
559
|
+
.split('\n')
|
|
560
|
+
.map((line) => line.trim())
|
|
561
|
+
.filter(Boolean);
|
|
562
|
+
const selected = lines.length <= 4
|
|
563
|
+
? lines
|
|
564
|
+
: [lines[0], ...lines.slice(-3)];
|
|
565
|
+
return selected.join(' | ').slice(0, 600);
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
function assertFocusContainsSkill(skills, focus) {
|
|
569
|
+
if (!focus?.path) return;
|
|
570
|
+
if (!skills.some(
|
|
571
|
+
(skillPath) => skillPath === focus.path ||
|
|
572
|
+
skillPath.startsWith(`${focus.path}/`)
|
|
573
|
+
)) {
|
|
574
|
+
throw gitUpgradeError(
|
|
575
|
+
'source-validation',
|
|
576
|
+
`Git source focus contains no scanner-visible skill: ${focus.path}`
|
|
577
|
+
);
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
function gitUpgradeError(category, message, exitCode = 1) {
|
|
582
|
+
return new SourceAcquisitionError(category, message, exitCode);
|
|
583
|
+
}
|