@sumicom/quicksave-shared 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.
@@ -0,0 +1,197 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import {
3
+ generateMessageId,
4
+ createMessage,
5
+ createRequest,
6
+ parseMessage,
7
+ serializeMessage,
8
+ isResponseMessage,
9
+ getRequestType,
10
+ REQUEST_RESPONSE_MAP,
11
+ } from './protocol.js';
12
+
13
+ describe('generateMessageId', () => {
14
+ it('should generate unique IDs', () => {
15
+ const ids = new Set<string>();
16
+ for (let i = 0; i < 100; i++) {
17
+ ids.add(generateMessageId());
18
+ }
19
+ expect(ids.size).toBe(100);
20
+ });
21
+
22
+ it('should generate IDs with expected format', () => {
23
+ const id = generateMessageId();
24
+ // Format: timestamp-randomString
25
+ expect(id).toMatch(/^\d+-[a-z0-9]+$/);
26
+ });
27
+ });
28
+
29
+ describe('createMessage', () => {
30
+ it('should create a properly structured message', () => {
31
+ const payload = { test: 'data' };
32
+ const message = createMessage('ping', payload);
33
+
34
+ expect(message.id).toBeDefined();
35
+ expect(message.type).toBe('ping');
36
+ expect(message.payload).toEqual(payload);
37
+ expect(message.timestamp).toBeDefined();
38
+ expect(typeof message.timestamp).toBe('number');
39
+ });
40
+
41
+ it('should create unique IDs for each message', () => {
42
+ const msg1 = createMessage('ping', {});
43
+ const msg2 = createMessage('ping', {});
44
+
45
+ expect(msg1.id).not.toBe(msg2.id);
46
+ });
47
+
48
+ it('should set timestamp close to current time', () => {
49
+ const before = Date.now();
50
+ const message = createMessage('pong', { timestamp: 0 });
51
+ const after = Date.now();
52
+
53
+ expect(message.timestamp).toBeGreaterThanOrEqual(before);
54
+ expect(message.timestamp).toBeLessThanOrEqual(after);
55
+ });
56
+ });
57
+
58
+ describe('createRequest', () => {
59
+ it('should create request with correct response type', () => {
60
+ const { message, responseType } = createRequest('git:status', { path: '/' });
61
+
62
+ expect(message.type).toBe('git:status');
63
+ expect(message.payload).toEqual({ path: '/' });
64
+ expect(responseType).toBe('git:status:response');
65
+ });
66
+
67
+ it('should work with different request types', () => {
68
+ const types = ['git:diff', 'git:stage', 'git:commit', 'git:log'];
69
+
70
+ for (const type of types) {
71
+ const { responseType } = createRequest(type as any, {});
72
+ expect(responseType).toBe(`${type}:response`);
73
+ }
74
+ });
75
+ });
76
+
77
+ describe('parseMessage', () => {
78
+ it('should parse valid JSON message', () => {
79
+ const original = createMessage('ping', { data: 'test' });
80
+ const json = JSON.stringify(original);
81
+
82
+ const parsed = parseMessage(json);
83
+
84
+ expect(parsed.id).toBe(original.id);
85
+ expect(parsed.type).toBe(original.type);
86
+ expect(parsed.payload).toEqual(original.payload);
87
+ expect(parsed.timestamp).toBe(original.timestamp);
88
+ });
89
+
90
+ it('should throw on invalid JSON', () => {
91
+ expect(() => parseMessage('not valid json')).toThrow();
92
+ });
93
+
94
+ it('should throw on missing id', () => {
95
+ const invalid = JSON.stringify({ type: 'ping', payload: {}, timestamp: 123 });
96
+ expect(() => parseMessage(invalid)).toThrow('Invalid message format');
97
+ });
98
+
99
+ it('should throw on missing type', () => {
100
+ const invalid = JSON.stringify({ id: '123', payload: {}, timestamp: 123 });
101
+ expect(() => parseMessage(invalid)).toThrow('Invalid message format');
102
+ });
103
+
104
+ it('should throw on missing timestamp', () => {
105
+ const invalid = JSON.stringify({ id: '123', type: 'ping', payload: {} });
106
+ expect(() => parseMessage(invalid)).toThrow('Invalid message format');
107
+ });
108
+
109
+ it('should throw on null message', () => {
110
+ expect(() => parseMessage('null')).toThrow('Invalid message format');
111
+ });
112
+
113
+ it('should throw on array', () => {
114
+ expect(() => parseMessage('[]')).toThrow('Invalid message format');
115
+ });
116
+ });
117
+
118
+ describe('serializeMessage', () => {
119
+ it('should serialize message to JSON string', () => {
120
+ const message = createMessage('ping', { test: true });
121
+ const json = serializeMessage(message);
122
+
123
+ expect(typeof json).toBe('string');
124
+
125
+ const parsed = JSON.parse(json);
126
+ expect(parsed.id).toBe(message.id);
127
+ expect(parsed.type).toBe(message.type);
128
+ expect(parsed.payload).toEqual(message.payload);
129
+ });
130
+
131
+ it('should produce valid JSON that can be parsed back', () => {
132
+ const original = createMessage('git:status:response', {
133
+ branch: 'main',
134
+ staged: [],
135
+ unstaged: [],
136
+ untracked: [],
137
+ ahead: 0,
138
+ behind: 0,
139
+ });
140
+
141
+ const json = serializeMessage(original);
142
+ const parsed = parseMessage(json);
143
+
144
+ expect(parsed).toEqual(original);
145
+ });
146
+ });
147
+
148
+ describe('isResponseMessage', () => {
149
+ it('should identify response messages', () => {
150
+ expect(isResponseMessage({ id: '1', type: 'git:status:response', payload: {}, timestamp: 1 })).toBe(true);
151
+ expect(isResponseMessage({ id: '1', type: 'git:diff:response', payload: {}, timestamp: 1 })).toBe(true);
152
+ expect(isResponseMessage({ id: '1', type: 'handshake:ack', payload: {}, timestamp: 1 })).toBe(false);
153
+ });
154
+
155
+ it('should identify non-response messages', () => {
156
+ expect(isResponseMessage({ id: '1', type: 'ping', payload: {}, timestamp: 1 })).toBe(false);
157
+ expect(isResponseMessage({ id: '1', type: 'git:status', payload: {}, timestamp: 1 })).toBe(false);
158
+ expect(isResponseMessage({ id: '1', type: 'handshake', payload: {}, timestamp: 1 })).toBe(false);
159
+ });
160
+ });
161
+
162
+ describe('getRequestType', () => {
163
+ it('should extract request type from response type', () => {
164
+ expect(getRequestType('git:status:response' as any)).toBe('git:status');
165
+ expect(getRequestType('git:diff:response' as any)).toBe('git:diff');
166
+ expect(getRequestType('git:commit:response' as any)).toBe('git:commit');
167
+ });
168
+ });
169
+
170
+ describe('REQUEST_RESPONSE_MAP', () => {
171
+ it('should contain all git operations', () => {
172
+ const expectedOperations = [
173
+ 'git:status',
174
+ 'git:diff',
175
+ 'git:stage',
176
+ 'git:unstage',
177
+ 'git:commit',
178
+ 'git:log',
179
+ 'git:branches',
180
+ 'git:checkout',
181
+ 'git:discard',
182
+ ];
183
+
184
+ for (const op of expectedOperations) {
185
+ expect(REQUEST_RESPONSE_MAP[op]).toBeDefined();
186
+ expect(REQUEST_RESPONSE_MAP[op]).toBe(`${op}:response`);
187
+ }
188
+ });
189
+
190
+ it('should contain handshake mapping', () => {
191
+ expect(REQUEST_RESPONSE_MAP['handshake']).toBe('handshake:ack');
192
+ });
193
+
194
+ it('should contain ping/pong mapping', () => {
195
+ expect(REQUEST_RESPONSE_MAP['ping']).toBe('pong');
196
+ });
197
+ });
@@ -0,0 +1,92 @@
1
+ import type { Message, MessageType } from './types.js';
2
+
3
+ /**
4
+ * Generate a unique message ID
5
+ */
6
+ export function generateMessageId(): string {
7
+ return `${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
8
+ }
9
+
10
+ /**
11
+ * Create a typed message
12
+ */
13
+ export function createMessage<T>(type: MessageType, payload: T): Message<T> {
14
+ return {
15
+ id: generateMessageId(),
16
+ type,
17
+ payload,
18
+ timestamp: Date.now(),
19
+ };
20
+ }
21
+
22
+ /**
23
+ * Create a request message and return both the message and a response handler
24
+ */
25
+ export function createRequest<TReq>(
26
+ type: MessageType,
27
+ payload: TReq
28
+ ): {
29
+ message: Message<TReq>;
30
+ responseType: MessageType;
31
+ } {
32
+ const message = createMessage(type, payload);
33
+ const responseType = `${type}:response` as MessageType;
34
+ return { message, responseType };
35
+ }
36
+
37
+ /**
38
+ * Parse and validate a message
39
+ */
40
+ export function parseMessage(data: string): Message {
41
+ const parsed = JSON.parse(data);
42
+
43
+ if (
44
+ typeof parsed !== 'object' ||
45
+ parsed === null ||
46
+ typeof parsed.id !== 'string' ||
47
+ typeof parsed.type !== 'string' ||
48
+ typeof parsed.timestamp !== 'number'
49
+ ) {
50
+ throw new Error('Invalid message format');
51
+ }
52
+
53
+ return parsed as Message;
54
+ }
55
+
56
+ /**
57
+ * Serialize a message to JSON
58
+ */
59
+ export function serializeMessage(message: Message): string {
60
+ return JSON.stringify(message);
61
+ }
62
+
63
+ /**
64
+ * Type guard for response messages
65
+ */
66
+ export function isResponseMessage(message: Message): boolean {
67
+ return message.type.endsWith(':response');
68
+ }
69
+
70
+ /**
71
+ * Get the request type from a response type
72
+ */
73
+ export function getRequestType(responseType: MessageType): MessageType {
74
+ return responseType.replace(':response', '') as MessageType;
75
+ }
76
+
77
+ /**
78
+ * Request types and their expected response types
79
+ */
80
+ export const REQUEST_RESPONSE_MAP: Record<string, string> = {
81
+ 'git:status': 'git:status:response',
82
+ 'git:diff': 'git:diff:response',
83
+ 'git:stage': 'git:stage:response',
84
+ 'git:unstage': 'git:unstage:response',
85
+ 'git:commit': 'git:commit:response',
86
+ 'git:log': 'git:log:response',
87
+ 'git:branches': 'git:branches:response',
88
+ 'git:checkout': 'git:checkout:response',
89
+ 'git:discard': 'git:discard:response',
90
+ handshake: 'handshake:ack',
91
+ ping: 'pong',
92
+ };
package/src/types.ts ADDED
@@ -0,0 +1,400 @@
1
+ // ============================================================================
2
+ // Message Types
3
+ // ============================================================================
4
+
5
+ export interface Message<T = unknown> {
6
+ id: string;
7
+ type: MessageType;
8
+ payload: T;
9
+ timestamp: number;
10
+ }
11
+
12
+ export type MessageType =
13
+ | 'ping'
14
+ | 'pong'
15
+ | 'handshake'
16
+ | 'handshake:ack'
17
+ | 'git:status'
18
+ | 'git:status:response'
19
+ | 'git:diff'
20
+ | 'git:diff:response'
21
+ | 'git:stage'
22
+ | 'git:stage:response'
23
+ | 'git:unstage'
24
+ | 'git:unstage:response'
25
+ | 'git:stage-patch'
26
+ | 'git:stage-patch:response'
27
+ | 'git:unstage-patch'
28
+ | 'git:unstage-patch:response'
29
+ | 'git:commit'
30
+ | 'git:commit:response'
31
+ | 'git:log'
32
+ | 'git:log:response'
33
+ | 'git:branches'
34
+ | 'git:branches:response'
35
+ | 'git:checkout'
36
+ | 'git:checkout:response'
37
+ | 'git:discard'
38
+ | 'git:discard:response'
39
+ | 'ai:generate-commit-summary'
40
+ | 'ai:generate-commit-summary:response'
41
+ | 'ai:set-api-key'
42
+ | 'ai:set-api-key:response'
43
+ | 'ai:get-api-key-status'
44
+ | 'ai:get-api-key-status:response'
45
+ | 'agent:list-repos'
46
+ | 'agent:list-repos:response'
47
+ | 'agent:switch-repo'
48
+ | 'agent:switch-repo:response'
49
+ | 'agent:browse-directory'
50
+ | 'agent:browse-directory:response'
51
+ | 'agent:add-repo'
52
+ | 'agent:add-repo:response'
53
+ | 'error';
54
+
55
+ // ============================================================================
56
+ // Git Types
57
+ // ============================================================================
58
+
59
+ export type FileStatus = 'added' | 'modified' | 'deleted' | 'renamed' | 'copied';
60
+
61
+ export interface FileChange {
62
+ path: string;
63
+ status: FileStatus;
64
+ oldPath?: string; // For renamed/copied files
65
+ }
66
+
67
+ export interface GitStatus {
68
+ branch: string;
69
+ ahead: number;
70
+ behind: number;
71
+ staged: FileChange[];
72
+ unstaged: FileChange[];
73
+ untracked: string[];
74
+ }
75
+
76
+ export interface DiffHunk {
77
+ oldStart: number;
78
+ oldLines: number;
79
+ newStart: number;
80
+ newLines: number;
81
+ content: string;
82
+ }
83
+
84
+ export interface FileDiff {
85
+ path: string;
86
+ oldPath?: string;
87
+ hunks: DiffHunk[];
88
+ isBinary: boolean;
89
+ truncated?: boolean;
90
+ truncatedReason?: string;
91
+ }
92
+
93
+ export interface Commit {
94
+ hash: string;
95
+ shortHash: string;
96
+ message: string;
97
+ author: string;
98
+ email: string;
99
+ date: string;
100
+ }
101
+
102
+ export interface Branch {
103
+ name: string;
104
+ current: boolean;
105
+ remote?: string;
106
+ }
107
+
108
+ export interface Repository {
109
+ path: string;
110
+ name: string;
111
+ currentBranch?: string;
112
+ }
113
+
114
+ // ============================================================================
115
+ // Request/Response Payloads
116
+ // ============================================================================
117
+
118
+ // Handshake
119
+ export interface HandshakePayload {
120
+ publicKey: string; // Base64 encoded
121
+ license?: License;
122
+ }
123
+
124
+ export interface HandshakeAckPayload {
125
+ success: boolean;
126
+ agentVersion: string;
127
+ repoPath: string;
128
+ availableRepos?: Repository[];
129
+ }
130
+
131
+ // Status
132
+ export interface StatusRequestPayload {
133
+ path?: string;
134
+ }
135
+
136
+ export type StatusResponsePayload = GitStatus;
137
+
138
+ // Diff
139
+ export interface DiffRequestPayload {
140
+ path: string;
141
+ staged?: boolean;
142
+ }
143
+
144
+ export type DiffResponsePayload = FileDiff;
145
+
146
+ // Stage/Unstage
147
+ export interface StageRequestPayload {
148
+ paths: string[];
149
+ }
150
+
151
+ export interface StageResponsePayload {
152
+ success: boolean;
153
+ error?: string;
154
+ }
155
+
156
+ export type UnstageRequestPayload = StageRequestPayload;
157
+ export type UnstageResponsePayload = StageResponsePayload;
158
+
159
+ // Stage/Unstage Patch (for line-level staging)
160
+ export interface StagePatchRequestPayload {
161
+ patch: string; // Unified diff format
162
+ }
163
+
164
+ export interface StagePatchResponsePayload {
165
+ success: boolean;
166
+ error?: string;
167
+ }
168
+
169
+ export type UnstagePatchRequestPayload = StagePatchRequestPayload;
170
+ export type UnstagePatchResponsePayload = StagePatchResponsePayload;
171
+
172
+ // Commit
173
+ export interface CommitRequestPayload {
174
+ message: string;
175
+ description?: string;
176
+ }
177
+
178
+ export interface CommitResponsePayload {
179
+ success: boolean;
180
+ hash?: string;
181
+ error?: string;
182
+ }
183
+
184
+ // Log
185
+ export interface LogRequestPayload {
186
+ limit?: number;
187
+ }
188
+
189
+ export interface LogResponsePayload {
190
+ commits: Commit[];
191
+ }
192
+
193
+ // Branches
194
+ export type BranchesRequestPayload = Record<string, never>;
195
+
196
+ export interface BranchesResponsePayload {
197
+ branches: Branch[];
198
+ current: string;
199
+ }
200
+
201
+ // Checkout
202
+ export interface CheckoutRequestPayload {
203
+ branch: string;
204
+ create?: boolean;
205
+ }
206
+
207
+ export interface CheckoutResponsePayload {
208
+ success: boolean;
209
+ error?: string;
210
+ }
211
+
212
+ // Discard
213
+ export interface DiscardRequestPayload {
214
+ paths: string[];
215
+ }
216
+
217
+ export interface DiscardResponsePayload {
218
+ success: boolean;
219
+ error?: string;
220
+ }
221
+
222
+ // Error
223
+ export interface ErrorPayload {
224
+ code: string;
225
+ message: string;
226
+ details?: unknown;
227
+ }
228
+
229
+ // List Repos
230
+ export type ListReposRequestPayload = Record<string, never>;
231
+
232
+ export interface ListReposResponsePayload {
233
+ repos: Repository[];
234
+ current: string;
235
+ }
236
+
237
+ // Switch Repo
238
+ export interface SwitchRepoRequestPayload {
239
+ path: string;
240
+ }
241
+
242
+ export interface SwitchRepoResponsePayload {
243
+ success: boolean;
244
+ newPath: string;
245
+ error?: string;
246
+ }
247
+
248
+ // Browse Directory
249
+ export interface BrowseDirectoryRequestPayload {
250
+ path: string;
251
+ }
252
+
253
+ export interface DirectoryEntry {
254
+ name: string;
255
+ path: string;
256
+ isDirectory: boolean;
257
+ isGitRepo: boolean;
258
+ }
259
+
260
+ export interface BrowseDirectoryResponsePayload {
261
+ path: string;
262
+ parentPath: string | null;
263
+ entries: DirectoryEntry[];
264
+ error?: string;
265
+ }
266
+
267
+ // Add Repo
268
+ export interface AddRepoRequestPayload {
269
+ path: string;
270
+ }
271
+
272
+ export interface AddRepoResponsePayload {
273
+ success: boolean;
274
+ repo?: Repository;
275
+ error?: string;
276
+ }
277
+
278
+ // ============================================================================
279
+ // License Types
280
+ // ============================================================================
281
+
282
+ export interface License {
283
+ version: 1;
284
+ publicKey: string;
285
+ issuedAt: number;
286
+ type: 'pro';
287
+ signature: string;
288
+ }
289
+
290
+ // ============================================================================
291
+ // Signaling Types
292
+ // ============================================================================
293
+
294
+ export type SignalingMessageType =
295
+ | 'peer-connected'
296
+ | 'peer-offline'
297
+ | 'data'
298
+ | 'bye';
299
+
300
+ export interface SignalingMessage {
301
+ type: SignalingMessageType;
302
+ payload?: unknown;
303
+ }
304
+
305
+ // ============================================================================
306
+ // Key Exchange Types (V2 Protocol)
307
+ // ============================================================================
308
+
309
+ /**
310
+ * Key exchange message - PWA sends encrypted session DEK to Agent
311
+ * This provides forward secrecy: if Agent is compromised, only current session is exposed
312
+ */
313
+ export interface KeyExchangeV2 {
314
+ type: 'key-exchange';
315
+ version: 2;
316
+ encryptedDEK: string; // Session DEK encrypted with Agent's public key (base64)
317
+ timestamp: number; // Unix timestamp for replay protection
318
+ }
319
+
320
+ /**
321
+ * V2 key exchange acknowledgment from Agent
322
+ */
323
+ export interface KeyExchangeV2Ack {
324
+ type: 'key-exchange-ack';
325
+ version: 2;
326
+ }
327
+
328
+ /**
329
+ * Key exchange message type
330
+ */
331
+ export type KeyExchangeMessage = KeyExchangeV2;
332
+
333
+ // ============================================================================
334
+ // Connection Types
335
+ // ============================================================================
336
+
337
+ export type ConnectionState =
338
+ | 'disconnected'
339
+ | 'connecting'
340
+ | 'connected'
341
+ | 'reconnecting'
342
+ | 'error';
343
+
344
+ export interface ConnectionInfo {
345
+ state: ConnectionState;
346
+ agentId: string;
347
+ signalingServer: string;
348
+ connectedAt?: number;
349
+ error?: string;
350
+ }
351
+
352
+ // ============================================================================
353
+ // AI Types
354
+ // ============================================================================
355
+
356
+ export type ClaudeModel =
357
+ | 'claude-haiku-4-5'
358
+ | 'claude-sonnet-4-5'
359
+ | 'claude-opus-4-5';
360
+
361
+ export const CLAUDE_MODELS: { id: ClaudeModel; name: string; description: string }[] = [
362
+ { id: 'claude-haiku-4-5', name: 'Haiku', description: 'Fast & affordable' },
363
+ { id: 'claude-sonnet-4-5', name: 'Sonnet', description: 'Balanced speed & quality' },
364
+ { id: 'claude-opus-4-5', name: 'Opus', description: 'Highest quality' },
365
+ ];
366
+
367
+ // Generate Commit Summary
368
+ export interface GenerateCommitSummaryRequestPayload {
369
+ context?: string;
370
+ model?: ClaudeModel;
371
+ }
372
+
373
+ export interface TokenUsage {
374
+ inputTokens: number;
375
+ outputTokens: number;
376
+ }
377
+
378
+ export interface GenerateCommitSummaryResponsePayload {
379
+ success: boolean;
380
+ summary?: string;
381
+ description?: string;
382
+ error?: string;
383
+ errorCode?: 'NO_API_KEY' | 'NO_STAGED_CHANGES' | 'API_ERROR' | 'RATE_LIMITED';
384
+ tokenUsage?: TokenUsage;
385
+ cached?: boolean;
386
+ }
387
+
388
+ // API Key Management
389
+ export interface SetApiKeyRequestPayload {
390
+ apiKey: string;
391
+ }
392
+
393
+ export interface SetApiKeyResponsePayload {
394
+ success: boolean;
395
+ error?: string;
396
+ }
397
+
398
+ export interface GetApiKeyStatusResponsePayload {
399
+ configured: boolean;
400
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,10 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "outDir": "./dist",
5
+ "rootDir": "./src",
6
+ "lib": ["ES2022"],
7
+ "composite": true
8
+ },
9
+ "include": ["src/**/*"]
10
+ }