@rubixkube/rubix 0.0.2 → 0.0.4
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/CHANGELOG.md +36 -0
- package/LICENSE +33 -0
- package/README.md +29 -12
- package/dist/cli.js +7 -0
- package/dist/commands/chat.js +2 -1
- package/dist/commands/login.js +23 -2
- package/dist/commands/model.js +84 -0
- package/dist/config/env.js +2 -0
- package/dist/core/device-auth.js +39 -1
- package/dist/core/rubix-api.js +211 -27
- package/dist/core/session-store.js +36 -0
- package/dist/core/settings.js +30 -0
- package/dist/core/update-check.js +51 -0
- package/dist/core/whats-new.js +56 -0
- package/dist/ui/App.js +453 -141
- package/dist/ui/components/BrandPanel.js +1 -1
- package/dist/ui/components/ChatTranscript.js +108 -22
- package/dist/ui/components/Composer.js +67 -8
- package/dist/ui/components/DashboardPanel.js +32 -9
- package/dist/ui/components/SplashScreen.js +2 -8
- package/dist/ui/hooks/useBracketedPaste.js +27 -0
- package/dist/ui/theme.js +3 -1
- package/package.json +6 -4
- package/patches/ink-multiline-input+0.1.0.patch +246 -16
package/dist/core/rubix-api.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { getConfig } from "../config/env.js";
|
|
2
|
+
import { refreshAccessToken } from "./device-auth.js";
|
|
3
|
+
import { saveAuthConfig } from "./auth-store.js";
|
|
2
4
|
const DEFAULT_APP_NAME = "SRI Agent";
|
|
3
5
|
export class StreamError extends Error {
|
|
4
6
|
reason;
|
|
@@ -19,9 +21,38 @@ function opelBase() {
|
|
|
19
21
|
}
|
|
20
22
|
return value.replace(/\/+$/, "");
|
|
21
23
|
}
|
|
24
|
+
function memoryBase() {
|
|
25
|
+
const value = getConfig().memoryApiBase;
|
|
26
|
+
if (!value) {
|
|
27
|
+
throw new Error("RUBIXKUBE_MEMORY_API_BASE is not set.");
|
|
28
|
+
}
|
|
29
|
+
return value.replace(/\/+$/, "");
|
|
30
|
+
}
|
|
31
|
+
export async function fetchSystemStats(auth) {
|
|
32
|
+
try {
|
|
33
|
+
const url = `${memoryBase()}/api/v1/observability/stats`;
|
|
34
|
+
const response = await fetchWithAutoRefresh(auth, url, {
|
|
35
|
+
method: "GET",
|
|
36
|
+
headers: headers(auth, true),
|
|
37
|
+
});
|
|
38
|
+
if (!response.ok)
|
|
39
|
+
return null;
|
|
40
|
+
const payload = (await response.json());
|
|
41
|
+
return {
|
|
42
|
+
insights: Number(payload.insights ?? 0),
|
|
43
|
+
activeInsights: Number(payload.activeInsights ?? 0),
|
|
44
|
+
resolvedInsights: Number(payload.resolvedInsights ?? 0),
|
|
45
|
+
rcaReports: Number(payload.rcaReports ?? 0),
|
|
46
|
+
lastUpdated: String(payload.lastUpdated ?? new Date().toISOString()),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
22
53
|
function ensureAuth(auth) {
|
|
23
54
|
const token = auth.idToken ?? auth.authToken;
|
|
24
|
-
const userId = auth.
|
|
55
|
+
const userId = auth.userEmail ?? auth.userId;
|
|
25
56
|
if (!token) {
|
|
26
57
|
throw new Error("Missing auth token. Run /login.");
|
|
27
58
|
}
|
|
@@ -48,6 +79,38 @@ function headers(auth, includeTenant = true) {
|
|
|
48
79
|
}
|
|
49
80
|
return out;
|
|
50
81
|
}
|
|
82
|
+
export async function refreshAndUpdateAuth(auth) {
|
|
83
|
+
try {
|
|
84
|
+
const refreshed = await refreshAccessToken(auth);
|
|
85
|
+
await saveAuthConfig(refreshed);
|
|
86
|
+
return refreshed;
|
|
87
|
+
}
|
|
88
|
+
catch (error) {
|
|
89
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
90
|
+
throw new StreamError(`Token refresh failed: ${msg}. Please run /login again.`, {
|
|
91
|
+
reason: "http_error",
|
|
92
|
+
status: 401,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
async function fetchWithAutoRefresh(auth, url, options) {
|
|
97
|
+
let response = await fetch(url, options);
|
|
98
|
+
if (response.status === 401) {
|
|
99
|
+
try {
|
|
100
|
+
auth = await refreshAndUpdateAuth(auth);
|
|
101
|
+
const newHeaders = { ...options.headers };
|
|
102
|
+
const token = auth.idToken ?? auth.authToken;
|
|
103
|
+
if (token) {
|
|
104
|
+
newHeaders.Authorization = `Bearer ${token}`;
|
|
105
|
+
}
|
|
106
|
+
response = await fetch(url, { ...options, headers: newHeaders });
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
return response;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return response;
|
|
113
|
+
}
|
|
51
114
|
async function parseJsonResponse(response) {
|
|
52
115
|
if (!response.ok) {
|
|
53
116
|
const text = await response.text();
|
|
@@ -159,6 +222,19 @@ function parseToolNameFromText(value) {
|
|
|
159
222
|
return callMatch[1];
|
|
160
223
|
return "";
|
|
161
224
|
}
|
|
225
|
+
export async function listApps(auth) {
|
|
226
|
+
const url = `${opelBase()}/apps`;
|
|
227
|
+
const response = await fetchWithAutoRefresh(auth, url, {
|
|
228
|
+
method: "GET",
|
|
229
|
+
headers: headers(auth),
|
|
230
|
+
});
|
|
231
|
+
if (!response.ok) {
|
|
232
|
+
const text = await response.text();
|
|
233
|
+
throw new Error(`Failed to load agents (${response.status}): ${text}`);
|
|
234
|
+
}
|
|
235
|
+
const payload = await parseJsonResponse(response);
|
|
236
|
+
return payload.apps ?? [DEFAULT_APP_NAME];
|
|
237
|
+
}
|
|
162
238
|
function normalizeWorkflowEvent(type, content, details) {
|
|
163
239
|
return {
|
|
164
240
|
id: `${type}-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
|
@@ -171,7 +247,7 @@ function normalizeWorkflowEvent(type, content, details) {
|
|
|
171
247
|
export async function listSessions(auth, limit = 20, offset = 0) {
|
|
172
248
|
const { userId } = ensureAuth(auth);
|
|
173
249
|
const url = `${opelBase()}/sessions/?user_id=${encodeURIComponent(userId)}&limit=${limit}&offset=${offset}`;
|
|
174
|
-
const response = await
|
|
250
|
+
const response = await fetchWithAutoRefresh(auth, url, {
|
|
175
251
|
method: "GET",
|
|
176
252
|
headers: headers(auth),
|
|
177
253
|
});
|
|
@@ -190,7 +266,7 @@ export async function listSessions(auth, limit = 20, offset = 0) {
|
|
|
190
266
|
}))
|
|
191
267
|
.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
|
|
192
268
|
}
|
|
193
|
-
export async function createSession(auth, appName = DEFAULT_APP_NAME, clusterId) {
|
|
269
|
+
export async function createSession(auth, appName = DEFAULT_APP_NAME, clusterId, modelId) {
|
|
194
270
|
const { userId, tenantId } = ensureAuth(auth);
|
|
195
271
|
if (!tenantId) {
|
|
196
272
|
throw new Error("Missing tenant ID. Run /login.");
|
|
@@ -208,11 +284,12 @@ export async function createSession(auth, appName = DEFAULT_APP_NAME, clusterId)
|
|
|
208
284
|
session_created_at: new Date().toISOString(),
|
|
209
285
|
client_type: "rubix-cli",
|
|
210
286
|
...(clusterId ? { cluster_id: clusterId, default_namespace: "default" } : {}),
|
|
287
|
+
...(modelId ? { model_id: modelId } : {}),
|
|
211
288
|
...(auth.userRole ? { user_role: auth.userRole } : {}),
|
|
212
289
|
...(auth.tenantPlan ? { tenant_plan: auth.tenantPlan } : {}),
|
|
213
290
|
},
|
|
214
291
|
};
|
|
215
|
-
const response = await
|
|
292
|
+
const response = await fetchWithAutoRefresh(auth, `${opelBase()}/sessions/`, {
|
|
216
293
|
method: "POST",
|
|
217
294
|
headers: headers(auth),
|
|
218
295
|
body: JSON.stringify(payload),
|
|
@@ -223,9 +300,9 @@ export async function createSession(auth, appName = DEFAULT_APP_NAME, clusterId)
|
|
|
223
300
|
}
|
|
224
301
|
return parsed.id;
|
|
225
302
|
}
|
|
226
|
-
export async function updateSessionState(auth, sessionId, state) {
|
|
227
|
-
const url = `${opelBase()}/sessions/${encodeURIComponent(sessionId)}?app_name=${encodeURIComponent(
|
|
228
|
-
const response = await
|
|
303
|
+
export async function updateSessionState(auth, sessionId, state, appName = DEFAULT_APP_NAME) {
|
|
304
|
+
const url = `${opelBase()}/sessions/${encodeURIComponent(sessionId)}?app_name=${encodeURIComponent(appName)}`;
|
|
305
|
+
const response = await fetchWithAutoRefresh(auth, url, {
|
|
229
306
|
method: "PUT",
|
|
230
307
|
headers: headers(auth),
|
|
231
308
|
body: JSON.stringify({ state }),
|
|
@@ -235,20 +312,46 @@ export async function updateSessionState(auth, sessionId, state) {
|
|
|
235
312
|
throw new Error(`Failed to update session (${response.status}): ${text}`);
|
|
236
313
|
}
|
|
237
314
|
}
|
|
238
|
-
export async function
|
|
315
|
+
export async function hasSessionMessages(auth, sessionId) {
|
|
316
|
+
try {
|
|
317
|
+
const { userId } = ensureAuth(auth);
|
|
318
|
+
// Fetch just 1 history event — cheapest possible check.
|
|
319
|
+
const url = `${opelBase()}/sessions/${encodeURIComponent(sessionId)}/chat-history?user_id=${encodeURIComponent(userId)}&limit=1&offset=0&format=detailed&order_desc=false`;
|
|
320
|
+
const response = await fetchWithAutoRefresh(auth, url, {
|
|
321
|
+
method: "GET",
|
|
322
|
+
headers: headers(auth),
|
|
323
|
+
});
|
|
324
|
+
if (!response.ok)
|
|
325
|
+
return false;
|
|
326
|
+
const payload = (await response.json());
|
|
327
|
+
return (payload.chat_history?.length ?? 0) > 0;
|
|
328
|
+
}
|
|
329
|
+
catch {
|
|
330
|
+
// If we can't determine, assume no messages so we reuse rather than spam new sessions.
|
|
331
|
+
return false;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
export async function getOrCreateSession(auth, preferredId, clusterId, appName = DEFAULT_APP_NAME) {
|
|
239
335
|
if (preferredId)
|
|
240
336
|
return preferredId;
|
|
241
337
|
const sessions = await listSessions(auth, 50, 0);
|
|
242
338
|
if (sessions.length > 0) {
|
|
243
|
-
|
|
339
|
+
// Attempt to find the most recent empty session for the same app
|
|
340
|
+
// Note: since list_sessions merges across apps now, we iterate
|
|
341
|
+
// until we find one that matches our appName.
|
|
342
|
+
const recent = sessions.find(s => s.appName === appName) ?? sessions[0];
|
|
244
343
|
// Reuse the session only if it has matching cluster context (or no cluster was requested)
|
|
245
|
-
|
|
246
|
-
|
|
344
|
+
// AND it has no messages — otherwise start fresh.
|
|
345
|
+
if (!clusterId || recent.clusterId === clusterId) {
|
|
346
|
+
const isEmpty = !(await hasSessionMessages(auth, recent.id));
|
|
347
|
+
if (isEmpty)
|
|
348
|
+
return recent.id;
|
|
349
|
+
}
|
|
247
350
|
}
|
|
248
|
-
return createSession(auth,
|
|
351
|
+
return createSession(auth, appName, clusterId);
|
|
249
352
|
}
|
|
250
353
|
const HEALTHY_STATUSES = new Set(["connected", "healthy", "active"]);
|
|
251
|
-
export async function
|
|
354
|
+
export async function listEnvironments(auth) {
|
|
252
355
|
const authBase = getConfig().authApiBase;
|
|
253
356
|
if (!authBase)
|
|
254
357
|
throw new Error("RUBIXKUBE_AUTH_API_BASE is not set.");
|
|
@@ -257,7 +360,7 @@ export async function listClusters(auth) {
|
|
|
257
360
|
throw new Error("Missing tenant ID. Run /login.");
|
|
258
361
|
}
|
|
259
362
|
const url = `${authBase.replace(/\/+$/, "")}/clusters/?page=1&page_size=50`;
|
|
260
|
-
const response = await
|
|
363
|
+
const response = await fetchWithAutoRefresh(auth, url, {
|
|
261
364
|
method: "GET",
|
|
262
365
|
headers: {
|
|
263
366
|
Authorization: `Bearer ${token}`,
|
|
@@ -267,22 +370,91 @@ export async function listClusters(auth) {
|
|
|
267
370
|
});
|
|
268
371
|
if (!response.ok) {
|
|
269
372
|
const text = await response.text();
|
|
270
|
-
throw new Error(`Failed to load
|
|
373
|
+
throw new Error(`Failed to load environments (${response.status}): ${text}`);
|
|
271
374
|
}
|
|
272
375
|
const payload = (await response.json());
|
|
273
376
|
return (payload.clusters ?? [])
|
|
274
377
|
.filter((c) => !!c?.cluster_id)
|
|
275
378
|
.map((c) => ({
|
|
276
379
|
id: c.id ?? c.cluster_id ?? "",
|
|
277
|
-
|
|
278
|
-
name: c.name ?? c.cluster_id ?? "Unnamed
|
|
380
|
+
environment_id: c.cluster_id ?? "",
|
|
381
|
+
name: c.name ?? c.cluster_id ?? "Unnamed environment",
|
|
279
382
|
status: (c.status ?? "unknown"),
|
|
280
383
|
region: c.region,
|
|
281
|
-
|
|
384
|
+
environment_type: c.cluster_type,
|
|
282
385
|
}));
|
|
283
386
|
}
|
|
284
|
-
export function
|
|
285
|
-
return
|
|
387
|
+
export function firstHealthyEnvironment(environments) {
|
|
388
|
+
return environments.find((c) => HEALTHY_STATUSES.has(c.status)) ?? environments[0] ?? null;
|
|
389
|
+
}
|
|
390
|
+
export async function listModels(auth) {
|
|
391
|
+
const url = `${opelBase()}/models`;
|
|
392
|
+
const response = await fetchWithAutoRefresh(auth, url, {
|
|
393
|
+
method: "GET",
|
|
394
|
+
headers: headers(auth, false),
|
|
395
|
+
});
|
|
396
|
+
if (!response.ok) {
|
|
397
|
+
const text = await response.text();
|
|
398
|
+
throw new StreamError(`Failed to load models (${response.status}): ${text}`, {
|
|
399
|
+
status: response.status,
|
|
400
|
+
reason: "http_error",
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
const payload = await parseJsonResponse(response);
|
|
404
|
+
return (payload.models ?? [])
|
|
405
|
+
.filter((m) => !!m?.id)
|
|
406
|
+
.map((m) => ({
|
|
407
|
+
id: m.id ?? "",
|
|
408
|
+
model: m.model ?? "",
|
|
409
|
+
display_name: m.display_name ?? "",
|
|
410
|
+
description: m.description,
|
|
411
|
+
thinking_supported: m.thinking_supported ?? false,
|
|
412
|
+
agent: m.agent ?? "sre_agent",
|
|
413
|
+
default: m.default ?? false,
|
|
414
|
+
experimental: m.experimental,
|
|
415
|
+
}));
|
|
416
|
+
}
|
|
417
|
+
export async function setSessionModel(auth, sessionId, modelId, appName = DEFAULT_APP_NAME) {
|
|
418
|
+
const { userId } = ensureAuth(auth);
|
|
419
|
+
const url = `${opelBase()}/sessions/${encodeURIComponent(sessionId)}/model`;
|
|
420
|
+
const response = await fetchWithAutoRefresh(auth, url, {
|
|
421
|
+
method: "POST",
|
|
422
|
+
headers: headers(auth, true),
|
|
423
|
+
body: JSON.stringify({
|
|
424
|
+
user_id: userId,
|
|
425
|
+
model_id: modelId,
|
|
426
|
+
app_name: appName,
|
|
427
|
+
}),
|
|
428
|
+
});
|
|
429
|
+
if (!response.ok) {
|
|
430
|
+
const text = await response.text();
|
|
431
|
+
let errorMsg = `HTTP ${response.status}: ${text || response.statusText}`;
|
|
432
|
+
try {
|
|
433
|
+
const errorPayload = JSON.parse(text);
|
|
434
|
+
const error = errorPayload.detail?.error ?? "Unknown error";
|
|
435
|
+
const available = errorPayload.detail?.available ?? [];
|
|
436
|
+
if (available.length > 0) {
|
|
437
|
+
errorMsg = `${error}. Available: ${available.join(", ")}`;
|
|
438
|
+
}
|
|
439
|
+
else {
|
|
440
|
+
errorMsg = error;
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
catch {
|
|
444
|
+
// Fall back to generic error message
|
|
445
|
+
}
|
|
446
|
+
throw new StreamError(errorMsg, {
|
|
447
|
+
status: response.status,
|
|
448
|
+
reason: "http_error",
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
const parsed = await parseJsonResponse(response);
|
|
452
|
+
return {
|
|
453
|
+
modelId: parsed.model_id ?? modelId,
|
|
454
|
+
model: parsed.model ?? "",
|
|
455
|
+
displayName: parsed.display_name ?? "",
|
|
456
|
+
thinkingSupported: parsed.thinking_supported ?? false,
|
|
457
|
+
};
|
|
286
458
|
}
|
|
287
459
|
function parseParts(content) {
|
|
288
460
|
if (!content)
|
|
@@ -301,7 +473,7 @@ function parseParts(content) {
|
|
|
301
473
|
export async function fetchChatHistory(auth, sessionId, limit = 50) {
|
|
302
474
|
const { userId } = ensureAuth(auth);
|
|
303
475
|
const url = `${opelBase()}/sessions/${encodeURIComponent(sessionId)}/chat-history?user_id=${encodeURIComponent(userId)}&limit=${limit}&offset=0&format=detailed&order_desc=false`;
|
|
304
|
-
const response = await
|
|
476
|
+
const response = await fetchWithAutoRefresh(auth, url, {
|
|
305
477
|
method: "GET",
|
|
306
478
|
headers: headers(auth),
|
|
307
479
|
});
|
|
@@ -318,15 +490,25 @@ export async function fetchChatHistory(auth, sessionId, limit = 50) {
|
|
|
318
490
|
let text = "";
|
|
319
491
|
const workflow = [];
|
|
320
492
|
for (const part of parts) {
|
|
321
|
-
if (part.thought === true)
|
|
493
|
+
if (part.thought === true) {
|
|
494
|
+
if (typeof part.text === "string" && part.text.trim()) {
|
|
495
|
+
workflow.push({
|
|
496
|
+
id: `hist-th-${idx}-${workflow.length}`,
|
|
497
|
+
type: "thought",
|
|
498
|
+
content: part.text.trim(),
|
|
499
|
+
ts,
|
|
500
|
+
});
|
|
501
|
+
}
|
|
322
502
|
continue;
|
|
503
|
+
}
|
|
323
504
|
const fc = part.functionCall ?? part.function_call;
|
|
324
505
|
if (fc) {
|
|
325
506
|
const name = typeof fc.name === "string" ? fc.name : "tool";
|
|
507
|
+
const argsStr = fc.args && Object.keys(fc.args).length > 0 ? JSON.stringify(fc.args) : "";
|
|
326
508
|
workflow.push({
|
|
327
509
|
id: `hist-fc-${idx}-${name}`,
|
|
328
510
|
type: "function_call",
|
|
329
|
-
content:
|
|
511
|
+
content: argsStr,
|
|
330
512
|
ts,
|
|
331
513
|
details: { name, id: fc.id },
|
|
332
514
|
});
|
|
@@ -338,7 +520,7 @@ export async function fetchChatHistory(auth, sessionId, limit = 50) {
|
|
|
338
520
|
workflow.push({
|
|
339
521
|
id: `hist-fr-${idx}-${name}`,
|
|
340
522
|
type: "function_response",
|
|
341
|
-
content: typeof fr.response === "string" ? fr.response : `[${name}]
|
|
523
|
+
content: typeof fr.response === "string" ? fr.response : (fr.response ? JSON.stringify(fr.response) : `[${name}]`),
|
|
342
524
|
ts,
|
|
343
525
|
details: { name, id: fr.id },
|
|
344
526
|
});
|
|
@@ -362,7 +544,7 @@ export async function fetchChatHistory(auth, sessionId, limit = 50) {
|
|
|
362
544
|
}
|
|
363
545
|
export async function streamChat(input, callbacks = {}) {
|
|
364
546
|
const { userId } = ensureAuth(input.auth);
|
|
365
|
-
const response = await
|
|
547
|
+
const response = await fetchWithAutoRefresh(input.auth, `${opelBase()}/chat/${encodeURIComponent(userId)}/session/${encodeURIComponent(input.sessionId)}`, {
|
|
366
548
|
method: "POST",
|
|
367
549
|
headers: headers(input.auth, true),
|
|
368
550
|
signal: input.signal,
|
|
@@ -376,6 +558,8 @@ export async function streamChat(input, callbacks = {}) {
|
|
|
376
558
|
streaming: true,
|
|
377
559
|
minify: true,
|
|
378
560
|
maxTextLen: -1,
|
|
561
|
+
...(input.stateDelta ? { stateDelta: input.stateDelta } : {}),
|
|
562
|
+
...(input.modelOverride ? { modelOverride: input.modelOverride } : {}),
|
|
379
563
|
}),
|
|
380
564
|
});
|
|
381
565
|
if (!response.ok) {
|
|
@@ -453,8 +637,8 @@ export async function streamChat(input, callbacks = {}) {
|
|
|
453
637
|
hasWorkflowEvents = true;
|
|
454
638
|
const name = asText(functionCall.name) || "tool";
|
|
455
639
|
const args = functionCall.args ?? {};
|
|
456
|
-
const
|
|
457
|
-
callbacks.onWorkflow?.(normalizeWorkflowEvent("function_call",
|
|
640
|
+
const argsStr = Object.keys(args).length > 0 ? JSON.stringify(args) : "";
|
|
641
|
+
callbacks.onWorkflow?.(normalizeWorkflowEvent("function_call", argsStr, {
|
|
458
642
|
name,
|
|
459
643
|
id: functionCall.id,
|
|
460
644
|
}));
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
const CONFIG_DIR = ".rubix";
|
|
5
|
+
const SESSION_FILE = "sessions.json";
|
|
6
|
+
export function getSessionPath() {
|
|
7
|
+
return path.join(os.homedir(), CONFIG_DIR, SESSION_FILE);
|
|
8
|
+
}
|
|
9
|
+
export async function loadLocalSessions() {
|
|
10
|
+
try {
|
|
11
|
+
const data = await fs.readFile(getSessionPath(), "utf8");
|
|
12
|
+
return JSON.parse(data);
|
|
13
|
+
}
|
|
14
|
+
catch (error) {
|
|
15
|
+
const asNodeError = error;
|
|
16
|
+
if (asNodeError.code === "ENOENT") {
|
|
17
|
+
return [];
|
|
18
|
+
}
|
|
19
|
+
throw error;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
export async function saveLocalSessions(sessions) {
|
|
23
|
+
await fs.mkdir(path.join(os.homedir(), CONFIG_DIR), { recursive: true, mode: 0o700 });
|
|
24
|
+
await fs.writeFile(getSessionPath(), JSON.stringify(sessions, null, 2), { mode: 0o600 });
|
|
25
|
+
}
|
|
26
|
+
export async function clearLocalSessions() {
|
|
27
|
+
try {
|
|
28
|
+
await fs.unlink(getSessionPath());
|
|
29
|
+
}
|
|
30
|
+
catch (error) {
|
|
31
|
+
const asNodeError = error;
|
|
32
|
+
if (asNodeError.code !== "ENOENT") {
|
|
33
|
+
throw error;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
const CONFIG_DIR = ".rubix";
|
|
5
|
+
const SETTINGS_FILE = "settings.json";
|
|
6
|
+
export function getSettingsPath() {
|
|
7
|
+
return path.join(os.homedir(), CONFIG_DIR, SETTINGS_FILE);
|
|
8
|
+
}
|
|
9
|
+
export async function loadSettings() {
|
|
10
|
+
try {
|
|
11
|
+
const data = await fs.readFile(getSettingsPath(), "utf8");
|
|
12
|
+
const raw = JSON.parse(data);
|
|
13
|
+
if (raw.clusterId && !raw.environmentId) {
|
|
14
|
+
raw.environmentId = raw.clusterId;
|
|
15
|
+
delete raw.clusterId;
|
|
16
|
+
}
|
|
17
|
+
return raw;
|
|
18
|
+
}
|
|
19
|
+
catch (error) {
|
|
20
|
+
const asNodeError = error;
|
|
21
|
+
if (asNodeError.code === "ENOENT") {
|
|
22
|
+
return {};
|
|
23
|
+
}
|
|
24
|
+
throw error;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
export async function saveSettings(settings) {
|
|
28
|
+
await fs.mkdir(path.join(os.homedir(), CONFIG_DIR), { recursive: true, mode: 0o700 });
|
|
29
|
+
await fs.writeFile(getSettingsPath(), JSON.stringify(settings, null, 2), { mode: 0o600 });
|
|
30
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Simple semver-ish comparison. Returns true if latest is strictly greater than current.
|
|
3
|
+
* Handles major.minor.patch format.
|
|
4
|
+
*/
|
|
5
|
+
function isNewer(latest, current) {
|
|
6
|
+
const l = latest.split(".").map(Number);
|
|
7
|
+
const c = current.split(".").map(Number);
|
|
8
|
+
for (let i = 0; i < 3; i++) {
|
|
9
|
+
const lv = l[i] ?? 0;
|
|
10
|
+
const cv = c[i] ?? 0;
|
|
11
|
+
if (lv > cv)
|
|
12
|
+
return true;
|
|
13
|
+
if (lv < cv)
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Check if a new version of the CLI is available on npm.
|
|
20
|
+
*
|
|
21
|
+
* To maintain performance, this should only be called once every 24 hours.
|
|
22
|
+
* Returns the latest version string if a new version is available, otherwise null.
|
|
23
|
+
*/
|
|
24
|
+
export async function checkForUpdate(currentVersion, lastCheckTime) {
|
|
25
|
+
const ONE_DAY_MS = 24 * 60 * 60 * 1000;
|
|
26
|
+
const now = Date.now();
|
|
27
|
+
// Only check once every 24 hours
|
|
28
|
+
if (lastCheckTime && now - lastCheckTime < ONE_DAY_MS) {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
try {
|
|
32
|
+
const controller = new AbortController();
|
|
33
|
+
const timeoutId = setTimeout(() => controller.abort(), 3000); // 3s timeout
|
|
34
|
+
const response = await fetch("https://registry.npmjs.org/@rubixkube/rubix/latest", {
|
|
35
|
+
signal: controller.signal,
|
|
36
|
+
});
|
|
37
|
+
clearTimeout(timeoutId);
|
|
38
|
+
if (!response.ok)
|
|
39
|
+
return null;
|
|
40
|
+
const data = (await response.json());
|
|
41
|
+
const latestVersion = data.version;
|
|
42
|
+
if (latestVersion && isNewer(latestVersion, currentVersion)) {
|
|
43
|
+
return latestVersion;
|
|
44
|
+
}
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
// Fail silently — never block startup or show errors for update checks.
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
5
|
+
/**
|
|
6
|
+
* Default items shown when CHANGELOG.md cannot be read or parsed.
|
|
7
|
+
*/
|
|
8
|
+
const FALLBACK = [];
|
|
9
|
+
/**
|
|
10
|
+
* Parse the `## [<version>]` section from CHANGELOG.md and return its bullet items.
|
|
11
|
+
*
|
|
12
|
+
* Looks for lines matching `- <text>` under the first matching version heading.
|
|
13
|
+
* Stops when it hits the next version heading (`## [`) or end-of-file.
|
|
14
|
+
*
|
|
15
|
+
* Returns an empty array on any error so the splash screen always renders.
|
|
16
|
+
*/
|
|
17
|
+
export function loadWhatsNew(version, maxItems = 4) {
|
|
18
|
+
try {
|
|
19
|
+
const changelogPath = join(__dirname, "..", "..", "CHANGELOG.md");
|
|
20
|
+
const raw = readFileSync(changelogPath, "utf-8");
|
|
21
|
+
return parseWhatsNew(raw, version).slice(0, maxItems);
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return FALLBACK;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Pure parser extracted for testability.
|
|
29
|
+
*/
|
|
30
|
+
export function parseWhatsNew(markdown, version) {
|
|
31
|
+
const lines = markdown.split("\n");
|
|
32
|
+
// Find the heading for the requested version: ## [0.0.3] or ## [0.0.3] - 2026-03-01
|
|
33
|
+
const versionHeading = `## [${version}]`;
|
|
34
|
+
let startIndex = -1;
|
|
35
|
+
for (let i = 0; i < lines.length; i++) {
|
|
36
|
+
if (lines[i].startsWith(versionHeading)) {
|
|
37
|
+
startIndex = i + 1;
|
|
38
|
+
break;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
if (startIndex === -1)
|
|
42
|
+
return FALLBACK;
|
|
43
|
+
const items = [];
|
|
44
|
+
for (let i = startIndex; i < lines.length; i++) {
|
|
45
|
+
const line = lines[i];
|
|
46
|
+
// Stop at the next version heading
|
|
47
|
+
if (line.startsWith("## ["))
|
|
48
|
+
break;
|
|
49
|
+
// Collect bullet items (- text)
|
|
50
|
+
const match = line.match(/^[-*]\s+(.+)/);
|
|
51
|
+
if (match) {
|
|
52
|
+
items.push(match[1].trim());
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return items;
|
|
56
|
+
}
|