routstrd 0.3.9 → 0.3.11
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 +4 -0
- package/bun.lock +5 -2
- package/dist/daemon/index.js +7054 -74
- package/dist/index.js +7197 -62
- package/package.json +3 -2
- package/src/cli.ts +48 -25
- package/src/integrations/hermes.ts +72 -29
- package/src/tui/usage/app.ts +41 -2
- package/src/tui/usage/render.ts +10 -3
- package/src/tui/usage/types.ts +6 -0
- package/src/utils/update-checker.ts +114 -0
- package/tests/integrations/hermes.test.ts +97 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "routstrd",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.11",
|
|
4
4
|
"module": "src/index.ts",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"private": false,
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
},
|
|
25
25
|
"dependencies": {
|
|
26
26
|
"@cashu/cashu-ts": "^4.3.0",
|
|
27
|
-
"@routstr/sdk": "^0.3.
|
|
27
|
+
"@routstr/sdk": "^0.3.18",
|
|
28
28
|
"applesauce-core": "^5.1.0",
|
|
29
29
|
"applesauce-relay": "^5.1.0",
|
|
30
30
|
"applesauce-wallet-connect": "^6.0.0",
|
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
"nostr-tools": "^2.12.0",
|
|
33
33
|
"qrcode": "^1.5.4",
|
|
34
34
|
"rxjs": "^7.8.1",
|
|
35
|
+
"yaml": "^2.9.0",
|
|
35
36
|
"zustand": "^5.0.5"
|
|
36
37
|
}
|
|
37
38
|
}
|
package/src/cli.ts
CHANGED
|
@@ -36,6 +36,11 @@ import {
|
|
|
36
36
|
resolveCocodExecutable,
|
|
37
37
|
} from "./daemon/wallet/cocod-client";
|
|
38
38
|
import packageJson from "../package.json" with { type: "json" };
|
|
39
|
+
import {
|
|
40
|
+
compareVersions,
|
|
41
|
+
getGlobalPackageVersion,
|
|
42
|
+
getLatestNpmVersion,
|
|
43
|
+
} from "./utils/update-checker.ts";
|
|
39
44
|
|
|
40
45
|
type RoutstrModel = {
|
|
41
46
|
id: string;
|
|
@@ -363,34 +368,52 @@ program
|
|
|
363
368
|
.command("update")
|
|
364
369
|
.description("Update routstrd and cocod to the latest versions")
|
|
365
370
|
.action(async () => {
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
});
|
|
371
|
-
const routstrdCode = await routstrdProc.exited;
|
|
372
|
-
if (routstrdCode !== 0) {
|
|
373
|
-
console.error("Failed to update routstrd.");
|
|
374
|
-
process.exit(1);
|
|
375
|
-
}
|
|
376
|
-
console.log("routstrd updated successfully.\n");
|
|
371
|
+
const packages = [
|
|
372
|
+
{ name: "routstrd", label: "routstrd" },
|
|
373
|
+
{ name: "@routstr/cocod", label: "cocod" },
|
|
374
|
+
];
|
|
377
375
|
|
|
378
|
-
|
|
379
|
-
const cocodProc = Bun.spawn(["bun", "install", "-g", "@routstr/cocod"], {
|
|
380
|
-
stdout: "inherit",
|
|
381
|
-
stderr: "inherit",
|
|
382
|
-
});
|
|
383
|
-
const cocodCode = await cocodProc.exited;
|
|
384
|
-
if (cocodCode !== 0) {
|
|
385
|
-
console.error("Failed to update cocod.");
|
|
386
|
-
process.exit(1);
|
|
387
|
-
}
|
|
388
|
-
console.log("cocod updated successfully.\n");
|
|
376
|
+
let updatedAny = false;
|
|
389
377
|
|
|
390
|
-
|
|
378
|
+
for (const { name, label } of packages) {
|
|
379
|
+
const installed = await getGlobalPackageVersion(name);
|
|
380
|
+
const latest = await getLatestNpmVersion(name);
|
|
391
381
|
|
|
392
|
-
|
|
393
|
-
|
|
382
|
+
// Only skip when we're confident the installed version is current.
|
|
383
|
+
// If we can't determine either version we fall through to installing.
|
|
384
|
+
if (
|
|
385
|
+
installed &&
|
|
386
|
+
latest &&
|
|
387
|
+
(compareVersions(installed, latest) ?? -1) >= 0
|
|
388
|
+
) {
|
|
389
|
+
console.log(`${label} is already up to date (v${installed}).`);
|
|
390
|
+
continue;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const fromPart = installed ? ` from v${installed}` : "";
|
|
394
|
+
const toPart = latest ? ` to v${latest}` : "";
|
|
395
|
+
console.log(`Updating ${label}${fromPart}${toPart}...`);
|
|
396
|
+
|
|
397
|
+
const proc = Bun.spawn(["bun", "install", "-g", name], {
|
|
398
|
+
stdout: "inherit",
|
|
399
|
+
stderr: "inherit",
|
|
400
|
+
});
|
|
401
|
+
const code = await proc.exited;
|
|
402
|
+
if (code !== 0) {
|
|
403
|
+
console.error(`Failed to update ${label}.`);
|
|
404
|
+
process.exit(1);
|
|
405
|
+
}
|
|
406
|
+
console.log(`${label} updated successfully.\n`);
|
|
407
|
+
updatedAny = true;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
if (updatedAny) {
|
|
411
|
+
console.log("All requested updates have been applied!");
|
|
412
|
+
// Restart daemons so the new binaries take effect immediately.
|
|
413
|
+
await restartDaemonsAfterUpdate();
|
|
414
|
+
} else {
|
|
415
|
+
console.log("\nAll packages are already up to date — nothing to do.");
|
|
416
|
+
}
|
|
394
417
|
});
|
|
395
418
|
|
|
396
419
|
program
|
|
@@ -1,10 +1,65 @@
|
|
|
1
1
|
import { existsSync, mkdirSync } from "fs";
|
|
2
2
|
import { readFile, writeFile } from "fs/promises";
|
|
3
3
|
import { dirname } from "path";
|
|
4
|
+
import { parseDocument } from "yaml";
|
|
4
5
|
import type { RoutstrdConfig } from "../utils/config";
|
|
5
6
|
import type { IntegrationConfig, RoutstrModel } from "./registry";
|
|
6
7
|
import { callDaemon, getDaemonBaseUrl } from "../utils/daemon-client";
|
|
7
8
|
|
|
9
|
+
interface HermesRoutstrConfig {
|
|
10
|
+
baseUrl: string;
|
|
11
|
+
apiKey: string;
|
|
12
|
+
defaultModel: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface HermesCustomProvider {
|
|
16
|
+
name?: string;
|
|
17
|
+
[key: string]: unknown;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function mergeHermesConfig(
|
|
21
|
+
content: string,
|
|
22
|
+
routstr: HermesRoutstrConfig,
|
|
23
|
+
): string {
|
|
24
|
+
const document = parseDocument(content || "{}", { prettyErrors: true });
|
|
25
|
+
if (document.errors.length > 0) {
|
|
26
|
+
throw document.errors[0];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const urlDisplay = routstr.baseUrl
|
|
30
|
+
.replace(/\/v1$/, "")
|
|
31
|
+
.replace(/^https?:\/\//, "");
|
|
32
|
+
const provider = {
|
|
33
|
+
name: `Routstr (${urlDisplay})`,
|
|
34
|
+
base_url: routstr.baseUrl,
|
|
35
|
+
api_key: routstr.apiKey,
|
|
36
|
+
model: routstr.defaultModel,
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const isNewConfig = content.trim() === "";
|
|
40
|
+
if (isNewConfig) {
|
|
41
|
+
document.set("model", {
|
|
42
|
+
default: routstr.defaultModel,
|
|
43
|
+
provider: "custom",
|
|
44
|
+
base_url: routstr.baseUrl,
|
|
45
|
+
api_key: routstr.apiKey,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const existingConfig = document.toJS() as {
|
|
50
|
+
custom_providers?: HermesCustomProvider[];
|
|
51
|
+
};
|
|
52
|
+
const existingProviders = existingConfig.custom_providers;
|
|
53
|
+
const providers = Array.isArray(existingProviders) ? existingProviders : [];
|
|
54
|
+
if (providers.some((item) => item.name?.startsWith("Routstr ("))) {
|
|
55
|
+
return content;
|
|
56
|
+
}
|
|
57
|
+
providers.push(provider);
|
|
58
|
+
document.set("custom_providers", providers);
|
|
59
|
+
|
|
60
|
+
return document.toString();
|
|
61
|
+
}
|
|
62
|
+
|
|
8
63
|
export async function installHermesIntegration(
|
|
9
64
|
config: RoutstrdConfig,
|
|
10
65
|
apiKey: string,
|
|
@@ -26,10 +81,10 @@ export async function installHermesIntegration(
|
|
|
26
81
|
|
|
27
82
|
if (models.length >= 3) {
|
|
28
83
|
defaultModel = models[2]!.id;
|
|
29
|
-
console.log(`
|
|
84
|
+
console.log(`Using 3rd available model for new Hermes configurations: ${defaultModel}`);
|
|
30
85
|
} else if (models.length > 0) {
|
|
31
86
|
defaultModel = models[0]!.id;
|
|
32
|
-
console.log(`Only ${models.length} models available, using ${defaultModel}
|
|
87
|
+
console.log(`Only ${models.length} models available, using ${defaultModel} for new Hermes configurations.`);
|
|
33
88
|
} else {
|
|
34
89
|
console.log("No models available from routstr daemon, using fallback default.");
|
|
35
90
|
}
|
|
@@ -47,36 +102,24 @@ export async function installHermesIntegration(
|
|
|
47
102
|
console.error(`Error reading ${configPath}, creating new one.`);
|
|
48
103
|
}
|
|
49
104
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
default: ${defaultModel}
|
|
61
|
-
provider: custom
|
|
62
|
-
base_url: ${baseUrlV1}
|
|
63
|
-
api_key: ${apiKey}`;
|
|
64
|
-
|
|
65
|
-
const providerBlock = `custom_providers:
|
|
66
|
-
- name: Routstr (${urlDisplay})
|
|
67
|
-
base_url: ${baseUrlV1}
|
|
68
|
-
api_key: ${apiKey}
|
|
69
|
-
model: ${defaultModel}`;
|
|
70
|
-
|
|
71
|
-
const parts: string[] = [modelBlock];
|
|
72
|
-
if (content) {
|
|
73
|
-
parts.push(content);
|
|
105
|
+
let newContent: string;
|
|
106
|
+
try {
|
|
107
|
+
newContent = mergeHermesConfig(content, {
|
|
108
|
+
baseUrl: baseUrlV1,
|
|
109
|
+
apiKey,
|
|
110
|
+
defaultModel,
|
|
111
|
+
});
|
|
112
|
+
} catch (error) {
|
|
113
|
+
console.error(`Failed to parse ${configPath} as YAML; leaving it unchanged:`, error);
|
|
114
|
+
return;
|
|
74
115
|
}
|
|
75
|
-
parts.push(providerBlock);
|
|
76
|
-
|
|
77
|
-
const newContent = parts.join("\n\n") + "\n";
|
|
78
116
|
|
|
79
117
|
try {
|
|
118
|
+
if (newContent === content) {
|
|
119
|
+
console.log(`${configPath} already contains current routstr settings.`);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
|
|
80
123
|
mkdirSync(dirname(configPath), { recursive: true });
|
|
81
124
|
await writeFile(configPath, newContent);
|
|
82
125
|
console.log(`Successfully updated ${configPath} with routstr settings.`);
|
package/src/tui/usage/app.ts
CHANGED
|
@@ -28,7 +28,8 @@ import {
|
|
|
28
28
|
} from "./terminal.ts";
|
|
29
29
|
import { COLORS } from "./constants.ts";
|
|
30
30
|
import { renderHeader, renderSearchBar, renderSeparator, renderTabContent, renderTabs } from "./render.ts";
|
|
31
|
-
import type { TabId, UsageStats } from "./types.ts";
|
|
31
|
+
import type { TabId, UpdateInfo, UsageStats } from "./types.ts";
|
|
32
|
+
import { checkForUpdates } from "../../utils/update-checker.ts";
|
|
32
33
|
|
|
33
34
|
export async function runUsageTui(): Promise<void> {
|
|
34
35
|
const running = await isDaemonRunning();
|
|
@@ -53,6 +54,39 @@ export async function runUsageTui(): Promise<void> {
|
|
|
53
54
|
let cleanedUp = false;
|
|
54
55
|
let fetching = false;
|
|
55
56
|
|
|
57
|
+
// Update-check state — re-checked at most every 210 minutes to avoid
|
|
58
|
+
// spamming the npm registry. The first check happens shortly after
|
|
59
|
+
// startup so the TUI doesn't block on a network call.
|
|
60
|
+
const UPDATE_CHECK_INTERVAL_MS = 210 * 60 * 1000;
|
|
61
|
+
const UPDATE_CHECK_DELAY_MS = 3000;
|
|
62
|
+
let updateInfo: UpdateInfo | null = null;
|
|
63
|
+
let lastUpdateCheck = 0;
|
|
64
|
+
let updateCheckInProgress = false;
|
|
65
|
+
|
|
66
|
+
async function maybeCheckForUpdates(): Promise<void> {
|
|
67
|
+
if (updateCheckInProgress) return;
|
|
68
|
+
const now = Date.now();
|
|
69
|
+
if (updateInfo && now - lastUpdateCheck < UPDATE_CHECK_INTERVAL_MS) return;
|
|
70
|
+
|
|
71
|
+
updateCheckInProgress = true;
|
|
72
|
+
lastUpdateCheck = now;
|
|
73
|
+
try {
|
|
74
|
+
const result = await checkForUpdates();
|
|
75
|
+
const outdated = result.packages.filter((p) => p.hasUpdate);
|
|
76
|
+
updateInfo = {
|
|
77
|
+
hasUpdate: result.hasUpdate,
|
|
78
|
+
text: outdated.length > 0
|
|
79
|
+
? `Update available: ${outdated.map((p) => p.label).join(", ")} ${outdated.map((p) => p.latest).join(", ")}`
|
|
80
|
+
: "",
|
|
81
|
+
};
|
|
82
|
+
render();
|
|
83
|
+
} catch {
|
|
84
|
+
// Silently ignore — update checks are best-effort
|
|
85
|
+
} finally {
|
|
86
|
+
updateCheckInProgress = false;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
56
90
|
if (isInteractive) {
|
|
57
91
|
stdout.write(enterAlternateScreen() + hideCursor());
|
|
58
92
|
stdin.setRawMode?.(true);
|
|
@@ -138,7 +172,7 @@ export async function runUsageTui(): Promise<void> {
|
|
|
138
172
|
|
|
139
173
|
const content = renderTabContent(currentTab, stats, balance, status, width, clients);
|
|
140
174
|
const footer = `${COLORS.dim}Press [Q] to quit, [R] to refresh, [A] to toggle auto-refresh${autoRefresh ? " (on)" : " (off)"} scroll:${vimState.scrollPos}${COLORS.reset}${vimState.mode === "normal" ? ` ${COLORS.yellow}vim: hjkl/arrows, / search, g top, gg bottom${COLORS.reset}` : ""}`;
|
|
141
|
-
const chrome = renderHeader(currentTab, width, visibleTabs) + renderTabs(currentTab, visibleTabs) + renderSeparator(width) + renderSearchBar();
|
|
175
|
+
const chrome = renderHeader(currentTab, width, visibleTabs, updateInfo ?? undefined) + renderTabs(currentTab, visibleTabs) + renderSeparator(width) + renderSearchBar();
|
|
142
176
|
const chromeLines = chrome.split("\n").length - 1;
|
|
143
177
|
const footerSeparator = renderSeparator(width);
|
|
144
178
|
const footerLines = footerSeparator.split("\n").length - 1;
|
|
@@ -265,9 +299,14 @@ export async function runUsageTui(): Promise<void> {
|
|
|
265
299
|
|
|
266
300
|
await fetchData();
|
|
267
301
|
|
|
302
|
+
// Kick off the first update check after a short delay so the initial
|
|
303
|
+
// data fetch isn't blocked by a network round-trip to npm.
|
|
304
|
+
setTimeout(() => void maybeCheckForUpdates(), UPDATE_CHECK_DELAY_MS);
|
|
305
|
+
|
|
268
306
|
refreshInterval = setInterval(() => {
|
|
269
307
|
if (autoRefresh) {
|
|
270
308
|
void fetchData();
|
|
271
309
|
}
|
|
310
|
+
void maybeCheckForUpdates();
|
|
272
311
|
}, 2000);
|
|
273
312
|
}
|
package/src/tui/usage/render.ts
CHANGED
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
import { vimState } from "./state.ts";
|
|
9
9
|
import { stripAnsi } from "./terminal.ts";
|
|
10
10
|
import type { BalanceInfo, StatusInfo } from "./data.ts";
|
|
11
|
-
import type { TabId, UsageStats } from "./types.ts";
|
|
11
|
+
import type { TabId, UpdateInfo, UsageStats } from "./types.ts";
|
|
12
12
|
|
|
13
13
|
/** Format a cost value: 0.12, 1.23, 12.34, 123.45, 1.23k, 1.23m */
|
|
14
14
|
function formatCost(value: number): string {
|
|
@@ -24,13 +24,20 @@ function formatReqs(value: number): string {
|
|
|
24
24
|
return value.toString();
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
export function renderHeader(activeTab: TabId, width: number, visibleTabs: Tab[]): string {
|
|
27
|
+
export function renderHeader(activeTab: TabId, width: number, visibleTabs: Tab[], updateInfo?: UpdateInfo): string {
|
|
28
28
|
const title = `${COLORS.bold}${COLORS.cyan}ROUTSTRD USAGE MONITOR${COLORS.reset}`;
|
|
29
29
|
const vimIndicator = `${COLORS.yellow}[vim]${COLORS.reset}`;
|
|
30
30
|
const maxKey = visibleTabs.length;
|
|
31
31
|
const help = `${COLORS.dim}[Q] Quit [↑↓] Scroll [←→] Tabs [1-${maxKey}] Tabs [R] Refresh${COLORS.reset}`;
|
|
32
32
|
const fill = width - title.length - help.length - vimIndicator.length - 6;
|
|
33
|
-
|
|
33
|
+
const headerLine = `${title}${vimIndicator}${" ".repeat(Math.max(1, fill))}${help}`;
|
|
34
|
+
|
|
35
|
+
if (updateInfo?.hasUpdate) {
|
|
36
|
+
const banner = `${COLORS.bold}${COLORS.yellow} ↻ UPDATE AVAILABLE — run ${COLORS.green}routstrd update${COLORS.yellow} to update${COLORS.reset}`;
|
|
37
|
+
return `${headerLine}\n${banner}\n`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return `${headerLine}\n`;
|
|
34
41
|
}
|
|
35
42
|
|
|
36
43
|
export function renderSearchBar(): string {
|
package/src/tui/usage/types.ts
CHANGED
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
import type { UsageTrackingEntry } from "../../daemon/types.ts";
|
|
2
2
|
import type { UsageSummary } from "../../daemon/http/usage-summary.ts";
|
|
3
|
+
import type { UpdateCheckResult } from "../../utils/update-checker.ts";
|
|
3
4
|
|
|
4
5
|
export type { UsageSummary };
|
|
5
6
|
|
|
7
|
+
export interface UpdateInfo {
|
|
8
|
+
hasUpdate: boolean;
|
|
9
|
+
text: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
6
12
|
export interface UsageStats {
|
|
7
13
|
entries: UsageTrackingEntry[];
|
|
8
14
|
totalEntries: number;
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
const NPM_REGISTRY = "https://registry.npmjs.org";
|
|
2
|
+
|
|
3
|
+
/** Packages that `routstrd update` manages. */
|
|
4
|
+
export const UPDATE_PACKAGES = [
|
|
5
|
+
{ name: "routstrd", label: "routstrd" },
|
|
6
|
+
{ name: "@routstr/cocod", label: "cocod" },
|
|
7
|
+
] as const;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Fetch the latest published version of a package from the npm registry.
|
|
11
|
+
* Returns null if the version cannot be determined (e.g. offline, not found).
|
|
12
|
+
*/
|
|
13
|
+
export async function getLatestNpmVersion(
|
|
14
|
+
packageName: string,
|
|
15
|
+
): Promise<string | null> {
|
|
16
|
+
try {
|
|
17
|
+
const response = await fetch(
|
|
18
|
+
`${NPM_REGISTRY}/${encodeURIComponent(packageName)}/latest`,
|
|
19
|
+
);
|
|
20
|
+
if (!response.ok) return null;
|
|
21
|
+
const data = (await response.json()) as { version?: string };
|
|
22
|
+
return data.version ?? null;
|
|
23
|
+
} catch {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Get the version of a globally-installed bun package.
|
|
30
|
+
* Returns null when the package is not installed globally or the version
|
|
31
|
+
* cannot be parsed as semver (e.g. installed from a git URL).
|
|
32
|
+
*/
|
|
33
|
+
export async function getGlobalPackageVersion(
|
|
34
|
+
packageName: string,
|
|
35
|
+
): Promise<string | null> {
|
|
36
|
+
try {
|
|
37
|
+
const proc = Bun.spawn(["bun", "pm", "ls", "-g"], {
|
|
38
|
+
stdout: "pipe",
|
|
39
|
+
stderr: "ignore",
|
|
40
|
+
});
|
|
41
|
+
const output = await new Response(proc.stdout).text();
|
|
42
|
+
await proc.exited;
|
|
43
|
+
// Lines look like: ├── routstrd@0.3.10 or └── @routstr/cocod@0.0.24
|
|
44
|
+
const escaped = packageName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
45
|
+
const match = output.match(new RegExp(`${escaped}@([^\\s]+)`));
|
|
46
|
+
if (!match) return null;
|
|
47
|
+
const version = match[1];
|
|
48
|
+
// Reject non-semver versions (e.g. github:routstr/cocod#3f6ac14)
|
|
49
|
+
if (!version || !/^\d+\.\d+\.\d+/.test(version)) return null;
|
|
50
|
+
return version;
|
|
51
|
+
} catch {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Compare two semver version strings.
|
|
58
|
+
* Returns a positive number if `a` is newer, negative if `b` is newer,
|
|
59
|
+
* 0 if equal, or null if either value is not parseable semver.
|
|
60
|
+
*/
|
|
61
|
+
export function compareVersions(a: string, b: string): number | null {
|
|
62
|
+
const parse = (v: string): [number, number, number] | null => {
|
|
63
|
+
const match = v.replace(/^v/, "").match(/^(\d+)\.(\d+)\.(\d+)/);
|
|
64
|
+
if (!match?.[1] || !match[2] || !match[3]) return null;
|
|
65
|
+
return [
|
|
66
|
+
parseInt(match[1], 10),
|
|
67
|
+
parseInt(match[2], 10),
|
|
68
|
+
parseInt(match[3], 10),
|
|
69
|
+
];
|
|
70
|
+
};
|
|
71
|
+
const va = parse(a);
|
|
72
|
+
const vb = parse(b);
|
|
73
|
+
if (!va || !vb) return null;
|
|
74
|
+
const [a1, a2, a3] = va;
|
|
75
|
+
const [b1, b2, b3] = vb;
|
|
76
|
+
if (a1 !== b1) return a1 - b1;
|
|
77
|
+
if (a2 !== b2) return a2 - b2;
|
|
78
|
+
if (a3 !== b3) return a3 - b3;
|
|
79
|
+
return 0;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface PackageUpdate {
|
|
83
|
+
name: string;
|
|
84
|
+
label: string;
|
|
85
|
+
current: string | null;
|
|
86
|
+
latest: string | null;
|
|
87
|
+
hasUpdate: boolean;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface UpdateCheckResult {
|
|
91
|
+
hasUpdate: boolean;
|
|
92
|
+
packages: PackageUpdate[];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Check both routstrd and cocod for available updates.
|
|
97
|
+
* Returns a result with per-package details and an overall `hasUpdate` flag.
|
|
98
|
+
*/
|
|
99
|
+
export async function checkForUpdates(): Promise<UpdateCheckResult> {
|
|
100
|
+
const packages = await Promise.all(
|
|
101
|
+
UPDATE_PACKAGES.map(async ({ name, label }) => {
|
|
102
|
+
const [current, latest] = await Promise.all([
|
|
103
|
+
getGlobalPackageVersion(name),
|
|
104
|
+
getLatestNpmVersion(name),
|
|
105
|
+
]);
|
|
106
|
+
const hasUpdate = !!(current && latest && (compareVersions(current, latest) ?? -1) < 0);
|
|
107
|
+
return { name, label, current, latest, hasUpdate } satisfies PackageUpdate;
|
|
108
|
+
}),
|
|
109
|
+
);
|
|
110
|
+
return {
|
|
111
|
+
hasUpdate: packages.some((p) => p.hasUpdate),
|
|
112
|
+
packages,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { parse } from "yaml";
|
|
3
|
+
import { mergeHermesConfig } from "../../src/integrations/hermes";
|
|
4
|
+
|
|
5
|
+
const ROUTSTR = {
|
|
6
|
+
baseUrl: "http://localhost:8008/v1",
|
|
7
|
+
apiKey: "routstr-key",
|
|
8
|
+
defaultModel: "deepseek-v4-flash",
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
describe("mergeHermesConfig", () => {
|
|
12
|
+
it("preserves the user's model and unrelated custom providers", () => {
|
|
13
|
+
const existing = `model:
|
|
14
|
+
default: user-model
|
|
15
|
+
provider: custom
|
|
16
|
+
base_url: https://user.example/v1
|
|
17
|
+
api_key: user-key
|
|
18
|
+
|
|
19
|
+
custom_providers:
|
|
20
|
+
- name: User Provider
|
|
21
|
+
base_url: https://user.example/v1
|
|
22
|
+
api_key: user-key
|
|
23
|
+
model: user-model
|
|
24
|
+
|
|
25
|
+
other_setting: true
|
|
26
|
+
`;
|
|
27
|
+
|
|
28
|
+
const merged = parse(mergeHermesConfig(existing, ROUTSTR));
|
|
29
|
+
|
|
30
|
+
expect(merged.model).toEqual({
|
|
31
|
+
default: "user-model",
|
|
32
|
+
provider: "custom",
|
|
33
|
+
base_url: "https://user.example/v1",
|
|
34
|
+
api_key: "user-key",
|
|
35
|
+
});
|
|
36
|
+
expect(merged.other_setting).toBe(true);
|
|
37
|
+
expect(merged.custom_providers).toContainEqual({
|
|
38
|
+
name: "User Provider",
|
|
39
|
+
base_url: "https://user.example/v1",
|
|
40
|
+
api_key: "user-key",
|
|
41
|
+
model: "user-model",
|
|
42
|
+
});
|
|
43
|
+
expect(merged.custom_providers).toContainEqual({
|
|
44
|
+
name: "Routstr (localhost:8008)",
|
|
45
|
+
base_url: ROUTSTR.baseUrl,
|
|
46
|
+
api_key: ROUTSTR.apiKey,
|
|
47
|
+
model: ROUTSTR.defaultModel,
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("does not add a model configuration to an existing Hermes file", () => {
|
|
52
|
+
const merged = parse(mergeHermesConfig("other_setting: true\n", ROUTSTR));
|
|
53
|
+
|
|
54
|
+
expect(merged.model).toBeUndefined();
|
|
55
|
+
expect(merged.other_setting).toBe(true);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("creates a Routstr default when creating a new Hermes file", () => {
|
|
59
|
+
const merged = parse(mergeHermesConfig("", ROUTSTR));
|
|
60
|
+
|
|
61
|
+
expect(merged.model).toEqual({
|
|
62
|
+
default: ROUTSTR.defaultModel,
|
|
63
|
+
provider: "custom",
|
|
64
|
+
base_url: ROUTSTR.baseUrl,
|
|
65
|
+
api_key: ROUTSTR.apiKey,
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("keeps an existing Routstr provider unchanged without duplicating it", () => {
|
|
70
|
+
const existing = `custom_providers:
|
|
71
|
+
- name: Routstr (old-host:8008)
|
|
72
|
+
base_url: http://old-host:8008/v1
|
|
73
|
+
api_key: old-key
|
|
74
|
+
model: old-model
|
|
75
|
+
`;
|
|
76
|
+
|
|
77
|
+
const once = mergeHermesConfig(existing, ROUTSTR);
|
|
78
|
+
const twice = mergeHermesConfig(once, ROUTSTR);
|
|
79
|
+
const merged = parse(twice);
|
|
80
|
+
const routstrProviders = merged.custom_providers.filter(
|
|
81
|
+
(provider: { name?: string }) => provider.name?.startsWith("Routstr ("),
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
expect(routstrProviders).toHaveLength(1);
|
|
85
|
+
expect(routstrProviders[0]).toEqual({
|
|
86
|
+
name: "Routstr (old-host:8008)",
|
|
87
|
+
base_url: "http://old-host:8008/v1",
|
|
88
|
+
api_key: "old-key",
|
|
89
|
+
model: "old-model",
|
|
90
|
+
});
|
|
91
|
+
expect(twice).toBe(once);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("rejects malformed YAML instead of replacing it", () => {
|
|
95
|
+
expect(() => mergeHermesConfig("model: [unterminated", ROUTSTR)).toThrow();
|
|
96
|
+
});
|
|
97
|
+
});
|