craftdesk 0.1.0 → 0.3.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/.claude/settings.local.json +19 -0
- package/.github/workflows/ci.yml +94 -0
- package/API.md +414 -0
- package/README.md +778 -0
- package/TESTING.md +263 -0
- package/bin/craftdesk.js +4 -0
- package/dist/commands/add.d.ts +3 -0
- package/dist/commands/add.d.ts.map +1 -0
- package/dist/commands/add.js +284 -0
- package/dist/commands/add.js.map +1 -0
- package/dist/commands/config.d.ts +3 -0
- package/dist/commands/config.d.ts.map +1 -0
- package/dist/commands/config.js +56 -0
- package/dist/commands/config.js.map +1 -0
- package/dist/commands/init.d.ts +3 -0
- package/dist/commands/init.d.ts.map +1 -0
- package/dist/commands/init.js +57 -0
- package/dist/commands/init.js.map +1 -0
- package/dist/commands/install.d.ts +3 -0
- package/dist/commands/install.d.ts.map +1 -0
- package/dist/commands/install.js +109 -0
- package/dist/commands/install.js.map +1 -0
- package/dist/commands/list.d.ts +3 -0
- package/dist/commands/list.d.ts.map +1 -0
- package/dist/commands/list.js +117 -0
- package/dist/commands/list.js.map +1 -0
- package/dist/commands/remove.d.ts +3 -0
- package/dist/commands/remove.d.ts.map +1 -0
- package/dist/commands/remove.js +74 -0
- package/dist/commands/remove.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +51 -0
- package/dist/index.js.map +1 -0
- package/dist/services/config-manager.d.ts +88 -0
- package/dist/services/config-manager.d.ts.map +1 -0
- package/dist/services/config-manager.js +143 -0
- package/dist/services/config-manager.js.map +1 -0
- package/dist/services/git-resolver.d.ts +139 -0
- package/dist/services/git-resolver.d.ts.map +1 -0
- package/dist/services/git-resolver.js +340 -0
- package/dist/services/git-resolver.js.map +1 -0
- package/dist/services/installer.d.ts +98 -0
- package/dist/services/installer.d.ts.map +1 -0
- package/dist/services/installer.js +274 -0
- package/dist/services/installer.js.map +1 -0
- package/dist/services/registry-client.d.ts +27 -0
- package/dist/services/registry-client.d.ts.map +1 -0
- package/dist/services/registry-client.js +134 -0
- package/dist/services/registry-client.js.map +1 -0
- package/dist/types/config.d.ts +17 -0
- package/dist/types/config.d.ts.map +1 -0
- package/dist/types/config.js +3 -0
- package/dist/types/config.js.map +1 -0
- package/dist/types/craftdesk-json.d.ts +42 -0
- package/dist/types/craftdesk-json.d.ts.map +1 -0
- package/dist/types/craftdesk-json.js +3 -0
- package/dist/types/craftdesk-json.js.map +1 -0
- package/dist/types/craftdesk-lock.d.ts +34 -0
- package/dist/types/craftdesk-lock.d.ts.map +1 -0
- package/dist/types/craftdesk-lock.js +3 -0
- package/dist/types/craftdesk-lock.js.map +1 -0
- package/dist/utils/file-system.d.ts +10 -0
- package/dist/utils/file-system.d.ts.map +1 -0
- package/dist/utils/file-system.js +60 -0
- package/dist/utils/file-system.js.map +1 -0
- package/dist/utils/logger.d.ts +18 -0
- package/dist/utils/logger.d.ts.map +1 -0
- package/dist/utils/logger.js +72 -0
- package/dist/utils/logger.js.map +1 -0
- package/docs/DEPENDENCY_RESOLUTION.md +237 -0
- package/examples/craftdesk-all-sources.json +79 -0
- package/examples/craftdesk-git-monorepo.json +41 -0
- package/examples/craftdesk-multi-registry.json +40 -0
- package/examples/craftdesk-with-registries.json +49 -0
- package/examples/simple-craftdesk.json +10 -0
- package/package.json +32 -25
- package/src/commands/add.ts +314 -0
- package/src/commands/init.ts +55 -0
- package/src/commands/install.ts +126 -0
- package/src/commands/list.ts +126 -0
- package/src/commands/remove.ts +82 -0
- package/src/index.ts +56 -0
- package/src/services/config-manager.ts +150 -0
- package/src/services/git-resolver.ts +373 -0
- package/src/services/installer.ts +314 -0
- package/src/services/registry-client.ts +163 -0
- package/src/types/craftdesk-json.ts +53 -0
- package/src/types/craftdesk-lock.ts +38 -0
- package/src/utils/file-system.ts +55 -0
- package/src/utils/logger.ts +82 -0
- package/tests/fixtures/craftdesk-git.json +17 -0
- package/tests/fixtures/craftdesk.json +15 -0
- package/tests/helpers/test-utils.ts +60 -0
- package/tests/integration/init-command.test.ts +70 -0
- package/tests/integration/list-command.test.ts +77 -0
- package/tests/unit/config-manager.test.ts +228 -0
- package/tests/unit/git-resolver.test.ts +303 -0
- package/tests/unit/installer.test.ts +105 -0
- package/tsconfig.json +20 -0
- package/vitest.config.ts +29 -0
- package/LICENSE +0 -21
- package/index.js +0 -4
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
import path from 'path';
|
|
2
|
+
import fs from 'fs-extra';
|
|
3
|
+
import { execSync } from 'child_process';
|
|
4
|
+
import { logger } from '../utils/logger';
|
|
5
|
+
import { CraftDeskJson, DependencyConfig } from '../types/craftdesk-json';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Information about a git dependency
|
|
9
|
+
*/
|
|
10
|
+
export interface GitDependencyInfo {
|
|
11
|
+
/** Git repository URL */
|
|
12
|
+
url: string;
|
|
13
|
+
/** Optional branch name */
|
|
14
|
+
branch?: string;
|
|
15
|
+
/** Optional tag name */
|
|
16
|
+
tag?: string;
|
|
17
|
+
/** Optional commit hash */
|
|
18
|
+
commit?: string;
|
|
19
|
+
/** Optional subdirectory path for monorepos */
|
|
20
|
+
path?: string;
|
|
21
|
+
/** Optional direct file path (e.g., skill.md, agent.md) */
|
|
22
|
+
file?: string;
|
|
23
|
+
/** Parsed craftdesk.json from the repository */
|
|
24
|
+
craftDeskJson?: CraftDeskJson;
|
|
25
|
+
/** Resolved full commit hash */
|
|
26
|
+
resolvedCommit?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Resolves git dependencies and extracts their metadata
|
|
31
|
+
*
|
|
32
|
+
* Handles cloning git repositories, reading craftdesk.json, inferring craft types
|
|
33
|
+
* when metadata is missing, and supporting monorepo structures with subdirectory paths.
|
|
34
|
+
*
|
|
35
|
+
* @example
|
|
36
|
+
* ```typescript
|
|
37
|
+
* const resolver = new GitResolver();
|
|
38
|
+
*
|
|
39
|
+
* // Resolve a git dependency
|
|
40
|
+
* const info = await resolver.resolveGitDependency({
|
|
41
|
+
* url: 'https://github.com/company/auth.git',
|
|
42
|
+
* branch: 'main'
|
|
43
|
+
* });
|
|
44
|
+
*
|
|
45
|
+
* console.log(info.craftDeskJson.name); // 'auth-skill'
|
|
46
|
+
* console.log(info.resolvedCommit); // Full commit hash
|
|
47
|
+
* ```
|
|
48
|
+
*/
|
|
49
|
+
export class GitResolver {
|
|
50
|
+
private tempDir: string;
|
|
51
|
+
|
|
52
|
+
constructor() {
|
|
53
|
+
this.tempDir = path.join(process.cwd(), '.craftdesk', 'tmp');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Resolves a git dependency by cloning and reading its craftdesk.json
|
|
58
|
+
*
|
|
59
|
+
* Process:
|
|
60
|
+
* 1. Clones the repository (shallow clone for efficiency)
|
|
61
|
+
* 2. Checks out specified branch/tag/commit
|
|
62
|
+
* 3. Reads craftdesk.json from repository, subdirectory, or infers from direct file
|
|
63
|
+
* 4. Generates minimal metadata if craftdesk.json is missing
|
|
64
|
+
* 5. Infers craft type from marker files, direct file extension, or directory structure
|
|
65
|
+
*
|
|
66
|
+
* @param gitInfo - Git dependency information
|
|
67
|
+
* @returns Enhanced git info with craftdesk.json and resolved commit
|
|
68
|
+
*
|
|
69
|
+
* @example
|
|
70
|
+
* ```typescript
|
|
71
|
+
* // With craftdesk.json in repository
|
|
72
|
+
* const info = await resolver.resolveGitDependency({
|
|
73
|
+
* url: 'https://github.com/user/skill.git',
|
|
74
|
+
* tag: 'v1.0.0'
|
|
75
|
+
* });
|
|
76
|
+
*
|
|
77
|
+
* // Monorepo with subdirectory
|
|
78
|
+
* const info = await resolver.resolveGitDependency({
|
|
79
|
+
* url: 'https://github.com/company/monorepo.git',
|
|
80
|
+
* tag: 'v3.0.0',
|
|
81
|
+
* path: 'crafts/auth'
|
|
82
|
+
* });
|
|
83
|
+
*
|
|
84
|
+
* // Direct file reference
|
|
85
|
+
* const info = await resolver.resolveGitDependency({
|
|
86
|
+
* url: 'https://github.com/user/repo.git',
|
|
87
|
+
* branch: 'main',
|
|
88
|
+
* file: 'rspec-dry-agent.md'
|
|
89
|
+
* });
|
|
90
|
+
* ```
|
|
91
|
+
*/
|
|
92
|
+
async resolveGitDependency(gitInfo: GitDependencyInfo): Promise<GitDependencyInfo> {
|
|
93
|
+
const repoTempDir = path.join(this.tempDir, `git-${Date.now()}`);
|
|
94
|
+
|
|
95
|
+
try {
|
|
96
|
+
await fs.ensureDir(repoTempDir);
|
|
97
|
+
|
|
98
|
+
// Clone the repository
|
|
99
|
+
logger.debug(`Cloning ${gitInfo.url} to analyze dependencies...`);
|
|
100
|
+
|
|
101
|
+
let cloneCmd = `git clone --depth 1`;
|
|
102
|
+
if (gitInfo.branch) {
|
|
103
|
+
cloneCmd += ` -b ${gitInfo.branch}`;
|
|
104
|
+
} else if (gitInfo.tag) {
|
|
105
|
+
cloneCmd += ` -b ${gitInfo.tag}`;
|
|
106
|
+
}
|
|
107
|
+
cloneCmd += ` ${gitInfo.url} ${repoTempDir}`;
|
|
108
|
+
|
|
109
|
+
execSync(cloneCmd, { stdio: 'pipe' });
|
|
110
|
+
|
|
111
|
+
// If specific commit, checkout that commit
|
|
112
|
+
if (gitInfo.commit) {
|
|
113
|
+
execSync(`cd ${repoTempDir} && git fetch --unshallow && git checkout ${gitInfo.commit}`, { stdio: 'pipe' });
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Get the actual commit hash for lockfile
|
|
117
|
+
const resolvedCommit = execSync(`cd ${repoTempDir} && git rev-parse HEAD`, { encoding: 'utf8' }).trim();
|
|
118
|
+
gitInfo.resolvedCommit = resolvedCommit;
|
|
119
|
+
|
|
120
|
+
// Handle direct file reference
|
|
121
|
+
if (gitInfo.file) {
|
|
122
|
+
const filePath = path.join(repoTempDir, gitInfo.file);
|
|
123
|
+
|
|
124
|
+
if (await fs.pathExists(filePath)) {
|
|
125
|
+
logger.debug(`Found direct file reference: ${gitInfo.file}`);
|
|
126
|
+
|
|
127
|
+
// Try to find craftdesk.json to get type and metadata
|
|
128
|
+
let craftType: 'skill' | 'agent' | 'command' | 'hook' | 'plugin' | undefined;
|
|
129
|
+
let craftName: string | undefined;
|
|
130
|
+
let craftVersion: string | undefined;
|
|
131
|
+
let craftDeps: Record<string, any> | undefined;
|
|
132
|
+
|
|
133
|
+
// Check for craftdesk.json in same directory as file
|
|
134
|
+
const fileDir = path.dirname(gitInfo.file);
|
|
135
|
+
const fileDirCraftPath = path.join(repoTempDir, fileDir, 'craftdesk.json');
|
|
136
|
+
|
|
137
|
+
// Check for craftdesk.json in repo root
|
|
138
|
+
const rootCraftPath = path.join(repoTempDir, 'craftdesk.json');
|
|
139
|
+
|
|
140
|
+
if (await fs.pathExists(fileDirCraftPath)) {
|
|
141
|
+
const content = await fs.readFile(fileDirCraftPath, 'utf-8');
|
|
142
|
+
const craftJson = JSON.parse(content);
|
|
143
|
+
logger.debug(`Found craftdesk.json in file directory: ${craftJson.name}@${craftJson.version}`);
|
|
144
|
+
craftType = craftJson.type;
|
|
145
|
+
craftName = craftJson.name;
|
|
146
|
+
craftVersion = craftJson.version;
|
|
147
|
+
craftDeps = craftJson.dependencies;
|
|
148
|
+
} else if (await fs.pathExists(rootCraftPath)) {
|
|
149
|
+
const content = await fs.readFile(rootCraftPath, 'utf-8');
|
|
150
|
+
const craftJson = JSON.parse(content);
|
|
151
|
+
logger.debug(`Found craftdesk.json in repo root: ${craftJson.name}@${craftJson.version}`);
|
|
152
|
+
craftType = craftJson.type;
|
|
153
|
+
craftName = craftJson.name;
|
|
154
|
+
craftVersion = craftJson.version;
|
|
155
|
+
craftDeps = craftJson.dependencies;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// If no craftdesk.json found, infer from filename
|
|
159
|
+
if (!craftType) {
|
|
160
|
+
craftType = this.inferCraftTypeFromFilename(gitInfo.file);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Create minimal craftdesk.json from file
|
|
164
|
+
gitInfo.craftDeskJson = {
|
|
165
|
+
name: craftName || path.basename(gitInfo.file, path.extname(gitInfo.file)),
|
|
166
|
+
version: craftVersion || gitInfo.tag || gitInfo.branch || resolvedCommit.substring(0, 7),
|
|
167
|
+
type: craftType,
|
|
168
|
+
description: `Git file dependency from ${gitInfo.url}/${gitInfo.file}`,
|
|
169
|
+
dependencies: craftDeps || {}
|
|
170
|
+
};
|
|
171
|
+
} else {
|
|
172
|
+
throw new Error(`File not found: ${gitInfo.file} in ${gitInfo.url}`);
|
|
173
|
+
}
|
|
174
|
+
} else {
|
|
175
|
+
// Look for craftdesk.json in the specified path or root
|
|
176
|
+
const craftPath = gitInfo.path
|
|
177
|
+
? path.join(repoTempDir, gitInfo.path, 'craftdesk.json')
|
|
178
|
+
: path.join(repoTempDir, 'craftdesk.json');
|
|
179
|
+
|
|
180
|
+
if (await fs.pathExists(craftPath)) {
|
|
181
|
+
const content = await fs.readFile(craftPath, 'utf-8');
|
|
182
|
+
gitInfo.craftDeskJson = JSON.parse(content);
|
|
183
|
+
logger.debug(`Found craftdesk.json in git repository: ${gitInfo.craftDeskJson!.name}@${gitInfo.craftDeskJson!.version}`);
|
|
184
|
+
} else {
|
|
185
|
+
// No craftdesk.json found - create a minimal one
|
|
186
|
+
logger.warn(`No craftdesk.json found in ${gitInfo.url}${gitInfo.path ? `#path:${gitInfo.path}` : ''}`);
|
|
187
|
+
|
|
188
|
+
// Try to infer the type from directory structure
|
|
189
|
+
const type = await this.inferCraftType(repoTempDir, gitInfo.path);
|
|
190
|
+
|
|
191
|
+
gitInfo.craftDeskJson = {
|
|
192
|
+
name: path.basename(gitInfo.url, '.git'),
|
|
193
|
+
version: gitInfo.tag || gitInfo.branch || resolvedCommit.substring(0, 7),
|
|
194
|
+
type: type,
|
|
195
|
+
description: `Git dependency from ${gitInfo.url}`,
|
|
196
|
+
dependencies: {}
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return gitInfo;
|
|
202
|
+
|
|
203
|
+
} finally {
|
|
204
|
+
// Clean up temp directory
|
|
205
|
+
await fs.remove(repoTempDir);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Infers the craft type from a filename
|
|
211
|
+
*
|
|
212
|
+
* Type inference strategy:
|
|
213
|
+
* 1. Check filename for type indicators: agent, skill, command, hook
|
|
214
|
+
* 2. Check file extension patterns
|
|
215
|
+
* 3. Default to 'skill' if no indicators found
|
|
216
|
+
*
|
|
217
|
+
* @param filename - The filename to analyze
|
|
218
|
+
* @returns The inferred craft type
|
|
219
|
+
* @private
|
|
220
|
+
*/
|
|
221
|
+
private inferCraftTypeFromFilename(filename: string): 'skill' | 'agent' | 'command' | 'hook' | 'plugin' {
|
|
222
|
+
const lower = filename.toLowerCase();
|
|
223
|
+
|
|
224
|
+
// Check filename for type indicators
|
|
225
|
+
if (lower.includes('agent')) return 'agent';
|
|
226
|
+
if (lower.includes('command')) return 'command';
|
|
227
|
+
if (lower.includes('hook')) return 'hook';
|
|
228
|
+
if (lower.includes('plugin')) return 'plugin';
|
|
229
|
+
if (lower.includes('skill')) return 'skill';
|
|
230
|
+
|
|
231
|
+
// Default to skill
|
|
232
|
+
return 'skill';
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Infers the craft type from the repository structure
|
|
237
|
+
*
|
|
238
|
+
* Type inference strategy:
|
|
239
|
+
* 1. Check for marker files: SKILL.md, AGENT.md, COMMAND.md, HOOK.md
|
|
240
|
+
* 2. Check directory name for type indicators
|
|
241
|
+
* 3. Default to 'skill' if no indicators found
|
|
242
|
+
*
|
|
243
|
+
* @param repoPath - Path to the cloned repository
|
|
244
|
+
* @param subPath - Optional subdirectory to check
|
|
245
|
+
* @returns The inferred craft type
|
|
246
|
+
* @private
|
|
247
|
+
*/
|
|
248
|
+
private async inferCraftType(repoPath: string, subPath?: string): Promise<'skill' | 'agent' | 'command' | 'hook' | 'plugin'> {
|
|
249
|
+
const checkPath = subPath ? path.join(repoPath, subPath) : repoPath;
|
|
250
|
+
|
|
251
|
+
// Check for type-indicating files
|
|
252
|
+
const files = await fs.readdir(checkPath);
|
|
253
|
+
|
|
254
|
+
// Look for SKILL.md, AGENT.md, COMMAND.md, HOOK.md, PLUGIN.md
|
|
255
|
+
if (files.includes('SKILL.md')) return 'skill';
|
|
256
|
+
if (files.includes('AGENT.md')) return 'agent';
|
|
257
|
+
if (files.includes('COMMAND.md')) return 'command';
|
|
258
|
+
if (files.includes('HOOK.md')) return 'hook';
|
|
259
|
+
if (files.includes('PLUGIN.md')) return 'plugin';
|
|
260
|
+
|
|
261
|
+
// Check directory name hints
|
|
262
|
+
const dirName = subPath ? path.basename(subPath) : path.basename(repoPath);
|
|
263
|
+
if (dirName.includes('skill')) return 'skill';
|
|
264
|
+
if (dirName.includes('agent')) return 'agent';
|
|
265
|
+
if (dirName.includes('command')) return 'command';
|
|
266
|
+
if (dirName.includes('hook')) return 'hook';
|
|
267
|
+
if (dirName.includes('plugin')) return 'plugin';
|
|
268
|
+
|
|
269
|
+
// Default to skill
|
|
270
|
+
return 'skill';
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Resolves all dependencies including transitive dependencies from git sources
|
|
275
|
+
*
|
|
276
|
+
* Uses a breadth-first traversal algorithm to resolve dependencies recursively.
|
|
277
|
+
* Tracks visited packages to avoid circular dependencies.
|
|
278
|
+
*
|
|
279
|
+
* @param dependencies - Map of dependency names to version strings or config objects
|
|
280
|
+
* @returns Object containing resolved dependencies and generated lockfile
|
|
281
|
+
*
|
|
282
|
+
* @example
|
|
283
|
+
* ```typescript
|
|
284
|
+
* const result = await resolver.resolveAllDependencies({
|
|
285
|
+
* 'ruby-on-rails': '^7.0.0',
|
|
286
|
+
* 'custom-auth': {
|
|
287
|
+
* git: 'https://github.com/company/auth.git',
|
|
288
|
+
* branch: 'main'
|
|
289
|
+
* }
|
|
290
|
+
* });
|
|
291
|
+
*
|
|
292
|
+
* console.log(result.resolved); // All dependencies including transitive
|
|
293
|
+
* console.log(result.lockfile); // Generated lockfile structure
|
|
294
|
+
* ```
|
|
295
|
+
*/
|
|
296
|
+
async resolveAllDependencies(dependencies: Record<string, string | DependencyConfig>): Promise<{
|
|
297
|
+
resolved: Record<string, any>;
|
|
298
|
+
lockfile: any;
|
|
299
|
+
}> {
|
|
300
|
+
const resolved: Record<string, any> = {};
|
|
301
|
+
// Queue of dependencies to resolve (breadth-first traversal)
|
|
302
|
+
const toResolve: Array<[string, string | DependencyConfig]> = Object.entries(dependencies);
|
|
303
|
+
// Track visited packages to prevent infinite loops from circular dependencies
|
|
304
|
+
const visited = new Set<string>();
|
|
305
|
+
|
|
306
|
+
// Process queue until all dependencies are resolved
|
|
307
|
+
while (toResolve.length > 0) {
|
|
308
|
+
const [name, dep] = toResolve.shift()!;
|
|
309
|
+
|
|
310
|
+
// Skip if already resolved (handles duplicates and circular deps)
|
|
311
|
+
if (visited.has(name)) continue;
|
|
312
|
+
visited.add(name);
|
|
313
|
+
|
|
314
|
+
// Determine if this is a git or registry dependency
|
|
315
|
+
if (typeof dep === 'object' && dep.git) {
|
|
316
|
+
// Git dependency - clone repository and extract metadata
|
|
317
|
+
const gitInfo = await this.resolveGitDependency({
|
|
318
|
+
url: dep.git,
|
|
319
|
+
branch: dep.branch,
|
|
320
|
+
tag: dep.tag,
|
|
321
|
+
commit: dep.commit,
|
|
322
|
+
path: dep.path,
|
|
323
|
+
file: dep.file
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
resolved[name] = {
|
|
327
|
+
version: gitInfo.craftDeskJson?.version || '0.0.0',
|
|
328
|
+
resolved: gitInfo.url,
|
|
329
|
+
integrity: gitInfo.resolvedCommit || 'git',
|
|
330
|
+
type: gitInfo.craftDeskJson?.type || 'skill',
|
|
331
|
+
author: gitInfo.craftDeskJson?.author || 'git',
|
|
332
|
+
git: gitInfo.url,
|
|
333
|
+
...(gitInfo.branch && { branch: gitInfo.branch }),
|
|
334
|
+
...(gitInfo.tag && { tag: gitInfo.tag }),
|
|
335
|
+
...(gitInfo.commit && { commit: gitInfo.resolvedCommit }),
|
|
336
|
+
...(gitInfo.path && { path: gitInfo.path }),
|
|
337
|
+
...(gitInfo.file && { file: gitInfo.file }),
|
|
338
|
+
dependencies: gitInfo.craftDeskJson?.dependencies || {}
|
|
339
|
+
};
|
|
340
|
+
|
|
341
|
+
// Add transitive dependencies to the resolution queue
|
|
342
|
+
// This enables recursive dependency resolution
|
|
343
|
+
if (gitInfo.craftDeskJson?.dependencies) {
|
|
344
|
+
for (const [depName, depVersion] of Object.entries(gitInfo.craftDeskJson.dependencies)) {
|
|
345
|
+
// Only add if not already visited (avoids redundant resolution)
|
|
346
|
+
if (!visited.has(depName)) {
|
|
347
|
+
toResolve.push([depName, depVersion]);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
} else {
|
|
352
|
+
// Registry dependency - mark for resolution by registry client
|
|
353
|
+
// The actual version resolution happens via the registry API
|
|
354
|
+
resolved[name] = {
|
|
355
|
+
needsResolution: true,
|
|
356
|
+
version: typeof dep === 'string' ? dep : dep.version,
|
|
357
|
+
registry: typeof dep === 'object' ? dep.registry : undefined
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
return {
|
|
363
|
+
resolved,
|
|
364
|
+
lockfile: {
|
|
365
|
+
version: '1.0.0',
|
|
366
|
+
lockfileVersion: 1,
|
|
367
|
+
crafts: resolved
|
|
368
|
+
}
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
export const gitResolver = new GitResolver();
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
import path from 'path';
|
|
2
|
+
import fs from 'fs-extra';
|
|
3
|
+
import axios from 'axios';
|
|
4
|
+
import { logger } from '../utils/logger';
|
|
5
|
+
import { configManager } from './config-manager';
|
|
6
|
+
import { CraftDeskLock, LockEntry } from '../types/craftdesk-lock';
|
|
7
|
+
import { ensureDir } from '../utils/file-system';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Handles installation of crafts from various sources
|
|
11
|
+
*
|
|
12
|
+
* Supports installing from:
|
|
13
|
+
* - Registry packages (via download URLs)
|
|
14
|
+
* - Git repositories (with monorepo support)
|
|
15
|
+
* - Local archives
|
|
16
|
+
*
|
|
17
|
+
* Organizes installations by craft type in the .claude directory.
|
|
18
|
+
*
|
|
19
|
+
* @example
|
|
20
|
+
* ```typescript
|
|
21
|
+
* const installer = new Installer();
|
|
22
|
+
*
|
|
23
|
+
* // Install from lockfile
|
|
24
|
+
* const lockfile = await readLockfile();
|
|
25
|
+
* await installer.installFromLockfile(lockfile);
|
|
26
|
+
*
|
|
27
|
+
* // Install single craft
|
|
28
|
+
* await installer.installCraft('ruby-on-rails', lockEntry);
|
|
29
|
+
* ```
|
|
30
|
+
*/
|
|
31
|
+
export class Installer {
|
|
32
|
+
private installPath: string;
|
|
33
|
+
|
|
34
|
+
constructor() {
|
|
35
|
+
this.installPath = configManager.getInstallPath();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Installs all crafts defined in a lockfile
|
|
40
|
+
*
|
|
41
|
+
* Processes each craft sequentially, updating progress as it goes.
|
|
42
|
+
* Fails fast if any installation errors occur.
|
|
43
|
+
*
|
|
44
|
+
* @param lockfile - The craftdesk.lock content with all dependencies
|
|
45
|
+
* @throws Error if any craft fails to install
|
|
46
|
+
*
|
|
47
|
+
* @example
|
|
48
|
+
* ```typescript
|
|
49
|
+
* const lockfile = {
|
|
50
|
+
* version: '1.0.0',
|
|
51
|
+
* lockfileVersion: 1,
|
|
52
|
+
* crafts: {
|
|
53
|
+
* 'ruby-on-rails': { version: '7.1.0', ... },
|
|
54
|
+
* 'postgres-expert': { version: '1.2.0', ... }
|
|
55
|
+
* }
|
|
56
|
+
* };
|
|
57
|
+
*
|
|
58
|
+
* await installer.installFromLockfile(lockfile);
|
|
59
|
+
* ```
|
|
60
|
+
*/
|
|
61
|
+
async installFromLockfile(lockfile: CraftDeskLock): Promise<void> {
|
|
62
|
+
const installDir = path.join(process.cwd(), this.installPath);
|
|
63
|
+
await ensureDir(installDir);
|
|
64
|
+
|
|
65
|
+
const crafts = Object.entries(lockfile.crafts);
|
|
66
|
+
let installed = 0;
|
|
67
|
+
|
|
68
|
+
for (const [name, entry] of crafts) {
|
|
69
|
+
try {
|
|
70
|
+
logger.updateSpinner(`Installing ${name}@${entry.version} (${installed + 1}/${crafts.length})`);
|
|
71
|
+
await this.installCraft(name, entry);
|
|
72
|
+
installed++;
|
|
73
|
+
} catch (error: any) {
|
|
74
|
+
logger.failSpinner(`Failed to install ${name}: ${error.message}`);
|
|
75
|
+
throw error;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
logger.succeedSpinner(`Installed ${installed} crafts`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Installs a single craft from registry or git source
|
|
84
|
+
*
|
|
85
|
+
* Installation process:
|
|
86
|
+
* 1. Determines installation directory based on craft type
|
|
87
|
+
* 2. For git dependencies: clones and extracts subdirectory
|
|
88
|
+
* 3. For registry: downloads archive, verifies, and extracts
|
|
89
|
+
* 4. Creates metadata file for tracking installation
|
|
90
|
+
*
|
|
91
|
+
* @param name - The name of the craft to install
|
|
92
|
+
* @param entry - Lockfile entry containing installation details
|
|
93
|
+
* @throws Error if download or extraction fails
|
|
94
|
+
*
|
|
95
|
+
* @example
|
|
96
|
+
* ```typescript
|
|
97
|
+
* // Install from registry
|
|
98
|
+
* await installer.installCraft('ruby-on-rails', {
|
|
99
|
+
* version: '7.1.0',
|
|
100
|
+
* resolved: 'https://craftdesk.ai/download/...',
|
|
101
|
+
* type: 'skill',
|
|
102
|
+
* dependencies: {}
|
|
103
|
+
* });
|
|
104
|
+
*
|
|
105
|
+
* // Install from git
|
|
106
|
+
* await installer.installCraft('custom-auth', {
|
|
107
|
+
* version: '1.0.0',
|
|
108
|
+
* git: 'https://github.com/company/auth.git',
|
|
109
|
+
* branch: 'main',
|
|
110
|
+
* type: 'agent',
|
|
111
|
+
* dependencies: {}
|
|
112
|
+
* });
|
|
113
|
+
* ```
|
|
114
|
+
*/
|
|
115
|
+
async installCraft(name: string, entry: LockEntry): Promise<void> {
|
|
116
|
+
const installDir = path.join(process.cwd(), this.installPath);
|
|
117
|
+
|
|
118
|
+
// Determine install subdirectory based on type
|
|
119
|
+
const typeDir = this.getTypeDirectory(entry.type);
|
|
120
|
+
const craftDir = path.join(installDir, typeDir, name);
|
|
121
|
+
|
|
122
|
+
// Create directory
|
|
123
|
+
await ensureDir(craftDir);
|
|
124
|
+
|
|
125
|
+
// Check if this is a git dependency
|
|
126
|
+
if (entry.git) {
|
|
127
|
+
await this.installFromGit(craftDir, entry);
|
|
128
|
+
} else {
|
|
129
|
+
// Download craft archive
|
|
130
|
+
const archivePath = path.join(craftDir, 'archive.tar.gz');
|
|
131
|
+
await this.downloadFile(entry.resolved, archivePath);
|
|
132
|
+
|
|
133
|
+
// Verify integrity (TODO: implement SHA-256 check)
|
|
134
|
+
// await this.verifyIntegrity(archivePath, entry.integrity);
|
|
135
|
+
|
|
136
|
+
// Extract archive
|
|
137
|
+
await this.extractArchive(archivePath, craftDir);
|
|
138
|
+
|
|
139
|
+
// Clean up archive
|
|
140
|
+
await fs.remove(archivePath);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Create metadata file
|
|
144
|
+
await this.createMetadata(craftDir, name, entry);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
private async installFromGit(craftDir: string, entry: LockEntry): Promise<void> {
|
|
148
|
+
const tempDir = path.join(craftDir, '.tmp-git');
|
|
149
|
+
|
|
150
|
+
try {
|
|
151
|
+
// Clone the repository
|
|
152
|
+
const { execSync } = require('child_process');
|
|
153
|
+
|
|
154
|
+
// Build clone command with appropriate ref
|
|
155
|
+
let cloneCmd = `git clone --depth 1`;
|
|
156
|
+
|
|
157
|
+
if (entry.branch) {
|
|
158
|
+
cloneCmd += ` -b ${entry.branch}`;
|
|
159
|
+
} else if (entry.tag) {
|
|
160
|
+
cloneCmd += ` -b ${entry.tag}`;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
cloneCmd += ` ${entry.git} ${tempDir}`;
|
|
164
|
+
|
|
165
|
+
logger.debug(`Cloning git repository: ${cloneCmd}`);
|
|
166
|
+
execSync(cloneCmd, { stdio: 'pipe' });
|
|
167
|
+
|
|
168
|
+
// If specific commit, checkout that commit
|
|
169
|
+
if (entry.commit) {
|
|
170
|
+
execSync(`cd ${tempDir} && git fetch --unshallow && git checkout ${entry.commit}`, { stdio: 'pipe' });
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Handle direct file reference
|
|
174
|
+
if (entry.file) {
|
|
175
|
+
const sourceFile = path.join(tempDir, entry.file);
|
|
176
|
+
const destFile = path.join(craftDir, path.basename(entry.file));
|
|
177
|
+
|
|
178
|
+
if (await fs.pathExists(sourceFile)) {
|
|
179
|
+
await fs.copy(sourceFile, destFile, { overwrite: true });
|
|
180
|
+
} else {
|
|
181
|
+
throw new Error(`File not found: ${entry.file} in ${entry.git}`);
|
|
182
|
+
}
|
|
183
|
+
} else {
|
|
184
|
+
// Copy from subdirectory if specified, otherwise copy entire repo
|
|
185
|
+
const sourcePath = entry.path ? path.join(tempDir, entry.path) : tempDir;
|
|
186
|
+
|
|
187
|
+
// Copy contents to craft directory
|
|
188
|
+
const files = await fs.readdir(sourcePath);
|
|
189
|
+
for (const file of files) {
|
|
190
|
+
if (file !== '.tmp-git' && file !== '.git') {
|
|
191
|
+
await fs.copy(
|
|
192
|
+
path.join(sourcePath, file),
|
|
193
|
+
path.join(craftDir, file),
|
|
194
|
+
{ overwrite: true }
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
} finally {
|
|
201
|
+
// Clean up temp directory
|
|
202
|
+
await fs.remove(tempDir);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
private getTypeDirectory(type: string): string {
|
|
207
|
+
switch (type) {
|
|
208
|
+
case 'skill':
|
|
209
|
+
return 'skills';
|
|
210
|
+
case 'agent':
|
|
211
|
+
return 'agents';
|
|
212
|
+
case 'command':
|
|
213
|
+
return 'commands';
|
|
214
|
+
case 'hook':
|
|
215
|
+
return 'hooks';
|
|
216
|
+
default:
|
|
217
|
+
return 'crafts';
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
private async downloadFile(url: string, outputPath: string): Promise<void> {
|
|
222
|
+
const response = await axios({
|
|
223
|
+
method: 'GET',
|
|
224
|
+
url: url,
|
|
225
|
+
responseType: 'stream'
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
const writer = fs.createWriteStream(outputPath);
|
|
229
|
+
response.data.pipe(writer);
|
|
230
|
+
|
|
231
|
+
return new Promise((resolve, reject) => {
|
|
232
|
+
writer.on('finish', resolve);
|
|
233
|
+
writer.on('error', reject);
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
private async extractArchive(archivePath: string, outputDir: string): Promise<void> {
|
|
238
|
+
// For now, just copy some placeholder files
|
|
239
|
+
// In production, this would extract a tar.gz or zip archive
|
|
240
|
+
|
|
241
|
+
const type = path.basename(outputDir).includes('skill') ? 'SKILL' : 'AGENT';
|
|
242
|
+
const mainFile = `${type}.md`;
|
|
243
|
+
|
|
244
|
+
await fs.writeFile(
|
|
245
|
+
path.join(outputDir, mainFile),
|
|
246
|
+
`# ${path.basename(outputDir)}\n\nThis is a placeholder for the ${type.toLowerCase()} content.\n`,
|
|
247
|
+
'utf-8'
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
private async createMetadata(craftDir: string, name: string, entry: LockEntry): Promise<void> {
|
|
252
|
+
const metadata = {
|
|
253
|
+
name,
|
|
254
|
+
version: entry.version,
|
|
255
|
+
type: entry.type,
|
|
256
|
+
author: entry.author,
|
|
257
|
+
installedAt: new Date().toISOString(),
|
|
258
|
+
registry: entry.registry,
|
|
259
|
+
dependencies: entry.dependencies
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
await fs.writeFile(
|
|
263
|
+
path.join(craftDir, '.craftdesk-metadata.json'),
|
|
264
|
+
JSON.stringify(metadata, null, 2),
|
|
265
|
+
'utf-8'
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
async removeCraft(name: string, type: string): Promise<void> {
|
|
270
|
+
const installDir = path.join(process.cwd(), this.installPath);
|
|
271
|
+
const typeDir = this.getTypeDirectory(type);
|
|
272
|
+
const craftDir = path.join(installDir, typeDir, name);
|
|
273
|
+
|
|
274
|
+
if (await fs.pathExists(craftDir)) {
|
|
275
|
+
await fs.remove(craftDir);
|
|
276
|
+
logger.success(`Removed ${name}`);
|
|
277
|
+
} else {
|
|
278
|
+
logger.warn(`${name} is not installed`);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
async listInstalled(): Promise<Array<{ name: string; version: string; type: string }>> {
|
|
283
|
+
const installDir = path.join(process.cwd(), this.installPath);
|
|
284
|
+
const installed: Array<{ name: string; version: string; type: string }> = [];
|
|
285
|
+
|
|
286
|
+
if (!await fs.pathExists(installDir)) {
|
|
287
|
+
return installed;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const typeDirs = ['skills', 'agents', 'commands', 'hooks', 'plugins'];
|
|
291
|
+
|
|
292
|
+
for (const typeDir of typeDirs) {
|
|
293
|
+
const dir = path.join(installDir, typeDir);
|
|
294
|
+
if (!await fs.pathExists(dir)) continue;
|
|
295
|
+
|
|
296
|
+
const crafts = await fs.readdir(dir);
|
|
297
|
+
for (const craftName of crafts) {
|
|
298
|
+
const metadataPath = path.join(dir, craftName, '.craftdesk-metadata.json');
|
|
299
|
+
if (await fs.pathExists(metadataPath)) {
|
|
300
|
+
const metadata = await fs.readJson(metadataPath);
|
|
301
|
+
installed.push({
|
|
302
|
+
name: craftName,
|
|
303
|
+
version: metadata.version,
|
|
304
|
+
type: metadata.type
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
return installed;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
export const installer = new Installer();
|