@sumicom/quicksave 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/LICENSE +21 -0
  2. package/dist/ai/commitSummary.d.ts +26 -0
  3. package/dist/ai/commitSummary.d.ts.map +1 -0
  4. package/dist/ai/commitSummary.js +145 -0
  5. package/dist/ai/commitSummary.js.map +1 -0
  6. package/dist/config.d.ts +22 -0
  7. package/dist/config.d.ts.map +1 -0
  8. package/dist/config.js +76 -0
  9. package/dist/config.js.map +1 -0
  10. package/dist/connection/connection.d.ts +54 -0
  11. package/dist/connection/connection.d.ts.map +1 -0
  12. package/dist/connection/connection.js +171 -0
  13. package/dist/connection/connection.js.map +1 -0
  14. package/dist/connection/signaling.d.ts +31 -0
  15. package/dist/connection/signaling.d.ts.map +1 -0
  16. package/dist/connection/signaling.js +144 -0
  17. package/dist/connection/signaling.js.map +1 -0
  18. package/dist/git/operations.d.ts +104 -0
  19. package/dist/git/operations.d.ts.map +1 -0
  20. package/dist/git/operations.js +383 -0
  21. package/dist/git/operations.js.map +1 -0
  22. package/dist/git/operations.test.d.ts +2 -0
  23. package/dist/git/operations.test.d.ts.map +1 -0
  24. package/dist/git/operations.test.js +232 -0
  25. package/dist/git/operations.test.js.map +1 -0
  26. package/dist/handlers/messageHandler.d.ts +33 -0
  27. package/dist/handlers/messageHandler.d.ts.map +1 -0
  28. package/dist/handlers/messageHandler.js +463 -0
  29. package/dist/handlers/messageHandler.js.map +1 -0
  30. package/dist/handlers/messageHandler.test.d.ts +2 -0
  31. package/dist/handlers/messageHandler.test.d.ts.map +1 -0
  32. package/dist/handlers/messageHandler.test.js +233 -0
  33. package/dist/handlers/messageHandler.test.js.map +1 -0
  34. package/dist/index.d.ts +3 -0
  35. package/dist/index.d.ts.map +1 -0
  36. package/dist/index.js +127 -0
  37. package/dist/index.js.map +1 -0
  38. package/dist/webrtc/connection.d.ts +52 -0
  39. package/dist/webrtc/connection.d.ts.map +1 -0
  40. package/dist/webrtc/connection.js +255 -0
  41. package/dist/webrtc/connection.js.map +1 -0
  42. package/dist/webrtc/signaling.d.ts +41 -0
  43. package/dist/webrtc/signaling.d.ts.map +1 -0
  44. package/dist/webrtc/signaling.js +152 -0
  45. package/dist/webrtc/signaling.js.map +1 -0
  46. package/package.json +36 -0
  47. package/src/ai/commitSummary.ts +199 -0
  48. package/src/config.ts +97 -0
  49. package/src/connection/connection.ts +223 -0
  50. package/src/connection/signaling.ts +172 -0
  51. package/src/git/operations.test.ts +305 -0
  52. package/src/git/operations.ts +443 -0
  53. package/src/handlers/messageHandler.test.ts +294 -0
  54. package/src/handlers/messageHandler.ts +550 -0
  55. package/src/index.ts +151 -0
  56. package/src/types/webrtc.d.ts +38 -0
  57. package/tsconfig.json +13 -0
  58. package/vitest.config.ts +9 -0
@@ -0,0 +1,550 @@
1
+ import {
2
+ Message,
3
+ createMessage,
4
+ StatusRequestPayload,
5
+ StatusResponsePayload,
6
+ DiffRequestPayload,
7
+ DiffResponsePayload,
8
+ StageRequestPayload,
9
+ StageResponsePayload,
10
+ UnstageRequestPayload,
11
+ UnstageResponsePayload,
12
+ StagePatchRequestPayload,
13
+ StagePatchResponsePayload,
14
+ UnstagePatchRequestPayload,
15
+ UnstagePatchResponsePayload,
16
+ CommitRequestPayload,
17
+ CommitResponsePayload,
18
+ LogRequestPayload,
19
+ LogResponsePayload,
20
+ BranchesResponsePayload,
21
+ CheckoutRequestPayload,
22
+ CheckoutResponsePayload,
23
+ DiscardRequestPayload,
24
+ DiscardResponsePayload,
25
+ ErrorPayload,
26
+ HandshakePayload,
27
+ HandshakeAckPayload,
28
+ License,
29
+ GenerateCommitSummaryRequestPayload,
30
+ GenerateCommitSummaryResponsePayload,
31
+ SetApiKeyRequestPayload,
32
+ SetApiKeyResponsePayload,
33
+ GetApiKeyStatusResponsePayload,
34
+ Repository,
35
+ ListReposResponsePayload,
36
+ SwitchRepoRequestPayload,
37
+ SwitchRepoResponsePayload,
38
+ BrowseDirectoryRequestPayload,
39
+ BrowseDirectoryResponsePayload,
40
+ DirectoryEntry,
41
+ AddRepoRequestPayload,
42
+ AddRepoResponsePayload,
43
+ } from '@sumicom/quicksave-shared';
44
+ import { GitOperations } from '../git/operations.js';
45
+ import { getAnthropicApiKey, setAnthropicApiKey, hasAnthropicApiKey } from '../config.js';
46
+ import { CommitSummaryService } from '../ai/commitSummary.js';
47
+ import { readdir, stat } from 'fs/promises';
48
+ import { join, dirname, basename } from 'path';
49
+ import { homedir } from 'os';
50
+
51
+ export class MessageHandler {
52
+ private repos: Map<string, GitOperations>;
53
+ private agentVersion = '0.1.0';
54
+ private currentRepoPath: string;
55
+ private availableRepos: Repository[];
56
+ private aiService: CommitSummaryService | null = null;
57
+
58
+ constructor(repos: Repository[], _license?: License) {
59
+ this.repos = new Map();
60
+ for (const repo of repos) {
61
+ this.repos.set(repo.path, new GitOperations(repo.path));
62
+ }
63
+ this.availableRepos = repos;
64
+ this.currentRepoPath = repos[0].path;
65
+ // License handling will be implemented later
66
+ }
67
+
68
+ private get git(): GitOperations {
69
+ return this.repos.get(this.currentRepoPath)!;
70
+ }
71
+
72
+ private getAiService(): CommitSummaryService | null {
73
+ const apiKey = getAnthropicApiKey();
74
+ if (!apiKey) return null;
75
+
76
+ // Create service lazily and reuse for caching
77
+ if (!this.aiService) {
78
+ this.aiService = new CommitSummaryService(apiKey);
79
+ }
80
+ return this.aiService;
81
+ }
82
+
83
+ async handleMessage(message: Message): Promise<Message> {
84
+ try {
85
+ switch (message.type) {
86
+ case 'handshake':
87
+ return this.handleHandshake(message as Message<HandshakePayload>);
88
+ case 'ping':
89
+ return createMessage('pong', { timestamp: Date.now() });
90
+ case 'git:status':
91
+ return this.handleStatus(message as Message<StatusRequestPayload>);
92
+ case 'git:diff':
93
+ return this.handleDiff(message as Message<DiffRequestPayload>);
94
+ case 'git:stage':
95
+ return this.handleStage(message as Message<StageRequestPayload>);
96
+ case 'git:unstage':
97
+ return this.handleUnstage(message as Message<UnstageRequestPayload>);
98
+ case 'git:stage-patch':
99
+ return this.handleStagePatch(message as Message<StagePatchRequestPayload>);
100
+ case 'git:unstage-patch':
101
+ return this.handleUnstagePatch(message as Message<UnstagePatchRequestPayload>);
102
+ case 'git:commit':
103
+ return this.handleCommit(message as Message<CommitRequestPayload>);
104
+ case 'git:log':
105
+ return this.handleLog(message as Message<LogRequestPayload>);
106
+ case 'git:branches':
107
+ return this.handleBranches();
108
+ case 'git:checkout':
109
+ return this.handleCheckout(message as Message<CheckoutRequestPayload>);
110
+ case 'git:discard':
111
+ return this.handleDiscard(message as Message<DiscardRequestPayload>);
112
+ case 'ai:generate-commit-summary':
113
+ return this.handleGenerateCommitSummary(message as Message<GenerateCommitSummaryRequestPayload>);
114
+ case 'ai:set-api-key':
115
+ return this.handleSetApiKey(message as Message<SetApiKeyRequestPayload>);
116
+ case 'ai:get-api-key-status':
117
+ return this.handleGetApiKeyStatus(message);
118
+ case 'agent:list-repos':
119
+ return this.handleListRepos(message);
120
+ case 'agent:switch-repo':
121
+ return this.handleSwitchRepo(message as Message<SwitchRepoRequestPayload>);
122
+ case 'agent:browse-directory':
123
+ return this.handleBrowseDirectory(message as Message<BrowseDirectoryRequestPayload>);
124
+ case 'agent:add-repo':
125
+ return this.handleAddRepo(message as Message<AddRepoRequestPayload>);
126
+ default:
127
+ return this.createErrorResponse(message.id, 'UNKNOWN_MESSAGE_TYPE', `Unknown message type: ${message.type}`);
128
+ }
129
+ } catch (error) {
130
+ const errorMessage = error instanceof Error ? error.message : 'Unknown error';
131
+ return this.createErrorResponse(message.id, 'HANDLER_ERROR', errorMessage);
132
+ }
133
+ }
134
+
135
+ private handleHandshake(message: Message<HandshakePayload>): Message<HandshakeAckPayload> {
136
+ const response = createMessage<HandshakeAckPayload>('handshake:ack', {
137
+ success: true,
138
+ agentVersion: this.agentVersion,
139
+ repoPath: this.currentRepoPath,
140
+ availableRepos: this.availableRepos,
141
+ });
142
+ response.id = message.id;
143
+ return response;
144
+ }
145
+
146
+ private async handleStatus(message: Message<StatusRequestPayload>): Promise<Message<StatusResponsePayload>> {
147
+ const status = await this.git.getStatus();
148
+ const response = createMessage<StatusResponsePayload>('git:status:response', status);
149
+ response.id = message.id;
150
+ return response;
151
+ }
152
+
153
+ private async handleDiff(message: Message<DiffRequestPayload>): Promise<Message<DiffResponsePayload>> {
154
+ const { path, staged } = message.payload;
155
+ const diff = await this.git.getDiff(path, staged);
156
+ const response = createMessage<DiffResponsePayload>('git:diff:response', diff);
157
+ response.id = message.id;
158
+ return response;
159
+ }
160
+
161
+ private async handleStage(message: Message<StageRequestPayload>): Promise<Message<StageResponsePayload>> {
162
+ try {
163
+ await this.git.stage(message.payload.paths);
164
+ const response = createMessage<StageResponsePayload>('git:stage:response', { success: true });
165
+ response.id = message.id;
166
+ return response;
167
+ } catch (error) {
168
+ const response = createMessage<StageResponsePayload>('git:stage:response', {
169
+ success: false,
170
+ error: error instanceof Error ? error.message : 'Failed to stage files',
171
+ });
172
+ response.id = message.id;
173
+ return response;
174
+ }
175
+ }
176
+
177
+ private async handleUnstage(message: Message<UnstageRequestPayload>): Promise<Message<UnstageResponsePayload>> {
178
+ try {
179
+ await this.git.unstage(message.payload.paths);
180
+ const response = createMessage<UnstageResponsePayload>('git:unstage:response', { success: true });
181
+ response.id = message.id;
182
+ return response;
183
+ } catch (error) {
184
+ const response = createMessage<UnstageResponsePayload>('git:unstage:response', {
185
+ success: false,
186
+ error: error instanceof Error ? error.message : 'Failed to unstage files',
187
+ });
188
+ response.id = message.id;
189
+ return response;
190
+ }
191
+ }
192
+
193
+ private async handleStagePatch(message: Message<StagePatchRequestPayload>): Promise<Message<StagePatchResponsePayload>> {
194
+ try {
195
+ await this.git.stagePatch(message.payload.patch);
196
+ const response = createMessage<StagePatchResponsePayload>('git:stage-patch:response', { success: true });
197
+ response.id = message.id;
198
+ return response;
199
+ } catch (error) {
200
+ const response = createMessage<StagePatchResponsePayload>('git:stage-patch:response', {
201
+ success: false,
202
+ error: error instanceof Error ? error.message : 'Failed to stage patch',
203
+ });
204
+ response.id = message.id;
205
+ return response;
206
+ }
207
+ }
208
+
209
+ private async handleUnstagePatch(message: Message<UnstagePatchRequestPayload>): Promise<Message<UnstagePatchResponsePayload>> {
210
+ try {
211
+ await this.git.unstagePatch(message.payload.patch);
212
+ const response = createMessage<UnstagePatchResponsePayload>('git:unstage-patch:response', { success: true });
213
+ response.id = message.id;
214
+ return response;
215
+ } catch (error) {
216
+ const response = createMessage<UnstagePatchResponsePayload>('git:unstage-patch:response', {
217
+ success: false,
218
+ error: error instanceof Error ? error.message : 'Failed to unstage patch',
219
+ });
220
+ response.id = message.id;
221
+ return response;
222
+ }
223
+ }
224
+
225
+ private async handleCommit(message: Message<CommitRequestPayload>): Promise<Message<CommitResponsePayload>> {
226
+ try {
227
+ const { message: commitMessage, description } = message.payload;
228
+ const hash = await this.git.commit(commitMessage, description);
229
+ const response = createMessage<CommitResponsePayload>('git:commit:response', {
230
+ success: true,
231
+ hash,
232
+ });
233
+ response.id = message.id;
234
+ return response;
235
+ } catch (error) {
236
+ const response = createMessage<CommitResponsePayload>('git:commit:response', {
237
+ success: false,
238
+ error: error instanceof Error ? error.message : 'Failed to commit',
239
+ });
240
+ response.id = message.id;
241
+ return response;
242
+ }
243
+ }
244
+
245
+ private async handleLog(message: Message<LogRequestPayload>): Promise<Message<LogResponsePayload>> {
246
+ const limit = message.payload.limit || 50;
247
+ const commits = await this.git.getLog(limit);
248
+ const response = createMessage<LogResponsePayload>('git:log:response', { commits });
249
+ response.id = message.id;
250
+ return response;
251
+ }
252
+
253
+ private async handleBranches(): Promise<Message<BranchesResponsePayload>> {
254
+ const { branches, current } = await this.git.getBranches();
255
+ return createMessage<BranchesResponsePayload>('git:branches:response', {
256
+ branches,
257
+ current,
258
+ });
259
+ }
260
+
261
+ private async handleCheckout(message: Message<CheckoutRequestPayload>): Promise<Message<CheckoutResponsePayload>> {
262
+ try {
263
+ const { branch, create } = message.payload;
264
+ await this.git.checkout(branch, create);
265
+ const response = createMessage<CheckoutResponsePayload>('git:checkout:response', { success: true });
266
+ response.id = message.id;
267
+ return response;
268
+ } catch (error) {
269
+ const response = createMessage<CheckoutResponsePayload>('git:checkout:response', {
270
+ success: false,
271
+ error: error instanceof Error ? error.message : 'Failed to checkout',
272
+ });
273
+ response.id = message.id;
274
+ return response;
275
+ }
276
+ }
277
+
278
+ private async handleDiscard(message: Message<DiscardRequestPayload>): Promise<Message<DiscardResponsePayload>> {
279
+ try {
280
+ await this.git.discard(message.payload.paths);
281
+ const response = createMessage<DiscardResponsePayload>('git:discard:response', { success: true });
282
+ response.id = message.id;
283
+ return response;
284
+ } catch (error) {
285
+ const response = createMessage<DiscardResponsePayload>('git:discard:response', {
286
+ success: false,
287
+ error: error instanceof Error ? error.message : 'Failed to discard changes',
288
+ });
289
+ response.id = message.id;
290
+ return response;
291
+ }
292
+ }
293
+
294
+ private async handleGenerateCommitSummary(
295
+ message: Message<GenerateCommitSummaryRequestPayload>
296
+ ): Promise<Message<GenerateCommitSummaryResponsePayload>> {
297
+ const aiService = this.getAiService();
298
+
299
+ if (!aiService) {
300
+ const response = createMessage<GenerateCommitSummaryResponsePayload>(
301
+ 'ai:generate-commit-summary:response',
302
+ {
303
+ success: false,
304
+ error: 'Configure your API key in Settings',
305
+ errorCode: 'NO_API_KEY',
306
+ }
307
+ );
308
+ response.id = message.id;
309
+ return response;
310
+ }
311
+
312
+ try {
313
+ const status = await this.git.getStatus();
314
+ if (status.staged.length === 0) {
315
+ const response = createMessage<GenerateCommitSummaryResponsePayload>(
316
+ 'ai:generate-commit-summary:response',
317
+ {
318
+ success: false,
319
+ error: 'No staged changes to summarize',
320
+ errorCode: 'NO_STAGED_CHANGES',
321
+ }
322
+ );
323
+ response.id = message.id;
324
+ return response;
325
+ }
326
+
327
+ // Collect diffs for all staged files
328
+ const diffs = await Promise.all(status.staged.map((file) => this.git.getDiff(file.path, true)));
329
+
330
+ // Generate summary (uses internal queue and cache)
331
+ const result = await aiService.generateSummary({
332
+ diffs,
333
+ context: message.payload.context,
334
+ model: message.payload.model,
335
+ });
336
+
337
+ const response = createMessage<GenerateCommitSummaryResponsePayload>(
338
+ 'ai:generate-commit-summary:response',
339
+ {
340
+ success: true,
341
+ summary: result.summary,
342
+ description: result.description,
343
+ tokenUsage: result.tokenUsage,
344
+ cached: result.cached,
345
+ }
346
+ );
347
+ response.id = message.id;
348
+ return response;
349
+ } catch (error) {
350
+ const errorMessage = error instanceof Error ? error.message : 'Failed to generate summary';
351
+ const isRateLimit = errorMessage.includes('rate_limit');
352
+
353
+ const response = createMessage<GenerateCommitSummaryResponsePayload>(
354
+ 'ai:generate-commit-summary:response',
355
+ {
356
+ success: false,
357
+ error: errorMessage,
358
+ errorCode: isRateLimit ? 'RATE_LIMITED' : 'API_ERROR',
359
+ }
360
+ );
361
+ response.id = message.id;
362
+ return response;
363
+ }
364
+ }
365
+
366
+ private handleSetApiKey(message: Message<SetApiKeyRequestPayload>): Message<SetApiKeyResponsePayload> {
367
+ try {
368
+ setAnthropicApiKey(message.payload.apiKey);
369
+ const response = createMessage<SetApiKeyResponsePayload>('ai:set-api-key:response', {
370
+ success: true,
371
+ });
372
+ response.id = message.id;
373
+ return response;
374
+ } catch (error) {
375
+ const response = createMessage<SetApiKeyResponsePayload>('ai:set-api-key:response', {
376
+ success: false,
377
+ error: error instanceof Error ? error.message : 'Failed to save API key',
378
+ });
379
+ response.id = message.id;
380
+ return response;
381
+ }
382
+ }
383
+
384
+ private handleGetApiKeyStatus(message: Message): Message<GetApiKeyStatusResponsePayload> {
385
+ const response = createMessage<GetApiKeyStatusResponsePayload>('ai:get-api-key-status:response', {
386
+ configured: hasAnthropicApiKey(),
387
+ });
388
+ response.id = message.id;
389
+ return response;
390
+ }
391
+
392
+ private async handleListRepos(message: Message): Promise<Message<ListReposResponsePayload>> {
393
+ // Refresh branch info for all repos
394
+ const repos: Repository[] = [];
395
+ for (const repo of this.availableRepos) {
396
+ const git = this.repos.get(repo.path)!;
397
+ try {
398
+ const { current } = await git.getBranches();
399
+ repos.push({ ...repo, currentBranch: current });
400
+ } catch {
401
+ repos.push(repo);
402
+ }
403
+ }
404
+ const response = createMessage<ListReposResponsePayload>('agent:list-repos:response', {
405
+ repos,
406
+ current: this.currentRepoPath,
407
+ });
408
+ response.id = message.id;
409
+ return response;
410
+ }
411
+
412
+ private handleSwitchRepo(message: Message<SwitchRepoRequestPayload>): Message<SwitchRepoResponsePayload> {
413
+ const { path } = message.payload;
414
+
415
+ // Check if the requested repo is in our available repos
416
+ if (!this.repos.has(path)) {
417
+ const response = createMessage<SwitchRepoResponsePayload>('agent:switch-repo:response', {
418
+ success: false,
419
+ newPath: this.currentRepoPath,
420
+ error: `Repository not available: ${path}`,
421
+ });
422
+ response.id = message.id;
423
+ return response;
424
+ }
425
+
426
+ this.currentRepoPath = path;
427
+ const response = createMessage<SwitchRepoResponsePayload>('agent:switch-repo:response', {
428
+ success: true,
429
+ newPath: path,
430
+ });
431
+ response.id = message.id;
432
+ return response;
433
+ }
434
+
435
+ private async handleBrowseDirectory(
436
+ message: Message<BrowseDirectoryRequestPayload>
437
+ ): Promise<Message<BrowseDirectoryResponsePayload>> {
438
+ const requestedPath = message.payload.path || homedir();
439
+
440
+ try {
441
+ const entries: DirectoryEntry[] = [];
442
+ const dirEntries = await readdir(requestedPath, { withFileTypes: true });
443
+
444
+ for (const entry of dirEntries) {
445
+ // Skip hidden files/folders (starting with .)
446
+ if (entry.name.startsWith('.')) continue;
447
+
448
+ const fullPath = join(requestedPath, entry.name);
449
+ const isDirectory = entry.isDirectory();
450
+
451
+ // Check if it's a git repo (has .git folder)
452
+ let isGitRepo = false;
453
+ if (isDirectory) {
454
+ try {
455
+ const gitPath = join(fullPath, '.git');
456
+ const gitStat = await stat(gitPath);
457
+ isGitRepo = gitStat.isDirectory();
458
+ } catch {
459
+ // Not a git repo
460
+ }
461
+ }
462
+
463
+ entries.push({
464
+ name: entry.name,
465
+ path: fullPath,
466
+ isDirectory,
467
+ isGitRepo,
468
+ });
469
+ }
470
+
471
+ // Sort: directories first, then alphabetically
472
+ entries.sort((a, b) => {
473
+ if (a.isDirectory && !b.isDirectory) return -1;
474
+ if (!a.isDirectory && b.isDirectory) return 1;
475
+ return a.name.localeCompare(b.name);
476
+ });
477
+
478
+ // Calculate parent path
479
+ const parentPath = requestedPath === '/' ? null : dirname(requestedPath);
480
+
481
+ const response = createMessage<BrowseDirectoryResponsePayload>('agent:browse-directory:response', {
482
+ path: requestedPath,
483
+ parentPath,
484
+ entries,
485
+ });
486
+ response.id = message.id;
487
+ return response;
488
+ } catch (error) {
489
+ const response = createMessage<BrowseDirectoryResponsePayload>('agent:browse-directory:response', {
490
+ path: requestedPath,
491
+ parentPath: dirname(requestedPath),
492
+ entries: [],
493
+ error: error instanceof Error ? error.message : 'Failed to read directory',
494
+ });
495
+ response.id = message.id;
496
+ return response;
497
+ }
498
+ }
499
+
500
+ private async handleAddRepo(
501
+ message: Message<AddRepoRequestPayload>
502
+ ): Promise<Message<AddRepoResponsePayload>> {
503
+ const { path: repoPath } = message.payload;
504
+
505
+ // Check if already added
506
+ if (this.repos.has(repoPath)) {
507
+ const response = createMessage<AddRepoResponsePayload>('agent:add-repo:response', {
508
+ success: false,
509
+ error: 'Repository already added',
510
+ });
511
+ response.id = message.id;
512
+ return response;
513
+ }
514
+
515
+ try {
516
+ // Verify it's a git repo by trying to get branches
517
+ const git = new GitOperations(repoPath);
518
+ const { current } = await git.getBranches();
519
+
520
+ // Add to our maps
521
+ this.repos.set(repoPath, git);
522
+ const newRepo: Repository = {
523
+ path: repoPath,
524
+ name: basename(repoPath),
525
+ currentBranch: current,
526
+ };
527
+ this.availableRepos.push(newRepo);
528
+
529
+ const response = createMessage<AddRepoResponsePayload>('agent:add-repo:response', {
530
+ success: true,
531
+ repo: newRepo,
532
+ });
533
+ response.id = message.id;
534
+ return response;
535
+ } catch (error) {
536
+ const response = createMessage<AddRepoResponsePayload>('agent:add-repo:response', {
537
+ success: false,
538
+ error: error instanceof Error ? error.message : 'Failed to add repository',
539
+ });
540
+ response.id = message.id;
541
+ return response;
542
+ }
543
+ }
544
+
545
+ private createErrorResponse(id: string, code: string, message: string): Message<ErrorPayload> {
546
+ const response = createMessage<ErrorPayload>('error', { code, message });
547
+ response.id = id;
548
+ return response;
549
+ }
550
+ }
package/src/index.ts ADDED
@@ -0,0 +1,151 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { Command } from 'commander';
4
+ // @ts-ignore - no types for qrcode-terminal
5
+ import qrcode from 'qrcode-terminal';
6
+ import { resolve, basename } from 'path';
7
+ import { WebRTCConnection } from './connection/connection.js';
8
+ import { MessageHandler } from './handlers/messageHandler.js';
9
+ import { GitOperations } from './git/operations.js';
10
+ import { getOrCreateConfig, getConfigPath } from './config.js';
11
+ import type { Message, Repository } from '@sumicom/quicksave-shared';
12
+
13
+ const DEFAULT_SIGNALING_SERVER = process.env.QUICKSAVE_SIGNALING_URL || 'wss://signal.quicksave.dev';
14
+
15
+ const program = new Command();
16
+
17
+ function collectRepos(value: string, previous: string[]): string[] {
18
+ return previous.concat([value]);
19
+ }
20
+
21
+ program
22
+ .name('quicksave-agent')
23
+ .description('Quicksave desktop agent for remote git control')
24
+ .version('0.1.0')
25
+ .option('-r, --repo <path>', 'Path to git repository (can specify multiple)', collectRepos, [])
26
+ .option('-s, --signaling <url>', 'Signaling server URL', DEFAULT_SIGNALING_SERVER)
27
+ .option('--no-qr', 'Disable QR code display')
28
+ .action(async (options) => {
29
+ // Default to current directory if no repos specified
30
+ const repoPaths: string[] = options.repo.length > 0 ? options.repo : ['.'];
31
+ const resolvedPaths = repoPaths.map((p: string) => resolve(p));
32
+ const signalingServer = options.signaling;
33
+
34
+ console.log('Quicksave Agent v0.1.0');
35
+ console.log('='.repeat(50));
36
+
37
+ // Verify all git repositories
38
+ const validRepos: Repository[] = [];
39
+ for (const repoPath of resolvedPaths) {
40
+ const git = new GitOperations(repoPath);
41
+ const isValid = await git.isValidRepo();
42
+ if (!isValid) {
43
+ console.error(`Warning: ${repoPath} is not a valid git repository, skipping`);
44
+ continue;
45
+ }
46
+ const { current: currentBranch } = await git.getBranches();
47
+ validRepos.push({
48
+ path: repoPath,
49
+ name: basename(repoPath),
50
+ currentBranch,
51
+ });
52
+ }
53
+
54
+ if (validRepos.length === 0) {
55
+ console.error('Error: No valid git repositories found');
56
+ process.exit(1);
57
+ }
58
+
59
+ console.log(`Repositories (${validRepos.length}):`);
60
+ for (const repo of validRepos) {
61
+ console.log(` - ${repo.name} (${repo.path}) [${repo.currentBranch}]`);
62
+ }
63
+
64
+ // Load or create config
65
+ const config = getOrCreateConfig(signalingServer);
66
+ console.log(`Config: ${getConfigPath()}`);
67
+ console.log(`Signaling: ${signalingServer}`);
68
+ console.log('');
69
+
70
+ // Create connection
71
+ const connection = new WebRTCConnection({
72
+ signalingServer,
73
+ agentId: config.agentId,
74
+ keyPair: config.keyPair,
75
+ });
76
+
77
+ // Create message handler with all valid repos
78
+ const messageHandler = new MessageHandler(validRepos, config.license);
79
+
80
+ // Handle incoming messages
81
+ connection.on('message', async (message: Message) => {
82
+ const response = await messageHandler.handleMessage(message);
83
+ connection.send(response);
84
+ });
85
+
86
+ connection.on('connected', () => {
87
+ console.log('\n✓ PWA connected! Ready for git operations.');
88
+ });
89
+
90
+ connection.on('disconnected', () => {
91
+ console.log('\n✗ PWA disconnected. Waiting for reconnection...');
92
+ displayConnectionInfo(config.agentId, config.keyPair.publicKey, options.qr);
93
+ });
94
+
95
+ connection.on('error', (error) => {
96
+ console.error('Connection error:', error.message);
97
+ });
98
+
99
+ // Start connection
100
+ try {
101
+ await connection.start();
102
+ displayConnectionInfo(config.agentId, config.keyPair.publicKey, options.qr);
103
+ } catch (error) {
104
+ console.error('Failed to start agent:', error);
105
+ process.exit(1);
106
+ }
107
+
108
+ // Handle shutdown
109
+ process.on('SIGINT', () => {
110
+ console.log('\nShutting down...');
111
+ connection.disconnect();
112
+ process.exit(0);
113
+ });
114
+
115
+ process.on('SIGTERM', () => {
116
+ connection.disconnect();
117
+ process.exit(0);
118
+ });
119
+ });
120
+
121
+ function displayConnectionInfo(agentId: string, publicKey: string, showQr: boolean): void {
122
+ console.log('');
123
+ console.log('='.repeat(50));
124
+ console.log('Connection Info');
125
+ console.log('='.repeat(50));
126
+ console.log('');
127
+ console.log('Agent ID:');
128
+ console.log(` ${agentId}`);
129
+ console.log('');
130
+ console.log('Public Key:');
131
+ console.log(` ${publicKey}`);
132
+ console.log('');
133
+
134
+ // Create connection URL for PWA
135
+ const connectionUrl = `https://quicksave.dev/connect?id=${agentId}&pk=${encodeURIComponent(publicKey)}`;
136
+
137
+ console.log('Connection URL:');
138
+ console.log(` ${connectionUrl}`);
139
+ console.log('');
140
+
141
+ if (showQr) {
142
+ console.log('Scan QR code to connect:');
143
+ console.log('');
144
+ qrcode.generate(connectionUrl, { small: true });
145
+ }
146
+
147
+ console.log('');
148
+ console.log('Waiting for PWA connection...');
149
+ }
150
+
151
+ program.parse();