claude-comms 1.0.1 → 1.0.3
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 +76 -0
- package/README.md +64 -23
- package/bin/claude-setup.js +118 -38
- package/package.json +67 -19
- package/src/cli/index.js +30 -0
- package/src/cli/interfaces.js +386 -0
- package/src/cli/parser.js +158 -0
- package/src/cli/prompts.js +317 -0
- package/src/cli/runner.js +436 -0
- package/src/fetcher/github.js +1092 -0
- package/src/index.js +221 -0
- package/src/installer/dependency-check.js +19 -0
- package/src/installer/file-writer.js +648 -0
- package/src/orchestrator/installer.js +875 -0
- package/src/orchestrator/main.js +267 -0
- package/src/orchestrator/mocks/index.js +388 -0
- package/src/utils/config.js +430 -0
- package/src/utils/constants.js +397 -0
- package/src/utils/errors.js +361 -0
- package/src/utils/index.js +46 -0
- package/src/utils/logger.js +177 -0
- package/src/utils/platform.js +362 -0
- package/src/utils/retry.js +358 -0
- package/templates/settings.local.json +1 -0
- package/.claude/hooks/comms/messages_UmaMatrix.json +0 -10
- package/.claude/hooks/comms/messages_VictorSpiral.json +0 -10
- package/.claude/hooks/comms/messages_WendyQuantum.json +0 -10
- package/.claude/hooks/comms/messages_XavierPrism.json +0 -10
- package/templates/get_unread_messages.py +0 -54
- package/templates/send_message.py +0 -75
|
@@ -0,0 +1,1092 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitHub API client for fetching Claude setup files
|
|
3
|
+
* Implements the multi-strategy approach from architecture design
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const fetch = require('node-fetch');
|
|
7
|
+
const tar = require('tar');
|
|
8
|
+
const { EventEmitter } = require('events');
|
|
9
|
+
const { GitHubAPIError } = require('../utils/errors');
|
|
10
|
+
const fs = require('fs');
|
|
11
|
+
const path = require('path');
|
|
12
|
+
const os = require('os');
|
|
13
|
+
|
|
14
|
+
class GitHubFetcher extends EventEmitter {
|
|
15
|
+
constructor(options = {}) {
|
|
16
|
+
super();
|
|
17
|
+
|
|
18
|
+
this.repository = options.repository || {
|
|
19
|
+
owner: 'screwyforcepush',
|
|
20
|
+
repo: 'claude-code-subagent-bus',
|
|
21
|
+
branch: 'main',
|
|
22
|
+
paths: {
|
|
23
|
+
claudeDir: '.claude',
|
|
24
|
+
claudeMd: 'CLAUDE.md'
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
this.config = {
|
|
29
|
+
baseUrl: 'https://api.github.com',
|
|
30
|
+
rawUrl: 'https://raw.githubusercontent.com',
|
|
31
|
+
timeout: options.timeout || 10000,
|
|
32
|
+
retryCount: options.retryCount || 3,
|
|
33
|
+
retryDelay: options.retryDelay || 1000,
|
|
34
|
+
maxRetryDelay: options.maxRetryDelay || 10000,
|
|
35
|
+
userAgent: options.userAgent || '@claude-code/setup-installer/1.0.0',
|
|
36
|
+
token: options.token || process.env.GITHUB_TOKEN
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
this.rateLimitReset = null;
|
|
40
|
+
this.requestCount = 0;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Fetch repository files according to FetcherAPI interface
|
|
45
|
+
* @param {string} path - Directory path to fetch
|
|
46
|
+
* @param {Object} options - Fetch options
|
|
47
|
+
* @returns {Promise<FileTree>} Directory structure
|
|
48
|
+
*/
|
|
49
|
+
async fetchDirectory(path, options = {}) {
|
|
50
|
+
const fetchOptions = this._prepareFetchOptions(options);
|
|
51
|
+
|
|
52
|
+
this.emit('progress', {
|
|
53
|
+
task: 'fetch_directory',
|
|
54
|
+
current: 0,
|
|
55
|
+
message: `Fetching directory: ${path}`,
|
|
56
|
+
status: 'running'
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
// Strategy 1: GitHub Trees API (fastest for recursive fetch)
|
|
61
|
+
const result = await this._fetchWithTrees(path, fetchOptions);
|
|
62
|
+
|
|
63
|
+
this.emit('progress', {
|
|
64
|
+
task: 'fetch_directory',
|
|
65
|
+
current: 100,
|
|
66
|
+
message: `Successfully fetched ${path}`,
|
|
67
|
+
status: 'complete'
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
return result;
|
|
71
|
+
} catch (treesError) {
|
|
72
|
+
console.warn(`Trees API failed: ${treesError.message}`);
|
|
73
|
+
|
|
74
|
+
try {
|
|
75
|
+
// Strategy 2: GitHub Contents API (fallback)
|
|
76
|
+
const result = await this._fetchWithContents(path, fetchOptions);
|
|
77
|
+
|
|
78
|
+
this.emit('progress', {
|
|
79
|
+
task: 'fetch_directory',
|
|
80
|
+
current: 100,
|
|
81
|
+
message: `Successfully fetched ${path} via Contents API`,
|
|
82
|
+
status: 'complete'
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
return result;
|
|
86
|
+
} catch (contentsError) {
|
|
87
|
+
console.warn(`Contents API failed: ${contentsError.message}`);
|
|
88
|
+
|
|
89
|
+
// Strategy 3: Raw URLs (last resort)
|
|
90
|
+
const result = await this._fetchWithRawUrls(path, fetchOptions);
|
|
91
|
+
|
|
92
|
+
this.emit('progress', {
|
|
93
|
+
task: 'fetch_directory',
|
|
94
|
+
current: 100,
|
|
95
|
+
message: `Successfully fetched ${path} via raw URLs`,
|
|
96
|
+
status: 'complete'
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
return result;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Fetch individual file according to FetcherAPI interface
|
|
106
|
+
* @param {string} path - File path to fetch
|
|
107
|
+
* @param {Object} options - Fetch options
|
|
108
|
+
* @returns {Promise<FileContent>} File content
|
|
109
|
+
*/
|
|
110
|
+
async fetchFile(path, options = {}) {
|
|
111
|
+
const fetchOptions = this._prepareFetchOptions(options);
|
|
112
|
+
|
|
113
|
+
this.emit('progress', {
|
|
114
|
+
task: 'fetch_file',
|
|
115
|
+
current: 0,
|
|
116
|
+
message: `Fetching file: ${path}`,
|
|
117
|
+
status: 'running'
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
try {
|
|
121
|
+
// Try Contents API first for single files
|
|
122
|
+
const result = await this._fetchSingleFileContent(path, fetchOptions);
|
|
123
|
+
|
|
124
|
+
this.emit('progress', {
|
|
125
|
+
task: 'fetch_file',
|
|
126
|
+
current: 100,
|
|
127
|
+
message: `Successfully fetched ${path}`,
|
|
128
|
+
status: 'complete'
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
return result;
|
|
132
|
+
} catch (contentsError) {
|
|
133
|
+
console.warn(`Contents API failed for ${path}: ${contentsError.message}`);
|
|
134
|
+
|
|
135
|
+
// Fallback to raw URL
|
|
136
|
+
const result = await this._fetchSingleFileRaw(path, fetchOptions);
|
|
137
|
+
|
|
138
|
+
this.emit('progress', {
|
|
139
|
+
task: 'fetch_file',
|
|
140
|
+
current: 100,
|
|
141
|
+
message: `Successfully fetched ${path} via raw URL`,
|
|
142
|
+
status: 'complete'
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
return result;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Fetch repository as tarball (single request, fastest approach)
|
|
151
|
+
* @param {Object} options - Fetch options
|
|
152
|
+
* @returns {Promise<Object>} Repository files extracted from tarball
|
|
153
|
+
*/
|
|
154
|
+
async fetchAsTarball(options = {}) {
|
|
155
|
+
const fetchOptions = this._prepareFetchOptions(options);
|
|
156
|
+
|
|
157
|
+
this.emit('progress', {
|
|
158
|
+
task: 'fetch_tarball',
|
|
159
|
+
current: 0,
|
|
160
|
+
message: 'Downloading repository tarball...',
|
|
161
|
+
status: 'running'
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
try {
|
|
165
|
+
// Download tarball from GitHub
|
|
166
|
+
const tarballBuffer = await this._downloadTarball(fetchOptions);
|
|
167
|
+
|
|
168
|
+
this.emit('progress', {
|
|
169
|
+
task: 'fetch_tarball',
|
|
170
|
+
current: 50,
|
|
171
|
+
message: 'Extracting files from tarball...',
|
|
172
|
+
status: 'running'
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
// Extract and filter files
|
|
176
|
+
const extractedFiles = await this._extractTarballFiles(tarballBuffer, fetchOptions);
|
|
177
|
+
|
|
178
|
+
this.emit('progress', {
|
|
179
|
+
task: 'fetch_tarball',
|
|
180
|
+
current: 100,
|
|
181
|
+
message: `Successfully extracted ${Object.keys(extractedFiles).length} file groups`,
|
|
182
|
+
status: 'complete'
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
return extractedFiles;
|
|
186
|
+
|
|
187
|
+
} catch (error) {
|
|
188
|
+
this.emit('progress', {
|
|
189
|
+
task: 'fetch_tarball',
|
|
190
|
+
current: 0,
|
|
191
|
+
message: `Tarball fetch failed: ${error.message}`,
|
|
192
|
+
status: 'failed'
|
|
193
|
+
});
|
|
194
|
+
throw error;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Validate GitHub connection
|
|
200
|
+
* @returns {Promise<boolean>} Connection status
|
|
201
|
+
*/
|
|
202
|
+
async validateConnection() {
|
|
203
|
+
try {
|
|
204
|
+
const url = `${this.config.baseUrl}/repos/${this.repository.owner}/${this.repository.repo}`;
|
|
205
|
+
const response = await this._makeRequest(url, { method: 'HEAD' });
|
|
206
|
+
return response.status === 200;
|
|
207
|
+
} catch (error) {
|
|
208
|
+
console.warn(`GitHub connection validation failed: ${error.message}`);
|
|
209
|
+
return false;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Strategy 1: GitHub Trees API for recursive directory fetch
|
|
215
|
+
* @private
|
|
216
|
+
*/
|
|
217
|
+
async _fetchWithTrees(path, options) {
|
|
218
|
+
const { version } = options;
|
|
219
|
+
const url = `${this.config.baseUrl}/repos/${this.repository.owner}/${this.repository.repo}/git/trees/${version}?recursive=1`;
|
|
220
|
+
|
|
221
|
+
const response = await this._makeRequest(url);
|
|
222
|
+
const data = await response.json();
|
|
223
|
+
|
|
224
|
+
if (data.truncated) {
|
|
225
|
+
throw new GitHubAPIError(
|
|
226
|
+
'Repository tree is too large for Trees API',
|
|
227
|
+
{ url, truncated: true }
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Filter and structure tree data
|
|
232
|
+
const claudeFiles = data.tree.filter(item => {
|
|
233
|
+
// Include files in the requested path or exact match for root files
|
|
234
|
+
const isInPath = item.path.startsWith(path + '/') || item.path === path;
|
|
235
|
+
const isBlob = item.type === 'blob';
|
|
236
|
+
const shouldSkip = this._shouldSkipFile(item.path.split('/').pop()) ||
|
|
237
|
+
this._shouldSkipDirectory(item.path.split('/').pop());
|
|
238
|
+
|
|
239
|
+
return isInPath && isBlob && !shouldSkip;
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
// Fetch file contents in parallel batches
|
|
243
|
+
const files = await this._fetchFilesBatch(claudeFiles, options);
|
|
244
|
+
|
|
245
|
+
return this._buildFileTree(path, files);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Strategy 2: GitHub Contents API for directory fetch
|
|
250
|
+
* @private
|
|
251
|
+
*/
|
|
252
|
+
async _fetchWithContents(path, options) {
|
|
253
|
+
const { version } = options;
|
|
254
|
+
const url = `${this.config.baseUrl}/repos/${this.repository.owner}/${this.repository.repo}/contents/${path}?ref=${version}`;
|
|
255
|
+
|
|
256
|
+
const response = await this._makeRequest(url);
|
|
257
|
+
const data = await response.json();
|
|
258
|
+
|
|
259
|
+
if (!Array.isArray(data)) {
|
|
260
|
+
throw new GitHubAPIError(
|
|
261
|
+
`Expected directory but got file: ${path}`,
|
|
262
|
+
{ path, type: data.type }
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const files = [];
|
|
267
|
+
|
|
268
|
+
// Process each item in directory
|
|
269
|
+
for (const item of data) {
|
|
270
|
+
if (item.type === 'file') {
|
|
271
|
+
// Skip binary files and cache files
|
|
272
|
+
if (!this._shouldSkipFile(item.name)) {
|
|
273
|
+
try {
|
|
274
|
+
const content = await this._fetchSingleFileContent(item.path, options);
|
|
275
|
+
files.push(content);
|
|
276
|
+
} catch (error) {
|
|
277
|
+
console.warn(`Failed to fetch file ${item.path}: ${error.message}`);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
} else if (item.type === 'dir') {
|
|
281
|
+
// Skip __pycache__ and other generated directories
|
|
282
|
+
if (!this._shouldSkipDirectory(item.name)) {
|
|
283
|
+
try {
|
|
284
|
+
// Recursively fetch subdirectories
|
|
285
|
+
const subTree = await this._fetchWithContents(item.path, options);
|
|
286
|
+
files.push(...this._flattenFileTree(subTree));
|
|
287
|
+
} catch (error) {
|
|
288
|
+
console.warn(`Failed to fetch directory ${item.path}: ${error.message}`);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
return this._buildFileTree(path, files);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Strategy 3: Raw URLs for direct file access
|
|
299
|
+
* @private
|
|
300
|
+
*/
|
|
301
|
+
async _fetchWithRawUrls(path, options) {
|
|
302
|
+
// Complete .claude directory structure based on actual repository
|
|
303
|
+
const knownFiles = [
|
|
304
|
+
// Root configuration
|
|
305
|
+
'.claude/settings.json',
|
|
306
|
+
'.claude/settings.local.json',
|
|
307
|
+
|
|
308
|
+
// Core agents
|
|
309
|
+
'.claude/agents/core/agent-orchestrator.md',
|
|
310
|
+
'.claude/agents/core/architect.md',
|
|
311
|
+
'.claude/agents/core/business-analyst.md',
|
|
312
|
+
'.claude/agents/core/deep-researcher.md',
|
|
313
|
+
'.claude/agents/core/designer.md',
|
|
314
|
+
'.claude/agents/core/engineer.md',
|
|
315
|
+
'.claude/agents/core/gatekeeper.md',
|
|
316
|
+
'.claude/agents/core/planner.md',
|
|
317
|
+
'.claude/agents/meta-agent-builder.md',
|
|
318
|
+
|
|
319
|
+
// Commands
|
|
320
|
+
'.claude/commands/cook.md',
|
|
321
|
+
'.claude/commands/new-agents.md',
|
|
322
|
+
|
|
323
|
+
// Communication hooks
|
|
324
|
+
'.claude/hooks/comms/get_unread_messages.py',
|
|
325
|
+
'.claude/hooks/comms/register_subagent.py',
|
|
326
|
+
'.claude/hooks/comms/send_message.py',
|
|
327
|
+
'.claude/hooks/comms/update_subagent_completion.py',
|
|
328
|
+
|
|
329
|
+
// Context hooks
|
|
330
|
+
'.claude/hooks/context/repo_map.py',
|
|
331
|
+
|
|
332
|
+
// Observability hooks
|
|
333
|
+
'.claude/hooks/observability/send_event.py',
|
|
334
|
+
|
|
335
|
+
// Root CLAUDE.md
|
|
336
|
+
'CLAUDE.md'
|
|
337
|
+
];
|
|
338
|
+
|
|
339
|
+
const files = [];
|
|
340
|
+
|
|
341
|
+
for (const filePath of knownFiles) {
|
|
342
|
+
if (filePath.startsWith(path) || (path === '.claude' && filePath.startsWith('.claude')) || (path === 'CLAUDE.md' && filePath === 'CLAUDE.md')) {
|
|
343
|
+
try {
|
|
344
|
+
const content = await this._fetchSingleFileRaw(filePath, options);
|
|
345
|
+
files.push(content);
|
|
346
|
+
} catch (error) {
|
|
347
|
+
// Continue on individual file failures for raw URL strategy
|
|
348
|
+
console.warn(`Failed to fetch ${filePath}: ${error.message}`);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
if (files.length === 0) {
|
|
354
|
+
throw new GitHubAPIError(
|
|
355
|
+
`No files found using raw URL strategy for path: ${path}`,
|
|
356
|
+
{ path, strategy: 'raw_urls' }
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
return this._buildFileTree(path, files);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Fetch single file content via Contents API
|
|
365
|
+
* @private
|
|
366
|
+
*/
|
|
367
|
+
async _fetchSingleFileContent(filePath, options) {
|
|
368
|
+
const { version } = options;
|
|
369
|
+
const url = `${this.config.baseUrl}/repos/${this.repository.owner}/${this.repository.repo}/contents/${filePath}?ref=${version}`;
|
|
370
|
+
|
|
371
|
+
const response = await this._makeRequest(url);
|
|
372
|
+
const data = await response.json();
|
|
373
|
+
|
|
374
|
+
if (data.type !== 'file') {
|
|
375
|
+
throw new GitHubAPIError(
|
|
376
|
+
`Expected file but got ${data.type}: ${filePath}`,
|
|
377
|
+
{ filePath, type: data.type }
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
return {
|
|
382
|
+
path: filePath,
|
|
383
|
+
content: data.encoding === 'base64' ?
|
|
384
|
+
Buffer.from(data.content, 'base64').toString('utf-8') :
|
|
385
|
+
data.content,
|
|
386
|
+
encoding: 'utf-8',
|
|
387
|
+
sha: data.sha
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Fetch single file via raw URL
|
|
393
|
+
* @private
|
|
394
|
+
*/
|
|
395
|
+
async _fetchSingleFileRaw(filePath, options) {
|
|
396
|
+
const { version } = options;
|
|
397
|
+
const url = `${this.config.rawUrl}/${this.repository.owner}/${this.repository.repo}/${version}/${filePath}`;
|
|
398
|
+
|
|
399
|
+
const response = await this._makeRequest(url);
|
|
400
|
+
const content = await response.text();
|
|
401
|
+
|
|
402
|
+
return {
|
|
403
|
+
path: filePath,
|
|
404
|
+
content: content,
|
|
405
|
+
encoding: 'utf-8',
|
|
406
|
+
sha: null // Not available for raw URLs
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Fetch multiple files in parallel batches
|
|
412
|
+
* @private
|
|
413
|
+
*/
|
|
414
|
+
async _fetchFilesBatch(fileItems, options, batchSize = 5) {
|
|
415
|
+
const files = [];
|
|
416
|
+
|
|
417
|
+
for (let i = 0; i < fileItems.length; i += batchSize) {
|
|
418
|
+
const batch = fileItems.slice(i, i + batchSize);
|
|
419
|
+
|
|
420
|
+
const batchPromises = batch.map(async (item) => {
|
|
421
|
+
try {
|
|
422
|
+
return await this._fetchSingleFileContent(item.path, options);
|
|
423
|
+
} catch (error) {
|
|
424
|
+
console.warn(`Failed to fetch ${item.path}: ${error.message}`);
|
|
425
|
+
return null;
|
|
426
|
+
}
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
const batchResults = await Promise.all(batchPromises);
|
|
430
|
+
files.push(...batchResults.filter(Boolean));
|
|
431
|
+
|
|
432
|
+
// Update progress
|
|
433
|
+
this.emit('progress', {
|
|
434
|
+
task: 'fetch_directory',
|
|
435
|
+
current: Math.min(100, Math.round((i + batchSize) / fileItems.length * 80)), // 80% for fetching
|
|
436
|
+
message: `Fetched ${Math.min(i + batchSize, fileItems.length)}/${fileItems.length} files`,
|
|
437
|
+
status: 'running'
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
// Rate limiting delay between batches
|
|
441
|
+
if (i + batchSize < fileItems.length) {
|
|
442
|
+
await this._rateLimitDelay();
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
return files;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* Build file tree structure
|
|
451
|
+
* @private
|
|
452
|
+
*/
|
|
453
|
+
_buildFileTree(rootPath, files) {
|
|
454
|
+
const tree = {
|
|
455
|
+
path: rootPath,
|
|
456
|
+
type: 'dir',
|
|
457
|
+
children: [],
|
|
458
|
+
files: files
|
|
459
|
+
};
|
|
460
|
+
|
|
461
|
+
// Add file tree structure for compatibility
|
|
462
|
+
const filePaths = files.map(f => f.path);
|
|
463
|
+
tree.children = this._buildTreeStructure(filePaths, rootPath);
|
|
464
|
+
|
|
465
|
+
return tree;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* Build hierarchical tree structure
|
|
470
|
+
* @private
|
|
471
|
+
*/
|
|
472
|
+
_buildTreeStructure(filePaths, rootPath) {
|
|
473
|
+
const tree = [];
|
|
474
|
+
const pathMap = new Map();
|
|
475
|
+
|
|
476
|
+
filePaths.forEach(filePath => {
|
|
477
|
+
const relativePath = filePath.startsWith(rootPath) ?
|
|
478
|
+
filePath.slice(rootPath.length + 1) : filePath;
|
|
479
|
+
|
|
480
|
+
const parts = relativePath.split('/');
|
|
481
|
+
let currentPath = rootPath;
|
|
482
|
+
|
|
483
|
+
parts.forEach((part, index) => {
|
|
484
|
+
currentPath = currentPath ? `${currentPath}/${part}` : part;
|
|
485
|
+
|
|
486
|
+
if (!pathMap.has(currentPath)) {
|
|
487
|
+
const node = {
|
|
488
|
+
path: currentPath,
|
|
489
|
+
type: index === parts.length - 1 ? 'file' : 'dir',
|
|
490
|
+
sha: null
|
|
491
|
+
};
|
|
492
|
+
|
|
493
|
+
pathMap.set(currentPath, node);
|
|
494
|
+
|
|
495
|
+
if (index === 0) {
|
|
496
|
+
tree.push(node);
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
});
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
return tree;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* Flatten file tree to file list
|
|
507
|
+
* @private
|
|
508
|
+
*/
|
|
509
|
+
_flattenFileTree(tree) {
|
|
510
|
+
if (tree.files) {
|
|
511
|
+
return tree.files;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
const files = [];
|
|
515
|
+
if (tree.children) {
|
|
516
|
+
tree.children.forEach(child => {
|
|
517
|
+
if (child.type === 'file') {
|
|
518
|
+
files.push(child);
|
|
519
|
+
} else {
|
|
520
|
+
files.push(...this._flattenFileTree(child));
|
|
521
|
+
}
|
|
522
|
+
});
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
return files;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* Make HTTP request with retry logic and rate limiting
|
|
530
|
+
* @private
|
|
531
|
+
*/
|
|
532
|
+
async _makeRequest(url, options = {}) {
|
|
533
|
+
const requestOptions = {
|
|
534
|
+
timeout: this.config.timeout,
|
|
535
|
+
headers: {
|
|
536
|
+
'User-Agent': this.config.userAgent,
|
|
537
|
+
'Accept': 'application/vnd.github.v3+json',
|
|
538
|
+
...options.headers
|
|
539
|
+
},
|
|
540
|
+
...options
|
|
541
|
+
};
|
|
542
|
+
|
|
543
|
+
if (this.config.token) {
|
|
544
|
+
requestOptions.headers['Authorization'] = `token ${this.config.token}`;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
return await this._executeWithRetry(async () => {
|
|
548
|
+
// Check rate limit before request
|
|
549
|
+
await this._checkRateLimit();
|
|
550
|
+
|
|
551
|
+
this.requestCount++;
|
|
552
|
+
const response = await fetch(url, requestOptions);
|
|
553
|
+
|
|
554
|
+
// Handle rate limiting
|
|
555
|
+
if (response.status === 403) {
|
|
556
|
+
const rateLimitReset = response.headers.get('x-ratelimit-reset');
|
|
557
|
+
if (rateLimitReset) {
|
|
558
|
+
this.rateLimitReset = parseInt(rateLimitReset) * 1000;
|
|
559
|
+
const error = new GitHubAPIError(
|
|
560
|
+
'GitHub API rate limit exceeded',
|
|
561
|
+
{ resetTime: this.rateLimitReset, status: 403, headers: response.headers }
|
|
562
|
+
);
|
|
563
|
+
error.code = 'GITHUB_RATE_LIMIT';
|
|
564
|
+
throw error;
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
if (!response.ok) {
|
|
569
|
+
throw new GitHubAPIError(
|
|
570
|
+
`GitHub API request failed: ${response.status} ${response.statusText}`,
|
|
571
|
+
{ url, status: response.status, statusText: response.statusText }
|
|
572
|
+
);
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
return response;
|
|
576
|
+
});
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/**
|
|
580
|
+
* Execute function with exponential backoff retry
|
|
581
|
+
* @private
|
|
582
|
+
*/
|
|
583
|
+
async _executeWithRetry(fn, attempt = 1) {
|
|
584
|
+
try {
|
|
585
|
+
return await fn();
|
|
586
|
+
} catch (error) {
|
|
587
|
+
if (attempt >= this.config.retryCount) {
|
|
588
|
+
throw error;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
if (error.code === 'GITHUB_RATE_LIMIT') {
|
|
592
|
+
// Check if we should fail fast
|
|
593
|
+
this._shouldFailFastOnRateLimit();
|
|
594
|
+
|
|
595
|
+
// Calculate smart rate limit delay
|
|
596
|
+
const delay = this._calculateRateLimitDelay();
|
|
597
|
+
|
|
598
|
+
if (delay > 0) {
|
|
599
|
+
console.warn(`Rate limit exceeded. Waiting ${Math.round(delay / 1000)}s before retry ${attempt + 1}/${this.config.retryCount}`);
|
|
600
|
+
|
|
601
|
+
if (!this.config.token) {
|
|
602
|
+
console.warn('💡 Tip: Set GITHUB_TOKEN environment variable to increase rate limits (5,000/hour vs 60/hour)');
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
// Show countdown for longer waits
|
|
606
|
+
if (delay > 5000) {
|
|
607
|
+
await this._waitWithCountdown(delay);
|
|
608
|
+
} else {
|
|
609
|
+
await this._sleep(delay);
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
} else {
|
|
613
|
+
// Exponential backoff for other errors
|
|
614
|
+
const delay = this._calculateRetryDelay(attempt);
|
|
615
|
+
console.warn(`Request failed, retrying in ${delay}ms (attempt ${attempt + 1}/${this.config.retryCount}): ${error.message}`);
|
|
616
|
+
await this._sleep(delay);
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
return this._executeWithRetry(fn, attempt + 1);
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
/**
|
|
624
|
+
* Calculate exponential backoff delay
|
|
625
|
+
* @private
|
|
626
|
+
*/
|
|
627
|
+
_calculateRetryDelay(attempt) {
|
|
628
|
+
const baseDelay = this.config.retryDelay * Math.pow(2, attempt - 1);
|
|
629
|
+
const jitter = Math.random() * 0.1 * baseDelay; // 10% jitter
|
|
630
|
+
return Math.min(baseDelay + jitter, this.config.maxRetryDelay);
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/**
|
|
634
|
+
* Calculate smart rate limit delay with reasonable caps
|
|
635
|
+
* @private
|
|
636
|
+
*/
|
|
637
|
+
_calculateRateLimitDelay() {
|
|
638
|
+
if (!this.rateLimitReset) {
|
|
639
|
+
return 0;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
const now = Date.now();
|
|
643
|
+
const resetTime = this.rateLimitReset;
|
|
644
|
+
const timeUntilReset = resetTime - now;
|
|
645
|
+
|
|
646
|
+
// If reset time is in the past, no delay needed
|
|
647
|
+
if (timeUntilReset <= 0) {
|
|
648
|
+
this.rateLimitReset = null;
|
|
649
|
+
return 0;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
// Cap rate limit delays at 30 seconds maximum
|
|
653
|
+
const cappedDelay = Math.min(timeUntilReset, 30000);
|
|
654
|
+
|
|
655
|
+
// If original delay would be over 5 minutes, suggest failing fast
|
|
656
|
+
if (timeUntilReset > 5 * 60 * 1000) {
|
|
657
|
+
throw new GitHubAPIError(
|
|
658
|
+
`Rate limit reset time too far in future (${Math.round(timeUntilReset / 1000)}s). Consider using a GitHub token or try again later.`,
|
|
659
|
+
{
|
|
660
|
+
resetTime: this.rateLimitReset,
|
|
661
|
+
delaySeconds: Math.round(timeUntilReset / 1000),
|
|
662
|
+
suggestion: 'Use GITHUB_TOKEN environment variable to increase rate limits'
|
|
663
|
+
}
|
|
664
|
+
);
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
return cappedDelay;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
/**
|
|
671
|
+
* Check if we should fail fast on rate limit
|
|
672
|
+
* @private
|
|
673
|
+
*/
|
|
674
|
+
_shouldFailFastOnRateLimit() {
|
|
675
|
+
if (!this.rateLimitReset) {
|
|
676
|
+
return false;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
const timeUntilReset = this.rateLimitReset - Date.now();
|
|
680
|
+
|
|
681
|
+
// Fail fast if rate limit won't reset for more than 5 minutes
|
|
682
|
+
if (timeUntilReset > 5 * 60 * 1000) {
|
|
683
|
+
throw new GitHubAPIError(
|
|
684
|
+
`Rate limit reset time too far in future (${Math.round(timeUntilReset / 1000)}s). Installation cancelled.`,
|
|
685
|
+
{
|
|
686
|
+
resetTime: this.rateLimitReset,
|
|
687
|
+
delaySeconds: Math.round(timeUntilReset / 1000),
|
|
688
|
+
suggestion: 'Use GITHUB_TOKEN environment variable or try again later'
|
|
689
|
+
}
|
|
690
|
+
);
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
return false;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
/**
|
|
697
|
+
* Check and wait for rate limit if necessary
|
|
698
|
+
* @private
|
|
699
|
+
*/
|
|
700
|
+
async _checkRateLimit() {
|
|
701
|
+
if (this.rateLimitReset && Date.now() < this.rateLimitReset) {
|
|
702
|
+
// Use smart rate limit delay calculation
|
|
703
|
+
const delay = this._calculateRateLimitDelay();
|
|
704
|
+
|
|
705
|
+
if (delay > 0) {
|
|
706
|
+
console.warn(`Rate limit active, waiting ${Math.round(delay / 1000)}s`);
|
|
707
|
+
|
|
708
|
+
if (delay > 5000) {
|
|
709
|
+
await this._waitWithCountdown(delay);
|
|
710
|
+
} else {
|
|
711
|
+
await this._sleep(delay);
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
this.rateLimitReset = null;
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
/**
|
|
720
|
+
* Rate limiting delay between requests
|
|
721
|
+
* @private
|
|
722
|
+
*/
|
|
723
|
+
async _rateLimitDelay() {
|
|
724
|
+
// GitHub allows 5000 requests/hour for authenticated users
|
|
725
|
+
// Be conservative with 1 request per 100ms
|
|
726
|
+
await this._sleep(100);
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
/**
|
|
730
|
+
* Sleep utility
|
|
731
|
+
* @private
|
|
732
|
+
*/
|
|
733
|
+
_sleep(ms) {
|
|
734
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
/**
|
|
738
|
+
* Wait with countdown display for better user experience
|
|
739
|
+
* @private
|
|
740
|
+
*/
|
|
741
|
+
async _waitWithCountdown(totalMs) {
|
|
742
|
+
const intervalMs = 1000; // Update every second
|
|
743
|
+
let remainingMs = totalMs;
|
|
744
|
+
|
|
745
|
+
while (remainingMs > 0) {
|
|
746
|
+
const remainingSeconds = Math.ceil(remainingMs / 1000);
|
|
747
|
+
process.stdout.write(`\r⏳ Rate limit wait: ${remainingSeconds}s remaining...`);
|
|
748
|
+
|
|
749
|
+
const sleepTime = Math.min(intervalMs, remainingMs);
|
|
750
|
+
await this._sleep(sleepTime);
|
|
751
|
+
remainingMs -= sleepTime;
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
process.stdout.write('\r✅ Rate limit wait complete.\n');
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
/**
|
|
758
|
+
* Wait for rate limit with user feedback
|
|
759
|
+
* @private
|
|
760
|
+
*/
|
|
761
|
+
async _waitForRateLimit() {
|
|
762
|
+
const delay = this._calculateRateLimitDelay();
|
|
763
|
+
|
|
764
|
+
if (delay > 0) {
|
|
765
|
+
console.warn(`Rate limit active, waiting ${Math.round(delay / 1000)}s`);
|
|
766
|
+
|
|
767
|
+
if (delay > 5000) {
|
|
768
|
+
await this._waitWithCountdown(delay);
|
|
769
|
+
} else {
|
|
770
|
+
await this._sleep(delay);
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
/**
|
|
776
|
+
* Prepare fetch options with defaults
|
|
777
|
+
* @private
|
|
778
|
+
*/
|
|
779
|
+
_prepareFetchOptions(options) {
|
|
780
|
+
return {
|
|
781
|
+
version: options.version || this.repository.branch,
|
|
782
|
+
useCache: options.useCache || false,
|
|
783
|
+
retryCount: options.retryCount || this.config.retryCount,
|
|
784
|
+
timeout: options.timeout || this.config.timeout
|
|
785
|
+
};
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
/**
|
|
789
|
+
* Check if file should be skipped during fetch
|
|
790
|
+
* @private
|
|
791
|
+
*/
|
|
792
|
+
_shouldSkipFile(filename) {
|
|
793
|
+
const skipPatterns = [
|
|
794
|
+
/\.pyc$/, // Python bytecode
|
|
795
|
+
/\.pyo$/, // Python optimized bytecode
|
|
796
|
+
/\.pyd$/, // Python extension modules
|
|
797
|
+
/\.DS_Store$/, // macOS metadata
|
|
798
|
+
/Thumbs\.db$/, // Windows thumbnails
|
|
799
|
+
/\.git/, // Git files
|
|
800
|
+
/\.svn/, // SVN files
|
|
801
|
+
/~$/, // Backup files
|
|
802
|
+
/\.tmp$/, // Temporary files
|
|
803
|
+
/\.temp$/ // Temporary files
|
|
804
|
+
];
|
|
805
|
+
|
|
806
|
+
return skipPatterns.some(pattern => pattern.test(filename));
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
/**
|
|
810
|
+
* Check if directory should be skipped during fetch
|
|
811
|
+
* @private
|
|
812
|
+
*/
|
|
813
|
+
_shouldSkipDirectory(dirname) {
|
|
814
|
+
const skipDirs = [
|
|
815
|
+
'__pycache__', // Python cache
|
|
816
|
+
'.git', // Git directory
|
|
817
|
+
'.svn', // SVN directory
|
|
818
|
+
'node_modules', // Node.js modules
|
|
819
|
+
'.vscode', // VS Code settings
|
|
820
|
+
'.idea', // IntelliJ settings
|
|
821
|
+
'.DS_Store', // macOS metadata
|
|
822
|
+
'Thumbs.db' // Windows thumbnails
|
|
823
|
+
];
|
|
824
|
+
|
|
825
|
+
return skipDirs.includes(dirname);
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
/**
|
|
829
|
+
* Download repository tarball from GitHub
|
|
830
|
+
* @private
|
|
831
|
+
*/
|
|
832
|
+
async _downloadTarball(options) {
|
|
833
|
+
const { version } = options;
|
|
834
|
+
const url = `${this.config.baseUrl}/repos/${this.repository.owner}/${this.repository.repo}/tarball/${version}`;
|
|
835
|
+
|
|
836
|
+
const response = await this._makeRequest(url);
|
|
837
|
+
|
|
838
|
+
if (!response.ok) {
|
|
839
|
+
throw new GitHubAPIError(
|
|
840
|
+
`Failed to download tarball: ${response.status} ${response.statusText}`,
|
|
841
|
+
{ url, status: response.status }
|
|
842
|
+
);
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
// Download tarball as buffer
|
|
846
|
+
const buffer = await response.buffer();
|
|
847
|
+
|
|
848
|
+
if (buffer.length === 0) {
|
|
849
|
+
throw new GitHubAPIError(
|
|
850
|
+
'Downloaded tarball is empty',
|
|
851
|
+
{ url, size: buffer.length }
|
|
852
|
+
);
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
return buffer;
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
/**
|
|
859
|
+
* Extract relevant files from tarball buffer
|
|
860
|
+
* @private
|
|
861
|
+
*/
|
|
862
|
+
async _extractTarballFiles(tarballBuffer, _options) {
|
|
863
|
+
return new Promise((resolve, reject) => {
|
|
864
|
+
const extractedFiles = {
|
|
865
|
+
'.claude': {
|
|
866
|
+
path: '.claude',
|
|
867
|
+
type: 'dir',
|
|
868
|
+
children: [],
|
|
869
|
+
files: []
|
|
870
|
+
}
|
|
871
|
+
};
|
|
872
|
+
|
|
873
|
+
const claudeMdFile = {
|
|
874
|
+
path: 'CLAUDE.md',
|
|
875
|
+
content: null,
|
|
876
|
+
encoding: 'utf-8'
|
|
877
|
+
};
|
|
878
|
+
|
|
879
|
+
// Create temporary directory for extraction
|
|
880
|
+
const tempDir = path.join(os.tmpdir(), `claude-extract-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
|
881
|
+
|
|
882
|
+
// Track files we want to keep
|
|
883
|
+
const wantedFiles = new Map();
|
|
884
|
+
let rootDirName = null;
|
|
885
|
+
|
|
886
|
+
const extractStream = tar.extract({
|
|
887
|
+
cwd: tempDir,
|
|
888
|
+
filter: (filePath, entry) => {
|
|
889
|
+
// Debug logging
|
|
890
|
+
console.log(`DEBUG: Processing ${filePath} (type: ${entry.type})`);
|
|
891
|
+
|
|
892
|
+
// First entry is usually the root directory (e.g., "screwyforcepush-claude-code-subagent-bus-abc123/")
|
|
893
|
+
if (!rootDirName && entry.type === 'Directory') {
|
|
894
|
+
rootDirName = filePath;
|
|
895
|
+
console.log(`DEBUG: Set root directory to: ${rootDirName}`);
|
|
896
|
+
return false; // Don't extract the root dir itself
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
// Skip if this doesn't belong to our repository root
|
|
900
|
+
if (rootDirName && !filePath.startsWith(rootDirName + '/')) {
|
|
901
|
+
return false;
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
// Remove root directory prefix to get actual file path
|
|
905
|
+
const actualPath = rootDirName ? filePath.slice(rootDirName.length + 1) : filePath;
|
|
906
|
+
|
|
907
|
+
// Only extract files we care about
|
|
908
|
+
const isClaudeFile = actualPath.startsWith('.claude/') || actualPath === '.claude';
|
|
909
|
+
const isClaudeMd = actualPath === 'CLAUDE.md';
|
|
910
|
+
|
|
911
|
+
console.log(`DEBUG: ${actualPath} -> claude: ${isClaudeFile}, md: ${isClaudeMd}, type: ${entry.type}`);
|
|
912
|
+
|
|
913
|
+
if ((isClaudeFile || isClaudeMd) && entry.type === 'File') {
|
|
914
|
+
wantedFiles.set(filePath, actualPath);
|
|
915
|
+
console.log(`DEBUG: Will extract: ${filePath} -> ${actualPath}`);
|
|
916
|
+
return true;
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
// Also allow .claude directories to be processed for their children
|
|
920
|
+
if (isClaudeFile && entry.type === 'Directory') {
|
|
921
|
+
return true;
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
return false;
|
|
925
|
+
}
|
|
926
|
+
});
|
|
927
|
+
|
|
928
|
+
extractStream.on('error', (error) => {
|
|
929
|
+
reject(new GitHubAPIError(
|
|
930
|
+
`Tarball extraction failed: ${error.message}`,
|
|
931
|
+
{ error: error.message }
|
|
932
|
+
));
|
|
933
|
+
});
|
|
934
|
+
|
|
935
|
+
extractStream.on('finish', async () => {
|
|
936
|
+
try {
|
|
937
|
+
// Process extracted files
|
|
938
|
+
for (const [extractedPath, actualPath] of wantedFiles) {
|
|
939
|
+
const fullPath = path.join(tempDir, extractedPath);
|
|
940
|
+
|
|
941
|
+
try {
|
|
942
|
+
const stats = await fs.promises.stat(fullPath);
|
|
943
|
+
|
|
944
|
+
if (stats.isFile()) {
|
|
945
|
+
const content = await fs.promises.readFile(fullPath, 'utf-8');
|
|
946
|
+
|
|
947
|
+
if (actualPath === 'CLAUDE.md') {
|
|
948
|
+
claudeMdFile.content = content;
|
|
949
|
+
claudeMdFile.path = 'CLAUDE.md';
|
|
950
|
+
extractedFiles['CLAUDE.md'] = claudeMdFile;
|
|
951
|
+
} else if (actualPath.startsWith('.claude/')) {
|
|
952
|
+
// Add to .claude files
|
|
953
|
+
const relativePath = actualPath.slice('.claude/'.length);
|
|
954
|
+
const fileObj = {
|
|
955
|
+
path: actualPath,
|
|
956
|
+
content: content,
|
|
957
|
+
encoding: 'utf-8'
|
|
958
|
+
};
|
|
959
|
+
|
|
960
|
+
extractedFiles['.claude'].files.push(fileObj);
|
|
961
|
+
|
|
962
|
+
// Build directory structure for compatibility
|
|
963
|
+
this._addToDirectoryStructure(extractedFiles['.claude'], relativePath, fileObj);
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
} catch (fileError) {
|
|
967
|
+
// Skip files that can't be read
|
|
968
|
+
console.warn(`Warning: Could not read extracted file ${fullPath}: ${fileError.message}`);
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
// Clean up temp directory
|
|
973
|
+
try {
|
|
974
|
+
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
|
975
|
+
} catch (cleanupError) {
|
|
976
|
+
// Log but don't fail
|
|
977
|
+
console.warn(`Warning: Could not clean up temp directory ${tempDir}: ${cleanupError.message}`);
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
resolve(extractedFiles);
|
|
981
|
+
|
|
982
|
+
} catch (processingError) {
|
|
983
|
+
reject(new GitHubAPIError(
|
|
984
|
+
`File processing failed: ${processingError.message}`,
|
|
985
|
+
{ error: processingError.message }
|
|
986
|
+
));
|
|
987
|
+
}
|
|
988
|
+
});
|
|
989
|
+
|
|
990
|
+
// Create temp directory and start extraction
|
|
991
|
+
fs.promises.mkdir(tempDir, { recursive: true })
|
|
992
|
+
.then(() => {
|
|
993
|
+
// Pipe tarball buffer to extraction stream
|
|
994
|
+
const { Readable } = require('stream');
|
|
995
|
+
const readable = new Readable();
|
|
996
|
+
readable.push(tarballBuffer);
|
|
997
|
+
readable.push(null);
|
|
998
|
+
readable.pipe(extractStream);
|
|
999
|
+
})
|
|
1000
|
+
.catch(reject);
|
|
1001
|
+
});
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
/**
|
|
1005
|
+
* Add file to directory structure for compatibility with existing code
|
|
1006
|
+
* @private
|
|
1007
|
+
*/
|
|
1008
|
+
_addToDirectoryStructure(claudeDir, relativePath, fileObj) {
|
|
1009
|
+
const parts = relativePath.split('/');
|
|
1010
|
+
let currentLevel = claudeDir.children;
|
|
1011
|
+
let currentPath = '.claude';
|
|
1012
|
+
|
|
1013
|
+
for (let i = 0; i < parts.length; i++) {
|
|
1014
|
+
const part = parts[i];
|
|
1015
|
+
currentPath = `${currentPath}/${part}`;
|
|
1016
|
+
|
|
1017
|
+
if (i === parts.length - 1) {
|
|
1018
|
+
// This is the file
|
|
1019
|
+
currentLevel.push({
|
|
1020
|
+
path: currentPath,
|
|
1021
|
+
type: 'file',
|
|
1022
|
+
sha: null
|
|
1023
|
+
});
|
|
1024
|
+
} else {
|
|
1025
|
+
// This is a directory
|
|
1026
|
+
let dirEntry = currentLevel.find(item => item.path === currentPath && item.type === 'dir');
|
|
1027
|
+
if (!dirEntry) {
|
|
1028
|
+
dirEntry = {
|
|
1029
|
+
path: currentPath,
|
|
1030
|
+
type: 'dir',
|
|
1031
|
+
children: []
|
|
1032
|
+
};
|
|
1033
|
+
currentLevel.push(dirEntry);
|
|
1034
|
+
}
|
|
1035
|
+
currentLevel = dirEntry.children;
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
/**
|
|
1042
|
+
* Main function for backwards compatibility
|
|
1043
|
+
* @param {string} gitRef - Git reference (branch/tag/commit)
|
|
1044
|
+
* @param {Object} options - Additional options
|
|
1045
|
+
* @returns {Promise<Object>} Repository files
|
|
1046
|
+
*/
|
|
1047
|
+
async function fetchRepository(gitRef = 'main', options = {}) {
|
|
1048
|
+
const fetcher = new GitHubFetcher(options);
|
|
1049
|
+
|
|
1050
|
+
try {
|
|
1051
|
+
// Strategy 1: Tarball approach (fastest, single request)
|
|
1052
|
+
console.log('Attempting tarball fetch strategy...');
|
|
1053
|
+
const tarballFiles = await fetcher.fetchAsTarball({ version: gitRef });
|
|
1054
|
+
|
|
1055
|
+
return {
|
|
1056
|
+
claudeDirectory: tarballFiles['.claude'],
|
|
1057
|
+
claudeFile: tarballFiles['CLAUDE.md'],
|
|
1058
|
+
files: [...(tarballFiles['.claude'].files || []), tarballFiles['CLAUDE.md']]
|
|
1059
|
+
};
|
|
1060
|
+
|
|
1061
|
+
} catch (tarballError) {
|
|
1062
|
+
console.warn(`Tarball fetch failed: ${tarballError.message}`);
|
|
1063
|
+
console.log('Falling back to individual file fetch strategy...');
|
|
1064
|
+
|
|
1065
|
+
try {
|
|
1066
|
+
// Strategy 2: Individual file fetch (fallback)
|
|
1067
|
+
const [claudeDir, claudeMd] = await Promise.all([
|
|
1068
|
+
fetcher.fetchDirectory('.claude', { version: gitRef }),
|
|
1069
|
+
fetcher.fetchFile('CLAUDE.md', { version: gitRef })
|
|
1070
|
+
]);
|
|
1071
|
+
|
|
1072
|
+
return {
|
|
1073
|
+
claudeDirectory: claudeDir,
|
|
1074
|
+
claudeFile: claudeMd,
|
|
1075
|
+
files: [...(claudeDir.files || []), claudeMd]
|
|
1076
|
+
};
|
|
1077
|
+
|
|
1078
|
+
} catch (fallbackError) {
|
|
1079
|
+
console.error('Both tarball and individual fetch strategies failed');
|
|
1080
|
+
throw new Error(
|
|
1081
|
+
`All fetch strategies failed. Tarball: ${tarballError.message}. Individual: ${fallbackError.message}`
|
|
1082
|
+
);
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
module.exports = {
|
|
1088
|
+
GitHubFetcher,
|
|
1089
|
+
fetchRepository,
|
|
1090
|
+
// Backwards compatibility
|
|
1091
|
+
fetchClaudeSetup: fetchRepository
|
|
1092
|
+
};
|