@loreai/gateway 0.13.3 → 0.14.0

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.
@@ -0,0 +1,353 @@
1
+ /**
2
+ * Binary Management
3
+ *
4
+ * Shared utilities for installing, replacing, and managing the CLI binary.
5
+ * Adapted from Sentry CLI's binary.ts for the Lore upgrade system.
6
+ *
7
+ * Key differences from Sentry CLI:
8
+ * - No musl detection (Lore doesn't target Alpine)
9
+ * - Lore binary naming: `lore-{os}-{arch}[.exe]`
10
+ * - Install dirs: ~/.lore/bin, ~/.local/bin
11
+ * - No Sentry SDK telemetry
12
+ */
13
+
14
+ import {
15
+ existsSync,
16
+ readFileSync,
17
+ renameSync,
18
+ unlinkSync,
19
+ writeFileSync,
20
+ } from "node:fs";
21
+ import { chmod, mkdir, unlink } from "node:fs/promises";
22
+ import { delimiter, join, resolve } from "node:path";
23
+ import { VERSION } from "../version";
24
+ import { stringifyUnknown, UpgradeError } from "./errors";
25
+
26
+ /** GitHub owner/repo for Lore releases */
27
+ const GITHUB_OWNER = "BYK";
28
+ const GITHUB_REPO = "loreai";
29
+
30
+ /** GitHub API base URL for releases */
31
+ export const GITHUB_RELEASES_URL =
32
+ `https://api.github.com/repos/${GITHUB_OWNER}/${GITHUB_REPO}/releases`;
33
+
34
+ /** Known directories where the curl installer may place the binary */
35
+ export const KNOWN_CURL_DIRS = [".local/bin", "bin", ".lore/bin"];
36
+
37
+ /**
38
+ * Build the platform-specific binary base name.
39
+ *
40
+ * Matches the naming convention used by GitHub Releases and GHCR:
41
+ * `lore-{os}-{arch}[.exe]`
42
+ */
43
+ export function getPlatformBinaryName(): string {
44
+ let os: string;
45
+ if (process.platform === "darwin") {
46
+ os = "darwin";
47
+ } else if (process.platform === "win32") {
48
+ os = "windows";
49
+ } else {
50
+ os = "linux";
51
+ }
52
+ const arch = process.arch === "arm64" ? "arm64" : "x64";
53
+ const suffix = process.platform === "win32" ? ".exe" : "";
54
+ return `lore-${os}-${arch}${suffix}`;
55
+ }
56
+
57
+ /**
58
+ * Build the download URL for a platform-specific binary from GitHub releases.
59
+ */
60
+ export function getBinaryDownloadUrl(version: string): string {
61
+ return `https://github.com/${GITHUB_OWNER}/${GITHUB_REPO}/releases/download/${version}/${getPlatformBinaryName()}`;
62
+ }
63
+
64
+ /**
65
+ * Detect whether a version string identifies a nightly build.
66
+ *
67
+ * Nightlies use the format `X.Y.Z-dev.<unix-seconds>`.
68
+ */
69
+ export function isNightlyVersion(version: string): boolean {
70
+ return version.includes("-dev.");
71
+ }
72
+
73
+ /**
74
+ * Compare two version strings and return their ordering.
75
+ *
76
+ * Uses `Bun.semver.order` which handles both stable (`X.Y.Z`) and
77
+ * nightly (`X.Y.Z-dev.<unix-seconds>`) versions correctly.
78
+ */
79
+ export function compareVersions(a: string, b: string): -1 | 0 | 1 {
80
+ return Bun.semver.order(a, b);
81
+ }
82
+
83
+ /**
84
+ * Check whether moving from `current` to `target` is a downgrade.
85
+ */
86
+ export function isDowngrade(current: string, target: string): boolean {
87
+ return compareVersions(current, target) === 1;
88
+ }
89
+
90
+ /**
91
+ * Get the binary filename for the current platform.
92
+ */
93
+ export function getBinaryFilename(): string {
94
+ return process.platform === "win32" ? "lore.exe" : "lore";
95
+ }
96
+
97
+ /**
98
+ * Build paths object from an install path.
99
+ */
100
+ export function getBinaryPaths(installPath: string): {
101
+ installPath: string;
102
+ tempPath: string;
103
+ oldPath: string;
104
+ lockPath: string;
105
+ } {
106
+ return {
107
+ installPath,
108
+ tempPath: `${installPath}.download`,
109
+ oldPath: `${installPath}.old`,
110
+ lockPath: `${installPath}.lock`,
111
+ };
112
+ }
113
+
114
+ /**
115
+ * Determine the install directory for a curl-installed binary.
116
+ *
117
+ * Priority:
118
+ * 1. $LORE_INSTALL_DIR environment variable
119
+ * 2. ~/.local/bin (if exists AND in $PATH)
120
+ * 3. ~/bin (if exists AND in $PATH)
121
+ * 4. ~/.lore/bin (fallback)
122
+ */
123
+ export function determineInstallDir(
124
+ homeDir: string,
125
+ env: NodeJS.ProcessEnv,
126
+ ): string {
127
+ const pathDirs = (env.PATH ?? "").split(delimiter);
128
+
129
+ if (env.LORE_INSTALL_DIR) {
130
+ return env.LORE_INSTALL_DIR;
131
+ }
132
+
133
+ const candidates = [join(homeDir, ".local", "bin"), join(homeDir, "bin")];
134
+
135
+ for (const dir of candidates) {
136
+ if (existsSync(dir) && pathDirs.includes(dir)) {
137
+ return dir;
138
+ }
139
+ }
140
+
141
+ return join(homeDir, ".lore", "bin");
142
+ }
143
+
144
+ /**
145
+ * Build headers for GitHub API requests.
146
+ */
147
+ export function getGitHubHeaders(): Record<string, string> {
148
+ return {
149
+ Accept: "application/vnd.github.v3+json",
150
+ "User-Agent": getUserAgent(),
151
+ };
152
+ }
153
+
154
+ /**
155
+ * Generate the User-Agent string for API requests.
156
+ */
157
+ export function getUserAgent(): string {
158
+ const runtime =
159
+ typeof process.versions.bun !== "undefined"
160
+ ? `bun/${process.versions.bun}`
161
+ : `node/${process.versions.node}`;
162
+ return `lore/${VERSION} (${process.platform}-${process.arch}) ${runtime}`;
163
+ }
164
+
165
+ /**
166
+ * Fetch wrapper that converts network errors to UpgradeError.
167
+ */
168
+ export async function fetchWithUpgradeError(
169
+ url: string,
170
+ init: RequestInit,
171
+ serviceName: string,
172
+ ): Promise<Response> {
173
+ try {
174
+ return await fetch(url, init);
175
+ } catch (error) {
176
+ if (error instanceof Error && error.name === "AbortError") {
177
+ throw error;
178
+ }
179
+ const msg = stringifyUnknown(error);
180
+ throw new UpgradeError(
181
+ "network_error",
182
+ `Failed to connect to ${serviceName}: ${msg}`,
183
+ );
184
+ }
185
+ }
186
+
187
+ /**
188
+ * Replace the binary at the install path, handling platform differences.
189
+ *
190
+ * Intentionally synchronous: the multi-step rename sequence must be
191
+ * uninterruptible to avoid leaving the install path in a broken state.
192
+ *
193
+ * - Unix: Atomic rename overwrites the target
194
+ * - Windows: Rename old binary to .old first, then rename temp into place
195
+ */
196
+ export function replaceBinarySync(tempPath: string, installPath: string): void {
197
+ if (process.platform === "win32") {
198
+ const oldPath = `${installPath}.old`;
199
+ try {
200
+ renameSync(installPath, oldPath);
201
+ } catch {
202
+ try {
203
+ unlinkSync(oldPath);
204
+ renameSync(installPath, oldPath);
205
+ } catch {
206
+ // Current binary might not exist — that's fine
207
+ }
208
+ }
209
+ renameSync(tempPath, installPath);
210
+ } else {
211
+ renameSync(tempPath, installPath);
212
+ }
213
+ }
214
+
215
+ /**
216
+ * Clean up leftover .old files from previous upgrades.
217
+ * Called on CLI startup. Fire-and-forget.
218
+ */
219
+ export function cleanupOldBinary(oldPath: string): void {
220
+ unlink(oldPath).catch(() => {
221
+ // Intentionally ignore — file may not exist
222
+ });
223
+ }
224
+
225
+ // Lock Management
226
+
227
+ /**
228
+ * Check if a process with the given PID is still running.
229
+ */
230
+ export function isProcessRunning(pid: number): boolean {
231
+ try {
232
+ process.kill(pid, 0);
233
+ return true;
234
+ } catch (error) {
235
+ if ((error as NodeJS.ErrnoException).code === "EPERM") {
236
+ return true;
237
+ }
238
+ return false;
239
+ }
240
+ }
241
+
242
+ /**
243
+ * Acquire an exclusive lock for binary installation/upgrade.
244
+ * Uses atomic file creation with 'wx' flag to prevent race conditions.
245
+ */
246
+ export function acquireLock(lockPath: string): void {
247
+ try {
248
+ writeFileSync(lockPath, String(process.pid), { flag: "wx" });
249
+ } catch (error) {
250
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") {
251
+ throw error;
252
+ }
253
+ handleExistingLock(lockPath);
254
+ }
255
+ }
256
+
257
+ function handleExistingLock(lockPath: string): void {
258
+ let content: string;
259
+ try {
260
+ content = readFileSync(lockPath, "utf-8").trim();
261
+ } catch (error) {
262
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
263
+ acquireLock(lockPath);
264
+ return;
265
+ }
266
+ throw error;
267
+ }
268
+
269
+ const existingPid = Number.parseInt(content, 10);
270
+
271
+ if (!Number.isNaN(existingPid) && isProcessRunning(existingPid)) {
272
+ if (existingPid === process.ppid) {
273
+ writeFileSync(lockPath, String(process.pid));
274
+ return;
275
+ }
276
+ throw new UpgradeError(
277
+ "execution_failed",
278
+ "Another upgrade is already in progress",
279
+ );
280
+ }
281
+
282
+ // Stale lock from dead process — remove and retry
283
+ try {
284
+ unlinkSync(lockPath);
285
+ } catch (error) {
286
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
287
+ throw error;
288
+ }
289
+ }
290
+
291
+ acquireLock(lockPath);
292
+ }
293
+
294
+ /**
295
+ * Release the binary lock.
296
+ */
297
+ export function releaseLock(lockPath: string): void {
298
+ try {
299
+ unlinkSync(lockPath);
300
+ } catch {
301
+ // Ignore — file might already be gone
302
+ }
303
+ }
304
+
305
+ /**
306
+ * Install a binary to the target directory.
307
+ */
308
+ export async function installBinary(
309
+ sourcePath: string,
310
+ installDir: string,
311
+ ): Promise<string> {
312
+ await mkdir(installDir, { recursive: true, mode: 0o755 });
313
+
314
+ const installPath = join(installDir, getBinaryFilename());
315
+ const { tempPath, lockPath } = getBinaryPaths(installPath);
316
+
317
+ acquireLock(lockPath);
318
+
319
+ try {
320
+ if (resolve(sourcePath) !== resolve(tempPath)) {
321
+ try {
322
+ await unlink(tempPath);
323
+ } catch {
324
+ // Ignore if doesn't exist
325
+ }
326
+
327
+ await Bun.write(tempPath, Bun.file(sourcePath));
328
+
329
+ if (process.platform !== "win32") {
330
+ await chmod(tempPath, 0o755);
331
+ }
332
+ }
333
+
334
+ replaceBinarySync(tempPath, installPath);
335
+ } finally {
336
+ releaseLock(lockPath);
337
+ }
338
+
339
+ return installPath;
340
+ }
341
+
342
+ /**
343
+ * Get the Lore config directory.
344
+ * Uses $LORE_CONFIG_DIR if set, otherwise ~/.lore
345
+ */
346
+ export function getConfigDir(): string {
347
+ if (process.env.LORE_CONFIG_DIR) {
348
+ return process.env.LORE_CONFIG_DIR;
349
+ }
350
+ const home =
351
+ process.env.HOME ?? process.env.USERPROFILE ?? require("node:os").homedir();
352
+ return join(home, ".lore");
353
+ }
@@ -0,0 +1,306 @@
1
+ /**
2
+ * Streaming TRDIFF10 Binary Patch Application
3
+ *
4
+ * Implements the bspatch algorithm for applying binary delta patches in the
5
+ * TRDIFF10 format (produced by zig-bsdiff with `--use-zstd`). Designed for
6
+ * minimal memory usage during CLI self-upgrades:
7
+ *
8
+ * - Old binary: copy-then-mmap for 0 JS heap (CoW on btrfs/xfs/APFS),
9
+ * falling back to `arrayBuffer()` if copy/mmap fails
10
+ * - Diff/extra blocks: streamed via `DecompressionStream('zstd')`
11
+ * - Output: written incrementally to disk via `Bun.file().writer()`
12
+ * - Integrity: SHA-256 computed inline via `Bun.CryptoHasher`
13
+ *
14
+ * TRDIFF10 format (from zig-bsdiff):
15
+ * ```
16
+ * [0..8] magic: "TRDIFF10"
17
+ * [8..16] controlLen: i64 LE (compressed size of control block)
18
+ * [16..24] diffLen: i64 LE (compressed size of diff block)
19
+ * [24..32] newSize: i64 LE (expected output size)
20
+ * [32..] zstd(control) | zstd(diff) | zstd(extra)
21
+ * ```
22
+ *
23
+ * Adapted from Sentry CLI's bspatch.ts — pure Bun, no external dependencies.
24
+ */
25
+
26
+ import { constants, copyFileSync } from "node:fs";
27
+ import { unlink } from "node:fs/promises";
28
+ import { tmpdir } from "node:os";
29
+ import { join } from "node:path";
30
+
31
+ /** TRDIFF10 header magic bytes */
32
+ const TRDIFF10_MAGIC = "TRDIFF10";
33
+
34
+ /** Header size in bytes (magic + 3 x i64) */
35
+ const HEADER_SIZE = 32;
36
+
37
+ /** Parsed TRDIFF10 header fields */
38
+ export type PatchHeader = {
39
+ controlLen: number;
40
+ diffLen: number;
41
+ newSize: number;
42
+ };
43
+
44
+ /**
45
+ * Read a signed 64-bit little-endian integer using the zig-bsdiff encoding.
46
+ *
47
+ * The sign is stored in bit 7 of byte 7 (the MSB of the last byte).
48
+ * The magnitude is in the lower 63 bits, read as unsigned LE.
49
+ * This differs from standard two's complement — it uses sign-magnitude.
50
+ */
51
+ export function offtin(buf: Uint8Array, offset: number): number {
52
+ const view = new DataView(buf.buffer, buf.byteOffset + offset, 8);
53
+ const lo = view.getUint32(0, true);
54
+ const hi = view.getUint32(4, true);
55
+
56
+ const magnitude = (hi % 0x80_00_00_00) * 0x1_00_00_00_00 + lo;
57
+
58
+ if (magnitude !== 0 && hi >= 0x80_00_00_00) {
59
+ return -magnitude;
60
+ }
61
+ return magnitude;
62
+ }
63
+
64
+ /**
65
+ * Parse and validate a TRDIFF10 patch header.
66
+ */
67
+ export function parsePatchHeader(patch: Uint8Array): PatchHeader {
68
+ if (patch.byteLength < HEADER_SIZE) {
69
+ throw new Error(
70
+ `Patch too small: ${patch.byteLength} bytes (need at least ${HEADER_SIZE})`,
71
+ );
72
+ }
73
+
74
+ const magic = new TextDecoder().decode(patch.subarray(0, 8));
75
+ if (magic !== TRDIFF10_MAGIC) {
76
+ throw new Error(`Invalid patch format: expected TRDIFF10, got "${magic}"`);
77
+ }
78
+
79
+ const controlLen = offtin(patch, 8);
80
+ const diffLen = offtin(patch, 16);
81
+ const newSize = offtin(patch, 24);
82
+
83
+ if (controlLen < 0 || diffLen < 0 || newSize < 0) {
84
+ throw new Error("Corrupt patch: negative length in header");
85
+ }
86
+
87
+ const totalCompressed = HEADER_SIZE + controlLen + diffLen;
88
+ if (totalCompressed > patch.byteLength) {
89
+ throw new Error(
90
+ `Corrupt patch: header lengths (${totalCompressed}) exceed file size (${patch.byteLength})`,
91
+ );
92
+ }
93
+
94
+ return { controlLen, diffLen, newSize };
95
+ }
96
+
97
+ /**
98
+ * Buffered reader over a `ReadableStream` that serves exact byte counts.
99
+ */
100
+ class BufferedStreamReader {
101
+ private readonly chunks: Uint8Array[] = [];
102
+ private buffered = 0;
103
+ private done = false;
104
+ private readonly reader: ReadableStreamDefaultReader<Uint8Array>;
105
+
106
+ constructor(reader: ReadableStreamDefaultReader<Uint8Array>) {
107
+ this.reader = reader;
108
+ }
109
+
110
+ async read(n: number): Promise<Uint8Array> {
111
+ while (this.buffered < n && !this.done) {
112
+ const result = await this.reader.read();
113
+ if (result.done) {
114
+ this.done = true;
115
+ break;
116
+ }
117
+ this.chunks.push(result.value);
118
+ this.buffered += result.value.byteLength;
119
+ }
120
+
121
+ if (this.buffered < n) {
122
+ throw new Error(
123
+ `Unexpected end of stream: needed ${n} bytes, have ${this.buffered}`,
124
+ );
125
+ }
126
+
127
+ const output = new Uint8Array(n);
128
+ let written = 0;
129
+
130
+ while (written < n) {
131
+ const front = this.chunks[0];
132
+ if (!front) break;
133
+ const needed = n - written;
134
+
135
+ if (front.byteLength <= needed) {
136
+ output.set(front, written);
137
+ written += front.byteLength;
138
+ this.buffered -= front.byteLength;
139
+ this.chunks.shift();
140
+ } else {
141
+ output.set(front.subarray(0, needed), written);
142
+ this.chunks[0] = front.subarray(needed);
143
+ this.buffered -= needed;
144
+ written = n;
145
+ }
146
+ }
147
+
148
+ return output;
149
+ }
150
+ }
151
+
152
+ /**
153
+ * Create a streaming zstd decompressor from a compressed buffer.
154
+ */
155
+ function createZstdStreamReader(compressed: Uint8Array): BufferedStreamReader {
156
+ const input = new ReadableStream<Uint8Array>({
157
+ start(controller) {
158
+ controller.enqueue(compressed);
159
+ controller.close();
160
+ },
161
+ });
162
+
163
+ // Bun supports 'zstd' but the standard CompressionFormat type doesn't include it.
164
+ // The double cast works around TypeScript's strict WritableStream<BufferSource>
165
+ // vs WritableStream<Uint8Array> mismatch in DecompressionStream.
166
+ const decompressed = input.pipeThrough(
167
+ new DecompressionStream("zstd" as "deflate") as unknown as TransformStream<Uint8Array, Uint8Array>,
168
+ );
169
+
170
+ return new BufferedStreamReader(
171
+ decompressed.getReader() as ReadableStreamDefaultReader<Uint8Array>,
172
+ );
173
+ }
174
+
175
+ type OldFileHandle = {
176
+ data: Uint8Array;
177
+ cleanup: () => void | Promise<void>;
178
+ };
179
+
180
+ /**
181
+ * Load the old binary for read access during patching.
182
+ *
183
+ * Strategy: copy to temp file, then try mmap on the copy. Falls back to
184
+ * arrayBuffer() if copy or mmap fails.
185
+ */
186
+ let loadCounter = 0;
187
+
188
+ async function loadOldBinary(oldPath: string): Promise<OldFileHandle> {
189
+ loadCounter += 1;
190
+ const tempCopy = join(
191
+ tmpdir(),
192
+ `lore-patch-old-${process.pid}-${loadCounter}`,
193
+ );
194
+ try {
195
+ copyFileSync(oldPath, tempCopy, constants.COPYFILE_FICLONE);
196
+ const data = Bun.mmap(tempCopy, { shared: false });
197
+ return {
198
+ data,
199
+ cleanup: () =>
200
+ unlink(tempCopy).catch(() => {
201
+ /* Best-effort */
202
+ }),
203
+ };
204
+ } catch {
205
+ await unlink(tempCopy).catch(() => {
206
+ /* May not exist */
207
+ });
208
+ return {
209
+ data: new Uint8Array(await Bun.file(oldPath).arrayBuffer()),
210
+ cleanup: () => {},
211
+ };
212
+ }
213
+ }
214
+
215
+ /**
216
+ * Apply a TRDIFF10 binary patch with streaming I/O for minimal memory usage.
217
+ *
218
+ * @param oldPath - Path to the existing (old) binary file
219
+ * @param patchData - Complete TRDIFF10 patch file contents
220
+ * @param destPath - Path to write the patched (new) binary
221
+ * @returns SHA-256 hex digest of the written output
222
+ */
223
+ export async function applyPatch(
224
+ oldPath: string,
225
+ patchData: Uint8Array,
226
+ destPath: string,
227
+ ): Promise<string> {
228
+ const { controlLen, diffLen, newSize } = parsePatchHeader(patchData);
229
+
230
+ const controlStart = HEADER_SIZE;
231
+ const diffStart = controlStart + controlLen;
232
+ const extraStart = diffStart + diffLen;
233
+
234
+ // Control block is tiny — decompress fully for random access
235
+ const controlBlock = Bun.zstdDecompressSync(
236
+ patchData.subarray(controlStart, diffStart),
237
+ );
238
+
239
+ // Diff and extra blocks are streamed
240
+ const diffReader = createZstdStreamReader(
241
+ patchData.subarray(diffStart, extraStart),
242
+ );
243
+ const extraReader = createZstdStreamReader(patchData.subarray(extraStart));
244
+
245
+ const { data: oldFile, cleanup: cleanupOldFile } =
246
+ await loadOldBinary(oldPath);
247
+
248
+ const writer = Bun.file(destPath).writer();
249
+ const hasher = new Bun.CryptoHasher("sha256");
250
+
251
+ let oldpos = 0;
252
+ let newpos = 0;
253
+
254
+ try {
255
+ for (
256
+ let controlPos = 0;
257
+ controlPos < controlBlock.byteLength;
258
+ controlPos += 24
259
+ ) {
260
+ const readDiffBy = offtin(controlBlock, controlPos);
261
+ const readExtraBy = offtin(controlBlock, controlPos + 8);
262
+ const seekBy = offtin(controlBlock, controlPos + 16);
263
+
264
+ // Step 1: Read diff bytes and add to old file bytes (wrapping u8 add)
265
+ if (readDiffBy > 0) {
266
+ const diffChunk = await diffReader.read(readDiffBy);
267
+ const outputChunk = new Uint8Array(readDiffBy);
268
+
269
+ for (let i = 0; i < readDiffBy; i++) {
270
+ outputChunk[i] =
271
+ ((oldFile[oldpos + i] ?? 0) + (diffChunk[i] ?? 0)) % 256;
272
+ }
273
+
274
+ writer.write(outputChunk);
275
+ hasher.update(outputChunk);
276
+ oldpos += readDiffBy;
277
+ newpos += readDiffBy;
278
+ }
279
+
280
+ // Step 2: Copy extra bytes directly to output
281
+ if (readExtraBy > 0) {
282
+ const extraChunk = await extraReader.read(readExtraBy);
283
+ writer.write(extraChunk);
284
+ hasher.update(extraChunk);
285
+ newpos += readExtraBy;
286
+ }
287
+
288
+ // Step 3: Seek old file position
289
+ oldpos += seekBy;
290
+ }
291
+ } finally {
292
+ try {
293
+ await writer.end();
294
+ } finally {
295
+ await cleanupOldFile();
296
+ }
297
+ }
298
+
299
+ if (newpos !== newSize) {
300
+ throw new Error(
301
+ `Output size mismatch: wrote ${newpos} bytes, expected ${newSize}`,
302
+ );
303
+ }
304
+
305
+ return hasher.digest("hex") as string;
306
+ }