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,295 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { SOURCE_FOLDERS } from './sourcePolicy.js';
|
|
3
|
+
import { validateRemoteSkillFileUrl } from './sourceRemoteFilePolicy.js';
|
|
4
|
+
|
|
5
|
+
const SUPPORTED_SCHEMA_VERSION = 1;
|
|
6
|
+
const SOURCE_BUCKET_BY_TYPE = {
|
|
7
|
+
git: 'github',
|
|
8
|
+
archive: 'official',
|
|
9
|
+
'remote-file': 'official',
|
|
10
|
+
local: 'personal'
|
|
11
|
+
};
|
|
12
|
+
const SOURCE_TYPES = new Set([...Object.keys(SOURCE_BUCKET_BY_TYPE), 'legacy-local']);
|
|
13
|
+
const SOURCE_SEGMENT = /^(?:\.?[A-Za-z0-9])(?:[A-Za-z0-9._-]*[A-Za-z0-9])?$/;
|
|
14
|
+
const SHA256 = /^[a-f0-9]{64}$/i;
|
|
15
|
+
|
|
16
|
+
export function validateSourceId(sourceId) {
|
|
17
|
+
if (typeof sourceId !== 'string') throw new Error('Invalid sourceId');
|
|
18
|
+
const segments = sourceId.split('/');
|
|
19
|
+
if (
|
|
20
|
+
segments.length < 2 ||
|
|
21
|
+
!SOURCE_FOLDERS.includes(segments[0]) ||
|
|
22
|
+
segments.some((segment) => !SOURCE_SEGMENT.test(segment))
|
|
23
|
+
) {
|
|
24
|
+
throw new Error(`Invalid sourceId: ${sourceId}`);
|
|
25
|
+
}
|
|
26
|
+
return sourceId;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function validateSourceRecord(rootDir, record, expectedSourceId) {
|
|
30
|
+
if (!record || typeof record !== 'object' || Array.isArray(record)) {
|
|
31
|
+
throw new Error('Source record must be an object');
|
|
32
|
+
}
|
|
33
|
+
if (record.schemaVersion !== SUPPORTED_SCHEMA_VERSION) {
|
|
34
|
+
throw new Error(`Unsupported source record schemaVersion: ${record.schemaVersion}`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const sourceId = validateSourceId(record.sourceId);
|
|
38
|
+
if (expectedSourceId && sourceId !== expectedSourceId) {
|
|
39
|
+
throw new Error(`Source record path does not match sourceId: ${sourceId}`);
|
|
40
|
+
}
|
|
41
|
+
if (!SOURCE_FOLDERS.includes(record.bucket) || !sourceId.startsWith(`${record.bucket}/`)) {
|
|
42
|
+
throw new Error(`Invalid source bucket for ${sourceId}`);
|
|
43
|
+
}
|
|
44
|
+
if (!SOURCE_TYPES.has(record.type)) {
|
|
45
|
+
throw new Error(`Unsupported source type: ${record.type}`);
|
|
46
|
+
}
|
|
47
|
+
if (SOURCE_BUCKET_BY_TYPE[record.type] && SOURCE_BUCKET_BY_TYPE[record.type] !== record.bucket) {
|
|
48
|
+
throw new Error(`Source type ${record.type} must use the ${SOURCE_BUCKET_BY_TYPE[record.type]} bucket`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const integrity = validateIntegrity(record.integrity);
|
|
52
|
+
const skills = validateSkillPaths(record.skills);
|
|
53
|
+
const installPath = validateInstallPath(rootDir, record.installPath);
|
|
54
|
+
if (record.type === 'remote-file' && !integrity) {
|
|
55
|
+
throw new Error('Remote file source record requires integrity');
|
|
56
|
+
}
|
|
57
|
+
if (
|
|
58
|
+
record.type === 'remote-file' &&
|
|
59
|
+
(skills.length !== 1 || skills[0] !== '.')
|
|
60
|
+
) {
|
|
61
|
+
throw new Error('Remote file source record requires exactly the root skill');
|
|
62
|
+
}
|
|
63
|
+
if (
|
|
64
|
+
record.type === 'remote-file' &&
|
|
65
|
+
(sourceId.split('/').length !== 2 || installPath !== sourceId)
|
|
66
|
+
) {
|
|
67
|
+
throw new Error(
|
|
68
|
+
'Remote file source install path must match its official source identity'
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
return {
|
|
72
|
+
schemaVersion: SUPPORTED_SCHEMA_VERSION,
|
|
73
|
+
sourceId,
|
|
74
|
+
bucket: record.bucket,
|
|
75
|
+
type: record.type,
|
|
76
|
+
installPath,
|
|
77
|
+
origin: validateOrigin(record.type, record.origin),
|
|
78
|
+
...(record.focus === undefined ? {} : { focus: validateSourceFocus(record.type, record.focus) }),
|
|
79
|
+
...(integrity ? { integrity } : {}),
|
|
80
|
+
skills
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function validateSourceFocus(type, focus) {
|
|
85
|
+
if (type !== 'git' || !focus || typeof focus !== 'object' || Array.isArray(focus)) {
|
|
86
|
+
throw new Error('Source focus is only valid for Git sources');
|
|
87
|
+
}
|
|
88
|
+
const ref = validateGitRef(focus.ref);
|
|
89
|
+
if (!ref) throw new Error('Git source focus requires a ref');
|
|
90
|
+
const allowedKeys = new Set(['ref', 'path']);
|
|
91
|
+
if (Object.keys(focus).some((key) => !allowedKeys.has(key))) {
|
|
92
|
+
throw new Error('Git source focus contains unsupported fields');
|
|
93
|
+
}
|
|
94
|
+
if (focus.path === undefined) return { ref };
|
|
95
|
+
if (
|
|
96
|
+
typeof focus.path !== 'string' ||
|
|
97
|
+
!focus.path ||
|
|
98
|
+
path.posix.isAbsolute(focus.path) ||
|
|
99
|
+
path.posix.normalize(focus.path) !== focus.path ||
|
|
100
|
+
focus.path.includes('\\') ||
|
|
101
|
+
hasControlCharacters(focus.path) ||
|
|
102
|
+
focus.path.split('/').some((segment) => !segment || segment === '.' || segment === '..')
|
|
103
|
+
) {
|
|
104
|
+
throw new Error('Invalid Git source focus path');
|
|
105
|
+
}
|
|
106
|
+
return { ref, path: focus.path };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function validateInstallPath(rootDir, installPath) {
|
|
110
|
+
if (
|
|
111
|
+
typeof installPath !== 'string' ||
|
|
112
|
+
path.isAbsolute(installPath) ||
|
|
113
|
+
installPath.includes('\\') ||
|
|
114
|
+
hasControlCharacters(installPath)
|
|
115
|
+
) {
|
|
116
|
+
throw new Error('installPath must stay inside the central-library root');
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const normalized = path.posix.normalize(installPath);
|
|
120
|
+
const segments = normalized.split('/');
|
|
121
|
+
const resolvedRoot = path.resolve(rootDir);
|
|
122
|
+
const relative = path.relative(resolvedRoot, path.resolve(resolvedRoot, normalized));
|
|
123
|
+
if (
|
|
124
|
+
normalized !== installPath ||
|
|
125
|
+
segments.length < 2 ||
|
|
126
|
+
!SOURCE_FOLDERS.includes(segments[0]) ||
|
|
127
|
+
segments.some((segment) => !segment || segment === '.' || segment === '..') ||
|
|
128
|
+
!relative ||
|
|
129
|
+
relative.startsWith('..') ||
|
|
130
|
+
path.isAbsolute(relative)
|
|
131
|
+
) {
|
|
132
|
+
throw new Error('installPath must stay inside the central-library root');
|
|
133
|
+
}
|
|
134
|
+
return normalized;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function validateOrigin(type, origin) {
|
|
138
|
+
if (!origin || typeof origin !== 'object' || Array.isArray(origin)) {
|
|
139
|
+
throw new Error(`Invalid origin for source type: ${type}`);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (type === 'git') {
|
|
143
|
+
if (origin.kind !== 'git' || typeof origin.remote !== 'string' || !origin.remote.trim()) {
|
|
144
|
+
throw new Error('Git source origin requires a remote');
|
|
145
|
+
}
|
|
146
|
+
return compactObject({
|
|
147
|
+
kind: 'git',
|
|
148
|
+
remote: sanitizeGitRemote(origin.remote),
|
|
149
|
+
ref: validateGitRef(origin.ref),
|
|
150
|
+
commit: optionalCommit(origin.commit)
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (type === 'remote-file') {
|
|
155
|
+
if (!['http', 'https'].includes(origin.kind) || typeof origin.display !== 'string') {
|
|
156
|
+
throw new Error('Remote file source origin requires a stable HTTP(S) URL');
|
|
157
|
+
}
|
|
158
|
+
let parsed;
|
|
159
|
+
try {
|
|
160
|
+
parsed = validateRemoteSkillFileUrl(origin.display);
|
|
161
|
+
} catch (error) {
|
|
162
|
+
throw new Error(`Remote file source origin is invalid: ${error.message}`);
|
|
163
|
+
}
|
|
164
|
+
if (parsed.protocol !== origin.kind) {
|
|
165
|
+
throw new Error('Remote file source origin kind must match its URL protocol');
|
|
166
|
+
}
|
|
167
|
+
return { kind: origin.kind, display: parsed.display };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (type === 'archive') {
|
|
171
|
+
if (!['http', 'https'].includes(origin.kind) || typeof origin.display !== 'string' || !origin.display.trim()) {
|
|
172
|
+
throw new Error('Archive source origin requires an HTTP(S) display URL');
|
|
173
|
+
}
|
|
174
|
+
const display = sanitizeUrl(origin.display, [`${origin.kind}:`]);
|
|
175
|
+
return { kind: origin.kind, display };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (type === 'local') {
|
|
179
|
+
if (
|
|
180
|
+
origin.kind !== 'local' ||
|
|
181
|
+
typeof origin.name !== 'string' ||
|
|
182
|
+
!origin.name ||
|
|
183
|
+
path.basename(origin.name) !== origin.name ||
|
|
184
|
+
hasControlCharacters(origin.name)
|
|
185
|
+
) {
|
|
186
|
+
throw new Error('Local source origin requires a basename');
|
|
187
|
+
}
|
|
188
|
+
return { kind: 'local', name: origin.name };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (origin.kind !== 'unknown') {
|
|
192
|
+
throw new Error('Legacy Local source origin must be unknown');
|
|
193
|
+
}
|
|
194
|
+
return { kind: 'unknown' };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function validateIntegrity(integrity) {
|
|
198
|
+
if (integrity === undefined) return null;
|
|
199
|
+
if (
|
|
200
|
+
!integrity ||
|
|
201
|
+
typeof integrity !== 'object' ||
|
|
202
|
+
integrity.algorithm !== 'sha256' ||
|
|
203
|
+
typeof integrity.value !== 'string' ||
|
|
204
|
+
!SHA256.test(integrity.value)
|
|
205
|
+
) {
|
|
206
|
+
throw new Error('Invalid source integrity');
|
|
207
|
+
}
|
|
208
|
+
return { algorithm: 'sha256', value: integrity.value.toLowerCase() };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function validateSkillPaths(skills) {
|
|
212
|
+
if (!Array.isArray(skills)) throw new Error('Source record skills must be an array');
|
|
213
|
+
const validated = skills.map((skillPath) => {
|
|
214
|
+
if (
|
|
215
|
+
typeof skillPath !== 'string' ||
|
|
216
|
+
!skillPath ||
|
|
217
|
+
path.posix.normalize(skillPath) !== skillPath ||
|
|
218
|
+
path.posix.isAbsolute(skillPath) ||
|
|
219
|
+
skillPath.includes('\\') ||
|
|
220
|
+
hasControlCharacters(skillPath) ||
|
|
221
|
+
skillPath !== '.' &&
|
|
222
|
+
skillPath.split('/').some((segment) => !segment || segment === '.' || segment === '..')
|
|
223
|
+
) {
|
|
224
|
+
throw new Error(`Invalid discovered skill path: ${skillPath}`);
|
|
225
|
+
}
|
|
226
|
+
return skillPath;
|
|
227
|
+
});
|
|
228
|
+
if (new Set(validated).size !== validated.length) {
|
|
229
|
+
throw new Error('Source record contains duplicate skill paths');
|
|
230
|
+
}
|
|
231
|
+
return validated;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export function sanitizeGitRemote(remote) {
|
|
235
|
+
const trimmed = remote.trim();
|
|
236
|
+
if (hasControlCharacters(trimmed)) throw new Error('Invalid Git source origin');
|
|
237
|
+
if (trimmed.includes('://')) return sanitizeUrl(trimmed, ['https:', 'ssh:']);
|
|
238
|
+
if (!/^(?:[^@\s]+@)?[^:/\s]+:[^?#\s]+(?:[?#].*)?$/.test(trimmed)) {
|
|
239
|
+
throw new Error('Invalid Git source origin');
|
|
240
|
+
}
|
|
241
|
+
return trimmed
|
|
242
|
+
.replace(/^[^@\s]+@(?=[^:\s]+:)/, '')
|
|
243
|
+
.replace(/[?#].*$/, '');
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function sanitizeUrl(value, protocols) {
|
|
247
|
+
let url;
|
|
248
|
+
try {
|
|
249
|
+
url = new URL(value);
|
|
250
|
+
} catch {
|
|
251
|
+
throw new Error('Invalid source origin URL');
|
|
252
|
+
}
|
|
253
|
+
if (!protocols.includes(url.protocol)) throw new Error('Invalid source origin URL');
|
|
254
|
+
url.username = '';
|
|
255
|
+
url.password = '';
|
|
256
|
+
url.search = '';
|
|
257
|
+
url.hash = '';
|
|
258
|
+
return url.toString();
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function validateGitRef(value) {
|
|
262
|
+
if (value === undefined) return undefined;
|
|
263
|
+
if (
|
|
264
|
+
typeof value !== 'string' ||
|
|
265
|
+
!value ||
|
|
266
|
+
hasControlCharacters(value) ||
|
|
267
|
+
/[\s~^:?*[\]\\]/.test(value) ||
|
|
268
|
+
value.includes('..') ||
|
|
269
|
+
value.includes('@{') ||
|
|
270
|
+
value.includes('//') ||
|
|
271
|
+
value.startsWith('/') ||
|
|
272
|
+
value.endsWith('/') ||
|
|
273
|
+
value.endsWith('.') ||
|
|
274
|
+
value.split('/').some((segment) => segment.startsWith('.') || segment.endsWith('.lock'))
|
|
275
|
+
) {
|
|
276
|
+
throw new Error('Invalid origin.ref');
|
|
277
|
+
}
|
|
278
|
+
return value;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function optionalCommit(value) {
|
|
282
|
+
if (value === undefined) return undefined;
|
|
283
|
+
if (typeof value !== 'string' || !/^[a-f0-9]{40}(?:[a-f0-9]{24})?$/i.test(value)) {
|
|
284
|
+
throw new Error('Invalid origin.commit');
|
|
285
|
+
}
|
|
286
|
+
return value.toLowerCase();
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function hasControlCharacters(value) {
|
|
290
|
+
return /[\u0000-\u001F\u007F]/.test(value);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function compactObject(value) {
|
|
294
|
+
return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined));
|
|
295
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import {
|
|
3
|
+
lstat,
|
|
4
|
+
link,
|
|
5
|
+
mkdir,
|
|
6
|
+
readFile,
|
|
7
|
+
readdir,
|
|
8
|
+
realpath,
|
|
9
|
+
rename,
|
|
10
|
+
rm,
|
|
11
|
+
writeFile
|
|
12
|
+
} from 'node:fs/promises';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
import { isPathInsideOrEqual } from './sourcePath.js';
|
|
15
|
+
import { validateSourceId, validateSourceRecord } from './sourceRecord.js';
|
|
16
|
+
|
|
17
|
+
export { validateSourceId };
|
|
18
|
+
|
|
19
|
+
export async function readSourceRecords(rootDir) {
|
|
20
|
+
const registryRoot = path.join(path.resolve(rootDir), '.skillcaddy', 'sources');
|
|
21
|
+
const files = await findJsonFiles(registryRoot);
|
|
22
|
+
const records = [];
|
|
23
|
+
|
|
24
|
+
for (const filePath of files) {
|
|
25
|
+
const expectedSourceId = path.relative(registryRoot, filePath).slice(0, -'.json'.length).split(path.sep).join('/');
|
|
26
|
+
const record = await readRecord(filePath);
|
|
27
|
+
const validated = validateSourceRecord(rootDir, record, expectedSourceId);
|
|
28
|
+
await assertExistingPathInsideRoot(
|
|
29
|
+
rootDir,
|
|
30
|
+
path.resolve(rootDir, validated.installPath),
|
|
31
|
+
'installPath resolves outside the central-library root'
|
|
32
|
+
);
|
|
33
|
+
records.push(validated);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return records.sort((left, right) => left.sourceId.localeCompare(right.sourceId));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function writeSourceRecord(rootDir, record) {
|
|
40
|
+
return publishSourceRecord(rootDir, record, replaceRecord);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function createSourceRecord(rootDir, record) {
|
|
44
|
+
return publishSourceRecord(rootDir, record, createRecord);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function publishSourceRecord(rootDir, record, publish) {
|
|
48
|
+
const staged = await stageSourceRecord(rootDir, record);
|
|
49
|
+
let published = false;
|
|
50
|
+
try {
|
|
51
|
+
await publish(staged.temporaryPath, staged.targetPath);
|
|
52
|
+
published = true;
|
|
53
|
+
} finally {
|
|
54
|
+
try {
|
|
55
|
+
await discardStagedSourceRecord(staged);
|
|
56
|
+
} catch (error) {
|
|
57
|
+
if (!published) throw error;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return staged.record;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function stageSourceRecord(rootDir, record) {
|
|
64
|
+
const validated = validateSourceRecord(rootDir, record);
|
|
65
|
+
await assertExistingPathInsideRoot(
|
|
66
|
+
rootDir,
|
|
67
|
+
path.resolve(rootDir, validated.installPath),
|
|
68
|
+
'installPath resolves outside the central-library root'
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
const registryRoot = path.join(path.resolve(rootDir), '.skillcaddy', 'sources');
|
|
72
|
+
const targetPath = path.join(registryRoot, ...validated.sourceId.split('/')) + '.json';
|
|
73
|
+
await assertExistingPathInsideRoot(
|
|
74
|
+
rootDir,
|
|
75
|
+
targetPath,
|
|
76
|
+
'Source registry path resolves outside the central-library root'
|
|
77
|
+
);
|
|
78
|
+
await mkdir(path.dirname(targetPath), { recursive: true });
|
|
79
|
+
|
|
80
|
+
const temporaryPath = `${targetPath}.${randomUUID()}.tmp`;
|
|
81
|
+
await writeFile(temporaryPath, `${JSON.stringify(validated, null, 2)}\n`, { flag: 'wx' });
|
|
82
|
+
return {
|
|
83
|
+
record: validated,
|
|
84
|
+
targetPath,
|
|
85
|
+
temporaryPath
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export async function discardStagedSourceRecord(staged) {
|
|
90
|
+
await rm(staged.temporaryPath, { force: true });
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export async function publishStagedSourceRecord(staged) {
|
|
94
|
+
await replaceRecord(staged.temporaryPath, staged.targetPath);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function replaceRecord(temporaryPath, targetPath) {
|
|
98
|
+
await rename(temporaryPath, targetPath);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function createRecord(temporaryPath, targetPath) {
|
|
102
|
+
await link(temporaryPath, targetPath);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function assertExistingPathInsideRoot(rootDir, targetPath, errorMessage) {
|
|
106
|
+
const resolvedRoot = await realpath(path.resolve(rootDir));
|
|
107
|
+
let existingAncestor = targetPath;
|
|
108
|
+
|
|
109
|
+
while (true) {
|
|
110
|
+
try {
|
|
111
|
+
await lstat(existingAncestor);
|
|
112
|
+
break;
|
|
113
|
+
} catch (error) {
|
|
114
|
+
if (error.code !== 'ENOENT') throw error;
|
|
115
|
+
const parent = path.dirname(existingAncestor);
|
|
116
|
+
if (parent === existingAncestor) throw error;
|
|
117
|
+
existingAncestor = parent;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const resolvedAncestor = await realpath(existingAncestor);
|
|
122
|
+
if (!isPathInsideOrEqual(resolvedRoot, resolvedAncestor)) {
|
|
123
|
+
throw new Error(errorMessage);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function readRecord(filePath) {
|
|
128
|
+
try {
|
|
129
|
+
return JSON.parse(await readFile(filePath, 'utf8'));
|
|
130
|
+
} catch (error) {
|
|
131
|
+
if (error instanceof SyntaxError) throw new Error(`Invalid source record JSON: ${filePath}`);
|
|
132
|
+
throw error;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function findJsonFiles(directory) {
|
|
137
|
+
let entries;
|
|
138
|
+
try {
|
|
139
|
+
entries = await readdir(directory, { withFileTypes: true });
|
|
140
|
+
} catch (error) {
|
|
141
|
+
if (error.code === 'ENOENT') return [];
|
|
142
|
+
throw error;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const files = [];
|
|
146
|
+
for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
|
|
147
|
+
const entryPath = path.join(directory, entry.name);
|
|
148
|
+
if (entry.isDirectory()) {
|
|
149
|
+
files.push(...await findJsonFiles(entryPath));
|
|
150
|
+
} else if (entry.isFile() && entry.name.endsWith('.json')) {
|
|
151
|
+
files.push(entryPath);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return files;
|
|
155
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { getDomain } from 'tldts';
|
|
2
|
+
import {
|
|
3
|
+
defineSourceAcquisitionAdapter
|
|
4
|
+
} from './sourceAcquisitionAdapter.js';
|
|
5
|
+
import { SourceAcquisitionError, sourceCollisionError } from './sourceAcquisitionError.js';
|
|
6
|
+
import {
|
|
7
|
+
assertDirectorySourceMatchesRecord,
|
|
8
|
+
sameJson
|
|
9
|
+
} from './sourceAcquisitionPolicy.js';
|
|
10
|
+
import {
|
|
11
|
+
inspectRemoteArchive,
|
|
12
|
+
stageRemoteArchive
|
|
13
|
+
} from './sourceArchiveWorkspace.js';
|
|
14
|
+
import { parseRemoteArchiveRequest } from './sourceHttp.js';
|
|
15
|
+
import { validateSourceId } from './sourceRegistry.js';
|
|
16
|
+
import { sourceInstallPathAvailable } from './sourceTree.js';
|
|
17
|
+
|
|
18
|
+
export function createRemoteArchiveAcquisitionAdapter(context, request) {
|
|
19
|
+
const parsed = parseRemoteArchiveRequest(request);
|
|
20
|
+
|
|
21
|
+
return defineSourceAcquisitionAdapter({
|
|
22
|
+
stalePlanMessage: 'Remote Archive changed since the add plan',
|
|
23
|
+
inspect: async () => buildRemoteArchiveCandidate(
|
|
24
|
+
parsed,
|
|
25
|
+
await inspectRemoteArchive(
|
|
26
|
+
context,
|
|
27
|
+
parsed,
|
|
28
|
+
'skillcaddy-archive-plan-'
|
|
29
|
+
)
|
|
30
|
+
),
|
|
31
|
+
prepare: async (workspaceRoot) => buildRemoteArchiveCandidate(
|
|
32
|
+
parsed,
|
|
33
|
+
await stageRemoteArchive(context, parsed, workspaceRoot)
|
|
34
|
+
),
|
|
35
|
+
resolveIdentity: (_candidate, records) =>
|
|
36
|
+
resolveArchiveIdentity(context.rootDir, records, parsed),
|
|
37
|
+
buildRecord(plan) {
|
|
38
|
+
return {
|
|
39
|
+
schemaVersion: 1,
|
|
40
|
+
sourceId: plan.sourceId,
|
|
41
|
+
bucket: 'official',
|
|
42
|
+
type: 'archive',
|
|
43
|
+
installPath: plan.installPath,
|
|
44
|
+
origin: plan.origin,
|
|
45
|
+
integrity: plan.integrity,
|
|
46
|
+
skills: plan.skills
|
|
47
|
+
};
|
|
48
|
+
},
|
|
49
|
+
matchesIdentity(record, plan) {
|
|
50
|
+
return record.type === 'archive' &&
|
|
51
|
+
record.installPath === plan.installPath;
|
|
52
|
+
},
|
|
53
|
+
matchesContent(record, plan) {
|
|
54
|
+
return record.integrity?.value === plan.integrity.value &&
|
|
55
|
+
sameJson(record.skills, plan.skills);
|
|
56
|
+
},
|
|
57
|
+
assertInstalled: assertDirectorySourceMatchesRecord
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function resolveArchiveIdentity(rootDir, records, parsed) {
|
|
62
|
+
if (parsed.namespace) {
|
|
63
|
+
return existingOrAvailableIdentity(
|
|
64
|
+
rootDir,
|
|
65
|
+
records,
|
|
66
|
+
archiveIdentity(parsed.namespace, parsed.name)
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const defaultIdentity = archiveIdentity(null, parsed.name);
|
|
71
|
+
const sameDefault = records.find(
|
|
72
|
+
(record) => record.sourceId === defaultIdentity.sourceId
|
|
73
|
+
);
|
|
74
|
+
if (
|
|
75
|
+
sameDefault?.type === 'archive' &&
|
|
76
|
+
archiveDomain(sameDefault.origin.display) === parsed.registrableDomain
|
|
77
|
+
) {
|
|
78
|
+
return {
|
|
79
|
+
sourceId: sameDefault.sourceId,
|
|
80
|
+
installPath: sameDefault.installPath
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
if (
|
|
84
|
+
!sameDefault &&
|
|
85
|
+
await sourceInstallPathAvailable(rootDir, records, defaultIdentity.installPath)
|
|
86
|
+
) {
|
|
87
|
+
return defaultIdentity;
|
|
88
|
+
}
|
|
89
|
+
return existingOrAvailableIdentity(
|
|
90
|
+
rootDir,
|
|
91
|
+
records,
|
|
92
|
+
archiveIdentity(parsed.registrableDomain, parsed.name)
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function existingOrAvailableIdentity(rootDir, records, identity) {
|
|
97
|
+
const existing = records.find((record) => record.sourceId === identity.sourceId);
|
|
98
|
+
if (existing) {
|
|
99
|
+
return { sourceId: existing.sourceId, installPath: existing.installPath };
|
|
100
|
+
}
|
|
101
|
+
if (await sourceInstallPathAvailable(rootDir, records, identity.installPath)) {
|
|
102
|
+
return identity;
|
|
103
|
+
}
|
|
104
|
+
throw sourceCollisionError(`Source destination collision: ${identity.installPath}`);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function archiveIdentity(namespace, name) {
|
|
108
|
+
const sourceId = namespace
|
|
109
|
+
? `official/${namespace}/${name}`
|
|
110
|
+
: `official/${name}`;
|
|
111
|
+
try {
|
|
112
|
+
validateSourceId(sourceId);
|
|
113
|
+
} catch {
|
|
114
|
+
throw new SourceAcquisitionError(
|
|
115
|
+
'unresolved-identity',
|
|
116
|
+
'Remote Archive name and namespace must form a safe source identity',
|
|
117
|
+
3
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
return {
|
|
121
|
+
sourceId,
|
|
122
|
+
installPath: `official/${namespace ? `${namespace}--` : ''}${name}`
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function buildRemoteArchiveCandidate(parsed, candidate) {
|
|
127
|
+
return {
|
|
128
|
+
input: {
|
|
129
|
+
type: 'remote-zip',
|
|
130
|
+
display: parsed.display
|
|
131
|
+
},
|
|
132
|
+
origin: {
|
|
133
|
+
kind: parsed.protocol,
|
|
134
|
+
display: parsed.display
|
|
135
|
+
},
|
|
136
|
+
integrity: candidate.integrity,
|
|
137
|
+
skills: candidate.skills,
|
|
138
|
+
warnings: candidate.warnings,
|
|
139
|
+
...(candidate.contentRoot ? { contentRoot: candidate.contentRoot } : {})
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function archiveDomain(display) {
|
|
144
|
+
const hostname = new URL(display).hostname;
|
|
145
|
+
return getDomain(hostname, { allowPrivateDomains: true }) ||
|
|
146
|
+
hostname.toLowerCase();
|
|
147
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import {
|
|
2
|
+
defineSourceAcquisitionAdapter
|
|
3
|
+
} from './sourceAcquisitionAdapter.js';
|
|
4
|
+
import { SourceAcquisitionError } from './sourceAcquisitionError.js';
|
|
5
|
+
import {
|
|
6
|
+
assertDirectorySourceMatchesRecord,
|
|
7
|
+
sameJson
|
|
8
|
+
} from './sourceAcquisitionPolicy.js';
|
|
9
|
+
import { parseRemoteSkillFileRequest } from './sourceHttp.js';
|
|
10
|
+
import {
|
|
11
|
+
inspectRemoteSkillFile,
|
|
12
|
+
stageRemoteSkillFile
|
|
13
|
+
} from './sourceRemoteFileWorkspace.js';
|
|
14
|
+
import { validateSourceId } from './sourceRegistry.js';
|
|
15
|
+
|
|
16
|
+
export function createRemoteFileAcquisitionAdapter(context, request) {
|
|
17
|
+
const parsed = parseRemoteSkillFileRequest(request);
|
|
18
|
+
|
|
19
|
+
return defineSourceAcquisitionAdapter({
|
|
20
|
+
stalePlanMessage: 'Remote SKILL.md changed since the add plan',
|
|
21
|
+
inspect: async () => buildRemoteFileCandidate(
|
|
22
|
+
parsed,
|
|
23
|
+
await inspectRemoteSkillFile(
|
|
24
|
+
context,
|
|
25
|
+
parsed,
|
|
26
|
+
'skillcaddy-remote-file-plan-'
|
|
27
|
+
)
|
|
28
|
+
),
|
|
29
|
+
prepare: async (workspaceRoot) => buildRemoteFileCandidate(
|
|
30
|
+
parsed,
|
|
31
|
+
await stageRemoteSkillFile(context, parsed, workspaceRoot)
|
|
32
|
+
),
|
|
33
|
+
resolveIdentity() {
|
|
34
|
+
return remoteFileIdentity(parsed.name);
|
|
35
|
+
},
|
|
36
|
+
buildRecord(plan) {
|
|
37
|
+
return {
|
|
38
|
+
schemaVersion: 1,
|
|
39
|
+
sourceId: plan.sourceId,
|
|
40
|
+
bucket: 'official',
|
|
41
|
+
type: 'remote-file',
|
|
42
|
+
installPath: plan.installPath,
|
|
43
|
+
origin: plan.origin,
|
|
44
|
+
integrity: plan.integrity,
|
|
45
|
+
skills: plan.skills
|
|
46
|
+
};
|
|
47
|
+
},
|
|
48
|
+
matchesIdentity(record, plan) {
|
|
49
|
+
return record.type === 'remote-file' &&
|
|
50
|
+
record.installPath === plan.installPath &&
|
|
51
|
+
record.origin.display === plan.origin.display;
|
|
52
|
+
},
|
|
53
|
+
matchesContent(record, plan) {
|
|
54
|
+
return record.integrity?.value === plan.integrity.value &&
|
|
55
|
+
sameJson(record.skills, plan.skills);
|
|
56
|
+
},
|
|
57
|
+
assertInstalled: assertDirectorySourceMatchesRecord
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function remoteFileIdentity(name) {
|
|
62
|
+
if (name.includes('/')) throw remoteFileIdentityError();
|
|
63
|
+
const sourceId = `official/${name}`;
|
|
64
|
+
try {
|
|
65
|
+
validateSourceId(sourceId);
|
|
66
|
+
} catch {
|
|
67
|
+
throw remoteFileIdentityError();
|
|
68
|
+
}
|
|
69
|
+
return { sourceId, installPath: sourceId };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function remoteFileIdentityError() {
|
|
73
|
+
return new SourceAcquisitionError(
|
|
74
|
+
'unresolved-identity',
|
|
75
|
+
'Remote file name must be a single safe identity segment',
|
|
76
|
+
3
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function buildRemoteFileCandidate(parsed, candidate) {
|
|
81
|
+
return {
|
|
82
|
+
input: {
|
|
83
|
+
type: 'remote-file',
|
|
84
|
+
display: parsed.display
|
|
85
|
+
},
|
|
86
|
+
origin: {
|
|
87
|
+
kind: parsed.protocol,
|
|
88
|
+
display: parsed.display
|
|
89
|
+
},
|
|
90
|
+
integrity: candidate.integrity,
|
|
91
|
+
skills: candidate.skills,
|
|
92
|
+
warnings: candidate.warnings,
|
|
93
|
+
...(candidate.contentRoot ? { contentRoot: candidate.contentRoot } : {})
|
|
94
|
+
};
|
|
95
|
+
}
|