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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "routstrd",
3
- "version": "0.3.9",
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.16",
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
- console.log("Updating routstrd...");
367
- const routstrdProc = Bun.spawn(["bun", "install", "-g", "routstrd"], {
368
- stdout: "inherit",
369
- stderr: "inherit",
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
- console.log("Updating cocod...");
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
- console.log("Both routstrd and cocod have been updated!");
378
+ for (const { name, label } of packages) {
379
+ const installed = await getGlobalPackageVersion(name);
380
+ const latest = await getLatestNpmVersion(name);
391
381
 
392
- // Restart daemons so the new binaries take effect immediately.
393
- await restartDaemonsAfterUpdate();
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(`Set default model to 3rd available model: ${defaultModel}`);
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} as default.`);
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
- // Remove existing model block
51
- content = content.replace(/^model:\n(?: .*\n)*/gm, "");
52
- // Remove existing custom_providers block
53
- content = content.replace(/^custom_providers:\n(?:- .*\n(?: .*\n)*)*/gm, "");
54
- // Clean up extra blank lines
55
- content = content.replace(/\n{3,}/g, "\n\n").trim();
56
-
57
- const urlDisplay = baseUrl.replace(/^https?:\/\//, "");
58
-
59
- const modelBlock = `model:
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.`);
@@ -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
  }
@@ -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
- return `${title}${vimIndicator}${" ".repeat(Math.max(1, fill))}${help}\n`;
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 {
@@ -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
+ });