skillcaddy 0.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/CHANGELOG.md +497 -0
  2. package/CONTEXT.md +149 -0
  3. package/LICENSE +21 -0
  4. package/README.md +499 -0
  5. package/README_CN.md +482 -0
  6. package/collection-metadata/github/mattpocock.json +16 -0
  7. package/lib/canonicalJson.js +11 -0
  8. package/lib/claudeStore.js +135 -0
  9. package/lib/collectionEnablement.js +115 -0
  10. package/lib/collectionSetup.js +199 -0
  11. package/lib/enablePlan.js +116 -0
  12. package/lib/enablementScope.js +8 -0
  13. package/lib/hermesStore.js +21 -0
  14. package/lib/libraryImage.js +1785 -0
  15. package/lib/managerInstall.js +109 -0
  16. package/lib/managerSourceWorkflow.js +234 -0
  17. package/lib/projectActions.js +24 -0
  18. package/lib/projectPath.js +51 -0
  19. package/lib/roots.js +99 -0
  20. package/lib/skillLinkStore.js +68 -0
  21. package/lib/skillMetadata.js +191 -0
  22. package/lib/skillStore.js +541 -0
  23. package/lib/sourceAcquisition.js +475 -0
  24. package/lib/sourceAcquisitionAdapter.js +47 -0
  25. package/lib/sourceAcquisitionError.js +18 -0
  26. package/lib/sourceAcquisitionPolicy.js +139 -0
  27. package/lib/sourceArchive.js +363 -0
  28. package/lib/sourceArchiveWorkspace.js +36 -0
  29. package/lib/sourceGit.js +95 -0
  30. package/lib/sourceGitAcquisitionAdapter.js +145 -0
  31. package/lib/sourceGitBatchUpdate.js +168 -0
  32. package/lib/sourceGitRepair.js +227 -0
  33. package/lib/sourceGitStaging.js +139 -0
  34. package/lib/sourceGitUpgradeAdapter.js +583 -0
  35. package/lib/sourceGitUrl.js +170 -0
  36. package/lib/sourceHttp.js +302 -0
  37. package/lib/sourceLocal.js +200 -0
  38. package/lib/sourceLocalAcquisitionAdapter.js +107 -0
  39. package/lib/sourceManager.js +242 -0
  40. package/lib/sourceMigration.js +172 -0
  41. package/lib/sourceMigrationIssue.js +6 -0
  42. package/lib/sourcePath.js +6 -0
  43. package/lib/sourcePolicy.js +53 -0
  44. package/lib/sourceRecord.js +295 -0
  45. package/lib/sourceRegistry.js +155 -0
  46. package/lib/sourceRemoteArchiveAcquisitionAdapter.js +147 -0
  47. package/lib/sourceRemoteFileAcquisitionAdapter.js +95 -0
  48. package/lib/sourceRemoteFilePolicy.js +62 -0
  49. package/lib/sourceRemoteFileWorkspace.js +55 -0
  50. package/lib/sourceSkillDiscovery.js +117 -0
  51. package/lib/sourceTree.js +185 -0
  52. package/lib/sourceUpgrade.js +596 -0
  53. package/lib/sourceUpgradeAdapter.js +41 -0
  54. package/lib/sourceUpgradePolicy.js +170 -0
  55. package/lib/sourceUpgradeTransaction.js +202 -0
  56. package/lib/sourceValidation.js +120 -0
  57. package/lib/sourceWorkspace.js +37 -0
  58. package/lib/specGate.js +235 -0
  59. package/lib/tuiActions.js +169 -0
  60. package/lib/tuiInstall.js +133 -0
  61. package/lib/tuiLayout.js +94 -0
  62. package/lib/version.js +17 -0
  63. package/lib/webManager.js +370 -0
  64. package/package.json +74 -0
  65. package/public/agentsUi.js +138 -0
  66. package/public/app.js +1431 -0
  67. package/public/claude.css +2 -0
  68. package/public/claudeUi.js +119 -0
  69. package/public/emptyState.js +12 -0
  70. package/public/enabled.css +303 -0
  71. package/public/favicon.svg +5 -0
  72. package/public/index.html +199 -0
  73. package/public/skillcaddy_CN.png +0 -0
  74. package/public/skillcaddy_EN.png +0 -0
  75. package/public/styles.css +1611 -0
  76. package/scripts/cli.js +647 -0
  77. package/scripts/cron-sync-update-git.sh +128 -0
  78. package/scripts/pull-github.sh +22 -0
  79. package/scripts/source.js +638 -0
  80. package/scripts/tui.js +1058 -0
  81. package/server.js +196 -0
  82. package/skills/skillcaddy-manager/SKILL.md +93 -0
  83. package/skills/skillcaddy-manager/agents/openai.yaml +7 -0
  84. package/skills/skillcaddy-manager/references/METADATA.md +75 -0
  85. package/skills/skillcaddy-manager/references/OPERATIONS.md +315 -0
  86. package/skills/skillcaddy-manager/references/RECOMMENDATION_GUIDE.md +51 -0
  87. package/skills/skillcaddy-manager/references/featured-skills.json +564 -0
  88. package/skills/skillcaddy-manager/references/skill-platforms.json +48 -0
  89. package/skills/skillcaddy-manager/scripts/check-conflicts.cjs +117 -0
  90. package/skills/skillcaddy-manager/scripts/check-global-skills.cjs +169 -0
  91. package/skills/skillcaddy-manager/scripts/translate-skill-notes.cjs +297 -0
  92. package/skills/skillcaddy-manager/scripts/version-manager.cjs +146 -0
  93. package/skills/skillcaddy-manager/scripts/view-recommendations.cjs +279 -0
  94. package/skills/skillcaddy-manager/skillcaddy.json +9 -0
  95. package/skills/spec-pipeline/spec-direction-gate/SKILL.md +119 -0
  96. package/skills/spec-pipeline/spec-direction-gate/check-approval.py +110 -0
  97. package/skills/spec-pipeline/spec-draft-intake/SKILL.md +116 -0
  98. package/skills/spec-pipeline/spec-role-contract/SKILL.md +113 -0
  99. package/skills/spec-pipeline/spec-role-contract/check-roles.py +108 -0
@@ -0,0 +1,363 @@
1
+ import { createReadStream, createWriteStream } from 'node:fs';
2
+ import {
3
+ mkdir,
4
+ open,
5
+ rm
6
+ } from 'node:fs/promises';
7
+ import path from 'node:path';
8
+ import { pipeline } from 'node:stream/promises';
9
+ import { Readable, Transform } from 'node:stream';
10
+ import { createInflateRaw } from 'node:zlib';
11
+ import { SourceAcquisitionError } from './sourceAcquisitionError.js';
12
+ import { isPathInsideOrEqual } from './sourcePath.js';
13
+
14
+ const ZIP_LOCAL_SIGNATURE = 0x04034b50;
15
+ const ZIP_CENTRAL_SIGNATURE = 0x02014b50;
16
+ const ZIP_END_SIGNATURE = 0x06054b50;
17
+ const ZIP_SIGNATURES = new Set([ZIP_LOCAL_SIGNATURE, ZIP_END_SIGNATURE, 0x08074b50]);
18
+
19
+ export const DEFAULT_ARCHIVE_LIMITS = Object.freeze({
20
+ maxExpandedBytes: 500 * 1024 * 1024,
21
+ maxEntries: 10_000,
22
+ maxFileBytes: 100 * 1024 * 1024,
23
+ maxDepth: 30
24
+ });
25
+
26
+ export async function hasZipSignature(filePath) {
27
+ const handle = await open(filePath, 'r');
28
+ try {
29
+ const signature = Buffer.alloc(4);
30
+ const { bytesRead } = await handle.read(signature, 0, signature.length, 0);
31
+ return bytesRead === signature.length && ZIP_SIGNATURES.has(signature.readUInt32LE(0));
32
+ } finally {
33
+ await handle.close();
34
+ }
35
+ }
36
+
37
+ export async function extractZip(filePath, destination, overrides = {}) {
38
+ const limits = resolveLimits(overrides);
39
+ const handle = await open(filePath, 'r');
40
+ try {
41
+ const stat = await handle.stat();
42
+ const directory = await readCentralDirectory(handle, stat.size, limits);
43
+ await validateOutputPaths(directory.entries, destination, limits);
44
+ await mkdir(destination, { recursive: true });
45
+
46
+ const totals = { expandedBytes: 0 };
47
+ for (const entry of directory.entries) {
48
+ if (entry.ignored) continue;
49
+ const targetPath = path.join(destination, ...entry.path.split('/'));
50
+ if (entry.directory) {
51
+ await mkdir(targetPath, { recursive: true, mode: entry.permissions });
52
+ } else {
53
+ await extractFile(handle, entry, targetPath, directory.centralOffset, limits, totals);
54
+ }
55
+ }
56
+ } catch (error) {
57
+ await rm(destination, { recursive: true, force: true });
58
+ if (error instanceof SourceAcquisitionError) throw error;
59
+ throw archiveError(error.message);
60
+ } finally {
61
+ await handle.close();
62
+ }
63
+ }
64
+
65
+ async function readCentralDirectory(handle, fileSize, limits) {
66
+ const tailLength = Math.min(fileSize, 65_557);
67
+ const tail = Buffer.alloc(tailLength);
68
+ await readExactly(handle, tail, fileSize - tailLength);
69
+ const endOffset = findEndRecord(tail);
70
+ const diskNumber = tail.readUInt16LE(endOffset + 4);
71
+ const centralDisk = tail.readUInt16LE(endOffset + 6);
72
+ const diskEntries = tail.readUInt16LE(endOffset + 8);
73
+ const totalEntries = tail.readUInt16LE(endOffset + 10);
74
+ const centralSize = tail.readUInt32LE(endOffset + 12);
75
+ const centralOffset = tail.readUInt32LE(endOffset + 16);
76
+ if (
77
+ diskNumber !== 0 ||
78
+ centralDisk !== 0 ||
79
+ diskEntries !== totalEntries ||
80
+ totalEntries === 0xffff ||
81
+ centralSize === 0xffffffff ||
82
+ centralOffset === 0xffffffff
83
+ ) {
84
+ throw archiveError('Multi-disk and ZIP64 archives are not supported');
85
+ }
86
+ if (totalEntries > limits.maxEntries) {
87
+ throw archiveError(`ZIP entry count exceeds limit ${limits.maxEntries}`);
88
+ }
89
+ if (centralOffset + centralSize > fileSize) {
90
+ throw archiveError('ZIP central directory escapes the archive');
91
+ }
92
+
93
+ const entries = [];
94
+ let cursor = centralOffset;
95
+ for (let index = 0; index < totalEntries; index += 1) {
96
+ const header = Buffer.alloc(46);
97
+ await readExactly(handle, header, cursor);
98
+ if (header.readUInt32LE(0) !== ZIP_CENTRAL_SIGNATURE) {
99
+ throw archiveError('Invalid ZIP central directory entry');
100
+ }
101
+ const nameLength = header.readUInt16LE(28);
102
+ const extraLength = header.readUInt16LE(30);
103
+ const commentLength = header.readUInt16LE(32);
104
+ const nameBuffer = Buffer.alloc(nameLength);
105
+ await readExactly(handle, nameBuffer, cursor + header.length);
106
+ entries.push(parseCentralEntry(header, nameBuffer));
107
+ cursor += header.length + nameLength + extraLength + commentLength;
108
+ if (cursor > centralOffset + centralSize) {
109
+ throw archiveError('ZIP central directory entry exceeds its declared size');
110
+ }
111
+ }
112
+ if (cursor !== centralOffset + centralSize) {
113
+ throw archiveError('ZIP central directory size does not match its entries');
114
+ }
115
+ return { centralOffset, entries };
116
+ }
117
+
118
+ function parseCentralEntry(header, nameBuffer) {
119
+ const flags = header.readUInt16LE(8);
120
+ if (flags & 0x1) throw archiveError('Encrypted ZIP entries are not supported');
121
+ const method = header.readUInt16LE(10);
122
+ if (![0, 8].includes(method)) {
123
+ throw archiveError(`Unsupported ZIP compression method: ${method}`);
124
+ }
125
+
126
+ const encoding = flags & 0x800 ? 'utf8' : 'ascii';
127
+ const name = decodeEntryName(nameBuffer, encoding);
128
+ const versionMadeBy = header.readUInt16LE(4);
129
+ const externalAttributes = header.readUInt32LE(38);
130
+ const unixMode = versionMadeBy >> 8 === 3 ? externalAttributes >>> 16 : 0;
131
+ const entryType = unixMode & 0o170000;
132
+ if (entryType === 0o120000) throw archiveError(`ZIP symlink entry is not allowed: ${safeName(name)}`);
133
+ if (entryType && ![0o040000, 0o100000].includes(entryType)) {
134
+ throw archiveError(`ZIP special file entry is not allowed: ${safeName(name)}`);
135
+ }
136
+
137
+ const entryKind = entryType === 0o040000 ||
138
+ Boolean(externalAttributes & 0x10) ||
139
+ name.endsWith('/') ? 'directory' : 'file';
140
+ return {
141
+ compressedSize: header.readUInt32LE(20),
142
+ crc32: header.readUInt32LE(16),
143
+ directory: entryKind === 'directory',
144
+ flags,
145
+ localOffset: header.readUInt32LE(42),
146
+ method,
147
+ nameBuffer,
148
+ path: validateEntryPath(name, entryKind),
149
+ permissions: unixMode ? unixMode & 0o777 : entryKind === 'directory' ? 0o755 : 0o644,
150
+ uncompressedSize: header.readUInt32LE(24)
151
+ };
152
+ }
153
+
154
+ async function validateOutputPaths(entries, destination, limits) {
155
+ const outputTypes = new Map();
156
+ for (const entry of entries) {
157
+ entry.ignored = isMacPackagingJunk(entry.path);
158
+ if (entry.ignored) continue;
159
+
160
+ const segments = entry.path.split('/');
161
+ const depth = entry.directory ? segments.length : segments.length - 1;
162
+ if (depth > limits.maxDepth) {
163
+ throw archiveError(`ZIP directory depth exceeds limit ${limits.maxDepth}: ${safeName(entry.path)}`);
164
+ }
165
+ const resolved = path.resolve(destination, ...segments);
166
+ if (!isPathInsideOrEqual(path.resolve(destination), resolved)) {
167
+ throw archiveError(`ZIP destination escape is not allowed: ${safeName(entry.path)}`);
168
+ }
169
+
170
+ if (outputTypes.has(entry.path)) {
171
+ throw archiveError(`Duplicate ZIP output path: ${safeName(entry.path)}`);
172
+ }
173
+ for (let index = 1; index < segments.length; index += 1) {
174
+ const ancestor = segments.slice(0, index).join('/');
175
+ if (outputTypes.get(ancestor) === 'file') {
176
+ throw archiveError(`ZIP output path crosses a file: ${safeName(entry.path)}`);
177
+ }
178
+ }
179
+ if (!entry.directory) {
180
+ for (const knownPath of outputTypes.keys()) {
181
+ if (knownPath.startsWith(`${entry.path}/`)) {
182
+ throw archiveError(`ZIP file conflicts with a directory: ${safeName(entry.path)}`);
183
+ }
184
+ }
185
+ }
186
+ outputTypes.set(entry.path, entry.directory ? 'directory' : 'file');
187
+ }
188
+ }
189
+
190
+ async function extractFile(handle, entry, targetPath, centralOffset, limits, totals) {
191
+ const localHeader = Buffer.alloc(30);
192
+ await readExactly(handle, localHeader, entry.localOffset);
193
+ if (localHeader.readUInt32LE(0) !== ZIP_LOCAL_SIGNATURE) {
194
+ throw archiveError(`Invalid ZIP local header: ${safeName(entry.path)}`);
195
+ }
196
+ const nameLength = localHeader.readUInt16LE(26);
197
+ const extraLength = localHeader.readUInt16LE(28);
198
+ const localName = Buffer.alloc(nameLength);
199
+ await readExactly(handle, localName, entry.localOffset + localHeader.length);
200
+ if (!localName.equals(entry.nameBuffer)) {
201
+ throw archiveError(`ZIP local and central names differ: ${safeName(entry.path)}`);
202
+ }
203
+ if (
204
+ localHeader.readUInt16LE(6) !== entry.flags ||
205
+ localHeader.readUInt16LE(8) !== entry.method
206
+ ) {
207
+ throw archiveError(`ZIP local and central metadata differ: ${safeName(entry.path)}`);
208
+ }
209
+
210
+ const dataStart = entry.localOffset + localHeader.length + nameLength + extraLength;
211
+ const dataEnd = dataStart + entry.compressedSize;
212
+ if (dataStart < 0 || dataEnd > centralOffset) {
213
+ throw archiveError(`ZIP entry data escapes the content region: ${safeName(entry.path)}`);
214
+ }
215
+
216
+ await mkdir(path.dirname(targetPath), { recursive: true });
217
+ const verifier = new ContentVerifier(entry, limits, totals);
218
+ const compressedStream = entry.compressedSize === 0
219
+ ? Readable.from([])
220
+ : createReadStream(null, {
221
+ fd: handle.fd,
222
+ start: dataStart,
223
+ end: dataEnd - 1,
224
+ autoClose: false
225
+ });
226
+ const output = createWriteStream(targetPath, {
227
+ flags: 'wx',
228
+ mode: entry.permissions
229
+ });
230
+ const streams = entry.method === 8
231
+ ? [compressedStream, createInflateRaw(), verifier, output]
232
+ : [compressedStream, verifier, output];
233
+ try {
234
+ await pipeline(...streams);
235
+ } catch (error) {
236
+ await rm(targetPath, { force: true });
237
+ if (error instanceof SourceAcquisitionError) throw error;
238
+ throw archiveError(`Could not extract ZIP entry ${safeName(entry.path)}: ${error.message}`);
239
+ }
240
+ verifier.assertComplete();
241
+ }
242
+
243
+ class ContentVerifier extends Transform {
244
+ constructor(entry, limits, totals) {
245
+ super();
246
+ this.entry = entry;
247
+ this.limits = limits;
248
+ this.totals = totals;
249
+ this.bytes = 0;
250
+ this.crc = 0xffffffff;
251
+ }
252
+
253
+ _transform(chunk, encoding, callback) {
254
+ this.bytes += chunk.length;
255
+ this.totals.expandedBytes += chunk.length;
256
+ if (this.bytes > this.limits.maxFileBytes) {
257
+ callback(archiveError(`ZIP individual file exceeds limit ${this.limits.maxFileBytes}: ${safeName(this.entry.path)}`));
258
+ return;
259
+ }
260
+ if (this.totals.expandedBytes > this.limits.maxExpandedBytes) {
261
+ callback(archiveError(`ZIP expanded content exceeds limit ${this.limits.maxExpandedBytes}`));
262
+ return;
263
+ }
264
+ this.crc = updateCrc32(this.crc, chunk);
265
+ callback(null, chunk);
266
+ }
267
+
268
+ assertComplete() {
269
+ if (this.bytes !== this.entry.uncompressedSize) {
270
+ throw archiveError(`ZIP written size does not match metadata: ${safeName(this.entry.path)}`);
271
+ }
272
+ if (((this.crc ^ 0xffffffff) >>> 0) !== this.entry.crc32) {
273
+ throw archiveError(`ZIP checksum does not match content: ${safeName(this.entry.path)}`);
274
+ }
275
+ }
276
+ }
277
+
278
+ function validateEntryPath(name, entryKind) {
279
+ if (name.includes('\0')) throw archiveError('ZIP entry name contains NUL');
280
+ if (name.includes('\\')) throw archiveError(`ZIP entry name contains a backslash: ${safeName(name)}`);
281
+ if (/^[A-Za-z]:/.test(name)) throw archiveError(`ZIP drive-letter path is not allowed: ${safeName(name)}`);
282
+ if (name.startsWith('/')) throw archiveError(`ZIP absolute path is not allowed: ${safeName(name)}`);
283
+
284
+ const withoutTrailingSlash = entryKind === 'directory' ? name.replace(/\/+$/, '') : name;
285
+ const segments = withoutTrailingSlash.split('/');
286
+ if (
287
+ !withoutTrailingSlash ||
288
+ segments.some((segment) => !segment || segment === '.' || segment === '..')
289
+ ) {
290
+ const reason = segments.includes('..') ? 'path traversal' : 'invalid path';
291
+ throw archiveError(`ZIP ${reason} is not allowed: ${safeName(name)}`);
292
+ }
293
+ return segments.join('/');
294
+ }
295
+
296
+ function decodeEntryName(buffer, encoding) {
297
+ if (encoding === 'ascii' && buffer.some((byte) => byte > 0x7f)) {
298
+ throw archiveError('Non-UTF-8 ZIP entry names are not supported');
299
+ }
300
+ try {
301
+ return new TextDecoder('utf-8', { fatal: true }).decode(buffer);
302
+ } catch {
303
+ throw archiveError('ZIP entry name is not valid UTF-8');
304
+ }
305
+ }
306
+
307
+ function findEndRecord(tail) {
308
+ for (let offset = tail.length - 22; offset >= 0; offset -= 1) {
309
+ if (tail.readUInt32LE(offset) !== ZIP_END_SIGNATURE) continue;
310
+ const commentLength = tail.readUInt16LE(offset + 20);
311
+ if (offset + 22 + commentLength === tail.length) return offset;
312
+ }
313
+ throw archiveError('ZIP end record was not found');
314
+ }
315
+
316
+ async function readExactly(handle, buffer, position) {
317
+ let offset = 0;
318
+ while (offset < buffer.length) {
319
+ const { bytesRead } = await handle.read(
320
+ buffer,
321
+ offset,
322
+ buffer.length - offset,
323
+ position + offset
324
+ );
325
+ if (bytesRead === 0) throw archiveError('ZIP file is truncated');
326
+ offset += bytesRead;
327
+ }
328
+ }
329
+
330
+ function resolveLimits(overrides) {
331
+ const limits = { ...DEFAULT_ARCHIVE_LIMITS, ...overrides };
332
+ for (const [name, value] of Object.entries(limits)) {
333
+ if (!Number.isSafeInteger(value) || value < 1) {
334
+ throw new Error(`Invalid archive limit ${name}`);
335
+ }
336
+ }
337
+ return limits;
338
+ }
339
+
340
+ function isMacPackagingJunk(entryPath) {
341
+ const segments = entryPath.split('/');
342
+ const basename = segments.at(-1);
343
+ return segments[0] === '__MACOSX' || basename === '.DS_Store' || basename.startsWith('._');
344
+ }
345
+
346
+ function updateCrc32(initial, buffer) {
347
+ let crc = initial;
348
+ for (const byte of buffer) {
349
+ crc ^= byte;
350
+ for (let bit = 0; bit < 8; bit += 1) {
351
+ crc = (crc >>> 1) ^ (crc & 1 ? 0xedb88320 : 0);
352
+ }
353
+ }
354
+ return crc >>> 0;
355
+ }
356
+
357
+ function safeName(name) {
358
+ return name.replace(/\0/g, '\\0');
359
+ }
360
+
361
+ function archiveError(message) {
362
+ return new SourceAcquisitionError('archive-safety', message);
363
+ }
@@ -0,0 +1,36 @@
1
+ import {
2
+ mkdtemp,
3
+ rm
4
+ } from 'node:fs/promises';
5
+ import { tmpdir } from 'node:os';
6
+ import path from 'node:path';
7
+ import { downloadRemoteArchive } from './sourceHttp.js';
8
+ import { inspectLocalInput, stageLocalInput } from './sourceLocal.js';
9
+
10
+ export async function inspectRemoteArchive(context, parsed, prefix) {
11
+ const stagingRoot = await mkdtemp(path.join(tmpdir(), prefix));
12
+ try {
13
+ const downloadPath = await downloadArchive(context, parsed, stagingRoot);
14
+ return await inspectLocalInput(downloadPath, {
15
+ archiveLimits: context.archiveLimits
16
+ });
17
+ } finally {
18
+ await rm(stagingRoot, { recursive: true, force: true });
19
+ }
20
+ }
21
+
22
+ export async function stageRemoteArchive(context, parsed, stagingRoot) {
23
+ const downloadPath = await downloadArchive(context, parsed, stagingRoot);
24
+ return stageLocalInput(downloadPath, stagingRoot, {
25
+ archiveLimits: context.archiveLimits
26
+ });
27
+ }
28
+
29
+ async function downloadArchive(context, parsed, stagingRoot) {
30
+ const downloadPath = path.join(stagingRoot, 'download');
31
+ await downloadRemoteArchive(parsed.input, downloadPath, {
32
+ limits: context.httpLimits,
33
+ lookup: context.httpLookup
34
+ });
35
+ return downloadPath;
36
+ }
@@ -0,0 +1,95 @@
1
+ import { execFile as execFileCallback } from 'node:child_process';
2
+ import { promisify } from 'node:util';
3
+ import { SourceMigrationIssue } from './sourceMigrationIssue.js';
4
+ import { sanitizeGitRemote } from './sourceRecord.js';
5
+
6
+ const execFile = promisify(execFileCallback);
7
+
8
+ export async function inspectGitSource(sourcePath) {
9
+ const remotes = await readOriginRemotes(sourcePath);
10
+ if (remotes.length !== 1) {
11
+ throw new SourceMigrationIssue(
12
+ 'ambiguous-remote',
13
+ 'Git source must have one unambiguous origin remote'
14
+ );
15
+ }
16
+
17
+ let remote;
18
+ let repositoryPath;
19
+ try {
20
+ remote = sanitizeGitRemote(remotes[0]);
21
+ repositoryPath = parseRepositoryPath(remote);
22
+ } catch (error) {
23
+ throw new SourceMigrationIssue('ambiguous-remote', error.message);
24
+ }
25
+
26
+ const commit = await readGitHead(sourcePath);
27
+ const ref = await readGitRef(sourcePath);
28
+ return {
29
+ sourceId: `github/${repositoryPath}`,
30
+ origin: {
31
+ kind: 'git',
32
+ remote,
33
+ ...(ref ? { ref } : {}),
34
+ commit
35
+ }
36
+ };
37
+ }
38
+
39
+ export async function readGitHead(sourcePath, { timeoutMs = 30_000 } = {}) {
40
+ const { stdout } = await execFile(
41
+ 'git',
42
+ ['-C', sourcePath, 'rev-parse', 'HEAD'],
43
+ { timeout: timeoutMs }
44
+ );
45
+ return stdout.trim();
46
+ }
47
+
48
+ async function readOriginRemotes(sourcePath) {
49
+ let remoteOutput;
50
+ try {
51
+ ({ stdout: remoteOutput } = await execFile('git', [
52
+ '-C',
53
+ sourcePath,
54
+ 'remote',
55
+ 'get-url',
56
+ '--all',
57
+ 'origin'
58
+ ]));
59
+ } catch {
60
+ throw new SourceMigrationIssue('ambiguous-remote', 'Git source has no readable origin remote');
61
+ }
62
+ return [...new Set(
63
+ remoteOutput.split('\n').map((remote) => remote.trim()).filter(Boolean)
64
+ )];
65
+ }
66
+
67
+ function parseRepositoryPath(remote) {
68
+ const repositoryPath = remote.includes('://')
69
+ ? new URL(remote).pathname
70
+ : remote.slice(remote.indexOf(':') + 1);
71
+ const normalized = repositoryPath
72
+ .replace(/^\/+/, '')
73
+ .replace(/\.git$/, '')
74
+ .replace(/\/+$/, '');
75
+ if (normalized.split('/').length < 2) {
76
+ throw new Error('Git origin does not identify an owner and repository');
77
+ }
78
+ return normalized;
79
+ }
80
+
81
+ async function readGitRef(sourcePath) {
82
+ try {
83
+ const { stdout } = await execFile('git', [
84
+ '-C',
85
+ sourcePath,
86
+ 'symbolic-ref',
87
+ '--quiet',
88
+ '--short',
89
+ 'HEAD'
90
+ ]);
91
+ return stdout.trim();
92
+ } catch {
93
+ return null;
94
+ }
95
+ }
@@ -0,0 +1,145 @@
1
+ import { execFile as execFileCallback } from 'node:child_process';
2
+ import { mkdtemp, rm } from 'node:fs/promises';
3
+ import { tmpdir } from 'node:os';
4
+ import path from 'node:path';
5
+ import { promisify } from 'node:util';
6
+ import {
7
+ defineSourceAcquisitionAdapter
8
+ } from './sourceAcquisitionAdapter.js';
9
+ import { sourceCollisionError } from './sourceAcquisitionError.js';
10
+ import {
11
+ resolveSourceInstallDestination,
12
+ sameJson
13
+ } from './sourceAcquisitionPolicy.js';
14
+ import {
15
+ cloneAndInspectGitSource,
16
+ prioritizeFocusedSkills
17
+ } from './sourceGitStaging.js';
18
+ import {
19
+ canonicalGitRepositoryLocation,
20
+ parseGitSourceRequest
21
+ } from './sourceGitUrl.js';
22
+ import {
23
+ sourceInstallPathAvailable,
24
+ sourcePathExists
25
+ } from './sourceTree.js';
26
+
27
+ const execFile = promisify(execFileCallback);
28
+
29
+ export function createGitAcquisitionAdapter({ rootDir }, request) {
30
+ const parsed = parseGitSourceRequest(request);
31
+
32
+ return defineSourceAcquisitionAdapter({
33
+ stalePlanMessage: 'Git source changed since the add plan',
34
+ async inspect() {
35
+ const stagingRoot = await mkdtemp(
36
+ path.join(tmpdir(), 'skillcaddy-git-plan-')
37
+ );
38
+ try {
39
+ return buildGitCandidate(
40
+ parsed,
41
+ await cloneAndInspectGitSource(stagingRoot, parsed)
42
+ );
43
+ } finally {
44
+ await rm(stagingRoot, { recursive: true, force: true });
45
+ }
46
+ },
47
+ prepare: async (workspaceRoot) => buildGitCandidate(
48
+ parsed,
49
+ await cloneAndInspectGitSource(workspaceRoot, parsed)
50
+ ),
51
+ resolveIdentity: (_candidate, records) =>
52
+ resolveGitIdentity(rootDir, records, parsed),
53
+ buildRecord(plan) {
54
+ return {
55
+ schemaVersion: 1,
56
+ sourceId: plan.sourceId,
57
+ bucket: 'github',
58
+ type: 'git',
59
+ installPath: plan.installPath,
60
+ origin: plan.origin,
61
+ ...(plan.focus ? { focus: plan.focus } : {}),
62
+ skills: plan.skills
63
+ };
64
+ },
65
+ matchesIdentity(record, plan) {
66
+ return record.type === 'git' &&
67
+ record.installPath === plan.installPath &&
68
+ canonicalGitRepositoryLocation(record.origin.remote) ===
69
+ canonicalGitRepositoryLocation(plan.origin.remote) &&
70
+ record.origin.ref === plan.origin.ref &&
71
+ sameJson(record.focus, plan.focus);
72
+ },
73
+ matchesContent(record, plan) {
74
+ return record.origin.commit === plan.origin.commit &&
75
+ sameJson(record.skills, plan.skills);
76
+ },
77
+ assertInstalled: assertGitSourceMatchesRecord
78
+ });
79
+ }
80
+
81
+ async function resolveGitIdentity(rootDir, records, parsed) {
82
+ const sameIdentity = records.find(
83
+ (record) => record.sourceId === parsed.sourceId
84
+ );
85
+ if (sameIdentity) {
86
+ return {
87
+ sourceId: parsed.sourceId,
88
+ installPath: sameIdentity.installPath
89
+ };
90
+ }
91
+
92
+ const defaultPath = `github/${parsed.repository}`;
93
+ if (await sourceInstallPathAvailable(rootDir, records, defaultPath)) {
94
+ return { sourceId: parsed.sourceId, installPath: defaultPath };
95
+ }
96
+
97
+ const namespacedPath = `github/${parsed.owner}--${parsed.repository}`;
98
+ if (await sourceInstallPathAvailable(rootDir, records, namespacedPath)) {
99
+ return { sourceId: parsed.sourceId, installPath: namespacedPath };
100
+ }
101
+ throw sourceCollisionError(
102
+ `Source destination collision: ${namespacedPath}. The repository owner namespace is already in use.`
103
+ );
104
+ }
105
+
106
+ async function assertGitSourceMatchesRecord(rootDir, record, plan) {
107
+ const destination = resolveSourceInstallDestination(
108
+ rootDir,
109
+ record.installPath
110
+ );
111
+ if (!(await sourcePathExists(destination))) {
112
+ throw sourceCollisionError(
113
+ `Registered source content is missing: ${record.sourceId}`
114
+ );
115
+ }
116
+ const [{ stdout: commitOutput }, { stdout: statusOutput }] = await Promise.all([
117
+ execFile('git', ['-C', destination, 'rev-parse', 'HEAD']),
118
+ execFile('git', ['-C', destination, 'status', '--porcelain'])
119
+ ]);
120
+ if (commitOutput.trim() !== plan.origin.commit || statusOutput.trim()) {
121
+ throw sourceCollisionError(
122
+ `Registered Git source content has changed: ${record.sourceId}`
123
+ );
124
+ }
125
+ }
126
+
127
+ function buildGitCandidate(parsed, prepared) {
128
+ const focus = prepared.focus;
129
+ return {
130
+ input: {
131
+ type: 'git',
132
+ remote: parsed.displayRemote
133
+ },
134
+ origin: {
135
+ kind: 'git',
136
+ remote: parsed.displayRemote,
137
+ ref: prepared.ref,
138
+ commit: prepared.commit
139
+ },
140
+ ...(focus ? { focus } : {}),
141
+ skills: prioritizeFocusedSkills(prepared.skills, focus),
142
+ warnings: prepared.warnings,
143
+ ...(prepared.contentRoot ? { contentRoot: prepared.contentRoot } : {})
144
+ };
145
+ }