routstrd 0.4.7 → 0.4.9

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.
@@ -6,7 +6,11 @@ import {
6
6
  ensureDaemonRunning,
7
7
  } from "./daemon-client";
8
8
  import { logger } from "./logger";
9
- import { CLIENT_INTEGRATIONS, CLIENT_CONFIGS } from "../integrations/registry";
9
+ import {
10
+ CLIENT_INTEGRATIONS,
11
+ CLIENT_CONFIGS,
12
+ runIntegrationsForClients,
13
+ } from "../integrations/registry";
10
14
 
11
15
  export interface ClientEntry {
12
16
  clientId: string;
@@ -125,6 +129,75 @@ export async function addDaemonClient(
125
129
  return { message: output.message, client: output.client, created: true };
126
130
  }
127
131
 
132
+ /**
133
+ * Refresh routstr21 models from Nostr, then re-run every registered client
134
+ * integration so local client configs pick up new models and API keys.
135
+ *
136
+ * Shared by `routstrd refresh` and `routstrd clients --manual-refresh` — the
137
+ * same work the daemon performs on its scheduled refresh.
138
+ */
139
+ export async function refreshModelsAndClientsAction(): Promise<void> {
140
+ const config = await loadConfig();
141
+
142
+ console.log("Refreshing routstr21 models...");
143
+ const result = await callDaemon("/v1/models?refresh=true");
144
+ if (result.error) {
145
+ console.log(`Model refresh failed: ${result.error}`);
146
+ process.exit(1);
147
+ }
148
+ console.log("Models refreshed.");
149
+
150
+ const clients = await getClientsList();
151
+ if (clients.length === 0) {
152
+ console.log("No clients to refresh.");
153
+ return;
154
+ }
155
+
156
+ console.log(`Refreshing ${clients.length} client integration(s)...`);
157
+ await runIntegrationsForClients(clients, config);
158
+ console.log("Client integrations refreshed.");
159
+ }
160
+
161
+ /**
162
+ * Toggle the daemon's scheduled refresh job. Routed through the daemon so it
163
+ * also works against a remote daemon, where the config lives on the host.
164
+ */
165
+ export async function setAutomaticRefreshAction(
166
+ enabled: boolean,
167
+ ): Promise<void> {
168
+ await ensureDaemonRunning();
169
+
170
+ const result = await callDaemon("/settings/auto-refresh", {
171
+ method: "POST",
172
+ body: { enabled },
173
+ });
174
+
175
+ if (result.error) {
176
+ console.log(result.error);
177
+ process.exit(1);
178
+ }
179
+
180
+ const output = result.output as { message?: string } | undefined;
181
+ console.log(
182
+ output?.message ?? `Automatic refresh ${enabled ? "enabled" : "disabled"}.`,
183
+ );
184
+
185
+ if (enabled) {
186
+ console.log(
187
+ "The daemon will keep refreshing models and client integrations on a schedule.",
188
+ );
189
+ return;
190
+ }
191
+
192
+ console.log(
193
+ "The daemon will stop the scheduled refresh of models and client integrations.",
194
+ );
195
+ console.log("Run 'routstrd clients --manual-refresh' to refresh on demand.");
196
+ console.log(
197
+ "Run 'routstrd clients --enable-automatic-refresh' to turn it back on.",
198
+ );
199
+ }
200
+
128
201
  export async function listClientsAction(): Promise<void> {
129
202
  await ensureDaemonRunning();
130
203
 
@@ -20,6 +20,18 @@ export interface NwcAutoRefillConfig {
20
20
  cooldownMs: number;
21
21
  }
22
22
 
23
+ /**
24
+ * Scheduled background refresh job (Nostr events, routstr21 models, and client
25
+ * integrations). Missing values mean "enabled" with the default interval, so
26
+ * older config files keep working unchanged.
27
+ */
28
+ export interface AutoRefreshConfig {
29
+ /** Run the recurring refresh job. Defaults to true. */
30
+ enabled?: boolean;
31
+ /** Milliseconds between refreshes. Defaults to 21 minutes. */
32
+ intervalMs?: number;
33
+ }
34
+
23
35
  /** NWC configuration section */
24
36
  export interface NwcConfig {
25
37
  /** NWC mode: "funding_source" = NWC funds the cocod Cashu wallet */
@@ -57,6 +69,8 @@ export interface RoutstrdConfig {
57
69
  relays?: string[];
58
70
  /** NWC integration configuration */
59
71
  nwc?: NwcConfig;
72
+ /** Scheduled refresh job settings (see AutoRefreshConfig). */
73
+ autoRefresh?: AutoRefreshConfig;
60
74
  /**
61
75
  * Default max_tokens (chat/completions) and max_output_tokens (responses)
62
76
  * injected into proxied requests when the client does not supply one.
@@ -0,0 +1,125 @@
1
+ import { existsSync, readFileSync } from "fs";
2
+ import { isDaemonRunning } from "./daemon-client";
3
+ import { defaultIsProcessRunning } from "../daemon/wallet/coco-client";
4
+ import { walletPidPath } from "../daemon/wallet/paths";
5
+ import { formatElapsed } from "../start-daemon";
6
+
7
+ /**
8
+ * Wait for a daemon that was asked to stop (POST /stop) to fully exit.
9
+ *
10
+ * The daemon shuts down gracefully: it stops accepting new connections
11
+ * right away, but keeps serving ongoing requests until they finish; only
12
+ * then does it dispose of the wallet, release the wallet PID lock, and
13
+ * exit. Spawning a replacement before the lock is released makes it abort
14
+ * with "Cannot claim the routstrd wallet lock", so restarts must wait for
15
+ * the old process to fully exit — telling the user why it is taking a
16
+ * while ("Finishing all ongoing requests...") and how to force it
17
+ * ("run 'kill -9 <PID>' to force stop") instead of racing ahead.
18
+ */
19
+ export interface WaitForDaemonToExitOptions {
20
+ /** Wallet PID lock file held by the running daemon. */
21
+ pidFilePath?: string;
22
+ /** Returns true while the daemon still answers /health. */
23
+ isHealthy?: () => Promise<boolean>;
24
+ /** Returns the live PID recorded in the wallet lock file, or null when the
25
+ * file is gone or unreadable. */
26
+ readLockPid?: (pidFilePath: string) => number | null;
27
+ /** Returns true when a PID is alive (zombies count as dead). */
28
+ isProcessRunning?: (pid: number) => boolean;
29
+ sleep?: (ms: number) => Promise<void>;
30
+ log?: (message: string) => void;
31
+ /** How long to wait for the health check to stop responding after /stop. */
32
+ healthTimeoutMs?: number;
33
+ /** Silent window before reporting that ongoing requests are finishing. */
34
+ drainGraceMs?: number;
35
+ /** How long to wait for the old daemon to exit and release the lock. */
36
+ drainTimeoutMs?: number;
37
+ /** How often to re-report that ongoing requests are still finishing. */
38
+ drainHeartbeatMs?: number;
39
+ /** Interval between health and lock polls. */
40
+ pollIntervalMs?: number;
41
+ }
42
+
43
+ /** How often to re-report that the daemon is still finishing requests. */
44
+ const DRAIN_HEARTBEAT_MS = 10_000;
45
+
46
+ function defaultReadLockPid(pidFilePath: string): number | null {
47
+ if (!existsSync(pidFilePath)) return null;
48
+ try {
49
+ const pid = Number.parseInt(readFileSync(pidFilePath, "utf8").trim(), 10);
50
+ return Number.isInteger(pid) && pid > 0 ? pid : null;
51
+ } catch {
52
+ return null;
53
+ }
54
+ }
55
+
56
+ export async function waitForDaemonToExit(
57
+ options: WaitForDaemonToExitOptions = {},
58
+ ): Promise<void> {
59
+ const {
60
+ pidFilePath = walletPidPath(),
61
+ isHealthy = isDaemonRunning,
62
+ readLockPid = defaultReadLockPid,
63
+ isProcessRunning = defaultIsProcessRunning,
64
+ sleep = (ms: number) =>
65
+ new Promise<void>((resolve) => setTimeout(resolve, ms)),
66
+ log = (message: string) => console.log(message),
67
+ healthTimeoutMs = 10_000,
68
+ drainGraceMs = 1_000,
69
+ drainTimeoutMs = 10 * 60_000,
70
+ drainHeartbeatMs = DRAIN_HEARTBEAT_MS,
71
+ pollIntervalMs = 100,
72
+ } = options;
73
+
74
+ // Phase 1: the daemon stops accepting new connections promptly after
75
+ // /stop, so its health check should stop responding within seconds.
76
+ const healthDeadline = Date.now() + healthTimeoutMs;
77
+ while (await isHealthy()) {
78
+ if (Date.now() >= healthDeadline) {
79
+ throw new Error(
80
+ `routstrd did not stop within ${Math.round(healthTimeoutMs / 1000)} seconds`,
81
+ );
82
+ }
83
+ await sleep(pollIntervalMs);
84
+ }
85
+
86
+ // Phase 2: the process stays alive while it finishes ongoing requests and
87
+ // disposes of the wallet. The wallet PID lock is released only right before
88
+ // the process exits, so wait for it (or for the recorded PID to die). Both
89
+ // progress messages offer 'kill -9 <PID>': only SIGKILL interrupts a stuck
90
+ // drain — a plain SIGTERM just re-runs the same graceful shutdown.
91
+ const drainStartedAt = Date.now();
92
+ const drainDeadline = drainStartedAt + drainTimeoutMs;
93
+ let drainAnnounced = false;
94
+ let nextHeartbeatAt = 0;
95
+
96
+ for (;;) {
97
+ const lockPid = readLockPid(pidFilePath);
98
+ if (lockPid === null || !isProcessRunning(lockPid)) return;
99
+
100
+ const now = Date.now();
101
+ if (now >= drainDeadline) {
102
+ throw new Error(
103
+ `the previous daemon (PID ${lockPid}) did not finish its ongoing requests within ` +
104
+ `${formatElapsed(drainTimeoutMs)} and still holds the wallet lock at ${pidFilePath}. ` +
105
+ `Wait for it to exit and try again, or run 'kill -9 ${lockPid}' to force it.`,
106
+ );
107
+ }
108
+
109
+ if (!drainAnnounced && now - drainStartedAt >= drainGraceMs) {
110
+ drainAnnounced = true;
111
+ log(
112
+ ` Finishing all ongoing requests... (run 'kill -9 ${lockPid}' to force stop)`,
113
+ );
114
+ nextHeartbeatAt = now + drainHeartbeatMs;
115
+ } else if (drainAnnounced && now >= nextHeartbeatAt) {
116
+ log(
117
+ ` Still finishing ongoing requests (${formatElapsed(now - drainStartedAt)} elapsed)... ` +
118
+ `(run 'kill -9 ${lockPid}' to force stop)`,
119
+ );
120
+ nextHeartbeatAt += drainHeartbeatMs;
121
+ }
122
+
123
+ await sleep(pollIntervalMs);
124
+ }
125
+ }
@@ -0,0 +1,214 @@
1
+ import { createHash, randomBytes } from "crypto";
2
+ import {
3
+ chmodSync,
4
+ constants,
5
+ copyFileSync,
6
+ mkdtempSync,
7
+ renameSync,
8
+ rmSync,
9
+ statSync,
10
+ writeFileSync,
11
+ } from "fs";
12
+ import { tmpdir } from "os";
13
+ import { basename, join } from "path";
14
+
15
+ const RELEASES_API = "https://api.github.com/repos/Routstr/routstrd/releases/latest";
16
+ const FETCH_TIMEOUT_MS = 30_000;
17
+ const PROCESS_TIMEOUT_MS = 30_000;
18
+ const MAX_ARCHIVE_BYTES = 250 * 1024 * 1024;
19
+ const MAX_CHECKSUM_BYTES = 1024 * 1024;
20
+
21
+ type ReleaseAsset = {
22
+ name: string;
23
+ browser_download_url: string;
24
+ };
25
+
26
+ type GithubRelease = {
27
+ tag_name: string;
28
+ assets: ReleaseAsset[];
29
+ };
30
+
31
+ export type StandaloneRelease = {
32
+ version: string;
33
+ archive: ReleaseAsset;
34
+ checksums: ReleaseAsset;
35
+ };
36
+
37
+ function normalizeVersion(version: string): string {
38
+ return version.replace(/^v/, "");
39
+ }
40
+
41
+ export function releaseArchiveName(
42
+ version: string,
43
+ platform: NodeJS.Platform,
44
+ arch: string,
45
+ ): string {
46
+ if (platform !== "linux" && platform !== "darwin") {
47
+ throw new Error(`Standalone updates are not supported on ${platform}.`);
48
+ }
49
+ if (arch !== "x64" && arch !== "arm64") {
50
+ throw new Error(`Standalone updates are not supported on ${platform}-${arch}.`);
51
+ }
52
+ return `routstrd-v${normalizeVersion(version)}-${platform}-${arch}.tar.gz`;
53
+ }
54
+
55
+ async function fetchOrThrow(
56
+ url: string,
57
+ fetchImpl: typeof fetch,
58
+ maxBytes = MAX_CHECKSUM_BYTES,
59
+ ): Promise<Response> {
60
+ const response = await fetchImpl(url, {
61
+ headers: {
62
+ Accept: "application/vnd.github+json",
63
+ "User-Agent": "routstrd",
64
+ },
65
+ redirect: "follow",
66
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
67
+ });
68
+ if (!response.ok) {
69
+ throw new Error(`Download failed (${response.status}) for ${url}`);
70
+ }
71
+ const contentLength = Number(response.headers.get("content-length"));
72
+ if (Number.isFinite(contentLength) && contentLength > maxBytes) {
73
+ throw new Error(`Download is too large (${contentLength} bytes) for ${url}`);
74
+ }
75
+ return response;
76
+ }
77
+
78
+ async function waitForExit(
79
+ proc: { exited: Promise<number>; kill(): void },
80
+ label: string,
81
+ ): Promise<number> {
82
+ let timeout: ReturnType<typeof setTimeout> | undefined;
83
+ try {
84
+ return await Promise.race([
85
+ proc.exited,
86
+ new Promise<never>((_, reject) => {
87
+ timeout = setTimeout(() => {
88
+ proc.kill();
89
+ reject(new Error(`${label} timed out.`));
90
+ }, PROCESS_TIMEOUT_MS);
91
+ }),
92
+ ]);
93
+ } finally {
94
+ if (timeout) clearTimeout(timeout);
95
+ }
96
+ }
97
+
98
+ export async function getLatestStandaloneRelease(
99
+ platform: NodeJS.Platform = process.platform,
100
+ arch: string = process.arch,
101
+ fetchImpl: typeof fetch = fetch,
102
+ ): Promise<StandaloneRelease> {
103
+ const response = await fetchOrThrow(RELEASES_API, fetchImpl);
104
+ const release = (await response.json()) as GithubRelease;
105
+ const version = normalizeVersion(release.tag_name || "");
106
+ if (!/^\d+\.\d+\.\d+/.test(version)) {
107
+ throw new Error("The latest GitHub Release has an invalid version tag.");
108
+ }
109
+
110
+ const archiveName = releaseArchiveName(version, platform, arch);
111
+ const archive = release.assets.find((asset) => asset.name === archiveName);
112
+ const checksums = release.assets.find((asset) => asset.name === "SHA256SUMS");
113
+ if (!archive || !checksums) {
114
+ throw new Error(`GitHub Release v${version} is missing ${archiveName} or SHA256SUMS.`);
115
+ }
116
+ return { version, archive, checksums };
117
+ }
118
+
119
+ export function expectedChecksum(contents: string, filename: string): string {
120
+ for (const line of contents.split("\n")) {
121
+ const match = line.trim().match(/^([a-fA-F0-9]{64})\s+\*?(.+)$/);
122
+ if (match?.[2] === filename) return match[1]!.toLowerCase();
123
+ }
124
+ throw new Error(`SHA256SUMS does not contain ${filename}.`);
125
+ }
126
+
127
+ export function sha256(bytes: ArrayBuffer | Uint8Array): string {
128
+ return createHash("sha256").update(new Uint8Array(bytes)).digest("hex");
129
+ }
130
+
131
+ async function verifyCandidate(path: string, version: string): Promise<void> {
132
+ const proc = Bun.spawn([path, "--version"], {
133
+ stdout: "pipe",
134
+ stderr: "pipe",
135
+ });
136
+ const [code, outputText, errorText] = await Promise.all([
137
+ waitForExit(proc, "Downloaded binary validation"),
138
+ new Response(proc.stdout).text(),
139
+ new Response(proc.stderr).text(),
140
+ ]);
141
+ const output = outputText.trim();
142
+ const error = errorText.trim();
143
+ if (code !== 0 || normalizeVersion(output) !== normalizeVersion(version)) {
144
+ throw new Error(
145
+ `Downloaded binary failed version validation.${error ? ` ${error}` : ""}`,
146
+ );
147
+ }
148
+ }
149
+
150
+ export async function installStandaloneRelease(
151
+ release: StandaloneRelease,
152
+ executablePath: string = process.execPath,
153
+ fetchImpl: typeof fetch = fetch,
154
+ ): Promise<void> {
155
+ const tempDir = mkdtempSync(join(tmpdir(), "routstrd-update-"));
156
+ const archivePath = join(tempDir, release.archive.name);
157
+ const stagedPath = `${executablePath}.update-${randomBytes(12).toString("hex")}`;
158
+
159
+ try {
160
+ const [archiveResponse, checksumsResponse] = await Promise.all([
161
+ fetchOrThrow(
162
+ release.archive.browser_download_url,
163
+ fetchImpl,
164
+ MAX_ARCHIVE_BYTES,
165
+ ),
166
+ fetchOrThrow(release.checksums.browser_download_url, fetchImpl),
167
+ ]);
168
+ const archiveBytes = await archiveResponse.arrayBuffer();
169
+ if (archiveBytes.byteLength > MAX_ARCHIVE_BYTES) {
170
+ throw new Error(`Download is too large (${archiveBytes.byteLength} bytes).`);
171
+ }
172
+ const checksums = await checksumsResponse.text();
173
+ if (Buffer.byteLength(checksums) > MAX_CHECKSUM_BYTES) {
174
+ throw new Error("SHA256SUMS is too large.");
175
+ }
176
+ const expected = expectedChecksum(checksums, release.archive.name);
177
+ const actual = sha256(archiveBytes);
178
+ if (actual !== expected) {
179
+ throw new Error(
180
+ `Checksum mismatch for ${release.archive.name}: expected ${expected}, got ${actual}.`,
181
+ );
182
+ }
183
+ writeFileSync(archivePath, new Uint8Array(archiveBytes));
184
+
185
+ const extract = Bun.spawn(["tar", "-xzf", archivePath, "-C", tempDir], {
186
+ stdout: "ignore",
187
+ stderr: "pipe",
188
+ });
189
+ const [extractCode, extractErrorText] = await Promise.all([
190
+ waitForExit(extract, "Archive extraction"),
191
+ new Response(extract.stderr).text(),
192
+ ]);
193
+ const extractError = extractErrorText.trim();
194
+ if (extractCode !== 0) {
195
+ throw new Error(`Could not extract ${release.archive.name}. ${extractError}`.trim());
196
+ }
197
+
198
+ const extractedPath = join(tempDir, "routstrd");
199
+ const mode = statSync(executablePath).mode & 0o777;
200
+ copyFileSync(extractedPath, stagedPath, constants.COPYFILE_EXCL);
201
+ chmodSync(stagedPath, mode || 0o755);
202
+ await verifyCandidate(stagedPath, release.version);
203
+ renameSync(stagedPath, executablePath);
204
+ } catch (error) {
205
+ const detail = error instanceof Error ? error.message : String(error);
206
+ throw new Error(
207
+ `Could not update ${basename(executablePath)} in place. ${detail}`,
208
+ { cause: error },
209
+ );
210
+ } finally {
211
+ rmSync(stagedPath, { force: true });
212
+ rmSync(tempDir, { recursive: true, force: true });
213
+ }
214
+ }
@@ -1,3 +1,7 @@
1
+ import { isStandaloneExecutable } from "../runtime";
2
+ import { VERSION } from "../version";
3
+ import { getLatestStandaloneRelease } from "./standalone-update";
4
+
1
5
  const NPM_REGISTRY = "https://registry.npmjs.org";
2
6
 
3
7
  /** Packages that `routstrd update` manages. */
@@ -53,30 +57,65 @@ export async function getGlobalPackageVersion(
53
57
  }
54
58
  }
55
59
 
60
+ type ParsedVersion = {
61
+ core: [number, number, number];
62
+ prerelease: string[] | null;
63
+ };
64
+
65
+ function parseVersion(version: string): ParsedVersion | null {
66
+ const match = version.match(
67
+ /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/,
68
+ );
69
+ if (!match?.[1] || !match[2] || !match[3]) return null;
70
+ return {
71
+ core: [
72
+ Number.parseInt(match[1], 10),
73
+ Number.parseInt(match[2], 10),
74
+ Number.parseInt(match[3], 10),
75
+ ],
76
+ prerelease: match[4]?.split(".") ?? null,
77
+ };
78
+ }
79
+
80
+ function comparePrerelease(a: string[] | null, b: string[] | null): number {
81
+ if (!a && !b) return 0;
82
+ if (!a) return 1;
83
+ if (!b) return -1;
84
+
85
+ for (let index = 0; index < Math.max(a.length, b.length); index++) {
86
+ const left = a[index];
87
+ const right = b[index];
88
+ if (left === undefined) return -1;
89
+ if (right === undefined) return 1;
90
+ if (left === right) continue;
91
+
92
+ const leftNumeric = /^\d+$/.test(left);
93
+ const rightNumeric = /^\d+$/.test(right);
94
+ if (leftNumeric && rightNumeric) {
95
+ return Number.parseInt(left, 10) - Number.parseInt(right, 10);
96
+ }
97
+ if (leftNumeric) return -1;
98
+ if (rightNumeric) return 1;
99
+ return left < right ? -1 : 1;
100
+ }
101
+ return 0;
102
+ }
103
+
56
104
  /**
57
105
  * Compare two semver version strings.
58
106
  * Returns a positive number if `a` is newer, negative if `b` is newer,
59
107
  * 0 if equal, or null if either value is not parseable semver.
60
108
  */
61
109
  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;
110
+ const left = parseVersion(a);
111
+ const right = parseVersion(b);
112
+ if (!left || !right) return null;
113
+
114
+ for (let index = 0; index < left.core.length; index++) {
115
+ const difference = left.core[index]! - right.core[index]!;
116
+ if (difference !== 0) return difference;
117
+ }
118
+ return comparePrerelease(left.prerelease, right.prerelease);
80
119
  }
81
120
 
82
121
  export interface PackageUpdate {
@@ -97,6 +136,30 @@ export interface UpdateCheckResult {
97
136
  * Returns a result with per-package details and an overall `hasUpdate` flag.
98
137
  */
99
138
  export async function checkForUpdates(): Promise<UpdateCheckResult> {
139
+ if (isStandaloneExecutable()) {
140
+ let latest: string | null = null;
141
+ try {
142
+ latest = (await getLatestStandaloneRelease()).version;
143
+ } catch {
144
+ // Update checks are best-effort and must not disrupt the TUI.
145
+ }
146
+ const hasUpdate = !!(
147
+ latest && (compareVersions(VERSION, latest) ?? -1) < 0
148
+ );
149
+ return {
150
+ hasUpdate,
151
+ packages: [
152
+ {
153
+ name: "routstrd",
154
+ label: "routstrd",
155
+ current: VERSION,
156
+ latest,
157
+ hasUpdate,
158
+ },
159
+ ],
160
+ };
161
+ }
162
+
100
163
  const packages = await Promise.all(
101
164
  UPDATE_PACKAGES.map(async ({ name, label }) => {
102
165
  const [current, latest] = await Promise.all([
package/src/version.ts ADDED
@@ -0,0 +1,3 @@
1
+ import packageJson from "../package.json" with { type: "json" };
2
+
3
+ export const VERSION = packageJson.version;