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.
- package/README.md +96 -6
- package/SKILL.md +341 -0
- package/dist/daemon/index.js +31092 -31824
- package/dist/index.js +162637 -14155
- package/package.json +9 -4
- package/src/cli.ts +279 -110
- package/src/daemon/args.ts +4 -5
- package/src/daemon/http/index.ts +37 -2
- package/src/daemon/index.ts +94 -33
- package/src/daemon/wallet/auto-refill.ts +1 -1
- package/src/daemon/wallet/coco-client.ts +205 -3
- package/src/daemon/wallet/cocod-client.ts +19 -3
- package/src/daemon/wallet/index.ts +1 -1
- package/src/index.ts +9 -1
- package/src/runtime.ts +55 -0
- package/src/start-daemon.ts +4 -8
- package/src/utils/clients.ts +74 -1
- package/src/utils/config.ts +14 -0
- package/src/utils/daemon-stop.ts +125 -0
- package/src/utils/standalone-update.ts +214 -0
- package/src/utils/update-checker.ts +81 -18
- package/src/version.ts +3 -0
- package/src/cli.test.ts +0 -192
- package/src/daemon/fatal-error.test.ts +0 -104
- package/src/daemon/http/usage-summary.test.ts +0 -272
- package/src/daemon/models.test.ts +0 -81
- package/src/daemon/wallet/cleanup.test.ts +0 -168
- package/src/daemon/wallet/coco-client.npc.test.ts +0 -252
- package/src/daemon/wallet/coco-client.test.ts +0 -669
- package/src/daemon/wallet/diagnostics.test.ts +0 -378
- package/src/daemon/wallet/fixtures/cocod-0.0.24-wallet.db.gz +0 -0
- package/src/daemon/wallet/migration.test.ts +0 -171
- package/src/daemon/wallet/receive-dedup.test.ts +0 -238
package/src/utils/clients.ts
CHANGED
|
@@ -6,7 +6,11 @@ import {
|
|
|
6
6
|
ensureDaemonRunning,
|
|
7
7
|
} from "./daemon-client";
|
|
8
8
|
import { logger } from "./logger";
|
|
9
|
-
import {
|
|
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
|
|
package/src/utils/config.ts
CHANGED
|
@@ -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
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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