spck 0.3.1

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 (155) hide show
  1. package/.oxlintrc.json +49 -0
  2. package/LICENSE +21 -0
  3. package/README.md +631 -0
  4. package/bin/cli.js +20 -0
  5. package/bin/validate-cwd.js +41 -0
  6. package/dist/config/__tests__/config.test.d.ts +2 -0
  7. package/dist/config/__tests__/config.test.js +262 -0
  8. package/dist/config/__tests__/credentials.test.d.ts +2 -0
  9. package/dist/config/__tests__/credentials.test.js +360 -0
  10. package/dist/config/config.d.ts +33 -0
  11. package/dist/config/config.js +185 -0
  12. package/dist/config/credentials.d.ts +75 -0
  13. package/dist/config/credentials.js +259 -0
  14. package/dist/config/server-selection.d.ts +40 -0
  15. package/dist/config/server-selection.js +130 -0
  16. package/dist/connection/__tests__/firebase-auth.test.d.ts +2 -0
  17. package/dist/connection/__tests__/firebase-auth.test.js +96 -0
  18. package/dist/connection/__tests__/hmac.test.d.ts +2 -0
  19. package/dist/connection/__tests__/hmac.test.js +372 -0
  20. package/dist/connection/auth.d.ts +13 -0
  21. package/dist/connection/auth.js +91 -0
  22. package/dist/connection/firebase-auth.d.ts +40 -0
  23. package/dist/connection/firebase-auth.js +429 -0
  24. package/dist/connection/hmac.d.ts +24 -0
  25. package/dist/connection/hmac.js +109 -0
  26. package/dist/i18n/index.d.ts +25 -0
  27. package/dist/i18n/index.js +101 -0
  28. package/dist/i18n/locales/en.json +313 -0
  29. package/dist/i18n/locales/es.json +302 -0
  30. package/dist/i18n/locales/fr.json +302 -0
  31. package/dist/i18n/locales/id.json +302 -0
  32. package/dist/i18n/locales/ja.json +302 -0
  33. package/dist/i18n/locales/ko.json +302 -0
  34. package/dist/i18n/locales/locales/en.json +309 -0
  35. package/dist/i18n/locales/locales/es.json +302 -0
  36. package/dist/i18n/locales/locales/fr.json +302 -0
  37. package/dist/i18n/locales/locales/id.json +302 -0
  38. package/dist/i18n/locales/locales/ja.json +302 -0
  39. package/dist/i18n/locales/locales/ko.json +302 -0
  40. package/dist/i18n/locales/locales/pt.json +302 -0
  41. package/dist/i18n/locales/locales/zh-Hans.json +302 -0
  42. package/dist/i18n/locales/pt.json +302 -0
  43. package/dist/i18n/locales/zh-Hans.json +302 -0
  44. package/dist/index.d.ts +25 -0
  45. package/dist/index.js +493 -0
  46. package/dist/proxy/ProxyClient.d.ts +125 -0
  47. package/dist/proxy/ProxyClient.js +781 -0
  48. package/dist/proxy/ProxySocketWrapper.d.ts +43 -0
  49. package/dist/proxy/ProxySocketWrapper.js +98 -0
  50. package/dist/proxy/__tests__/ProxyClient.test.d.ts +2 -0
  51. package/dist/proxy/__tests__/ProxyClient.test.js +445 -0
  52. package/dist/proxy/__tests__/ProxySocketWrapper.test.d.ts +2 -0
  53. package/dist/proxy/__tests__/ProxySocketWrapper.test.js +190 -0
  54. package/dist/proxy/__tests__/handshake-validation.test.d.ts +2 -0
  55. package/dist/proxy/__tests__/handshake-validation.test.js +282 -0
  56. package/dist/proxy/__tests__/token-refresh-race.test.d.ts +14 -0
  57. package/dist/proxy/__tests__/token-refresh-race.test.js +173 -0
  58. package/dist/proxy/chunking.d.ts +53 -0
  59. package/dist/proxy/chunking.js +127 -0
  60. package/dist/proxy/handshake-validation.d.ts +21 -0
  61. package/dist/proxy/handshake-validation.js +49 -0
  62. package/dist/rpc/__tests__/router.test.d.ts +2 -0
  63. package/dist/rpc/__tests__/router.test.js +262 -0
  64. package/dist/rpc/router.d.ts +37 -0
  65. package/dist/rpc/router.js +132 -0
  66. package/dist/services/BrowserProxyService.d.ts +13 -0
  67. package/dist/services/BrowserProxyService.js +139 -0
  68. package/dist/services/FilesystemService.d.ts +99 -0
  69. package/dist/services/FilesystemService.js +742 -0
  70. package/dist/services/GitService.d.ts +243 -0
  71. package/dist/services/GitService.js +1439 -0
  72. package/dist/services/SearchService.d.ts +93 -0
  73. package/dist/services/SearchService.js +670 -0
  74. package/dist/services/TerminalService.d.ts +62 -0
  75. package/dist/services/TerminalService.js +337 -0
  76. package/dist/services/__tests__/BrowserProxyService.test.d.ts +2 -0
  77. package/dist/services/__tests__/BrowserProxyService.test.js +145 -0
  78. package/dist/services/__tests__/FilesystemService.test.d.ts +2 -0
  79. package/dist/services/__tests__/FilesystemService.test.js +609 -0
  80. package/dist/services/__tests__/GitService.test.d.ts +2 -0
  81. package/dist/services/__tests__/GitService.test.js +953 -0
  82. package/dist/services/__tests__/SearchService.test.d.ts +2 -0
  83. package/dist/services/__tests__/SearchService.test.js +384 -0
  84. package/dist/services/__tests__/TerminalService.test.d.ts +2 -0
  85. package/dist/services/__tests__/TerminalService.test.js +513 -0
  86. package/dist/setup/wizard.d.ts +10 -0
  87. package/dist/setup/wizard.js +172 -0
  88. package/dist/types.d.ts +196 -0
  89. package/dist/types.js +44 -0
  90. package/dist/utils/__tests__/gitignore.test.d.ts +2 -0
  91. package/dist/utils/__tests__/gitignore.test.js +127 -0
  92. package/dist/utils/gitignore.d.ts +24 -0
  93. package/dist/utils/gitignore.js +77 -0
  94. package/dist/utils/logger.d.ts +96 -0
  95. package/dist/utils/logger.js +456 -0
  96. package/dist/utils/project-dir.d.ts +51 -0
  97. package/dist/utils/project-dir.js +191 -0
  98. package/dist/utils/ripgrep.d.ts +34 -0
  99. package/dist/utils/ripgrep.js +148 -0
  100. package/dist/utils/tool-detection.d.ts +17 -0
  101. package/dist/utils/tool-detection.js +126 -0
  102. package/dist/watcher/FileWatcher.d.ts +10 -0
  103. package/dist/watcher/FileWatcher.js +42 -0
  104. package/package.json +70 -0
  105. package/src/config/__tests__/config.test.ts +318 -0
  106. package/src/config/__tests__/credentials.test.ts +494 -0
  107. package/src/config/config.ts +206 -0
  108. package/src/config/credentials.ts +302 -0
  109. package/src/config/server-selection.ts +150 -0
  110. package/src/connection/__tests__/firebase-auth.test.ts +121 -0
  111. package/src/connection/__tests__/hmac.test.ts +509 -0
  112. package/src/connection/auth.ts +140 -0
  113. package/src/connection/firebase-auth.ts +504 -0
  114. package/src/connection/hmac.ts +139 -0
  115. package/src/i18n/index.ts +119 -0
  116. package/src/i18n/locales/en.json +313 -0
  117. package/src/i18n/locales/es.json +302 -0
  118. package/src/i18n/locales/fr.json +302 -0
  119. package/src/i18n/locales/id.json +302 -0
  120. package/src/i18n/locales/ja.json +302 -0
  121. package/src/i18n/locales/ko.json +302 -0
  122. package/src/i18n/locales/pt.json +302 -0
  123. package/src/i18n/locales/zh-Hans.json +302 -0
  124. package/src/index.ts +542 -0
  125. package/src/proxy/ProxyClient.ts +968 -0
  126. package/src/proxy/ProxySocketWrapper.ts +113 -0
  127. package/src/proxy/__tests__/ProxyClient.test.ts +575 -0
  128. package/src/proxy/__tests__/ProxySocketWrapper.test.ts +251 -0
  129. package/src/proxy/__tests__/handshake-validation.test.ts +367 -0
  130. package/src/proxy/chunking.ts +162 -0
  131. package/src/proxy/handshake-validation.ts +64 -0
  132. package/src/rpc/__tests__/router.test.ts +400 -0
  133. package/src/rpc/router.ts +183 -0
  134. package/src/services/BrowserProxyService.ts +179 -0
  135. package/src/services/FilesystemService.ts +841 -0
  136. package/src/services/GitService.ts +1639 -0
  137. package/src/services/SearchService.ts +809 -0
  138. package/src/services/TerminalService.ts +413 -0
  139. package/src/services/__tests__/BrowserProxyService.test.ts +155 -0
  140. package/src/services/__tests__/FilesystemService.test.ts +1002 -0
  141. package/src/services/__tests__/GitService.test.ts +1552 -0
  142. package/src/services/__tests__/SearchService.test.ts +484 -0
  143. package/src/services/__tests__/TerminalService.test.ts +702 -0
  144. package/src/setup/wizard.ts +242 -0
  145. package/src/types/fossil-delta.d.ts +4 -0
  146. package/src/types.ts +287 -0
  147. package/src/utils/__tests__/gitignore.test.ts +174 -0
  148. package/src/utils/gitignore.ts +91 -0
  149. package/src/utils/logger.ts +578 -0
  150. package/src/utils/project-dir.ts +218 -0
  151. package/src/utils/ripgrep.ts +180 -0
  152. package/src/utils/tool-detection.ts +141 -0
  153. package/src/watcher/FileWatcher.ts +53 -0
  154. package/tsconfig.json +24 -0
  155. package/vitest.config.ts +19 -0
@@ -0,0 +1,494 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi, type Mocked } from 'vitest';
2
+ /**
3
+ * Unit tests for credentials management
4
+ */
5
+
6
+ import * as fs from 'fs';
7
+ import * as os from 'os';
8
+ import {
9
+ loadCredentials,
10
+ saveCredentials,
11
+ loadConnectionSettings,
12
+ saveConnectionSettings,
13
+ isServerTokenExpired,
14
+ getCredentialsPath,
15
+ getConnectionSettingsPath,
16
+ getGlobalConfigPath,
17
+ loadGlobalConfig,
18
+ saveGlobalConfig,
19
+ clearCredentials,
20
+ clearConnectionSettings,
21
+ } from '../credentials.js';
22
+ import { StoredCredentials, GlobalConfig } from '../../types.js';
23
+
24
+ // Mock modules
25
+ vi.mock('fs');
26
+ vi.mock('os');
27
+
28
+ const mockFs = fs as Mocked<typeof fs>;
29
+ const mockOs = os as Mocked<typeof os>;
30
+
31
+ describe('credentials', () => {
32
+ const mockHomedir = '/mock/home';
33
+ const mockCwd = '/mock/project';
34
+
35
+ beforeEach(() => {
36
+ vi.clearAllMocks();
37
+ mockOs.homedir.mockReturnValue(mockHomedir);
38
+ vi.spyOn(process, 'cwd').mockReturnValue(mockCwd);
39
+ });
40
+
41
+ afterEach(() => {
42
+ vi.restoreAllMocks();
43
+ });
44
+
45
+ describe('getCredentialsPath()', () => {
46
+ it('should return path in user home directory', () => {
47
+ const path = getCredentialsPath();
48
+ expect(path).toBe(`${mockHomedir}/.spck-editor/.credentials.json`);
49
+ });
50
+ });
51
+
52
+ describe('getConnectionSettingsPath()', () => {
53
+ it('should return path in .spck-editor/config subdirectory', () => {
54
+ const path = getConnectionSettingsPath();
55
+ expect(path).toBe(`${mockCwd}/.spck-editor/config/connection-settings.json`);
56
+ });
57
+ });
58
+
59
+ describe('loadCredentials()', () => {
60
+ it('should return null if file does not exist', () => {
61
+ mockFs.existsSync.mockReturnValue(false);
62
+
63
+ const result = loadCredentials();
64
+
65
+ expect(result).toBeNull();
66
+ });
67
+
68
+ it('should load and return valid stored credentials', () => {
69
+ const mockCredentials: StoredCredentials = {
70
+ refreshToken: 'mock-refresh-token',
71
+ userId: 'user-123',
72
+ };
73
+
74
+ mockFs.existsSync.mockReturnValue(true);
75
+ mockFs.readFileSync.mockReturnValue(JSON.stringify(mockCredentials));
76
+
77
+ const result = loadCredentials();
78
+
79
+ expect(result).toEqual(mockCredentials);
80
+ });
81
+
82
+ it('should throw CORRUPTED error for invalid JSON', () => {
83
+ mockFs.existsSync.mockReturnValue(true);
84
+ mockFs.readFileSync.mockReturnValue('invalid json{');
85
+
86
+ expect(() => loadCredentials()).toThrow('Credentials file is corrupted');
87
+ expect(() => {
88
+ try {
89
+ loadCredentials();
90
+ } catch (error: any) {
91
+ expect(error.code).toBe('CORRUPTED');
92
+ throw error;
93
+ }
94
+ }).toThrow();
95
+ });
96
+
97
+ it('should throw CORRUPTED error for missing refreshToken', () => {
98
+ const invalidCredentials = {
99
+ userId: 'user-123',
100
+ };
101
+
102
+ mockFs.existsSync.mockReturnValue(true);
103
+ mockFs.readFileSync.mockReturnValue(JSON.stringify(invalidCredentials));
104
+
105
+ expect(() => {
106
+ try {
107
+ loadCredentials();
108
+ } catch (error: any) {
109
+ expect(error.code).toBe('CORRUPTED');
110
+ throw error;
111
+ }
112
+ }).toThrow();
113
+ });
114
+
115
+ it('should throw CORRUPTED error for missing userId', () => {
116
+ const invalidCredentials = {
117
+ refreshToken: 'mock-refresh-token',
118
+ };
119
+
120
+ mockFs.existsSync.mockReturnValue(true);
121
+ mockFs.readFileSync.mockReturnValue(JSON.stringify(invalidCredentials));
122
+
123
+ expect(() => {
124
+ try {
125
+ loadCredentials();
126
+ } catch (error: any) {
127
+ expect(error.code).toBe('CORRUPTED');
128
+ throw error;
129
+ }
130
+ }).toThrow();
131
+ });
132
+
133
+ it('should only return refreshToken and userId even if file has extra fields', () => {
134
+ const storedWithExtra = {
135
+ refreshToken: 'mock-refresh-token',
136
+ userId: 'user-123',
137
+ firebaseToken: 'old-token',
138
+ firebaseTokenExpiry: Date.now(),
139
+ };
140
+
141
+ mockFs.existsSync.mockReturnValue(true);
142
+ mockFs.readFileSync.mockReturnValue(JSON.stringify(storedWithExtra));
143
+
144
+ const result = loadCredentials();
145
+
146
+ // Should only return the stored credentials fields
147
+ expect(result).toEqual({
148
+ refreshToken: 'mock-refresh-token',
149
+ userId: 'user-123',
150
+ });
151
+ });
152
+ });
153
+
154
+ describe('saveCredentials()', () => {
155
+ const mockCredentials: StoredCredentials = {
156
+ refreshToken: 'mock-refresh-token',
157
+ userId: 'user-123',
158
+ };
159
+
160
+ it('should create directory if it does not exist', () => {
161
+ mockFs.existsSync.mockReturnValue(false);
162
+ mockFs.mkdirSync.mockReturnValue(undefined);
163
+ mockFs.writeFileSync.mockReturnValue(undefined);
164
+
165
+ saveCredentials(mockCredentials);
166
+
167
+ expect(mockFs.mkdirSync).toHaveBeenCalledWith(
168
+ `${mockHomedir}/.spck-editor`,
169
+ { recursive: true, mode: 0o700 }
170
+ );
171
+ });
172
+
173
+ it('should write only refreshToken and userId with correct permissions', () => {
174
+ mockFs.existsSync.mockReturnValue(true);
175
+ mockFs.writeFileSync.mockReturnValue(undefined);
176
+
177
+ saveCredentials(mockCredentials);
178
+
179
+ // Should only persist refreshToken + userId
180
+ const expectedStored: StoredCredentials = {
181
+ refreshToken: 'mock-refresh-token',
182
+ userId: 'user-123',
183
+ };
184
+
185
+ expect(mockFs.writeFileSync).toHaveBeenCalledWith(
186
+ `${mockHomedir}/.spck-editor/.credentials.json`,
187
+ JSON.stringify(expectedStored, null, 2),
188
+ { encoding: 'utf8', mode: 0o600 }
189
+ );
190
+ });
191
+
192
+ it('should throw error with operation context on write failure', () => {
193
+ mockFs.existsSync.mockReturnValue(true);
194
+ const mockError: any = new Error('ENOSPC: no space left');
195
+ mockError.code = 'ENOSPC';
196
+ mockFs.writeFileSync.mockImplementation(() => {
197
+ throw mockError;
198
+ });
199
+
200
+ expect(() => {
201
+ try {
202
+ saveCredentials(mockCredentials);
203
+ } catch (error: any) {
204
+ expect(error.operation).toBe('save credentials');
205
+ throw error;
206
+ }
207
+ }).toThrow();
208
+ });
209
+ });
210
+
211
+ describe('loadConnectionSettings()', () => {
212
+ it('should return null if file does not exist', () => {
213
+ mockFs.existsSync.mockReturnValue(false);
214
+
215
+ const result = loadConnectionSettings();
216
+
217
+ expect(result).toBeNull();
218
+ });
219
+
220
+ it('should load and return valid connection settings', () => {
221
+ const mockSettings = {
222
+ serverToken: 'server-token',
223
+ serverTokenExpiry: Date.now() + 3600000,
224
+ clientId: 'client-123',
225
+ secret: 'secret-abc',
226
+ userId: 'user-123',
227
+ connectedAt: Date.now(),
228
+ };
229
+
230
+ mockFs.existsSync.mockReturnValue(true);
231
+ mockFs.readFileSync.mockReturnValue(JSON.stringify(mockSettings));
232
+
233
+ const result = loadConnectionSettings();
234
+
235
+ expect(result).toEqual(mockSettings);
236
+ });
237
+
238
+ it('should throw CORRUPTED error for invalid JSON', () => {
239
+ mockFs.existsSync.mockReturnValue(true);
240
+ mockFs.readFileSync.mockReturnValue('not valid json');
241
+
242
+ expect(() => {
243
+ try {
244
+ loadConnectionSettings();
245
+ } catch (error: any) {
246
+ expect(error.code).toBe('CORRUPTED');
247
+ throw error;
248
+ }
249
+ }).toThrow();
250
+ });
251
+
252
+ it('should throw CORRUPTED error for missing required fields', () => {
253
+ const invalidSettings = {
254
+ serverToken: 'token',
255
+ // missing clientId and secret
256
+ };
257
+
258
+ mockFs.existsSync.mockReturnValue(true);
259
+ mockFs.readFileSync.mockReturnValue(JSON.stringify(invalidSettings));
260
+
261
+ expect(() => {
262
+ try {
263
+ loadConnectionSettings();
264
+ } catch (error: any) {
265
+ expect(error.code).toBe('CORRUPTED');
266
+ throw error;
267
+ }
268
+ }).toThrow();
269
+ });
270
+ });
271
+
272
+ describe('saveConnectionSettings()', () => {
273
+ const mockSettings = {
274
+ serverToken: 'server-token',
275
+ serverTokenExpiry: Date.now() + 3600000,
276
+ clientId: 'client-123',
277
+ secret: 'secret-abc',
278
+ userId: 'user-123',
279
+ connectedAt: Date.now(),
280
+ };
281
+
282
+ it('should create directory if it does not exist', () => {
283
+ mockFs.existsSync.mockReturnValue(false);
284
+ mockFs.mkdirSync.mockReturnValue(undefined);
285
+ mockFs.writeFileSync.mockReturnValue(undefined);
286
+
287
+ saveConnectionSettings(mockSettings);
288
+
289
+ expect(mockFs.mkdirSync).toHaveBeenCalledWith(
290
+ `${mockCwd}/.spck-editor/config`,
291
+ { recursive: true, mode: 0o700 }
292
+ );
293
+ });
294
+
295
+ it('should write settings file with restricted permissions', () => {
296
+ mockFs.existsSync.mockReturnValue(true);
297
+ mockFs.writeFileSync.mockReturnValue(undefined);
298
+
299
+ saveConnectionSettings(mockSettings);
300
+
301
+ expect(mockFs.writeFileSync).toHaveBeenCalledWith(
302
+ `${mockCwd}/.spck-editor/config/connection-settings.json`,
303
+ JSON.stringify(mockSettings, null, 2),
304
+ { encoding: 'utf8', mode: 0o600 }
305
+ );
306
+ });
307
+
308
+ it('should throw error with operation context on write failure', () => {
309
+ mockFs.existsSync.mockReturnValue(true);
310
+ const mockError: any = new Error('EACCES: permission denied');
311
+ mockError.code = 'EACCES';
312
+ mockFs.writeFileSync.mockImplementation(() => {
313
+ throw mockError;
314
+ });
315
+
316
+ expect(() => {
317
+ try {
318
+ saveConnectionSettings(mockSettings);
319
+ } catch (error: any) {
320
+ expect(error.operation).toBe('save connection settings');
321
+ throw error;
322
+ }
323
+ }).toThrow();
324
+ });
325
+ });
326
+
327
+ describe('isServerTokenExpired()', () => {
328
+ it('should return true if settings is null', () => {
329
+ expect(isServerTokenExpired(null)).toBe(true);
330
+ });
331
+
332
+ it('should return true if serverTokenExpiry is missing', () => {
333
+ expect(isServerTokenExpired({ serverToken: 'token' })).toBe(true);
334
+ });
335
+
336
+ it('should return true if token has expired', () => {
337
+ const expiredSettings = {
338
+ serverToken: 'token',
339
+ serverTokenExpiry: Date.now() - 1000,
340
+ };
341
+
342
+ expect(isServerTokenExpired(expiredSettings)).toBe(true);
343
+ });
344
+
345
+ it('should return false if token is still valid', () => {
346
+ const validSettings = {
347
+ serverToken: 'token',
348
+ serverTokenExpiry: Date.now() + 3600000,
349
+ };
350
+
351
+ expect(isServerTokenExpired(validSettings)).toBe(false);
352
+ });
353
+ });
354
+
355
+ describe('clearCredentials()', () => {
356
+ it('should delete credentials file if it exists', () => {
357
+ mockFs.existsSync.mockReturnValue(true);
358
+ mockFs.unlinkSync.mockReturnValue(undefined);
359
+
360
+ clearCredentials();
361
+
362
+ expect(mockFs.unlinkSync).toHaveBeenCalledWith(
363
+ `${mockHomedir}/.spck-editor/.credentials.json`
364
+ );
365
+ });
366
+
367
+ it('should do nothing if file does not exist', () => {
368
+ mockFs.existsSync.mockReturnValue(false);
369
+
370
+ clearCredentials();
371
+
372
+ expect(mockFs.unlinkSync).not.toHaveBeenCalled();
373
+ });
374
+ });
375
+
376
+ describe('clearConnectionSettings()', () => {
377
+ it('should delete settings file if it exists', () => {
378
+ mockFs.existsSync.mockReturnValue(true);
379
+ mockFs.unlinkSync.mockReturnValue(undefined);
380
+
381
+ clearConnectionSettings();
382
+
383
+ expect(mockFs.unlinkSync).toHaveBeenCalledWith(
384
+ `${mockCwd}/.spck-editor/config/connection-settings.json`
385
+ );
386
+ });
387
+
388
+ it('should do nothing if file does not exist', () => {
389
+ mockFs.existsSync.mockReturnValue(false);
390
+
391
+ clearConnectionSettings();
392
+
393
+ expect(mockFs.unlinkSync).not.toHaveBeenCalled();
394
+ });
395
+ });
396
+
397
+ describe('getGlobalConfigPath()', () => {
398
+ it('should return path in user home directory', () => {
399
+ const configPath = getGlobalConfigPath();
400
+ expect(configPath).toBe(`${mockHomedir}/.spck-editor/global.config`);
401
+ });
402
+ });
403
+
404
+ describe('loadGlobalConfig()', () => {
405
+ it('should return default config if file does not exist', () => {
406
+ mockFs.existsSync.mockReturnValue(false);
407
+
408
+ const result = loadGlobalConfig();
409
+
410
+ expect(result).toEqual({ knownDeviceIds: [] });
411
+ });
412
+
413
+ it('should load and return valid global config', () => {
414
+ const mockConfig: GlobalConfig = {
415
+ knownDeviceIds: ['device-aaa', 'device-bbb'],
416
+ };
417
+
418
+ mockFs.existsSync.mockReturnValue(true);
419
+ mockFs.readFileSync.mockReturnValue(JSON.stringify(mockConfig));
420
+
421
+ const result = loadGlobalConfig();
422
+
423
+ expect(result).toEqual(mockConfig);
424
+ });
425
+
426
+ it('should return default config if JSON is invalid', () => {
427
+ mockFs.existsSync.mockReturnValue(true);
428
+ mockFs.readFileSync.mockReturnValue('not valid json{');
429
+
430
+ const result = loadGlobalConfig();
431
+
432
+ expect(result).toEqual({ knownDeviceIds: [] });
433
+ });
434
+
435
+ it('should return default config if knownDeviceIds is not an array', () => {
436
+ mockFs.existsSync.mockReturnValue(true);
437
+ mockFs.readFileSync.mockReturnValue(JSON.stringify({ knownDeviceIds: 'bad' }));
438
+
439
+ const result = loadGlobalConfig();
440
+
441
+ expect(result).toEqual({ knownDeviceIds: [] });
442
+ });
443
+
444
+ it('should return empty knownDeviceIds if field is missing', () => {
445
+ mockFs.existsSync.mockReturnValue(true);
446
+ mockFs.readFileSync.mockReturnValue(JSON.stringify({}));
447
+
448
+ const result = loadGlobalConfig();
449
+
450
+ expect(result).toEqual({ knownDeviceIds: [] });
451
+ });
452
+ });
453
+
454
+ describe('saveGlobalConfig()', () => {
455
+ const mockConfig: GlobalConfig = {
456
+ knownDeviceIds: ['device-aaa', 'device-bbb'],
457
+ };
458
+
459
+ it('should create directory if it does not exist', () => {
460
+ mockFs.existsSync.mockReturnValue(false);
461
+ mockFs.mkdirSync.mockReturnValue(undefined);
462
+ mockFs.writeFileSync.mockReturnValue(undefined);
463
+
464
+ saveGlobalConfig(mockConfig);
465
+
466
+ expect(mockFs.mkdirSync).toHaveBeenCalledWith(
467
+ `${mockHomedir}/.spck-editor`,
468
+ { recursive: true, mode: 0o700 }
469
+ );
470
+ });
471
+
472
+ it('should write config file with correct content and restricted permissions', () => {
473
+ mockFs.existsSync.mockReturnValue(true);
474
+ mockFs.writeFileSync.mockReturnValue(undefined);
475
+
476
+ saveGlobalConfig(mockConfig);
477
+
478
+ expect(mockFs.writeFileSync).toHaveBeenCalledWith(
479
+ `${mockHomedir}/.spck-editor/global.config`,
480
+ JSON.stringify(mockConfig, null, 2),
481
+ { encoding: 'utf8', mode: 0o600 }
482
+ );
483
+ });
484
+
485
+ it('should not create directory if it already exists', () => {
486
+ mockFs.existsSync.mockReturnValue(true);
487
+ mockFs.writeFileSync.mockReturnValue(undefined);
488
+
489
+ saveGlobalConfig(mockConfig);
490
+
491
+ expect(mockFs.mkdirSync).not.toHaveBeenCalled();
492
+ });
493
+ });
494
+ });
@@ -0,0 +1,206 @@
1
+ /**
2
+ * Configuration management for spck-cli server
3
+ */
4
+
5
+ import * as fs from 'fs';
6
+ import * as path from 'path';
7
+ import { ServerConfig } from '../types.js';
8
+ import { getProjectFilePath } from '../utils/project-dir.js';
9
+ import { t } from '../i18n/index.js';
10
+
11
+ const DEFAULT_CONFIG_FILENAME = 'spck-cli.config.json';
12
+
13
+ /**
14
+ * Load server configuration from file
15
+ * If config file doesn't exist, runs setup wizard
16
+ * @throws {ConfigNotFoundError} if file doesn't exist
17
+ * @throws {Error} with code 'CORRUPTED' if file is corrupted
18
+ */
19
+ export function loadConfig(configPath?: string): ServerConfig {
20
+ // If a custom config path is provided, use it as-is
21
+ // Otherwise use the default location in the project directory
22
+ const fullPath = configPath
23
+ ? path.resolve(process.cwd(), configPath)
24
+ : getProjectFilePath(process.cwd(), DEFAULT_CONFIG_FILENAME);
25
+
26
+ if (!fs.existsSync(fullPath)) {
27
+ console.log('\n' + t('config.fileNotFound', { path: fullPath }));
28
+ console.log(t('config.fileNotFoundHint') + '\n');
29
+
30
+ // Signal to caller that setup is needed
31
+ throw new ConfigNotFoundError(fullPath);
32
+ }
33
+
34
+ try {
35
+ const configData = fs.readFileSync(fullPath, 'utf8');
36
+ const config: ServerConfig = JSON.parse(configData);
37
+
38
+ // Validate required fields
39
+ validateConfig(config);
40
+
41
+ // Backward-compatibility: populate missing optional sections and re-save
42
+ let needsSave = false;
43
+ if (!config.browserProxy) {
44
+ config.browserProxy = { enabled: true };
45
+ needsSave = true;
46
+ }
47
+ if (needsSave) {
48
+ try { saveConfig(config, configPath); } catch { /* best-effort */ }
49
+ }
50
+
51
+ return config;
52
+ } catch (error: any) {
53
+ // JSON parse error or validation error
54
+ if (error instanceof SyntaxError || (error.message && error.message.includes('Invalid'))) {
55
+ console.warn('⚠️ ' + t('config.fileCorrupted', { path: fullPath }));
56
+ console.warn(' ' + t('config.fileCorruptedHint') + '\n');
57
+ const corruptedError: any = new Error('Configuration file is corrupted');
58
+ corruptedError.code = 'CORRUPTED';
59
+ corruptedError.path = fullPath;
60
+ corruptedError.originalError = error;
61
+ throw corruptedError;
62
+ }
63
+ // Other errors (permission, etc.)
64
+ throw error;
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Custom error for missing configuration
70
+ */
71
+ export class ConfigNotFoundError extends Error {
72
+ constructor(public configPath: string) {
73
+ super(`Configuration file not found: ${configPath}`);
74
+ this.name = 'ConfigNotFoundError';
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Save server configuration to file
80
+ * @throws {Error} with code 'EACCES' for permission errors
81
+ * @throws {Error} with code 'ENOSPC' for disk full errors
82
+ */
83
+ export function saveConfig(config: ServerConfig, configPath?: string): void {
84
+ // If a custom config path is provided, use it as-is
85
+ // Otherwise use the default location in the project directory
86
+ const fullPath = configPath
87
+ ? path.resolve(process.cwd(), configPath)
88
+ : getProjectFilePath(process.cwd(), DEFAULT_CONFIG_FILENAME);
89
+
90
+ try {
91
+ // Ensure directory exists
92
+ const dir = path.dirname(fullPath);
93
+ if (!fs.existsSync(dir)) {
94
+ fs.mkdirSync(dir, { recursive: true });
95
+ }
96
+
97
+ // Validate before saving
98
+ validateConfig(config);
99
+
100
+ // Write config file
101
+ fs.writeFileSync(fullPath, JSON.stringify(config, null, 2), 'utf8');
102
+ } catch (error: any) {
103
+ // Add context to error
104
+ error.path = error.path || fullPath;
105
+ error.operation = 'save config';
106
+ throw error;
107
+ }
108
+ }
109
+
110
+ /**
111
+ * Validate configuration
112
+ */
113
+ function validateConfig(config: ServerConfig): void {
114
+ if (!config.version || config.version !== 1) {
115
+ throw new Error('Invalid config version. Expected version 1.');
116
+ }
117
+
118
+ if (!config.root || typeof config.root !== 'string') {
119
+ throw new Error('Invalid or missing root directory in configuration.');
120
+ }
121
+
122
+ if (!fs.existsSync(config.root)) {
123
+ throw new Error(`Root directory does not exist: ${config.root}`);
124
+ }
125
+
126
+ if (!config.terminal || typeof config.terminal !== 'object') {
127
+ throw new Error('Invalid or missing terminal configuration.');
128
+ }
129
+
130
+ if (typeof config.terminal.enabled !== 'boolean') {
131
+ throw new Error('Terminal enabled must be a boolean.');
132
+ }
133
+
134
+ if (!config.security || typeof config.security !== 'object') {
135
+ throw new Error('Invalid or missing security configuration.');
136
+ }
137
+
138
+ if (!config.filesystem || typeof config.filesystem !== 'object') {
139
+ throw new Error('Invalid or missing filesystem configuration.');
140
+ }
141
+ }
142
+
143
+ /**
144
+ * Get current directory name for default server name
145
+ */
146
+ function getDefaultServerName(): string {
147
+ const cwd = process.cwd();
148
+ return path.basename(cwd);
149
+ }
150
+
151
+ /**
152
+ * Create default configuration template
153
+ */
154
+ export function createDefaultConfig(overrides: Partial<ServerConfig> = {}): ServerConfig {
155
+ return {
156
+ version: 1,
157
+ root: process.cwd(),
158
+ name: getDefaultServerName(),
159
+ terminal: {
160
+ enabled: true,
161
+ maxBufferedLines: 5000,
162
+ maxTerminals: 10,
163
+ },
164
+ security: {
165
+ userAuthenticationEnabled: false,
166
+ },
167
+ filesystem: {
168
+ maxFileSize: '10MB',
169
+ watchIgnorePatterns: [
170
+ '**/.git/**',
171
+ '**/.spck-editor/**',
172
+ '**/node_modules/**',
173
+ '**/*.log',
174
+ '**/.DS_Store',
175
+ '**/dist/**',
176
+ '**/build/**'
177
+ ],
178
+ },
179
+ browserProxy: {
180
+ enabled: true,
181
+ },
182
+ ...overrides,
183
+ };
184
+ }
185
+
186
+ /**
187
+ * Parse file size string to bytes
188
+ */
189
+ export function parseFileSize(sizeStr: string): number {
190
+ const match = sizeStr.match(/^(\d+(?:\.\d+)?)\s*(B|KB|MB|GB)?$/i);
191
+ if (!match) {
192
+ throw new Error(`Invalid file size format: ${sizeStr}`);
193
+ }
194
+
195
+ const value = parseFloat(match[1]);
196
+ const unit = (match[2] || 'B').toUpperCase();
197
+
198
+ const multipliers: { [key: string]: number } = {
199
+ B: 1,
200
+ KB: 1024,
201
+ MB: 1024 * 1024,
202
+ GB: 1024 * 1024 * 1024,
203
+ };
204
+
205
+ return value * multipliers[unit];
206
+ }