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