@exreve/exk 1.0.50 → 1.0.52

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.
@@ -0,0 +1,525 @@
1
+ /**
2
+ * GitHub Handlers Module
3
+ *
4
+ * Extracted from app-child.ts. Provides all git/gh CLI operations
5
+ * using async execFile (non-blocking). Registered via registerGitHubHandlers().
6
+ */
7
+ import { execFile } from 'child_process';
8
+ import { promisify } from 'util';
9
+ import fs from 'fs/promises';
10
+ import path from 'path';
11
+ const execFileAsync = promisify(execFile);
12
+ // ============ Helpers ============
13
+ /** Run a git command in a project directory. Returns trimmed stdout. */
14
+ async function git(args, cwd, options) {
15
+ const { stdout } = await execFileAsync('git', args, {
16
+ cwd,
17
+ maxBuffer: options?.maxBuffer ?? 10 * 1024 * 1024,
18
+ });
19
+ return (stdout || '').trim();
20
+ }
21
+ /** Run a gh command in a project directory. Returns trimmed stdout. */
22
+ async function gh(args, cwd) {
23
+ const { stdout } = await execFileAsync('gh', args, {
24
+ cwd,
25
+ maxBuffer: 10 * 1024 * 1024,
26
+ });
27
+ return (stdout || '').trim();
28
+ }
29
+ /** Check if directory is a git repository */
30
+ async function isGitRepo(projectPath) {
31
+ try {
32
+ const stat = await fs.stat(path.join(projectPath, '.git'));
33
+ return stat.isDirectory();
34
+ }
35
+ catch {
36
+ return false;
37
+ }
38
+ }
39
+ /** Cached gh CLI availability check */
40
+ let ghCheckCache = { available: false, checked: 0 };
41
+ async function isGhAvailable() {
42
+ const now = Date.now();
43
+ if (ghCheckCache.checked && now - ghCheckCache.checked < 60_000)
44
+ return ghCheckCache.available;
45
+ try {
46
+ await execFileAsync('gh', ['auth', 'status'], { timeout: 5000 });
47
+ ghCheckCache.available = true;
48
+ }
49
+ catch {
50
+ ghCheckCache.available = false;
51
+ }
52
+ ghCheckCache.checked = now;
53
+ return ghCheckCache.available;
54
+ }
55
+ /** Parse error from git/gh CLI output into a user-friendly message */
56
+ function parseError(error) {
57
+ const stderr = (error.stderr || error.message || '').toString().trim();
58
+ if (stderr.includes('not a git repository'))
59
+ return 'Not a git repository';
60
+ if (stderr.includes('CONFLICT'))
61
+ return 'Merge conflict detected. Resolve conflicts and try again.';
62
+ if (stderr.includes('Authentication failed') || stderr.includes('Permission denied'))
63
+ return 'Authentication failed. Check your git credentials.';
64
+ if (stderr.includes('gh auth'))
65
+ return 'GitHub CLI not authenticated. Run: gh auth login';
66
+ if (stderr.includes('nothing to commit'))
67
+ return 'Nothing to commit';
68
+ if (stderr.includes('no upstream'))
69
+ return 'No upstream branch set. Push with -u flag first.';
70
+ if (stderr.includes('dirty working tree'))
71
+ return 'Working tree has uncommitted changes. Commit or stash first.';
72
+ // Return first line of error, truncated
73
+ const firstLine = stderr.split('\n')[0];
74
+ return firstLine.length > 200 ? firstLine.slice(0, 200) + '...' : firstLine || 'Unknown error';
75
+ }
76
+ /** Get current branch name */
77
+ async function getCurrentBranch(cwd) {
78
+ return git(['rev-parse', '--abbrev-ref', 'HEAD'], cwd);
79
+ }
80
+ // ============ Handler Implementation ============
81
+ /** Parse git status --porcelain output into structured file list */
82
+ function parsePorcelainStatus(output) {
83
+ if (!output)
84
+ return [];
85
+ const files = [];
86
+ for (const line of output.split('\n')) {
87
+ if (!line.trim())
88
+ continue;
89
+ const x = line[0]; // index status
90
+ const y = line[1]; // working tree status
91
+ const filePath = line.substring(3).trim();
92
+ if (!filePath)
93
+ continue;
94
+ const staged = x !== ' ' && x !== '?';
95
+ const unstaged = y !== ' ' && y !== '?';
96
+ const status = line.substring(0, 2);
97
+ files.push({ path: filePath, status, staged, unstaged });
98
+ }
99
+ return files;
100
+ }
101
+ /** Handler: github:status — full repo status */
102
+ async function handleStatus(projectPath) {
103
+ try {
104
+ const repo = await isGitRepo(projectPath);
105
+ if (!repo)
106
+ return { success: true, status: { isRepo: false, branch: '', remote: undefined, ahead: 0, behind: 0, changedFiles: [], stashCount: 0, ghAvailable: false } };
107
+ const [branch, remote, porcelainOutput, ghOk] = await Promise.all([
108
+ getCurrentBranch(projectPath).catch(() => ''),
109
+ git(['config', '--get', 'remote.origin.url'], projectPath).catch(() => ''),
110
+ git(['status', '--porcelain'], projectPath).catch(() => ''),
111
+ isGhAvailable(),
112
+ ]);
113
+ // Ahead/behind (may fail if no upstream)
114
+ let ahead = 0, behind = 0;
115
+ try {
116
+ const abOutput = await git(['rev-list', '--left-right', '--count', '@{upstream}...HEAD'], projectPath);
117
+ const parts = abOutput.split(/\s+/);
118
+ behind = parseInt(parts[0], 10) || 0;
119
+ ahead = parseInt(parts[1], 10) || 0;
120
+ }
121
+ catch {
122
+ // No upstream configured
123
+ }
124
+ // Stash count
125
+ let stashCount = 0;
126
+ try {
127
+ const stashList = await git(['stash', 'list'], projectPath);
128
+ if (stashList)
129
+ stashCount = stashList.split('\n').filter(Boolean).length;
130
+ }
131
+ catch {
132
+ // empty
133
+ }
134
+ const changedFiles = parsePorcelainStatus(porcelainOutput);
135
+ const MAX_CHANGED_FILES = 1000;
136
+ const totalChangedFiles = changedFiles.length;
137
+ const truncated = totalChangedFiles > MAX_CHANGED_FILES ? changedFiles.slice(0, MAX_CHANGED_FILES) : changedFiles;
138
+ return {
139
+ success: true,
140
+ status: {
141
+ isRepo: true,
142
+ branch: branch || 'HEAD',
143
+ remote: remote || undefined,
144
+ ahead,
145
+ behind,
146
+ changedFiles: truncated,
147
+ stashCount,
148
+ ghAvailable: ghOk,
149
+ ...(totalChangedFiles > MAX_CHANGED_FILES && { totalChangedFiles }),
150
+ },
151
+ };
152
+ }
153
+ catch (error) {
154
+ return { success: false, error: parseError(error) };
155
+ }
156
+ }
157
+ /** Handler: github:diff — per-file diff */
158
+ async function handleDiff(projectPath, file, staged) {
159
+ try {
160
+ const args = staged ? ['diff', '--cached', '--', file] : ['diff', '--', file];
161
+ const diffText = await git(args, projectPath);
162
+ return { success: true, diff: { file, diff: diffText } };
163
+ }
164
+ catch (error) {
165
+ return { success: false, error: parseError(error) };
166
+ }
167
+ }
168
+ /** Handler: github:log — commit history */
169
+ async function handleLog(projectPath, count) {
170
+ try {
171
+ const n = count || 15;
172
+ const format = '%H|%h|%an|%aI|%s|%D';
173
+ const output = await git(['log', `--pretty=format:${format}`, `-n`, String(n)], projectPath);
174
+ if (!output)
175
+ return { success: true, entries: [] };
176
+ const entries = output.split('\n').filter(Boolean).map(line => {
177
+ const [hash, shortHash, author, date, message, refs] = line.split('|');
178
+ return { hash: hash || '', shortHash: shortHash || '', author: author || '', date: date || '', message: message || '', refs: refs || undefined };
179
+ });
180
+ return { success: true, entries };
181
+ }
182
+ catch (error) {
183
+ return { success: false, error: parseError(error) };
184
+ }
185
+ }
186
+ /** Handler: github:branches — list branches */
187
+ async function handleBranches(projectPath) {
188
+ try {
189
+ const output = await git(['branch', '-a', '--no-color'], projectPath);
190
+ if (!output)
191
+ return { success: true, branches: [] };
192
+ const branches = [];
193
+ const seen = new Set();
194
+ for (const line of output.split('\n')) {
195
+ const trimmed = line.trim();
196
+ if (!trimmed)
197
+ continue;
198
+ const current = trimmed.startsWith('* ');
199
+ let name = current ? trimmed.substring(2) : trimmed;
200
+ // Skip remote HEAD references
201
+ if (name.includes('->'))
202
+ continue;
203
+ // Normalize remote branches
204
+ if (name.startsWith('remotes/origin/')) {
205
+ name = name.replace('remotes/origin/', '');
206
+ if (name === 'HEAD')
207
+ continue;
208
+ }
209
+ // Deduplicate (local + remote with same name)
210
+ if (seen.has(name))
211
+ continue;
212
+ seen.add(name);
213
+ branches.push({ name, current });
214
+ }
215
+ // Sort: current first, then alphabetically
216
+ branches.sort((a, b) => {
217
+ if (a.current && !b.current)
218
+ return -1;
219
+ if (!a.current && b.current)
220
+ return 1;
221
+ return a.name.localeCompare(b.name);
222
+ });
223
+ return { success: true, branches };
224
+ }
225
+ catch (error) {
226
+ return { success: false, error: parseError(error) };
227
+ }
228
+ }
229
+ /** Handler: github:checkout — switch branch */
230
+ async function handleCheckout(projectPath, branch) {
231
+ try {
232
+ await git(['checkout', branch], projectPath);
233
+ return { success: true };
234
+ }
235
+ catch (error) {
236
+ return { success: false, error: parseError(error) };
237
+ }
238
+ }
239
+ /** Handler: github:create-branch — create and switch to new branch */
240
+ async function handleCreateBranch(projectPath, name, base) {
241
+ try {
242
+ const args = base ? ['checkout', '-b', name, base] : ['checkout', '-b', name];
243
+ await git(args, projectPath);
244
+ return { success: true };
245
+ }
246
+ catch (error) {
247
+ return { success: false, error: parseError(error) };
248
+ }
249
+ }
250
+ /** Handler: github:add — stage files */
251
+ async function handleAdd(projectPath, files) {
252
+ try {
253
+ await git(['add', '--', ...files], projectPath);
254
+ return { success: true };
255
+ }
256
+ catch (error) {
257
+ return { success: false, error: parseError(error) };
258
+ }
259
+ }
260
+ /** Handler: github:commit — commit staged changes (no auto-push, no auto-add) */
261
+ async function handleCommit(projectPath, message) {
262
+ try {
263
+ await git(['commit', '-m', message], projectPath);
264
+ return { success: true };
265
+ }
266
+ catch (error) {
267
+ return { success: false, error: parseError(error) };
268
+ }
269
+ }
270
+ /** Handler: github:push — push to remote */
271
+ async function handlePush(projectPath, branch) {
272
+ try {
273
+ const targetBranch = branch || await getCurrentBranch(projectPath);
274
+ await git(['push', 'origin', targetBranch], projectPath);
275
+ return { success: true };
276
+ }
277
+ catch (error) {
278
+ return { success: false, error: parseError(error) };
279
+ }
280
+ }
281
+ /** Handler: github:pull — pull with rebase */
282
+ async function handlePull(projectPath) {
283
+ try {
284
+ await git(['pull', '--rebase'], projectPath);
285
+ return { success: true };
286
+ }
287
+ catch (error) {
288
+ return { success: false, error: parseError(error) };
289
+ }
290
+ }
291
+ /** Handler: github:fetch — fetch and prune */
292
+ async function handleFetch(projectPath) {
293
+ try {
294
+ await git(['fetch', '--prune'], projectPath);
295
+ return { success: true };
296
+ }
297
+ catch (error) {
298
+ return { success: false, error: parseError(error) };
299
+ }
300
+ }
301
+ /** Handler: github:stash — stash or pop */
302
+ async function handleStash(projectPath, pop) {
303
+ try {
304
+ if (pop) {
305
+ await git(['stash', 'pop'], projectPath);
306
+ }
307
+ else {
308
+ await git(['stash', 'push'], projectPath);
309
+ }
310
+ // Return updated stash count
311
+ let stashCount = 0;
312
+ try {
313
+ const stashList = await git(['stash', 'list'], projectPath);
314
+ if (stashList)
315
+ stashCount = stashList.split('\n').filter(Boolean).length;
316
+ }
317
+ catch { /* empty */ }
318
+ return { success: true, stashCount };
319
+ }
320
+ catch (error) {
321
+ return { success: false, error: parseError(error) };
322
+ }
323
+ }
324
+ /** Handler: github:discard — discard changes */
325
+ async function handleDiscard(projectPath, files) {
326
+ try {
327
+ if (files && files.length > 0) {
328
+ // Discard specific files
329
+ await git(['checkout', '--', ...files], projectPath);
330
+ }
331
+ else {
332
+ // Discard all changes
333
+ await git(['reset', '--hard', 'HEAD'], projectPath);
334
+ try {
335
+ await git(['clean', '-fd'], projectPath);
336
+ }
337
+ catch { /* no untracked files */ }
338
+ }
339
+ return { success: true };
340
+ }
341
+ catch (error) {
342
+ return { success: false, error: parseError(error) };
343
+ }
344
+ }
345
+ /** Handler: github:pr:list — list pull requests via gh CLI */
346
+ async function handlePrList(projectPath, state) {
347
+ try {
348
+ const s = state || 'open';
349
+ const output = await gh(['pr', 'list', '--state', s, '--json', 'number,title,state,author,headRefName,baseRefName,url,createdAt', '--limit', '30'], projectPath);
350
+ const parsed = JSON.parse(output);
351
+ const prs = parsed.map(pr => ({
352
+ number: pr.number,
353
+ title: pr.title,
354
+ state: (pr.state || 'OPEN').toUpperCase(),
355
+ author: pr.author?.login || pr.author?.name || 'unknown',
356
+ headRefName: pr.headRefName || '',
357
+ baseRefName: pr.baseRefName || '',
358
+ url: pr.url || '',
359
+ createdAt: pr.createdAt || '',
360
+ }));
361
+ return { success: true, prs };
362
+ }
363
+ catch (error) {
364
+ return { success: false, error: parseError(error) };
365
+ }
366
+ }
367
+ /** Handler: github:pr:create — create a PR via gh CLI */
368
+ async function handlePrCreate(projectPath, title, body, base, head) {
369
+ try {
370
+ const args = ['pr', 'create', '--title', title];
371
+ if (body)
372
+ args.push('--body', body);
373
+ else
374
+ args.push('--body', '');
375
+ if (base)
376
+ args.push('--base', base);
377
+ if (head)
378
+ args.push('--head', head);
379
+ const output = await gh(args, projectPath);
380
+ // gh pr create returns the URL on success
381
+ const url = output.split('\n').pop() || output;
382
+ // Extract PR number from URL
383
+ const match = url.match(/\/pull\/(\d+)/);
384
+ const number = match ? parseInt(match[1], 10) : 0;
385
+ return {
386
+ success: true,
387
+ pr: {
388
+ number,
389
+ title,
390
+ state: 'OPEN',
391
+ author: '',
392
+ headRefName: head || '',
393
+ baseRefName: base || '',
394
+ url,
395
+ createdAt: new Date().toISOString(),
396
+ },
397
+ };
398
+ }
399
+ catch (error) {
400
+ return { success: false, error: parseError(error) };
401
+ }
402
+ }
403
+ /** Handler: github:pr:merge — merge a PR via gh CLI */
404
+ async function handlePrMerge(projectPath, prNumber) {
405
+ try {
406
+ await gh(['pr', 'merge', String(prNumber), '--squash', '--delete-branch'], projectPath);
407
+ return { success: true };
408
+ }
409
+ catch (error) {
410
+ return { success: false, error: parseError(error) };
411
+ }
412
+ }
413
+ // ============ Module Registration ============
414
+ /**
415
+ * Register all GitHub socket handlers on the given socket.
416
+ * Call this inside the connectAndRegister() function in app-child.ts.
417
+ */
418
+ export function registerGitHubHandlers(socket, foreground) {
419
+ socket.on('github:status', async (data, callback) => {
420
+ if (foreground)
421
+ console.log(`[github] Status check for ${data.projectId}`);
422
+ const result = await handleStatus(data.projectPath);
423
+ if (foreground && result.success) {
424
+ const s = result.status;
425
+ console.log(`[github] Status: branch=${s?.branch}, files=${s?.changedFiles.length}, ahead=${s?.ahead}, behind=${s?.behind}`);
426
+ }
427
+ callback?.(result);
428
+ });
429
+ socket.on('github:diff', async (data, callback) => {
430
+ const result = await handleDiff(data.projectPath, data.file, data.staged);
431
+ callback?.(result);
432
+ });
433
+ socket.on('github:log', async (data, callback) => {
434
+ const result = await handleLog(data.projectPath, data.count);
435
+ callback?.(result);
436
+ });
437
+ socket.on('github:branches', async (data, callback) => {
438
+ const result = await handleBranches(data.projectPath);
439
+ callback?.(result);
440
+ });
441
+ socket.on('github:checkout', async (data, callback) => {
442
+ if (foreground)
443
+ console.log(`[github] Checkout: ${data.branch}`);
444
+ const result = await handleCheckout(data.projectPath, data.branch);
445
+ if (foreground && result.success)
446
+ console.log(`[github] Switched to ${data.branch}`);
447
+ callback?.(result);
448
+ });
449
+ socket.on('github:create-branch', async (data, callback) => {
450
+ if (foreground)
451
+ console.log(`[github] Create branch: ${data.name}`);
452
+ const result = await handleCreateBranch(data.projectPath, data.name, data.base);
453
+ if (foreground && result.success)
454
+ console.log(`[github] Created and switched to ${data.name}`);
455
+ callback?.(result);
456
+ });
457
+ socket.on('github:add', async (data, callback) => {
458
+ const result = await handleAdd(data.projectPath, data.files);
459
+ callback?.(result);
460
+ });
461
+ socket.on('github:commit', async (data, callback) => {
462
+ if (foreground)
463
+ console.log(`[github] Commit: ${data.message.substring(0, 60)}`);
464
+ const result = await handleCommit(data.projectPath, data.message);
465
+ if (foreground && result.success)
466
+ console.log(`[github] Committed successfully`);
467
+ callback?.(result);
468
+ });
469
+ socket.on('github:push', async (data, callback) => {
470
+ if (foreground)
471
+ console.log(`[github] Push`);
472
+ const result = await handlePush(data.projectPath, data.branch);
473
+ if (foreground && result.success)
474
+ console.log(`[github] Pushed successfully`);
475
+ callback?.(result);
476
+ });
477
+ socket.on('github:pull', async (data, callback) => {
478
+ if (foreground)
479
+ console.log(`[github] Pull`);
480
+ const result = await handlePull(data.projectPath);
481
+ if (foreground && result.success)
482
+ console.log(`[github] Pulled successfully`);
483
+ callback?.(result);
484
+ });
485
+ socket.on('github:fetch', async (data, callback) => {
486
+ const result = await handleFetch(data.projectPath);
487
+ callback?.(result);
488
+ });
489
+ socket.on('github:stash', async (data, callback) => {
490
+ if (foreground)
491
+ console.log(`[github] Stash ${data.pop ? 'pop' : 'push'}`);
492
+ const result = await handleStash(data.projectPath, data.pop);
493
+ callback?.(result);
494
+ });
495
+ socket.on('github:discard', async (data, callback) => {
496
+ if (foreground)
497
+ console.log(`[github] Discard changes${data.files ? ` (${data.files.length} files)` : ' (all)'}`);
498
+ const result = await handleDiscard(data.projectPath, data.files);
499
+ if (foreground && result.success)
500
+ console.log(`[github] Changes discarded`);
501
+ callback?.(result);
502
+ });
503
+ socket.on('github:pr:list', async (data, callback) => {
504
+ if (foreground)
505
+ console.log(`[github] PR list`);
506
+ const result = await handlePrList(data.projectPath, data.state);
507
+ callback?.(result);
508
+ });
509
+ socket.on('github:pr:create', async (data, callback) => {
510
+ if (foreground)
511
+ console.log(`[github] PR create: ${data.title}`);
512
+ const result = await handlePrCreate(data.projectPath, data.title, data.body, data.base, data.head);
513
+ if (foreground && result.success)
514
+ console.log(`[github] PR created: ${result.pr?.url}`);
515
+ callback?.(result);
516
+ });
517
+ socket.on('github:pr:merge', async (data, callback) => {
518
+ if (foreground)
519
+ console.log(`[github] PR merge: #${data.number}`);
520
+ const result = await handlePrMerge(data.projectPath, data.number);
521
+ if (foreground && result.success)
522
+ console.log(`[github] PR #${data.number} merged`);
523
+ callback?.(result);
524
+ });
525
+ }