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,302 @@
1
+ /**
2
+ * Firebase credentials management
3
+ * Handles user-level credential storage in ~/.spck-editor/.credentials.json
4
+ */
5
+
6
+ import * as fs from 'fs';
7
+ import * as path from 'path';
8
+ import * as os from 'os';
9
+ import { StoredCredentials, GlobalConfig } from '../types.js';
10
+ import { getProjectFilePath } from '../utils/project-dir.js';
11
+ import { logAuth } from '../utils/logger.js';
12
+ import { t } from '../i18n/index.js';
13
+
14
+ /**
15
+ * Get the user-level credentials directory
16
+ */
17
+ export function getCredentialsDir(): string {
18
+ return path.join(os.homedir(), '.spck-editor');
19
+ }
20
+
21
+ /**
22
+ * Get the credentials file path
23
+ */
24
+ export function getCredentialsPath(): string {
25
+ return path.join(getCredentialsDir(), '.credentials.json');
26
+ }
27
+
28
+ /**
29
+ * Get the global config file path
30
+ */
31
+ export function getGlobalConfigPath(): string {
32
+ return path.join(getCredentialsDir(), 'global.config');
33
+ }
34
+
35
+ /**
36
+ * Load global config from user-level storage
37
+ */
38
+ export function loadGlobalConfig(): GlobalConfig {
39
+ const configPath = getGlobalConfigPath();
40
+
41
+ if (!fs.existsSync(configPath)) {
42
+ return { knownDeviceIds: [] };
43
+ }
44
+
45
+ try {
46
+ const data = fs.readFileSync(configPath, 'utf8');
47
+ const parsed = JSON.parse(data);
48
+ return {
49
+ knownDeviceIds: Array.isArray(parsed.knownDeviceIds) ? parsed.knownDeviceIds : [],
50
+ };
51
+ } catch {
52
+ return { knownDeviceIds: [] };
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Save global config to user-level storage
58
+ */
59
+ export function saveGlobalConfig(config: GlobalConfig): void {
60
+ const credentialsDir = getCredentialsDir();
61
+ const configPath = getGlobalConfigPath();
62
+
63
+ if (!fs.existsSync(credentialsDir)) {
64
+ fs.mkdirSync(credentialsDir, { recursive: true, mode: 0o700 });
65
+ }
66
+
67
+ fs.writeFileSync(
68
+ configPath,
69
+ JSON.stringify(config, null, 2),
70
+ { encoding: 'utf8', mode: 0o600 }
71
+ );
72
+ }
73
+
74
+ /**
75
+ * Get the connection settings file path (project-level)
76
+ * This uses the symlinked project directory
77
+ */
78
+ export function getConnectionSettingsPath(): string {
79
+ return getProjectFilePath(process.cwd(), 'connection-settings.json');
80
+ }
81
+
82
+ /**
83
+ * Load stored credentials from user-level storage
84
+ * Returns only refreshToken + userId; firebaseToken is generated on demand
85
+ * @throws {Error} with code 'CORRUPTED' if file is corrupted
86
+ */
87
+ export function loadCredentials(): StoredCredentials | null {
88
+ const credentialsPath = getCredentialsPath();
89
+
90
+ if (!fs.existsSync(credentialsPath)) {
91
+ return null;
92
+ }
93
+
94
+ try {
95
+ const data = fs.readFileSync(credentialsPath, 'utf8');
96
+ const credentials = JSON.parse(data);
97
+
98
+ // Validate required fields for stored credentials
99
+ if (!credentials.refreshToken || !credentials.userId) {
100
+ const error: any = new Error('Invalid credentials format - missing refreshToken or userId');
101
+ error.code = 'CORRUPTED';
102
+ error.path = credentialsPath;
103
+ throw error;
104
+ }
105
+
106
+ // Return only the stored fields (refreshToken + userId + optional proxyServerUrl)
107
+ const result: StoredCredentials = {
108
+ refreshToken: credentials.refreshToken,
109
+ userId: credentials.userId,
110
+ proxyServerUrl: credentials.proxyServerUrl
111
+ };
112
+ return result;
113
+ } catch (error: any) {
114
+ // JSON parse error or validation error
115
+ if (error instanceof SyntaxError || error.code === 'CORRUPTED') {
116
+ logAuth('credentials_corrupted', {
117
+ path: credentialsPath,
118
+ error: error.message
119
+ }, 'error');
120
+ console.warn(`⚠️ ${t('credentials.corrupted', { path: credentialsPath })}`);
121
+ console.warn(` ${t('credentials.corruptedHint')}\n`);
122
+ const corruptedError: any = new Error('Credentials file is corrupted');
123
+ corruptedError.code = 'CORRUPTED';
124
+ corruptedError.path = credentialsPath;
125
+ corruptedError.originalError = error;
126
+ throw corruptedError;
127
+ }
128
+ // Other errors (permission, etc.)
129
+ throw error;
130
+ }
131
+ }
132
+
133
+ /**
134
+ * Save stored credentials to user-level storage
135
+ * Only persists refreshToken + userId (not firebaseToken or expiry)
136
+ * @throws {Error} with code 'EACCES' for permission errors
137
+ * @throws {Error} with code 'ENOSPC' for disk full errors
138
+ */
139
+ export function saveCredentials(credentials: StoredCredentials): void {
140
+ const credentialsDir = getCredentialsDir();
141
+ const credentialsPath = getCredentialsPath();
142
+
143
+ try {
144
+ // Ensure directory exists
145
+ if (!fs.existsSync(credentialsDir)) {
146
+ fs.mkdirSync(credentialsDir, { recursive: true, mode: 0o700 });
147
+ }
148
+
149
+ // Persist refreshToken + userId + optional proxyServerUrl
150
+ const storedData: StoredCredentials = {
151
+ refreshToken: credentials.refreshToken,
152
+ userId: credentials.userId
153
+ };
154
+ if (credentials.proxyServerUrl) {
155
+ storedData.proxyServerUrl = credentials.proxyServerUrl;
156
+ }
157
+
158
+ // Write credentials file with restricted permissions
159
+ fs.writeFileSync(
160
+ credentialsPath,
161
+ JSON.stringify(storedData, null, 2),
162
+ { encoding: 'utf8', mode: 0o600 }
163
+ );
164
+ } catch (error: any) {
165
+ // Add context to error
166
+ error.path = error.path || credentialsPath;
167
+ error.operation = 'save credentials';
168
+ throw error;
169
+ }
170
+ }
171
+
172
+
173
+ /**
174
+ * Clear credentials (logout)
175
+ */
176
+ export function clearCredentials(): void {
177
+ const credentialsPath = getCredentialsPath();
178
+
179
+ if (fs.existsSync(credentialsPath)) {
180
+ fs.unlinkSync(credentialsPath);
181
+ }
182
+ }
183
+
184
+ /**
185
+ * Load connection settings from project-level storage
186
+ * @throws {Error} with code 'CORRUPTED' if file is corrupted
187
+ */
188
+ export function loadConnectionSettings(): any | null {
189
+ const settingsPath = getConnectionSettingsPath();
190
+
191
+ if (!fs.existsSync(settingsPath)) {
192
+ return null;
193
+ }
194
+
195
+ try {
196
+ const data = fs.readFileSync(settingsPath, 'utf8');
197
+ const settings = JSON.parse(data);
198
+
199
+ // Validate basic structure
200
+ if (!settings.serverToken || !settings.clientId || !settings.secret) {
201
+ const error: any = new Error('Invalid connection settings format - missing required fields');
202
+ error.code = 'CORRUPTED';
203
+ error.path = settingsPath;
204
+ throw error;
205
+ }
206
+
207
+ return settings;
208
+ } catch (error: any) {
209
+ // JSON parse error or validation error
210
+ if (error instanceof SyntaxError || error.code === 'CORRUPTED') {
211
+ logAuth('connection_settings_corrupted', {
212
+ path: settingsPath,
213
+ error: error.message
214
+ }, 'error');
215
+ console.warn(`⚠️ ${t('credentials.settingsCorrupted', { path: settingsPath })}`);
216
+ console.warn(` ${t('credentials.settingsCorruptedHint')}\n`);
217
+ const corruptedError: any = new Error('Connection settings file is corrupted');
218
+ corruptedError.code = 'CORRUPTED';
219
+ corruptedError.path = settingsPath;
220
+ corruptedError.originalError = error;
221
+ throw corruptedError;
222
+ }
223
+ // Other errors (permission, etc.)
224
+ throw error;
225
+ }
226
+ }
227
+
228
+ /**
229
+ * Save connection settings to project-level storage
230
+ * @throws {Error} with code 'EACCES' for permission errors
231
+ * @throws {Error} with code 'ENOSPC' for disk full errors
232
+ */
233
+ export function saveConnectionSettings(settings: any): void {
234
+ const settingsDir = path.dirname(getConnectionSettingsPath());
235
+ const settingsPath = getConnectionSettingsPath();
236
+
237
+ try {
238
+ // Ensure directory exists with restricted permissions
239
+ if (!fs.existsSync(settingsDir)) {
240
+ fs.mkdirSync(settingsDir, { recursive: true, mode: 0o700 });
241
+ }
242
+
243
+ // Write settings file with restricted permissions (owner read/write only)
244
+ fs.writeFileSync(
245
+ settingsPath,
246
+ JSON.stringify(settings, null, 2),
247
+ { encoding: 'utf8', mode: 0o600 }
248
+ );
249
+ } catch (error: any) {
250
+ // Add context to error
251
+ error.path = error.path || settingsPath;
252
+ error.operation = 'save connection settings';
253
+ throw error;
254
+ }
255
+ }
256
+
257
+ /**
258
+ * Clear connection settings
259
+ */
260
+ export function clearConnectionSettings(): void {
261
+ const settingsPath = getConnectionSettingsPath();
262
+
263
+ if (fs.existsSync(settingsPath)) {
264
+ fs.unlinkSync(settingsPath);
265
+ }
266
+ }
267
+
268
+ /**
269
+ * Load saved proxy server preference from user-level credentials
270
+ */
271
+ export function loadServerPreference(): string | null {
272
+ try {
273
+ const credentials = loadCredentials();
274
+ return credentials?.proxyServerUrl || null;
275
+ } catch {
276
+ return null;
277
+ }
278
+ }
279
+
280
+ /**
281
+ * Save proxy server preference to user-level credentials
282
+ */
283
+ export function saveServerPreference(proxyServerUrl: string): void {
284
+ const credentials = loadCredentials();
285
+ if (credentials) {
286
+ credentials.proxyServerUrl = proxyServerUrl;
287
+ saveCredentials(credentials);
288
+ }
289
+ }
290
+
291
+ /**
292
+ * Check if server JWT is expired
293
+ */
294
+ export function isServerTokenExpired(settings: any): boolean {
295
+ if (!settings || !settings.serverTokenExpiry) {
296
+ return true;
297
+ }
298
+
299
+ // Add 5-minute buffer for safety
300
+ const expiryWithBuffer = settings.serverTokenExpiry - (5 * 60 * 1000);
301
+ return Date.now() > expiryWithBuffer;
302
+ }
@@ -0,0 +1,150 @@
1
+ /**
2
+ * CLI Server Selection
3
+ * Fetches available relay servers, checks ping, and auto-selects the best one
4
+ */
5
+
6
+ import { t } from '../i18n/index.js';
7
+ import { getLocale } from '../i18n/index.js';
8
+
9
+ export interface CLIServer {
10
+ label: Record<string, string>;
11
+ url: string;
12
+ locale: Record<string, number>;
13
+ }
14
+
15
+ /**
16
+ * Hardcoded default server list — used as fallback when no server responds
17
+ */
18
+ const DEFAULT_SERVERS: CLIServer[] = [
19
+ {
20
+ label: {en: 'Europe', es: 'Europa', fr: 'Europe', de: 'Europa', pt: 'Europa', ru: '\u0415\u0432\u0440\u043e\u043f\u0430', ja: '\u30e8\u30fc\u30ed\u30c3\u30d1', ko: '\uc720\ub7fd', zh: '\u6b27\u6d32', zhTW: '\u6b50\u6d32', id: 'Eropa'},
21
+ url: 'cli-eu-1.spck.io',
22
+ locale: {en: 3, es: 2, fr: 1, de: 1, pt: 2, ru: 1, ja: 3, ko: 3, zh: 3, zhTW: 3, id: 3}
23
+ },
24
+ {
25
+ label: {en: 'North America', es: 'Am\u00e9rica del Norte', fr: 'Am\u00e9rique du Nord', de: 'Nordamerika', pt: 'Am\u00e9rica do Norte', ru: '\u0421\u0435\u0432\u0435\u0440\u043d\u0430\u044f \u0410\u043c\u0435\u0440\u0438\u043a\u0430', ja: '\u5317\u30a2\u30e1\u30ea\u30ab', ko: '\ubd81\uc544\uba54\ub9ac\uce74', zh: '\u5317\u7f8e\u6d32', zhTW: '\u5317\u7f8e\u6d32', id: 'Amerika Utara'},
26
+ url: 'cli-na-1.spck.io',
27
+ locale: {en: 1, es: 1, fr: 2, de: 2, pt: 1, ru: 2, ja: 4, ko: 4, zh: 4, zhTW: 4, id: 4}
28
+ },
29
+ {
30
+ label: {en: 'South Asia', es: 'Asia del Sur', fr: 'Asie du Sud', de: 'S\u00fcdasien', pt: '\u00c1sia Meridional', ru: '\u042e\u0436\u043d\u0430\u044f \u0410\u0437\u0438\u044f', ja: '\u5357\u30a2\u30b8\u30a2', ko: '\ub0a8\uc544\uc2dc\uc544', zh: '\u5357\u4e9a', zhTW: '\u5357\u4e9e', id: 'Asia Selatan'},
31
+ url: 'cli-sas-1.spck.io',
32
+ locale: {en: 2, es: 4, fr: 4, de: 4, pt: 4, ru: 4, ja: 2, ko: 2, zh: 2, zhTW: 2, id: 1}
33
+ },
34
+ {
35
+ label: {en: 'East Asia', es: 'Asia Oriental', fr: 'Asie de l\u2019Est', de: 'Ostasien', pt: '\u00c1sia Oriental', ru: '\u0412\u043e\u0441\u0442\u043e\u0447\u043d\u0430\u044f \u0410\u0437\u0438\u044f', ja: '\u6771\u30a2\u30b8\u30a2', ko: '\ub3d9\uc544\uc2dc\uc544', zh: '\u4e1c\u4e9a', zhTW: '\u6771\u4e9e', id: 'Asia Timur'},
36
+ url: 'cli-ea-1.spck.io',
37
+ locale: {en: 4, es: 3, fr: 3, de: 3, pt: 3, ru: 3, ja: 1, ko: 1, zh: 1, zhTW: 1, id: 2}
38
+ }
39
+ ];
40
+
41
+ /**
42
+ * Validate that a server URL belongs to spck.io (any subdomain).
43
+ * Rejects bare IPs, other domains, and URLs with protocols.
44
+ */
45
+ export function isValidDomain(url: string): boolean {
46
+ return /^([a-zA-Z0-9-]+\.)*spck\.io$/.test(url);
47
+ }
48
+
49
+ /**
50
+ * Get the hardcoded default server list
51
+ */
52
+ export function getDefaultServerList(): CLIServer[] {
53
+ return DEFAULT_SERVERS;
54
+ }
55
+
56
+ /**
57
+ * Fetch the list of available CLI servers from the closest relay server.
58
+ * Tries servers sorted by locale proximity, falls back to hardcoded list.
59
+ */
60
+ export async function fetchServerList(): Promise<CLIServer[]> {
61
+ // Race all servers in parallel — first successful response wins
62
+ try {
63
+ return await Promise.any(
64
+ DEFAULT_SERVERS.map(async (server) => {
65
+ const response = await fetch(`https://${server.url}/servers`, {
66
+ signal: AbortSignal.timeout(5000),
67
+ });
68
+ if (!response.ok) throw new Error(`${response.status}`);
69
+ const list = await response.json() as CLIServer[];
70
+ // Reject any server whose URL is not a *.spck.io domain
71
+ return list.filter(s => isValidDomain(s.url));
72
+ })
73
+ );
74
+ } catch {
75
+ // All servers failed — fall back to hardcoded list
76
+ return DEFAULT_SERVERS;
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Check ping to a server by making 4 parallel HTTP calls to /health
82
+ * and averaging the latency
83
+ */
84
+ export async function checkServerPing(serverUrl: string): Promise<number> {
85
+ const shortestTime = await Promise.any(
86
+ Array.from({ length: 4 }, async () => {
87
+ const start = Date.now();
88
+ try {
89
+ const response = await fetch(`https://${serverUrl}/health`, {
90
+ signal: AbortSignal.timeout(5000),
91
+ });
92
+ if (!response.ok) return Infinity;
93
+ } catch {
94
+ return Infinity;
95
+ }
96
+ return Date.now() - start;
97
+ })
98
+ );
99
+ return Math.round(shortestTime);
100
+ }
101
+
102
+ /**
103
+ * Ping all servers in parallel and select the one with the lowest latency
104
+ */
105
+ export async function selectBestServer(
106
+ servers: CLIServer[]
107
+ ): Promise<{ server: CLIServer; ping: number }> {
108
+ const results = await Promise.all(
109
+ servers.map(async (server) => {
110
+ try {
111
+ const ping = await checkServerPing(server.url);
112
+ return { server, ping };
113
+ } catch {
114
+ return { server, ping: Infinity };
115
+ }
116
+ })
117
+ );
118
+ results.sort((a, b) => a.ping - b.ping);
119
+ return results[0];
120
+ }
121
+
122
+ /**
123
+ * Display ping results for all servers
124
+ */
125
+ export async function displayServerPings(servers: CLIServer[]): Promise<Map<string, number>> {
126
+ console.log('\n ' + t('server.checkingLatency') + '\n');
127
+
128
+ const pingResults = new Map<string, number>();
129
+ const results = await Promise.all(
130
+ servers.map(async (server) => {
131
+ try {
132
+ const ping = await checkServerPing(server.url);
133
+ return { server, ping };
134
+ } catch {
135
+ return { server, ping: Infinity };
136
+ }
137
+ })
138
+ );
139
+
140
+ const locale = getLocale();
141
+ for (const { server, ping } of results) {
142
+ const label = server.label[locale] || server.label.en || server.url;
143
+ const pingStr = ping === Infinity ? t('server.unreachable') : `${ping}ms`;
144
+ console.log(` ${label}: ${pingStr}`);
145
+ pingResults.set(server.url, ping);
146
+ }
147
+
148
+ console.log('');
149
+ return pingResults;
150
+ }
@@ -0,0 +1,121 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
+ /**
3
+ * Tests for Firebase auth token refresh logic
4
+ */
5
+
6
+ import { StoredCredentials } from '../../types.js';
7
+
8
+ // Mock all external dependencies before importing the module
9
+ vi.mock('open', () => ({ default: vi.fn() }));
10
+ vi.mock('qrcode-terminal', () => ({ default: { generate: vi.fn() } }));
11
+ vi.mock('jsonwebtoken', () => ({ default: { decode: vi.fn() } }));
12
+ vi.mock('../../config/credentials.js', () => ({
13
+ saveCredentials: vi.fn(),
14
+ }));
15
+ vi.mock('../../utils/logger.js', () => ({
16
+ logAuth: vi.fn(),
17
+ }));
18
+ vi.mock('../../i18n/index.js', () => ({
19
+ t: (key: string) => key,
20
+ }));
21
+
22
+ // Import after mocks are set up
23
+ import { refreshFirebaseToken } from '../firebase-auth.js';
24
+
25
+ describe('refreshFirebaseToken()', () => {
26
+ const mockStoredCredentials: StoredCredentials = {
27
+ refreshToken: 'mock-refresh-token',
28
+ userId: 'user-123',
29
+ };
30
+
31
+ beforeEach(() => {
32
+ vi.clearAllMocks();
33
+ });
34
+
35
+ afterEach(() => {
36
+ vi.restoreAllMocks();
37
+ });
38
+
39
+ it('should use expires_in value of 0 without falling back to default', async () => {
40
+ const now = Date.now();
41
+ vi.spyOn(Date, 'now').mockReturnValue(now);
42
+
43
+ // Firebase returns expires_in: "0"
44
+ const mockResponse = {
45
+ ok: true,
46
+ json: vi.fn().mockResolvedValue({
47
+ id_token: 'new-id-token',
48
+ refresh_token: 'new-refresh-token',
49
+ expires_in: '0',
50
+ user_id: 'user-123',
51
+ }),
52
+ };
53
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue(mockResponse));
54
+
55
+ const result = await refreshFirebaseToken(mockStoredCredentials);
56
+
57
+ // With the bug (|| 3600), this would be now + 3600000
58
+ // With the fix (isNaN check), expires_in=0 means token expires immediately
59
+ expect(result.firebaseTokenExpiry).toBe(now);
60
+ });
61
+
62
+ it('should use the actual expires_in from the response', async () => {
63
+ const now = Date.now();
64
+ vi.spyOn(Date, 'now').mockReturnValue(now);
65
+
66
+ const mockResponse = {
67
+ ok: true,
68
+ json: vi.fn().mockResolvedValue({
69
+ id_token: 'new-id-token',
70
+ refresh_token: 'new-refresh-token',
71
+ expires_in: '7200',
72
+ user_id: 'user-123',
73
+ }),
74
+ };
75
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue(mockResponse));
76
+
77
+ const result = await refreshFirebaseToken(mockStoredCredentials);
78
+
79
+ expect(result.firebaseTokenExpiry).toBe(now + 7200 * 1000);
80
+ });
81
+
82
+ it('should default to 3600 when expires_in is missing', async () => {
83
+ const now = Date.now();
84
+ vi.spyOn(Date, 'now').mockReturnValue(now);
85
+
86
+ const mockResponse = {
87
+ ok: true,
88
+ json: vi.fn().mockResolvedValue({
89
+ id_token: 'new-id-token',
90
+ refresh_token: 'new-refresh-token',
91
+ user_id: 'user-123',
92
+ // expires_in is missing
93
+ }),
94
+ };
95
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue(mockResponse));
96
+
97
+ const result = await refreshFirebaseToken(mockStoredCredentials);
98
+
99
+ expect(result.firebaseTokenExpiry).toBe(now + 3600 * 1000);
100
+ });
101
+
102
+ it('should default to 3600 when expires_in is non-numeric', async () => {
103
+ const now = Date.now();
104
+ vi.spyOn(Date, 'now').mockReturnValue(now);
105
+
106
+ const mockResponse = {
107
+ ok: true,
108
+ json: vi.fn().mockResolvedValue({
109
+ id_token: 'new-id-token',
110
+ refresh_token: 'new-refresh-token',
111
+ expires_in: 'invalid',
112
+ user_id: 'user-123',
113
+ }),
114
+ };
115
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue(mockResponse));
116
+
117
+ const result = await refreshFirebaseToken(mockStoredCredentials);
118
+
119
+ expect(result.firebaseTokenExpiry).toBe(now + 3600 * 1000);
120
+ });
121
+ });