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,170 @@
|
|
|
1
|
+
import { SourceAcquisitionError } from './sourceAcquisitionError.js';
|
|
2
|
+
import { sanitizeGitRemote, validateSourceId } from './sourceRecord.js';
|
|
3
|
+
import { isGitHubBlobSkillFileInput } from './sourceRemoteFilePolicy.js';
|
|
4
|
+
|
|
5
|
+
export function isGitSourceInput(input) {
|
|
6
|
+
if (typeof input !== 'string') return false;
|
|
7
|
+
const trimmed = input.trim();
|
|
8
|
+
return isSshGitInput(trimmed) || /^https:\/\//i.test(trimmed);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function isDefiniteGitSourceInput(input) {
|
|
12
|
+
if (typeof input !== 'string') return false;
|
|
13
|
+
const trimmed = input.trim();
|
|
14
|
+
if (isSshGitInput(trimmed)) return true;
|
|
15
|
+
if (isGitHubBlobSkillFileInput(trimmed)) return false;
|
|
16
|
+
let url;
|
|
17
|
+
try {
|
|
18
|
+
url = new URL(trimmed);
|
|
19
|
+
} catch {
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
if (url.protocol !== 'https:') return false;
|
|
23
|
+
if (/\.git\/?$/i.test(url.pathname)) return true;
|
|
24
|
+
return ['github.com', 'www.github.com'].includes(url.hostname.toLowerCase()) &&
|
|
25
|
+
url.pathname.split('/').filter(Boolean).length >= 2;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function isSshGitInput(input) {
|
|
29
|
+
if (
|
|
30
|
+
/^ssh:\/\//i.test(input) ||
|
|
31
|
+
!input.includes('://') &&
|
|
32
|
+
/^(?:[^@\s]+@)?[^:/\s]+:[^/\s][^\s]*$/.test(input)
|
|
33
|
+
) {
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function parseGitSourceRequest(request) {
|
|
40
|
+
if (!request || typeof request.input !== 'string' || !request.input.trim()) {
|
|
41
|
+
throw new Error('Source add requires request.input');
|
|
42
|
+
}
|
|
43
|
+
if (request.name !== undefined || request.namespace !== undefined) {
|
|
44
|
+
throw identityError('Git source identity is derived from its repository owner and name');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const input = request.input.trim();
|
|
48
|
+
if (input.includes('://')) return parseGitUrl(input);
|
|
49
|
+
return parseScpGitUrl(input);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function canonicalGitRepositoryLocation(remote) {
|
|
53
|
+
if (remote.includes('://')) {
|
|
54
|
+
const url = new URL(remote);
|
|
55
|
+
return `${url.hostname.toLowerCase()}/${normalizeRepositoryPath(url.pathname)}`;
|
|
56
|
+
}
|
|
57
|
+
const separator = remote.indexOf(':');
|
|
58
|
+
const host = remote.slice(0, separator).replace(/^.*@/, '').toLowerCase();
|
|
59
|
+
return `${host}/${normalizeRepositoryPath(remote.slice(separator + 1))}`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function parseGitUrl(input) {
|
|
63
|
+
let url;
|
|
64
|
+
try {
|
|
65
|
+
url = new URL(input);
|
|
66
|
+
} catch {
|
|
67
|
+
throw identityError('Invalid Git source URL');
|
|
68
|
+
}
|
|
69
|
+
if (!['https:', 'ssh:'].includes(url.protocol)) {
|
|
70
|
+
throw identityError('Git source URL must use HTTPS or SSH');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const segments = decodePathSegments(url.pathname);
|
|
74
|
+
if (
|
|
75
|
+
url.protocol === 'https:' &&
|
|
76
|
+
['github.com', 'www.github.com'].includes(url.hostname.toLowerCase()) &&
|
|
77
|
+
segments[2] === 'tree'
|
|
78
|
+
) {
|
|
79
|
+
if (segments.length < 4) {
|
|
80
|
+
throw identityError('GitHub tree URL must include a branch');
|
|
81
|
+
}
|
|
82
|
+
const [owner, rawRepository, , ...treeSegments] = segments;
|
|
83
|
+
const repository = stripGitSuffix(rawRepository);
|
|
84
|
+
const repositoryRemote = `https://github.com/${owner}/${repository}.git`;
|
|
85
|
+
return validateParsedGitSource({
|
|
86
|
+
owner,
|
|
87
|
+
repository,
|
|
88
|
+
cloneRemote: repositoryRemote,
|
|
89
|
+
displayRemote: repositoryRemote,
|
|
90
|
+
treeSegments
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (segments.length < 2) {
|
|
95
|
+
throw identityError('Git source URL must identify a repository owner and name');
|
|
96
|
+
}
|
|
97
|
+
const owner = segments.at(-2);
|
|
98
|
+
const repository = stripGitSuffix(segments.at(-1));
|
|
99
|
+
const cloneUrl = new URL(url);
|
|
100
|
+
cloneUrl.search = '';
|
|
101
|
+
cloneUrl.hash = '';
|
|
102
|
+
if (cloneUrl.protocol === 'https:') {
|
|
103
|
+
cloneUrl.username = '';
|
|
104
|
+
cloneUrl.password = '';
|
|
105
|
+
} else {
|
|
106
|
+
cloneUrl.password = '';
|
|
107
|
+
}
|
|
108
|
+
return validateParsedGitSource({
|
|
109
|
+
owner,
|
|
110
|
+
repository,
|
|
111
|
+
cloneRemote: cloneUrl.toString(),
|
|
112
|
+
displayRemote: sanitizeGitRemote(cloneUrl.toString())
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function parseScpGitUrl(input) {
|
|
117
|
+
if (!/^(?:[^@\s]+@)?[^:/\s]+:[^?#\s]+(?:[?#].*)?$/.test(input)) {
|
|
118
|
+
throw identityError('Invalid SSH Git source URL');
|
|
119
|
+
}
|
|
120
|
+
const withoutSensitiveSuffix = input.replace(/[?#].*$/, '');
|
|
121
|
+
const repositoryPath = withoutSensitiveSuffix.slice(withoutSensitiveSuffix.indexOf(':') + 1);
|
|
122
|
+
const segments = decodePathSegments(repositoryPath);
|
|
123
|
+
if (segments.length < 2) {
|
|
124
|
+
throw identityError('Git source URL must identify a repository owner and name');
|
|
125
|
+
}
|
|
126
|
+
return validateParsedGitSource({
|
|
127
|
+
owner: segments.at(-2),
|
|
128
|
+
repository: stripGitSuffix(segments.at(-1)),
|
|
129
|
+
cloneRemote: withoutSensitiveSuffix,
|
|
130
|
+
displayRemote: sanitizeGitRemote(withoutSensitiveSuffix)
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function validateParsedGitSource(parsed) {
|
|
135
|
+
const sourceId = `github/${parsed.owner}/${parsed.repository}`;
|
|
136
|
+
try {
|
|
137
|
+
validateSourceId(sourceId);
|
|
138
|
+
} catch {
|
|
139
|
+
throw identityError('Git repository owner and name must form a safe source identity');
|
|
140
|
+
}
|
|
141
|
+
return { ...parsed, sourceId };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function decodePathSegments(pathname) {
|
|
145
|
+
try {
|
|
146
|
+
return pathname
|
|
147
|
+
.split('/')
|
|
148
|
+
.filter(Boolean)
|
|
149
|
+
.map((segment) => decodeURIComponent(segment));
|
|
150
|
+
} catch {
|
|
151
|
+
throw identityError('Git source URL contains invalid path encoding');
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function stripGitSuffix(repository) {
|
|
156
|
+
const normalized = repository.replace(/\.git$/i, '');
|
|
157
|
+
if (!normalized) throw identityError('Git source URL has no repository name');
|
|
158
|
+
return normalized;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function normalizeRepositoryPath(repositoryPath) {
|
|
162
|
+
return repositoryPath
|
|
163
|
+
.replace(/^\/+/, '')
|
|
164
|
+
.replace(/\/+$/, '')
|
|
165
|
+
.replace(/\.git$/i, '');
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function identityError(message) {
|
|
169
|
+
return new SourceAcquisitionError('unresolved-identity', message, 3);
|
|
170
|
+
}
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
import { createWriteStream } from 'node:fs';
|
|
2
|
+
import { rm } from 'node:fs/promises';
|
|
3
|
+
import http from 'node:http';
|
|
4
|
+
import https from 'node:https';
|
|
5
|
+
import { pipeline } from 'node:stream/promises';
|
|
6
|
+
import { getDomain } from 'tldts';
|
|
7
|
+
import { SourceAcquisitionError } from './sourceAcquisitionError.js';
|
|
8
|
+
import {
|
|
9
|
+
isRemoteSkillFilePathInput,
|
|
10
|
+
validateRemoteSkillFileUrl
|
|
11
|
+
} from './sourceRemoteFilePolicy.js';
|
|
12
|
+
|
|
13
|
+
const DEFAULT_HTTP_LIMITS = {
|
|
14
|
+
maxDownloadBytes: 100 * 1024 * 1024,
|
|
15
|
+
maxRedirects: 5,
|
|
16
|
+
connectionTimeoutMs: 15_000,
|
|
17
|
+
completeTimeoutMs: 120_000
|
|
18
|
+
};
|
|
19
|
+
const DEFAULT_REMOTE_SKILL_FILE_LIMITS = {
|
|
20
|
+
...DEFAULT_HTTP_LIMITS,
|
|
21
|
+
maxDownloadBytes: 1024 * 1024
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export function isRemoteArchiveInput(input) {
|
|
25
|
+
if (typeof input !== 'string') return false;
|
|
26
|
+
return /^[A-Za-z][A-Za-z0-9+.-]*:\/\//.test(input.trim());
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function isRemoteSkillFileInput(input) {
|
|
30
|
+
return isRemoteSkillFilePathInput(input);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function parseRemoteSkillFileRequest(request) {
|
|
34
|
+
if (!request || typeof request.input !== 'string' || !request.input.trim()) {
|
|
35
|
+
throw new Error('Source add requires request.input');
|
|
36
|
+
}
|
|
37
|
+
if (typeof request.name !== 'string' || !request.name) {
|
|
38
|
+
throw identityError('Remote file source requires an explicit non-empty name');
|
|
39
|
+
}
|
|
40
|
+
if (request.namespace !== undefined) {
|
|
41
|
+
throw identityError('Remote file source does not accept a namespace');
|
|
42
|
+
}
|
|
43
|
+
const parsedInput = parseRemoteSkillFileInput(request.input);
|
|
44
|
+
return {
|
|
45
|
+
...parsedInput,
|
|
46
|
+
name: request.name
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function parseRemoteSkillFileInput(input) {
|
|
51
|
+
if (typeof input !== 'string' || !input.trim()) {
|
|
52
|
+
throw new Error('Remote file source input is required');
|
|
53
|
+
}
|
|
54
|
+
let parsed;
|
|
55
|
+
try {
|
|
56
|
+
parsed = validateRemoteSkillFileUrl(input, { allowFragment: true });
|
|
57
|
+
} catch (error) {
|
|
58
|
+
throw downloadError(error.message);
|
|
59
|
+
}
|
|
60
|
+
return {
|
|
61
|
+
input: parsed.display,
|
|
62
|
+
...parsed
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function parseRemoteArchiveRequest(request) {
|
|
67
|
+
if (!request || typeof request.input !== 'string' || !request.input.trim()) {
|
|
68
|
+
throw new Error('Source add requires request.input');
|
|
69
|
+
}
|
|
70
|
+
const parsedInput = parseRemoteArchiveInput(request.input);
|
|
71
|
+
const url = parsedInput.url;
|
|
72
|
+
const name = request.name === undefined ? inferArchiveName(url) : request.name;
|
|
73
|
+
if (typeof name !== 'string' || !name) {
|
|
74
|
+
throw identityError('Remote Archive name must be a non-empty identity segment');
|
|
75
|
+
}
|
|
76
|
+
if (
|
|
77
|
+
request.namespace !== undefined &&
|
|
78
|
+
(typeof request.namespace !== 'string' || !request.namespace)
|
|
79
|
+
) {
|
|
80
|
+
throw identityError('Remote Archive namespace must be a non-empty identity segment');
|
|
81
|
+
}
|
|
82
|
+
return {
|
|
83
|
+
...parsedInput,
|
|
84
|
+
name,
|
|
85
|
+
namespace: request.namespace,
|
|
86
|
+
registrableDomain: getDomain(url.hostname, { allowPrivateDomains: true }) ||
|
|
87
|
+
url.hostname.toLowerCase()
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function parseRemoteArchiveInput(input) {
|
|
92
|
+
if (typeof input !== 'string' || !input.trim()) {
|
|
93
|
+
throw new Error('Remote Archive input is required');
|
|
94
|
+
}
|
|
95
|
+
const url = requireHttpUrl(input);
|
|
96
|
+
return {
|
|
97
|
+
input: input.trim(),
|
|
98
|
+
url,
|
|
99
|
+
display: sanitizeRemoteArchiveUrl(url),
|
|
100
|
+
protocol: url.protocol.slice(0, -1)
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export async function downloadRemoteArchive(input, destination, options = {}) {
|
|
105
|
+
return downloadRemoteResource(input, destination, {
|
|
106
|
+
...options,
|
|
107
|
+
defaultLimits: DEFAULT_HTTP_LIMITS,
|
|
108
|
+
resourceLabel: 'Remote ZIP'
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export async function downloadRemoteSkillFile(input, destination, options = {}) {
|
|
113
|
+
return downloadRemoteResource(input, destination, {
|
|
114
|
+
...options,
|
|
115
|
+
defaultLimits: DEFAULT_REMOTE_SKILL_FILE_LIMITS,
|
|
116
|
+
resourceLabel: 'Remote SKILL.md',
|
|
117
|
+
validateUrl: (url) => parseRemoteSkillFileInput(url.toString())
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function downloadRemoteResource(input, destination, options) {
|
|
122
|
+
const limits = { ...options.defaultLimits, ...options.limits };
|
|
123
|
+
const resourceLabel = options.resourceLabel;
|
|
124
|
+
const controller = new AbortController();
|
|
125
|
+
const completeTimer = setTimeout(() => {
|
|
126
|
+
controller.abort(downloadError(
|
|
127
|
+
`${resourceLabel} complete-download timeout exceeded`
|
|
128
|
+
));
|
|
129
|
+
}, limits.completeTimeoutMs);
|
|
130
|
+
|
|
131
|
+
try {
|
|
132
|
+
const response = await followRedirects(
|
|
133
|
+
requireHttpUrl(input),
|
|
134
|
+
limits,
|
|
135
|
+
options.lookup,
|
|
136
|
+
controller.signal,
|
|
137
|
+
options.validateUrl,
|
|
138
|
+
resourceLabel
|
|
139
|
+
);
|
|
140
|
+
await writeResponse(
|
|
141
|
+
response,
|
|
142
|
+
destination,
|
|
143
|
+
limits.maxDownloadBytes,
|
|
144
|
+
resourceLabel
|
|
145
|
+
);
|
|
146
|
+
} catch (error) {
|
|
147
|
+
await rm(destination, { force: true });
|
|
148
|
+
if (error instanceof SourceAcquisitionError) throw error;
|
|
149
|
+
if (
|
|
150
|
+
controller.signal.aborted &&
|
|
151
|
+
controller.signal.reason instanceof SourceAcquisitionError
|
|
152
|
+
) {
|
|
153
|
+
throw controller.signal.reason;
|
|
154
|
+
}
|
|
155
|
+
if (error.cause instanceof SourceAcquisitionError) throw error.cause;
|
|
156
|
+
throw downloadError(`${resourceLabel} download was truncated or interrupted`);
|
|
157
|
+
} finally {
|
|
158
|
+
clearTimeout(completeTimer);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function followRedirects(
|
|
163
|
+
url,
|
|
164
|
+
limits,
|
|
165
|
+
lookup,
|
|
166
|
+
signal,
|
|
167
|
+
validateUrl,
|
|
168
|
+
resourceLabel,
|
|
169
|
+
redirectCount = 0
|
|
170
|
+
) {
|
|
171
|
+
if (validateUrl) validateUrl(url);
|
|
172
|
+
const response = await requestUrl(url, {
|
|
173
|
+
connectionTimeoutMs: limits.connectionTimeoutMs,
|
|
174
|
+
lookup,
|
|
175
|
+
signal,
|
|
176
|
+
resourceLabel
|
|
177
|
+
});
|
|
178
|
+
if (![301, 302, 303, 307, 308].includes(response.statusCode)) {
|
|
179
|
+
if (response.statusCode < 200 || response.statusCode >= 300) {
|
|
180
|
+
response.resume();
|
|
181
|
+
throw downloadError(
|
|
182
|
+
`${resourceLabel} request failed with HTTP ${response.statusCode}`
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
return response;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
response.resume();
|
|
189
|
+
if (redirectCount >= limits.maxRedirects) {
|
|
190
|
+
throw downloadError(
|
|
191
|
+
`${resourceLabel} exceeded ${limits.maxRedirects} redirects`
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
const location = response.headers.location;
|
|
195
|
+
if (!location) {
|
|
196
|
+
throw downloadError(`${resourceLabel} redirect is missing Location`);
|
|
197
|
+
}
|
|
198
|
+
let next;
|
|
199
|
+
try {
|
|
200
|
+
next = new URL(location, url);
|
|
201
|
+
} catch {
|
|
202
|
+
throw downloadError(`${resourceLabel} redirect has an invalid Location`);
|
|
203
|
+
}
|
|
204
|
+
if (next.protocol !== url.protocol) {
|
|
205
|
+
throw downloadError(`${resourceLabel} redirect changed protocol`);
|
|
206
|
+
}
|
|
207
|
+
return followRedirects(
|
|
208
|
+
next,
|
|
209
|
+
limits,
|
|
210
|
+
lookup,
|
|
211
|
+
signal,
|
|
212
|
+
validateUrl,
|
|
213
|
+
resourceLabel,
|
|
214
|
+
redirectCount + 1
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function requestUrl(url, {
|
|
219
|
+
connectionTimeoutMs,
|
|
220
|
+
lookup,
|
|
221
|
+
signal,
|
|
222
|
+
resourceLabel
|
|
223
|
+
}) {
|
|
224
|
+
const client = url.protocol === 'http:' ? http : https;
|
|
225
|
+
return new Promise((resolve, reject) => {
|
|
226
|
+
const request = client.get(url, { lookup, signal }, resolve);
|
|
227
|
+
let connectionTimer;
|
|
228
|
+
request.once('socket', (socket) => {
|
|
229
|
+
if (!socket.connecting) return;
|
|
230
|
+
connectionTimer = setTimeout(() => {
|
|
231
|
+
request.destroy(downloadError(
|
|
232
|
+
`${resourceLabel} connection timeout exceeded`
|
|
233
|
+
));
|
|
234
|
+
}, connectionTimeoutMs);
|
|
235
|
+
const event = url.protocol === 'https:' ? 'secureConnect' : 'connect';
|
|
236
|
+
socket.once(event, () => clearTimeout(connectionTimer));
|
|
237
|
+
});
|
|
238
|
+
request.once('error', reject);
|
|
239
|
+
request.once('close', () => clearTimeout(connectionTimer));
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async function writeResponse(
|
|
244
|
+
response,
|
|
245
|
+
destination,
|
|
246
|
+
maxDownloadBytes,
|
|
247
|
+
resourceLabel
|
|
248
|
+
) {
|
|
249
|
+
let downloadedBytes = 0;
|
|
250
|
+
response.on('data', (chunk) => {
|
|
251
|
+
downloadedBytes += chunk.length;
|
|
252
|
+
if (downloadedBytes > maxDownloadBytes) {
|
|
253
|
+
response.destroy(downloadError(
|
|
254
|
+
`${resourceLabel} exceeds the ${maxDownloadBytes}-byte download limit`
|
|
255
|
+
));
|
|
256
|
+
}
|
|
257
|
+
});
|
|
258
|
+
await pipeline(response, createWriteStream(destination, { flags: 'wx' }));
|
|
259
|
+
if (!response.complete) {
|
|
260
|
+
throw downloadError(`${resourceLabel} download was truncated`);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function requireHttpUrl(input, inputLabel = 'Remote Archive') {
|
|
265
|
+
let url;
|
|
266
|
+
try {
|
|
267
|
+
url = new URL(input);
|
|
268
|
+
} catch {
|
|
269
|
+
throw downloadError(`${inputLabel} input must be a valid HTTP(S) URL`);
|
|
270
|
+
}
|
|
271
|
+
if (!['http:', 'https:'].includes(url.protocol)) {
|
|
272
|
+
throw downloadError(`${inputLabel} input must use HTTP or HTTPS`);
|
|
273
|
+
}
|
|
274
|
+
return url;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function sanitizeRemoteArchiveUrl(url) {
|
|
278
|
+
const sanitized = new URL(url);
|
|
279
|
+
sanitized.username = '';
|
|
280
|
+
sanitized.password = '';
|
|
281
|
+
sanitized.search = '';
|
|
282
|
+
sanitized.hash = '';
|
|
283
|
+
return sanitized.toString();
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function inferArchiveName(url) {
|
|
287
|
+
let basename;
|
|
288
|
+
try {
|
|
289
|
+
basename = decodeURIComponent(url.pathname.split('/').filter(Boolean).at(-1) || '');
|
|
290
|
+
} catch {
|
|
291
|
+
throw identityError('Remote Archive URL contains invalid path encoding');
|
|
292
|
+
}
|
|
293
|
+
return basename.replace(/\.zip$/i, '');
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function identityError(message) {
|
|
297
|
+
return new SourceAcquisitionError('unresolved-identity', message, 3);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function downloadError(message) {
|
|
301
|
+
return new SourceAcquisitionError('download-failure', message);
|
|
302
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { constants, createReadStream, createWriteStream } from 'node:fs';
|
|
2
|
+
import {
|
|
3
|
+
lstat,
|
|
4
|
+
mkdir,
|
|
5
|
+
mkdtemp,
|
|
6
|
+
open,
|
|
7
|
+
readlink,
|
|
8
|
+
realpath,
|
|
9
|
+
readdir,
|
|
10
|
+
rm,
|
|
11
|
+
symlink
|
|
12
|
+
} from 'node:fs/promises';
|
|
13
|
+
import { tmpdir } from 'node:os';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
import { pipeline } from 'node:stream/promises';
|
|
16
|
+
import { SourceAcquisitionError } from './sourceAcquisitionError.js';
|
|
17
|
+
import { extractZip, hasZipSignature } from './sourceArchive.js';
|
|
18
|
+
import { isPathInsideOrEqual } from './sourcePath.js';
|
|
19
|
+
import { checksumDirectory, sourcePathExists } from './sourceTree.js';
|
|
20
|
+
import { validateStagedSource } from './sourceValidation.js';
|
|
21
|
+
|
|
22
|
+
export async function inspectLocalInput(inputPath, options = {}) {
|
|
23
|
+
const input = await classifyLocalInput(inputPath);
|
|
24
|
+
if (input.type === 'local-directory') {
|
|
25
|
+
await validateLocalDirectory(inputPath);
|
|
26
|
+
return buildPreparedInput(input, inputPath);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const temporaryRoot = await mkdtemp(path.join(tmpdir(), 'skillcaddy-zip-plan-'));
|
|
30
|
+
try {
|
|
31
|
+
const extractedRoot = path.join(temporaryRoot, 'content');
|
|
32
|
+
await extractZip(inputPath, extractedRoot, options.archiveLimits);
|
|
33
|
+
const contentRoot = await unwrapArchiveRoot(extractedRoot);
|
|
34
|
+
return await buildPreparedInput(input, contentRoot);
|
|
35
|
+
} finally {
|
|
36
|
+
await rm(temporaryRoot, { recursive: true, force: true });
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function stageLocalInput(inputPath, stagingRoot, options = {}) {
|
|
41
|
+
const input = await classifyLocalInput(inputPath);
|
|
42
|
+
const contentRoot = path.join(stagingRoot, 'content');
|
|
43
|
+
if (input.type === 'local-directory') {
|
|
44
|
+
await validateLocalDirectory(inputPath);
|
|
45
|
+
await copyLocalDirectory(inputPath, contentRoot);
|
|
46
|
+
await validateLocalDirectory(contentRoot);
|
|
47
|
+
return buildPreparedInput(input, contentRoot);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
await extractZip(inputPath, contentRoot, options.archiveLimits);
|
|
51
|
+
return buildPreparedInput(input, await unwrapArchiveRoot(contentRoot));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function buildPreparedInput(input, contentRoot) {
|
|
55
|
+
const validation = await validateStagedSource(contentRoot);
|
|
56
|
+
return {
|
|
57
|
+
...input,
|
|
58
|
+
contentRoot,
|
|
59
|
+
integrity: {
|
|
60
|
+
algorithm: 'sha256',
|
|
61
|
+
value: await checksumDirectory(contentRoot)
|
|
62
|
+
},
|
|
63
|
+
...validation
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function classifyLocalInput(inputPath) {
|
|
68
|
+
let stat;
|
|
69
|
+
try {
|
|
70
|
+
stat = await lstat(inputPath);
|
|
71
|
+
} catch (error) {
|
|
72
|
+
if (error.code === 'ENOENT') {
|
|
73
|
+
throw new SourceAcquisitionError(
|
|
74
|
+
'invalid-input',
|
|
75
|
+
`Local input does not exist: ${path.basename(inputPath)}`
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
throw error;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const basename = path.basename(path.resolve(inputPath));
|
|
82
|
+
if (stat.isDirectory()) {
|
|
83
|
+
return { type: 'local-directory', basename, defaultName: basename };
|
|
84
|
+
}
|
|
85
|
+
if (!stat.isFile()) {
|
|
86
|
+
throw new SourceAcquisitionError('invalid-input', 'Local input must be a directory or ZIP file');
|
|
87
|
+
}
|
|
88
|
+
if (!(await hasZipSignature(inputPath))) {
|
|
89
|
+
throw new SourceAcquisitionError('invalid-input', 'Local file does not have a ZIP signature');
|
|
90
|
+
}
|
|
91
|
+
return {
|
|
92
|
+
type: 'local-zip',
|
|
93
|
+
basename,
|
|
94
|
+
defaultName: basename.replace(/\.zip$/i, '') || basename
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function validateLocalDirectory(sourceRoot) {
|
|
99
|
+
const resolvedRoot = await realpath(sourceRoot);
|
|
100
|
+
|
|
101
|
+
async function walk(directory, relativeDirectory = '') {
|
|
102
|
+
const entries = await readdir(directory, { withFileTypes: true });
|
|
103
|
+
for (const entry of entries) {
|
|
104
|
+
const entryPath = path.join(directory, entry.name);
|
|
105
|
+
const relativePath = path.posix.join(relativeDirectory, entry.name);
|
|
106
|
+
if (entry.isDirectory()) {
|
|
107
|
+
await walk(entryPath, relativePath);
|
|
108
|
+
} else if (entry.isSymbolicLink()) {
|
|
109
|
+
await validateLocalSymlink(resolvedRoot, entryPath, relativePath);
|
|
110
|
+
} else if (!entry.isFile()) {
|
|
111
|
+
throw new SourceAcquisitionError(
|
|
112
|
+
'source-safety',
|
|
113
|
+
`Unsupported local source entry: ${relativePath}`
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
await walk(sourceRoot);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function validateLocalSymlink(resolvedRoot, entryPath, relativePath) {
|
|
123
|
+
const target = await readlink(entryPath);
|
|
124
|
+
if (path.isAbsolute(target)) {
|
|
125
|
+
throw new SourceAcquisitionError(
|
|
126
|
+
'source-safety',
|
|
127
|
+
`Absolute local symlink is not allowed: ${relativePath}`
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
let resolvedTarget;
|
|
131
|
+
try {
|
|
132
|
+
resolvedTarget = await realpath(entryPath);
|
|
133
|
+
} catch {
|
|
134
|
+
throw new SourceAcquisitionError(
|
|
135
|
+
'source-safety',
|
|
136
|
+
`Broken local symlink is not allowed: ${relativePath}`
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
if (!isPathInsideOrEqual(resolvedRoot, resolvedTarget)) {
|
|
140
|
+
throw new SourceAcquisitionError(
|
|
141
|
+
'source-safety',
|
|
142
|
+
`Escaping local symlink is not allowed: ${relativePath}`
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function copyLocalDirectory(sourceRoot, destinationRoot) {
|
|
148
|
+
await mkdir(destinationRoot, { recursive: true });
|
|
149
|
+
|
|
150
|
+
async function copyDirectory(sourceDirectory, destinationDirectory) {
|
|
151
|
+
const entries = await readdir(sourceDirectory, { withFileTypes: true });
|
|
152
|
+
for (const entry of entries) {
|
|
153
|
+
const sourcePath = path.join(sourceDirectory, entry.name);
|
|
154
|
+
const destinationPath = path.join(destinationDirectory, entry.name);
|
|
155
|
+
if (entry.isDirectory()) {
|
|
156
|
+
await mkdir(destinationPath);
|
|
157
|
+
await copyDirectory(sourcePath, destinationPath);
|
|
158
|
+
} else if (entry.isSymbolicLink()) {
|
|
159
|
+
await symlink(await readlink(sourcePath), destinationPath);
|
|
160
|
+
} else if (entry.isFile()) {
|
|
161
|
+
await copyRegularFile(sourcePath, destinationPath);
|
|
162
|
+
} else {
|
|
163
|
+
throw new SourceAcquisitionError(
|
|
164
|
+
'source-safety',
|
|
165
|
+
`Unsupported local source entry: ${path.relative(sourceRoot, sourcePath)}`
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
await copyDirectory(sourceRoot, destinationRoot);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async function copyRegularFile(sourcePath, destinationPath) {
|
|
175
|
+
const handle = await open(sourcePath, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
176
|
+
try {
|
|
177
|
+
const stat = await handle.stat();
|
|
178
|
+
if (!stat.isFile()) {
|
|
179
|
+
throw new SourceAcquisitionError('source-safety', 'Local source changed during copying');
|
|
180
|
+
}
|
|
181
|
+
await pipeline(
|
|
182
|
+
createReadStream(null, { fd: handle.fd, autoClose: false }),
|
|
183
|
+
createWriteStream(destinationPath, { flags: 'wx', mode: stat.mode & 0o777 })
|
|
184
|
+
);
|
|
185
|
+
} finally {
|
|
186
|
+
await handle.close();
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async function unwrapArchiveRoot(extractedRoot) {
|
|
191
|
+
let currentRoot = extractedRoot;
|
|
192
|
+
while (true) {
|
|
193
|
+
const entries = await readdir(currentRoot, { withFileTypes: true });
|
|
194
|
+
if (entries.length !== 1 || !entries[0].isDirectory()) return currentRoot;
|
|
195
|
+
const directSkill = path.join(currentRoot, 'SKILL.md');
|
|
196
|
+
const skillsDirectory = path.join(currentRoot, 'skills');
|
|
197
|
+
if (await sourcePathExists(directSkill) || await sourcePathExists(skillsDirectory)) return currentRoot;
|
|
198
|
+
currentRoot = path.join(currentRoot, entries[0].name);
|
|
199
|
+
}
|
|
200
|
+
}
|