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.
- package/.oxlintrc.json +49 -0
- package/LICENSE +21 -0
- package/README.md +631 -0
- package/bin/cli.js +20 -0
- package/bin/validate-cwd.js +41 -0
- package/dist/config/__tests__/config.test.d.ts +2 -0
- package/dist/config/__tests__/config.test.js +262 -0
- package/dist/config/__tests__/credentials.test.d.ts +2 -0
- package/dist/config/__tests__/credentials.test.js +360 -0
- package/dist/config/config.d.ts +33 -0
- package/dist/config/config.js +185 -0
- package/dist/config/credentials.d.ts +75 -0
- package/dist/config/credentials.js +259 -0
- package/dist/config/server-selection.d.ts +40 -0
- package/dist/config/server-selection.js +130 -0
- package/dist/connection/__tests__/firebase-auth.test.d.ts +2 -0
- package/dist/connection/__tests__/firebase-auth.test.js +96 -0
- package/dist/connection/__tests__/hmac.test.d.ts +2 -0
- package/dist/connection/__tests__/hmac.test.js +372 -0
- package/dist/connection/auth.d.ts +13 -0
- package/dist/connection/auth.js +91 -0
- package/dist/connection/firebase-auth.d.ts +40 -0
- package/dist/connection/firebase-auth.js +429 -0
- package/dist/connection/hmac.d.ts +24 -0
- package/dist/connection/hmac.js +109 -0
- package/dist/i18n/index.d.ts +25 -0
- package/dist/i18n/index.js +101 -0
- package/dist/i18n/locales/en.json +313 -0
- package/dist/i18n/locales/es.json +302 -0
- package/dist/i18n/locales/fr.json +302 -0
- package/dist/i18n/locales/id.json +302 -0
- package/dist/i18n/locales/ja.json +302 -0
- package/dist/i18n/locales/ko.json +302 -0
- package/dist/i18n/locales/locales/en.json +309 -0
- package/dist/i18n/locales/locales/es.json +302 -0
- package/dist/i18n/locales/locales/fr.json +302 -0
- package/dist/i18n/locales/locales/id.json +302 -0
- package/dist/i18n/locales/locales/ja.json +302 -0
- package/dist/i18n/locales/locales/ko.json +302 -0
- package/dist/i18n/locales/locales/pt.json +302 -0
- package/dist/i18n/locales/locales/zh-Hans.json +302 -0
- package/dist/i18n/locales/pt.json +302 -0
- package/dist/i18n/locales/zh-Hans.json +302 -0
- package/dist/index.d.ts +25 -0
- package/dist/index.js +493 -0
- package/dist/proxy/ProxyClient.d.ts +125 -0
- package/dist/proxy/ProxyClient.js +781 -0
- package/dist/proxy/ProxySocketWrapper.d.ts +43 -0
- package/dist/proxy/ProxySocketWrapper.js +98 -0
- package/dist/proxy/__tests__/ProxyClient.test.d.ts +2 -0
- package/dist/proxy/__tests__/ProxyClient.test.js +445 -0
- package/dist/proxy/__tests__/ProxySocketWrapper.test.d.ts +2 -0
- package/dist/proxy/__tests__/ProxySocketWrapper.test.js +190 -0
- package/dist/proxy/__tests__/handshake-validation.test.d.ts +2 -0
- package/dist/proxy/__tests__/handshake-validation.test.js +282 -0
- package/dist/proxy/__tests__/token-refresh-race.test.d.ts +14 -0
- package/dist/proxy/__tests__/token-refresh-race.test.js +173 -0
- package/dist/proxy/chunking.d.ts +53 -0
- package/dist/proxy/chunking.js +127 -0
- package/dist/proxy/handshake-validation.d.ts +21 -0
- package/dist/proxy/handshake-validation.js +49 -0
- package/dist/rpc/__tests__/router.test.d.ts +2 -0
- package/dist/rpc/__tests__/router.test.js +262 -0
- package/dist/rpc/router.d.ts +37 -0
- package/dist/rpc/router.js +132 -0
- package/dist/services/BrowserProxyService.d.ts +13 -0
- package/dist/services/BrowserProxyService.js +139 -0
- package/dist/services/FilesystemService.d.ts +99 -0
- package/dist/services/FilesystemService.js +742 -0
- package/dist/services/GitService.d.ts +243 -0
- package/dist/services/GitService.js +1439 -0
- package/dist/services/SearchService.d.ts +93 -0
- package/dist/services/SearchService.js +670 -0
- package/dist/services/TerminalService.d.ts +62 -0
- package/dist/services/TerminalService.js +337 -0
- package/dist/services/__tests__/BrowserProxyService.test.d.ts +2 -0
- package/dist/services/__tests__/BrowserProxyService.test.js +145 -0
- package/dist/services/__tests__/FilesystemService.test.d.ts +2 -0
- package/dist/services/__tests__/FilesystemService.test.js +609 -0
- package/dist/services/__tests__/GitService.test.d.ts +2 -0
- package/dist/services/__tests__/GitService.test.js +953 -0
- package/dist/services/__tests__/SearchService.test.d.ts +2 -0
- package/dist/services/__tests__/SearchService.test.js +384 -0
- package/dist/services/__tests__/TerminalService.test.d.ts +2 -0
- package/dist/services/__tests__/TerminalService.test.js +513 -0
- package/dist/setup/wizard.d.ts +10 -0
- package/dist/setup/wizard.js +172 -0
- package/dist/types.d.ts +196 -0
- package/dist/types.js +44 -0
- package/dist/utils/__tests__/gitignore.test.d.ts +2 -0
- package/dist/utils/__tests__/gitignore.test.js +127 -0
- package/dist/utils/gitignore.d.ts +24 -0
- package/dist/utils/gitignore.js +77 -0
- package/dist/utils/logger.d.ts +96 -0
- package/dist/utils/logger.js +456 -0
- package/dist/utils/project-dir.d.ts +51 -0
- package/dist/utils/project-dir.js +191 -0
- package/dist/utils/ripgrep.d.ts +34 -0
- package/dist/utils/ripgrep.js +148 -0
- package/dist/utils/tool-detection.d.ts +17 -0
- package/dist/utils/tool-detection.js +126 -0
- package/dist/watcher/FileWatcher.d.ts +10 -0
- package/dist/watcher/FileWatcher.js +42 -0
- package/package.json +70 -0
- package/src/config/__tests__/config.test.ts +318 -0
- package/src/config/__tests__/credentials.test.ts +494 -0
- package/src/config/config.ts +206 -0
- package/src/config/credentials.ts +302 -0
- package/src/config/server-selection.ts +150 -0
- package/src/connection/__tests__/firebase-auth.test.ts +121 -0
- package/src/connection/__tests__/hmac.test.ts +509 -0
- package/src/connection/auth.ts +140 -0
- package/src/connection/firebase-auth.ts +504 -0
- package/src/connection/hmac.ts +139 -0
- package/src/i18n/index.ts +119 -0
- package/src/i18n/locales/en.json +313 -0
- package/src/i18n/locales/es.json +302 -0
- package/src/i18n/locales/fr.json +302 -0
- package/src/i18n/locales/id.json +302 -0
- package/src/i18n/locales/ja.json +302 -0
- package/src/i18n/locales/ko.json +302 -0
- package/src/i18n/locales/pt.json +302 -0
- package/src/i18n/locales/zh-Hans.json +302 -0
- package/src/index.ts +542 -0
- package/src/proxy/ProxyClient.ts +968 -0
- package/src/proxy/ProxySocketWrapper.ts +113 -0
- package/src/proxy/__tests__/ProxyClient.test.ts +575 -0
- package/src/proxy/__tests__/ProxySocketWrapper.test.ts +251 -0
- package/src/proxy/__tests__/handshake-validation.test.ts +367 -0
- package/src/proxy/chunking.ts +162 -0
- package/src/proxy/handshake-validation.ts +64 -0
- package/src/rpc/__tests__/router.test.ts +400 -0
- package/src/rpc/router.ts +183 -0
- package/src/services/BrowserProxyService.ts +179 -0
- package/src/services/FilesystemService.ts +841 -0
- package/src/services/GitService.ts +1639 -0
- package/src/services/SearchService.ts +809 -0
- package/src/services/TerminalService.ts +413 -0
- package/src/services/__tests__/BrowserProxyService.test.ts +155 -0
- package/src/services/__tests__/FilesystemService.test.ts +1002 -0
- package/src/services/__tests__/GitService.test.ts +1552 -0
- package/src/services/__tests__/SearchService.test.ts +484 -0
- package/src/services/__tests__/TerminalService.test.ts +702 -0
- package/src/setup/wizard.ts +242 -0
- package/src/types/fossil-delta.d.ts +4 -0
- package/src/types.ts +287 -0
- package/src/utils/__tests__/gitignore.test.ts +174 -0
- package/src/utils/gitignore.ts +91 -0
- package/src/utils/logger.ts +578 -0
- package/src/utils/project-dir.ts +218 -0
- package/src/utils/ripgrep.ts +180 -0
- package/src/utils/tool-detection.ts +141 -0
- package/src/watcher/FileWatcher.ts +53 -0
- package/tsconfig.json +24 -0
- 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
|
+
}
|