@girardmedia/bootspring 2.2.0 → 2.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/bin/bootspring.js +35 -96
- package/claude-commands/agent.md +34 -0
- package/claude-commands/bs.md +31 -0
- package/claude-commands/build.md +25 -0
- package/claude-commands/skill.md +31 -0
- package/claude-commands/todo.md +25 -0
- package/dist/cli/index.cjs +17808 -0
- package/dist/core/index.d.ts +5814 -0
- package/dist/core.js +5780 -0
- package/dist/mcp/index.d.ts +1 -0
- package/dist/mcp-server.js +2299 -0
- package/generators/api-docs.js +2 -2
- package/generators/decisions.js +3 -3
- package/generators/health.js +16 -16
- package/generators/sprint.js +2 -2
- package/package.json +27 -59
- package/core/api-client.d.ts +0 -69
- package/core/api-client.js +0 -1482
- package/core/auth.d.ts +0 -98
- package/core/auth.js +0 -737
- package/core/build-orchestrator.js +0 -508
- package/core/build-state.js +0 -612
- package/core/config.d.ts +0 -106
- package/core/config.js +0 -1328
- package/core/context-loader.js +0 -580
- package/core/context.d.ts +0 -61
- package/core/context.js +0 -327
- package/core/entitlements.d.ts +0 -70
- package/core/entitlements.js +0 -322
- package/core/index.d.ts +0 -53
- package/core/index.js +0 -62
- package/core/mcp-config.js +0 -115
- package/core/policies.d.ts +0 -43
- package/core/policies.js +0 -113
- package/core/policy-matrix.js +0 -303
- package/core/project-activity.js +0 -175
- package/core/redaction.d.ts +0 -5
- package/core/redaction.js +0 -63
- package/core/self-update.js +0 -259
- package/core/session.js +0 -353
- package/core/task-extractor.js +0 -1098
- package/core/telemetry.d.ts +0 -55
- package/core/telemetry.js +0 -617
- package/core/tier-enforcement.js +0 -928
- package/core/utils.d.ts +0 -90
- package/core/utils.js +0 -455
- package/core/validation.js +0 -572
- package/mcp/server.d.ts +0 -57
- package/mcp/server.js +0 -264
package/core/auth.js
DELETED
|
@@ -1,737 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Bootspring Auth Module
|
|
3
|
-
*
|
|
4
|
-
* Manages authentication tokens stored in ~/.bootspring/
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
const fs = require('fs');
|
|
8
|
-
const path = require('path');
|
|
9
|
-
const os = require('os');
|
|
10
|
-
const crypto = require('crypto');
|
|
11
|
-
|
|
12
|
-
const BOOTSPRING_DIR = path.join(os.homedir(), '.bootspring');
|
|
13
|
-
const CREDENTIALS_FILE = path.join(BOOTSPRING_DIR, 'credentials.json');
|
|
14
|
-
const CONFIG_FILE = path.join(BOOTSPRING_DIR, 'config.json');
|
|
15
|
-
const DEVICE_FILE = path.join(BOOTSPRING_DIR, 'device.json');
|
|
16
|
-
|
|
17
|
-
// Simple encryption key derived from machine ID
|
|
18
|
-
const getEncryptionKey = () => {
|
|
19
|
-
const machineId = os.hostname() + os.userInfo().username;
|
|
20
|
-
return crypto.createHash('sha256').update(machineId).digest();
|
|
21
|
-
};
|
|
22
|
-
|
|
23
|
-
/**
|
|
24
|
-
* Ensure ~/.bootspring directory exists
|
|
25
|
-
*/
|
|
26
|
-
function ensureDir() {
|
|
27
|
-
if (!fs.existsSync(BOOTSPRING_DIR)) {
|
|
28
|
-
fs.mkdirSync(BOOTSPRING_DIR, { recursive: true, mode: 0o700 });
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* Encrypt data
|
|
34
|
-
* @throws {Error} If encryption fails - never falls back to plaintext
|
|
35
|
-
*/
|
|
36
|
-
function encrypt(data) {
|
|
37
|
-
const key = getEncryptionKey();
|
|
38
|
-
const iv = crypto.randomBytes(16);
|
|
39
|
-
const cipher = crypto.createCipheriv('aes-256-cbc', key, iv);
|
|
40
|
-
let encrypted = cipher.update(JSON.stringify(data), 'utf8', 'hex');
|
|
41
|
-
encrypted += cipher.final('hex');
|
|
42
|
-
return { iv: iv.toString('hex'), data: encrypted, v: 1 };
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/**
|
|
46
|
-
* Decrypt data
|
|
47
|
-
* @throws {Error} If decryption fails - indicates corrupted or tampered credentials
|
|
48
|
-
*/
|
|
49
|
-
function decrypt(encrypted) {
|
|
50
|
-
// Check if data is in encrypted format (has iv, data, and optionally v)
|
|
51
|
-
if (!encrypted || typeof encrypted !== 'object' || !encrypted.iv || !encrypted.data) {
|
|
52
|
-
// Legacy unencrypted data - migrate on next save but don't fail
|
|
53
|
-
// This handles the transition from old plaintext storage
|
|
54
|
-
if (encrypted && typeof encrypted === 'object' && (encrypted.token || encrypted.apiKey)) {
|
|
55
|
-
console.error('[bootspring] Migrating legacy unencrypted credentials to encrypted storage');
|
|
56
|
-
return encrypted;
|
|
57
|
-
}
|
|
58
|
-
throw new Error('Invalid credential format');
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
const key = getEncryptionKey();
|
|
62
|
-
const iv = Buffer.from(encrypted.iv, 'hex');
|
|
63
|
-
const decipher = crypto.createDecipheriv('aes-256-cbc', key, iv);
|
|
64
|
-
let decrypted = decipher.update(encrypted.data, 'hex', 'utf8');
|
|
65
|
-
decrypted += decipher.final('utf8');
|
|
66
|
-
return JSON.parse(decrypted);
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
/**
|
|
70
|
-
* Get stored credentials
|
|
71
|
-
* @returns {object|null} Decrypted credentials or null if not available
|
|
72
|
-
*/
|
|
73
|
-
function getCredentials() {
|
|
74
|
-
try {
|
|
75
|
-
if (fs.existsSync(CREDENTIALS_FILE)) {
|
|
76
|
-
const raw = JSON.parse(fs.readFileSync(CREDENTIALS_FILE, 'utf-8'));
|
|
77
|
-
const decrypted = decrypt(raw);
|
|
78
|
-
|
|
79
|
-
// If we got legacy unencrypted data, re-encrypt it immediately
|
|
80
|
-
if (raw && typeof raw === 'object' && !raw.iv && !raw.data) {
|
|
81
|
-
saveCredentials(decrypted);
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
return decrypted;
|
|
85
|
-
}
|
|
86
|
-
} catch (err) {
|
|
87
|
-
// Log decryption failures for debugging but don't expose details
|
|
88
|
-
console.error('[bootspring] Failed to read credentials:', err.message);
|
|
89
|
-
}
|
|
90
|
-
return null;
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
/**
|
|
94
|
-
* Save credentials (encrypted)
|
|
95
|
-
* @param {object} credentials - Credentials to save
|
|
96
|
-
* @throws {Error} If encryption or file write fails
|
|
97
|
-
*/
|
|
98
|
-
function saveCredentials(credentials) {
|
|
99
|
-
ensureDir();
|
|
100
|
-
|
|
101
|
-
// Never store API keys in credentials - only tokens
|
|
102
|
-
const sanitized = { ...credentials };
|
|
103
|
-
if (sanitized.apiKey) {
|
|
104
|
-
// API keys should only be in memory or env vars, not on disk
|
|
105
|
-
console.error('[bootspring] Warning: API keys should not be stored in credentials. Use project-scoped tokens instead.');
|
|
106
|
-
delete sanitized.apiKey;
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
const encrypted = encrypt(sanitized);
|
|
110
|
-
fs.writeFileSync(
|
|
111
|
-
CREDENTIALS_FILE,
|
|
112
|
-
JSON.stringify(encrypted, null, 2),
|
|
113
|
-
{ mode: 0o600 }
|
|
114
|
-
);
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
/**
|
|
118
|
-
* Clear credentials (logout)
|
|
119
|
-
*/
|
|
120
|
-
function clearCredentials() {
|
|
121
|
-
try {
|
|
122
|
-
if (fs.existsSync(CREDENTIALS_FILE)) {
|
|
123
|
-
fs.unlinkSync(CREDENTIALS_FILE);
|
|
124
|
-
}
|
|
125
|
-
} catch {
|
|
126
|
-
// Ignore delete errors
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
/**
|
|
131
|
-
* Get auth token (JWT)
|
|
132
|
-
*/
|
|
133
|
-
function getToken() {
|
|
134
|
-
const creds = getCredentials();
|
|
135
|
-
if (!creds) return null;
|
|
136
|
-
|
|
137
|
-
// Check if token is expired
|
|
138
|
-
if (creds.expiresAt && new Date(creds.expiresAt) < new Date()) {
|
|
139
|
-
return null; // Token expired
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
return creds.token;
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
/**
|
|
146
|
-
* Check if the current token is expiring soon
|
|
147
|
-
* @param {number} [thresholdMs=300000] - Threshold in milliseconds (default 5 minutes)
|
|
148
|
-
* @returns {{ expiringSoon: boolean, expiresAt: string|null, msUntilExpiry: number }}
|
|
149
|
-
*/
|
|
150
|
-
function getTokenExpiryStatus(thresholdMs = 5 * 60 * 1000) {
|
|
151
|
-
const creds = getCredentials();
|
|
152
|
-
if (!creds || !creds.expiresAt) {
|
|
153
|
-
return { expiringSoon: false, expiresAt: null, msUntilExpiry: 0 };
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
const expiresAt = new Date(creds.expiresAt).getTime();
|
|
157
|
-
const now = Date.now();
|
|
158
|
-
const msUntilExpiry = expiresAt - now;
|
|
159
|
-
|
|
160
|
-
return {
|
|
161
|
-
expiringSoon: msUntilExpiry > 0 && msUntilExpiry <= thresholdMs,
|
|
162
|
-
expired: msUntilExpiry <= 0,
|
|
163
|
-
expiresAt: creds.expiresAt,
|
|
164
|
-
msUntilExpiry: Math.max(0, msUntilExpiry)
|
|
165
|
-
};
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
/**
|
|
169
|
-
* Get API key from project's .bootspring.json (for AI assistant sharing)
|
|
170
|
-
*/
|
|
171
|
-
function findNearestProjectConfigPath() {
|
|
172
|
-
let dir = process.cwd();
|
|
173
|
-
for (let i = 0; i < 10; i++) {
|
|
174
|
-
const configPath = path.join(dir, '.bootspring.json');
|
|
175
|
-
if (fs.existsSync(configPath)) {
|
|
176
|
-
return configPath;
|
|
177
|
-
}
|
|
178
|
-
const parent = path.dirname(dir);
|
|
179
|
-
if (parent === dir) break;
|
|
180
|
-
dir = parent;
|
|
181
|
-
}
|
|
182
|
-
return null;
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
function readNearestProjectConfig() {
|
|
186
|
-
try {
|
|
187
|
-
const configPath = findNearestProjectConfigPath();
|
|
188
|
-
if (!configPath) {
|
|
189
|
-
return null;
|
|
190
|
-
}
|
|
191
|
-
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
|
192
|
-
return { path: configPath, config };
|
|
193
|
-
} catch {
|
|
194
|
-
// Ignore errors
|
|
195
|
-
}
|
|
196
|
-
return null;
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
function writeNearestProjectConfig(configPath, config) {
|
|
200
|
-
try {
|
|
201
|
-
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
|
|
202
|
-
return true;
|
|
203
|
-
} catch {
|
|
204
|
-
return false;
|
|
205
|
-
}
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
function getProjectScopedSessionState() {
|
|
209
|
-
const projectConfig = readNearestProjectConfig();
|
|
210
|
-
if (!projectConfig || !projectConfig.config.projectAuth) {
|
|
211
|
-
return null;
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
const projectAuth = projectConfig.config.projectAuth;
|
|
215
|
-
if (
|
|
216
|
-
typeof projectAuth.token !== 'string' ||
|
|
217
|
-
projectAuth.token.length === 0 ||
|
|
218
|
-
typeof projectAuth.expiresAt !== 'string' ||
|
|
219
|
-
projectAuth.expiresAt.length === 0
|
|
220
|
-
) {
|
|
221
|
-
return null;
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
return {
|
|
225
|
-
token: projectAuth.token,
|
|
226
|
-
expiresAt: projectAuth.expiresAt,
|
|
227
|
-
issuedAt: typeof projectAuth.issuedAt === 'string' ? projectAuth.issuedAt : new Date().toISOString(),
|
|
228
|
-
source: typeof projectAuth.source === 'string' ? projectAuth.source : undefined,
|
|
229
|
-
migratedFromLegacyApiKey: Boolean(projectAuth.migratedFromLegacyApiKey)
|
|
230
|
-
};
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
let legacyProjectApiKeyWarned = false;
|
|
234
|
-
|
|
235
|
-
function getLegacyProjectApiKey() {
|
|
236
|
-
const projectConfig = readNearestProjectConfig();
|
|
237
|
-
if (!projectConfig) {
|
|
238
|
-
return null;
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
const legacyApiKey = projectConfig.config.apiKey;
|
|
242
|
-
if (typeof legacyApiKey === 'string' && legacyApiKey.length > 0) {
|
|
243
|
-
return legacyApiKey;
|
|
244
|
-
}
|
|
245
|
-
return null;
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
function getProjectScopedToken() {
|
|
249
|
-
const projectAuth = getProjectScopedSessionState();
|
|
250
|
-
if (!projectAuth) {
|
|
251
|
-
return null;
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
const expiresAt = new Date(projectAuth.expiresAt).getTime();
|
|
255
|
-
if (!Number.isFinite(expiresAt)) {
|
|
256
|
-
return null;
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
// Consider token expired slightly early to avoid boundary race conditions.
|
|
260
|
-
if (Date.now() >= expiresAt - 60_000) {
|
|
261
|
-
return null;
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
return projectAuth.token;
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
/**
|
|
268
|
-
* Get stored API key
|
|
269
|
-
* @deprecated API keys are no longer stored on disk. Returns null.
|
|
270
|
-
* Use environment variable BOOTSPRING_API_KEY or project-scoped tokens instead.
|
|
271
|
-
*/
|
|
272
|
-
function getStoredApiKey() {
|
|
273
|
-
// API keys are no longer stored in credentials for security
|
|
274
|
-
// They should only come from env vars or be exchanged for scoped tokens
|
|
275
|
-
return null;
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
/**
|
|
279
|
-
* Backwards-compatible alias for legacy .bootspring.json project API key.
|
|
280
|
-
*/
|
|
281
|
-
function getProjectApiKey() {
|
|
282
|
-
return getLegacyProjectApiKey();
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
/**
|
|
286
|
-
* Get API key (if using API key auth)
|
|
287
|
-
* Checks:
|
|
288
|
-
* 1) env var
|
|
289
|
-
* 2) short-lived project scoped token in .bootspring.json
|
|
290
|
-
* 3) encrypted credential API key
|
|
291
|
-
* 4) legacy .bootspring.json apiKey (migration fallback only)
|
|
292
|
-
*/
|
|
293
|
-
function getApiKey() {
|
|
294
|
-
// 1. Environment variable (for CI/CD)
|
|
295
|
-
const envApiKey = process.env.BOOTSPRING_API_KEY;
|
|
296
|
-
if (envApiKey) {
|
|
297
|
-
return envApiKey;
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
// If a valid JWT session exists, prefer session auth and suppress API-key fallback.
|
|
301
|
-
if (getToken()) {
|
|
302
|
-
return null;
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
// 2. Project-scoped short-lived token.
|
|
306
|
-
const projectScopedToken = getProjectScopedToken();
|
|
307
|
-
if (projectScopedToken) {
|
|
308
|
-
return projectScopedToken;
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
// 3. Encrypted credentials file.
|
|
312
|
-
const storedApiKey = getStoredApiKey();
|
|
313
|
-
if (storedApiKey) {
|
|
314
|
-
return storedApiKey;
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
// 4. Legacy project API key fallback (read-only migration path).
|
|
318
|
-
const legacyApiKey = getLegacyProjectApiKey();
|
|
319
|
-
if (legacyApiKey) {
|
|
320
|
-
if (!legacyProjectApiKeyWarned) {
|
|
321
|
-
legacyProjectApiKeyWarned = true;
|
|
322
|
-
console.warn(
|
|
323
|
-
'[bootspring] Using legacy .bootspring.json apiKey fallback. ' +
|
|
324
|
-
'Run `bootspring auth login --api-key <key>` to migrate to short-lived project tokens.'
|
|
325
|
-
);
|
|
326
|
-
}
|
|
327
|
-
return legacyApiKey;
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
return null;
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
/**
|
|
334
|
-
* Check if using API key authentication
|
|
335
|
-
*/
|
|
336
|
-
function isApiKeyAuth() {
|
|
337
|
-
if (process.env.BOOTSPRING_API_KEY) {
|
|
338
|
-
return true;
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
if (getToken()) {
|
|
342
|
-
return false;
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
return Boolean(
|
|
346
|
-
getProjectScopedToken() ||
|
|
347
|
-
getStoredApiKey() ||
|
|
348
|
-
getLegacyProjectApiKey()
|
|
349
|
-
);
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
/**
|
|
353
|
-
* Get refresh token
|
|
354
|
-
*/
|
|
355
|
-
function getRefreshToken() {
|
|
356
|
-
const creds = getCredentials();
|
|
357
|
-
return creds?.refreshToken || null;
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
/**
|
|
361
|
-
* Check if user is authenticated
|
|
362
|
-
*/
|
|
363
|
-
function isAuthenticated() {
|
|
364
|
-
return !!getToken() || !!getApiKey();
|
|
365
|
-
}
|
|
366
|
-
|
|
367
|
-
/**
|
|
368
|
-
* Get user info from stored credentials
|
|
369
|
-
*/
|
|
370
|
-
function getUser() {
|
|
371
|
-
const creds = getCredentials();
|
|
372
|
-
return creds?.user || null;
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
/**
|
|
376
|
-
* Get user's current tier
|
|
377
|
-
* Checks environment variable first, then stored credentials
|
|
378
|
-
*/
|
|
379
|
-
function getTier() {
|
|
380
|
-
// Environment variable takes precedence (for CI/CD and sandboxed AI assistants)
|
|
381
|
-
const envTier = process.env.BOOTSPRING_USER_TIER;
|
|
382
|
-
if (envTier) {
|
|
383
|
-
return envTier.toLowerCase();
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
const user = getUser();
|
|
387
|
-
return user?.tier || 'free';
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
/**
|
|
391
|
-
* Save login response (JWT auth)
|
|
392
|
-
*/
|
|
393
|
-
function login(response) {
|
|
394
|
-
// Calculate expiration time
|
|
395
|
-
const expiresIn = response.expiresIn || '15m';
|
|
396
|
-
const expiresMs = parseExpiry(expiresIn);
|
|
397
|
-
const expiresAt = new Date(Date.now() + expiresMs).toISOString();
|
|
398
|
-
|
|
399
|
-
saveCredentials({
|
|
400
|
-
token: response.token,
|
|
401
|
-
refreshToken: response.refreshToken,
|
|
402
|
-
expiresAt,
|
|
403
|
-
user: response.user
|
|
404
|
-
});
|
|
405
|
-
}
|
|
406
|
-
|
|
407
|
-
/**
|
|
408
|
-
* Save API key to project's .bootspring.json (for AI assistant sharing)
|
|
409
|
-
*/
|
|
410
|
-
function saveApiKeyToProject(apiKey) {
|
|
411
|
-
try {
|
|
412
|
-
const projectConfig = readNearestProjectConfig();
|
|
413
|
-
if (!projectConfig) {
|
|
414
|
-
return false;
|
|
415
|
-
}
|
|
416
|
-
|
|
417
|
-
const config = { ...projectConfig.config };
|
|
418
|
-
config.apiKey = apiKey;
|
|
419
|
-
return writeNearestProjectConfig(projectConfig.path, config);
|
|
420
|
-
} catch {
|
|
421
|
-
// Ignore errors - project config save is best-effort
|
|
422
|
-
}
|
|
423
|
-
return false;
|
|
424
|
-
}
|
|
425
|
-
|
|
426
|
-
/**
|
|
427
|
-
* Clear API key from project's .bootspring.json in current working tree.
|
|
428
|
-
*/
|
|
429
|
-
function clearProjectApiKey() {
|
|
430
|
-
try {
|
|
431
|
-
const projectConfig = readNearestProjectConfig();
|
|
432
|
-
if (!projectConfig) {
|
|
433
|
-
return false;
|
|
434
|
-
}
|
|
435
|
-
|
|
436
|
-
const config = { ...projectConfig.config };
|
|
437
|
-
if (!Object.prototype.hasOwnProperty.call(config, 'apiKey')) {
|
|
438
|
-
return false;
|
|
439
|
-
}
|
|
440
|
-
|
|
441
|
-
delete config.apiKey;
|
|
442
|
-
return writeNearestProjectConfig(projectConfig.path, config);
|
|
443
|
-
} catch {
|
|
444
|
-
// Ignore errors - project config cleanup is best-effort
|
|
445
|
-
}
|
|
446
|
-
return false;
|
|
447
|
-
}
|
|
448
|
-
|
|
449
|
-
/**
|
|
450
|
-
* Save API key session
|
|
451
|
-
* Note: API keys are NOT stored on disk. Only user info is saved.
|
|
452
|
-
* The API key should be exchanged for a short-lived scoped token immediately.
|
|
453
|
-
* @param {string} apiKey - API key (kept in memory only, not stored)
|
|
454
|
-
* @param {object} user - User info to store
|
|
455
|
-
*/
|
|
456
|
-
function loginWithApiKey(apiKey, user) {
|
|
457
|
-
// Only save user info, NOT the API key itself
|
|
458
|
-
// API key should be exchanged for scoped token via api-client.js
|
|
459
|
-
if (user !== undefined) {
|
|
460
|
-
const credentials = { user };
|
|
461
|
-
saveCredentials(credentials);
|
|
462
|
-
}
|
|
463
|
-
|
|
464
|
-
// Clear any legacy API keys from project config
|
|
465
|
-
clearProjectApiKey();
|
|
466
|
-
}
|
|
467
|
-
|
|
468
|
-
/**
|
|
469
|
-
* Update tokens (after refresh)
|
|
470
|
-
*/
|
|
471
|
-
function updateTokens(response) {
|
|
472
|
-
const creds = getCredentials() || {};
|
|
473
|
-
const expiresIn = response.expiresIn || '15m';
|
|
474
|
-
const expiresMs = parseExpiry(expiresIn);
|
|
475
|
-
const expiresAt = new Date(Date.now() + expiresMs).toISOString();
|
|
476
|
-
|
|
477
|
-
saveCredentials({
|
|
478
|
-
...creds,
|
|
479
|
-
token: response.token,
|
|
480
|
-
refreshToken: response.refreshToken,
|
|
481
|
-
expiresAt
|
|
482
|
-
});
|
|
483
|
-
}
|
|
484
|
-
|
|
485
|
-
/**
|
|
486
|
-
* Parse expiry string (e.g., '15m', '1h', '7d')
|
|
487
|
-
*/
|
|
488
|
-
function parseExpiry(expiry) {
|
|
489
|
-
const match = expiry.match(/^(\d+)([mhd])$/);
|
|
490
|
-
if (!match) return 15 * 60 * 1000; // Default 15 minutes
|
|
491
|
-
|
|
492
|
-
const value = parseInt(match[1]);
|
|
493
|
-
const unit = match[2];
|
|
494
|
-
|
|
495
|
-
switch (unit) {
|
|
496
|
-
case 'm': return value * 60 * 1000;
|
|
497
|
-
case 'h': return value * 60 * 60 * 1000;
|
|
498
|
-
case 'd': return value * 24 * 60 * 60 * 1000;
|
|
499
|
-
default: return 15 * 60 * 1000;
|
|
500
|
-
}
|
|
501
|
-
}
|
|
502
|
-
|
|
503
|
-
function saveProjectScopedSession(token, options = {}) {
|
|
504
|
-
try {
|
|
505
|
-
const projectConfig = readNearestProjectConfig();
|
|
506
|
-
if (!projectConfig || !token) {
|
|
507
|
-
return false;
|
|
508
|
-
}
|
|
509
|
-
|
|
510
|
-
const resolvedExpiresAt = (() => {
|
|
511
|
-
if (options.expiresAt) {
|
|
512
|
-
return options.expiresAt;
|
|
513
|
-
}
|
|
514
|
-
if (typeof options.expiresIn === 'number' && Number.isFinite(options.expiresIn)) {
|
|
515
|
-
return new Date(Date.now() + options.expiresIn * 1000).toISOString();
|
|
516
|
-
}
|
|
517
|
-
if (typeof options.expiresIn === 'string') {
|
|
518
|
-
return new Date(Date.now() + parseExpiry(options.expiresIn)).toISOString();
|
|
519
|
-
}
|
|
520
|
-
return new Date(Date.now() + 15 * 60 * 1000).toISOString();
|
|
521
|
-
})();
|
|
522
|
-
|
|
523
|
-
const nextConfig = {
|
|
524
|
-
...projectConfig.config,
|
|
525
|
-
projectAuth: {
|
|
526
|
-
token,
|
|
527
|
-
expiresAt: resolvedExpiresAt,
|
|
528
|
-
issuedAt: new Date().toISOString(),
|
|
529
|
-
source: options.source || 'api-key-exchange',
|
|
530
|
-
migratedFromLegacyApiKey: Boolean(options.migratedFromLegacyApiKey)
|
|
531
|
-
}
|
|
532
|
-
};
|
|
533
|
-
|
|
534
|
-
if (options.migratedFromLegacyApiKey && Object.prototype.hasOwnProperty.call(nextConfig, 'apiKey')) {
|
|
535
|
-
delete nextConfig.apiKey;
|
|
536
|
-
}
|
|
537
|
-
|
|
538
|
-
return writeNearestProjectConfig(projectConfig.path, nextConfig);
|
|
539
|
-
} catch {
|
|
540
|
-
// Ignore errors - project scoped session save is best-effort.
|
|
541
|
-
}
|
|
542
|
-
return false;
|
|
543
|
-
}
|
|
544
|
-
|
|
545
|
-
function clearProjectScopedSession() {
|
|
546
|
-
try {
|
|
547
|
-
const projectConfig = readNearestProjectConfig();
|
|
548
|
-
if (!projectConfig || !projectConfig.config.projectAuth) {
|
|
549
|
-
return false;
|
|
550
|
-
}
|
|
551
|
-
|
|
552
|
-
const nextConfig = { ...projectConfig.config };
|
|
553
|
-
delete nextConfig.projectAuth;
|
|
554
|
-
return writeNearestProjectConfig(projectConfig.path, nextConfig);
|
|
555
|
-
} catch {
|
|
556
|
-
// Ignore errors - project scoped session cleanup is best-effort.
|
|
557
|
-
}
|
|
558
|
-
return false;
|
|
559
|
-
}
|
|
560
|
-
|
|
561
|
-
/**
|
|
562
|
-
* Logout
|
|
563
|
-
*/
|
|
564
|
-
function logout() {
|
|
565
|
-
clearCredentials();
|
|
566
|
-
clearProjectScopedSession();
|
|
567
|
-
clearProjectApiKey();
|
|
568
|
-
}
|
|
569
|
-
|
|
570
|
-
/**
|
|
571
|
-
* Get global config
|
|
572
|
-
*/
|
|
573
|
-
function getConfig() {
|
|
574
|
-
try {
|
|
575
|
-
if (fs.existsSync(CONFIG_FILE)) {
|
|
576
|
-
return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf-8'));
|
|
577
|
-
}
|
|
578
|
-
} catch {
|
|
579
|
-
// Ignore
|
|
580
|
-
}
|
|
581
|
-
return {};
|
|
582
|
-
}
|
|
583
|
-
|
|
584
|
-
/**
|
|
585
|
-
* Save global config
|
|
586
|
-
*/
|
|
587
|
-
function saveConfig(config) {
|
|
588
|
-
ensureDir();
|
|
589
|
-
fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2));
|
|
590
|
-
}
|
|
591
|
-
|
|
592
|
-
/**
|
|
593
|
-
* Get credentials file path (for display)
|
|
594
|
-
*/
|
|
595
|
-
function getCredentialsPath() {
|
|
596
|
-
return CREDENTIALS_FILE;
|
|
597
|
-
}
|
|
598
|
-
|
|
599
|
-
/**
|
|
600
|
-
* Generate a stable device fingerprint based on machine characteristics
|
|
601
|
-
* This creates a unique identifier for this device that persists across sessions
|
|
602
|
-
* @returns {string} Device fingerprint hash
|
|
603
|
-
*/
|
|
604
|
-
function generateDeviceFingerprint() {
|
|
605
|
-
const components = [
|
|
606
|
-
os.hostname(),
|
|
607
|
-
os.userInfo().username,
|
|
608
|
-
os.platform(),
|
|
609
|
-
os.arch(),
|
|
610
|
-
os.cpus()[0]?.model || 'unknown-cpu',
|
|
611
|
-
os.homedir(),
|
|
612
|
-
// Network interfaces (stable MAC addresses)
|
|
613
|
-
Object.values(os.networkInterfaces())
|
|
614
|
-
.flat()
|
|
615
|
-
.filter(iface => iface && !iface.internal && iface.mac !== '00:00:00:00:00:00')
|
|
616
|
-
.map(iface => iface.mac)
|
|
617
|
-
.sort()
|
|
618
|
-
.join(',')
|
|
619
|
-
];
|
|
620
|
-
|
|
621
|
-
return crypto
|
|
622
|
-
.createHash('sha256')
|
|
623
|
-
.update(components.join('|'))
|
|
624
|
-
.digest('hex');
|
|
625
|
-
}
|
|
626
|
-
|
|
627
|
-
/**
|
|
628
|
-
* Get or create persistent device ID
|
|
629
|
-
* The device ID is generated once and stored for consistency
|
|
630
|
-
* @returns {object} Device info { deviceId, fingerprint, createdAt }
|
|
631
|
-
*/
|
|
632
|
-
function getDeviceInfo() {
|
|
633
|
-
ensureDir();
|
|
634
|
-
|
|
635
|
-
try {
|
|
636
|
-
if (fs.existsSync(DEVICE_FILE)) {
|
|
637
|
-
const stored = JSON.parse(fs.readFileSync(DEVICE_FILE, 'utf-8'));
|
|
638
|
-
// Verify fingerprint still matches (detect if copied to another machine)
|
|
639
|
-
const currentFingerprint = generateDeviceFingerprint();
|
|
640
|
-
if (stored.fingerprint === currentFingerprint) {
|
|
641
|
-
return stored;
|
|
642
|
-
}
|
|
643
|
-
// Fingerprint mismatch - device file was copied, regenerate
|
|
644
|
-
}
|
|
645
|
-
} catch {
|
|
646
|
-
// Ignore read errors, regenerate
|
|
647
|
-
}
|
|
648
|
-
|
|
649
|
-
// Generate new device info
|
|
650
|
-
const deviceInfo = {
|
|
651
|
-
deviceId: crypto.randomUUID(),
|
|
652
|
-
fingerprint: generateDeviceFingerprint(),
|
|
653
|
-
createdAt: new Date().toISOString(),
|
|
654
|
-
platform: os.platform(),
|
|
655
|
-
arch: os.arch(),
|
|
656
|
-
hostname: os.hostname()
|
|
657
|
-
};
|
|
658
|
-
|
|
659
|
-
fs.writeFileSync(DEVICE_FILE, JSON.stringify(deviceInfo, null, 2), { mode: 0o600 });
|
|
660
|
-
return deviceInfo;
|
|
661
|
-
}
|
|
662
|
-
|
|
663
|
-
/**
|
|
664
|
-
* Get device ID for authentication requests
|
|
665
|
-
* @returns {string} Device ID
|
|
666
|
-
*/
|
|
667
|
-
function getDeviceId() {
|
|
668
|
-
return getDeviceInfo().deviceId;
|
|
669
|
-
}
|
|
670
|
-
|
|
671
|
-
/**
|
|
672
|
-
* Get full device context for authentication
|
|
673
|
-
* Includes device ID plus current session info for server-side tracking
|
|
674
|
-
* @returns {object} Device context for auth requests
|
|
675
|
-
*/
|
|
676
|
-
function getDeviceContext() {
|
|
677
|
-
const info = getDeviceInfo();
|
|
678
|
-
return {
|
|
679
|
-
deviceId: info.deviceId,
|
|
680
|
-
platform: info.platform,
|
|
681
|
-
arch: info.arch,
|
|
682
|
-
hostname: info.hostname,
|
|
683
|
-
cliVersion: require('../package.json').version,
|
|
684
|
-
nodeVersion: process.version,
|
|
685
|
-
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone
|
|
686
|
-
};
|
|
687
|
-
}
|
|
688
|
-
|
|
689
|
-
/**
|
|
690
|
-
* Clear device info (for testing or reset)
|
|
691
|
-
*/
|
|
692
|
-
function clearDeviceInfo() {
|
|
693
|
-
try {
|
|
694
|
-
if (fs.existsSync(DEVICE_FILE)) {
|
|
695
|
-
fs.unlinkSync(DEVICE_FILE);
|
|
696
|
-
}
|
|
697
|
-
} catch {
|
|
698
|
-
// Ignore delete errors
|
|
699
|
-
}
|
|
700
|
-
}
|
|
701
|
-
|
|
702
|
-
module.exports = {
|
|
703
|
-
BOOTSPRING_DIR,
|
|
704
|
-
ensureDir,
|
|
705
|
-
getCredentials,
|
|
706
|
-
saveCredentials,
|
|
707
|
-
clearCredentials,
|
|
708
|
-
getToken,
|
|
709
|
-
getTokenExpiryStatus,
|
|
710
|
-
getApiKey,
|
|
711
|
-
getProjectApiKey,
|
|
712
|
-
getProjectScopedToken,
|
|
713
|
-
getStoredApiKey,
|
|
714
|
-
getLegacyProjectApiKey,
|
|
715
|
-
isApiKeyAuth,
|
|
716
|
-
getRefreshToken,
|
|
717
|
-
isAuthenticated,
|
|
718
|
-
getUser,
|
|
719
|
-
getTier,
|
|
720
|
-
login,
|
|
721
|
-
loginWithApiKey,
|
|
722
|
-
saveApiKeyToProject,
|
|
723
|
-
saveProjectScopedSession,
|
|
724
|
-
clearProjectScopedSession,
|
|
725
|
-
clearProjectApiKey,
|
|
726
|
-
updateTokens,
|
|
727
|
-
logout,
|
|
728
|
-
getConfig,
|
|
729
|
-
saveConfig,
|
|
730
|
-
getCredentialsPath,
|
|
731
|
-
// Device fingerprinting
|
|
732
|
-
generateDeviceFingerprint,
|
|
733
|
-
getDeviceInfo,
|
|
734
|
-
getDeviceId,
|
|
735
|
-
getDeviceContext,
|
|
736
|
-
clearDeviceInfo
|
|
737
|
-
};
|