gloomberb 0.3.1 → 0.3.3

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/src/updater.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { writeFileSync, renameSync, unlinkSync, chmodSync, realpathSync } from "fs";
2
2
  import { basename } from "path";
3
+ import { gunzipSync } from "zlib";
3
4
 
4
5
  const REPO = "vincelwt/gloomberb";
5
6
  const API_URL = `https://api.github.com/repos/${REPO}/releases/latest`;
@@ -10,6 +11,7 @@ export interface ReleaseInfo {
10
11
  downloadUrl: string;
11
12
  publishedAt: string;
12
13
  updateAction: UpdateAction;
14
+ compressed?: boolean;
13
15
  }
14
16
 
15
17
  export interface UpdateProgress {
@@ -22,6 +24,12 @@ export type UpdateAction =
22
24
  | { kind: "self" }
23
25
  | { kind: "manual"; command: string };
24
26
 
27
+ export type UpdateCheckResult =
28
+ | { kind: "available"; release: ReleaseInfo }
29
+ | { kind: "current" }
30
+ | { kind: "disabled" }
31
+ | { kind: "error"; error: string };
32
+
25
33
  function compareSemver(a: string, b: string): number {
26
34
  const pa = a.split(".").map(Number);
27
35
  const pb = b.split(".").map(Number);
@@ -32,13 +40,30 @@ function compareSemver(a: string, b: string): number {
32
40
  return 0;
33
41
  }
34
42
 
35
- function getAssetName(): string {
43
+ function getAssetBaseName(): string {
36
44
  const os = process.platform === "darwin" ? "darwin" : "linux";
37
45
  // macOS x64 uses arm64 binary (runs via Rosetta 2)
38
46
  const arch = os === "darwin" || process.arch === "arm64" ? "arm64" : "x64";
39
47
  return `gloomberb-${os}-${arch}`;
40
48
  }
41
49
 
50
+ function resolveReleaseAsset(
51
+ assets: { name: string; browser_download_url: string }[],
52
+ ): { name: string; browser_download_url: string; compressed: boolean } | null {
53
+ const assetBaseName = getAssetBaseName();
54
+ const gzAsset = assets.find((asset) => asset.name === `${assetBaseName}.gz`);
55
+ if (gzAsset) {
56
+ return { ...gzAsset, compressed: true };
57
+ }
58
+
59
+ const rawAsset = assets.find((asset) => asset.name === assetBaseName);
60
+ if (rawAsset) {
61
+ return { ...rawAsset, compressed: false };
62
+ }
63
+
64
+ return null;
65
+ }
66
+
42
67
  function normalizePath(value: string): string {
43
68
  return value.replace(/\\/g, "/").toLowerCase();
44
69
  }
@@ -123,23 +148,27 @@ export function canSelfUpdate(release: Pick<ReleaseInfo, "updateAction"> | null
123
148
  return release?.updateAction.kind === "self";
124
149
  }
125
150
 
126
- export async function checkForUpdate(
151
+ export async function checkForUpdateDetailed(
127
152
  currentVersion: string,
128
- ): Promise<ReleaseInfo | null> {
153
+ ): Promise<UpdateCheckResult> {
129
154
  const updateAction = detectUpdateAction();
130
- if (!updateAction) return null;
155
+ if (!updateAction) {
156
+ return { kind: "disabled" };
157
+ }
131
158
 
159
+ let timeout: ReturnType<typeof setTimeout> | undefined;
132
160
  try {
133
161
  const controller = new AbortController();
134
- const timeout = setTimeout(() => controller.abort(), 5000);
162
+ timeout = setTimeout(() => controller.abort(), 5000);
135
163
 
136
164
  const res = await fetch(API_URL, {
137
165
  signal: controller.signal,
138
166
  headers: { Accept: "application/vnd.github+json" },
139
167
  });
140
- clearTimeout(timeout);
141
168
 
142
- if (!res.ok) return null;
169
+ if (!res.ok) {
170
+ return { kind: "error", error: `GitHub returned ${res.status}` };
171
+ }
143
172
 
144
173
  const data = (await res.json()) as {
145
174
  tag_name: string;
@@ -148,24 +177,49 @@ export async function checkForUpdate(
148
177
  };
149
178
 
150
179
  const version = data.tag_name.replace(/^v/, "");
151
- if (compareSemver(version, currentVersion) <= 0) return null;
180
+ if (compareSemver(version, currentVersion) <= 0) {
181
+ return { kind: "current" };
182
+ }
152
183
 
153
- const assetName = getAssetName();
154
- const asset = data.assets.find((a) => a.name === assetName);
155
- if (!asset) return null;
184
+ const asset = resolveReleaseAsset(data.assets);
185
+ if (!asset) {
186
+ return {
187
+ kind: "error",
188
+ error: `No compatible release asset found for ${getAssetBaseName()}`,
189
+ };
190
+ }
156
191
 
157
192
  return {
158
- version,
159
- tagName: data.tag_name,
160
- downloadUrl: asset.browser_download_url,
161
- publishedAt: data.published_at,
162
- updateAction,
193
+ kind: "available",
194
+ release: {
195
+ version,
196
+ tagName: data.tag_name,
197
+ downloadUrl: asset.browser_download_url,
198
+ publishedAt: data.published_at,
199
+ updateAction,
200
+ compressed: asset.compressed,
201
+ },
163
202
  };
164
- } catch {
165
- return null;
203
+ } catch (error: unknown) {
204
+ if (error instanceof Error && error.name === "AbortError") {
205
+ return { kind: "error", error: "Update check timed out" };
206
+ }
207
+ return {
208
+ kind: "error",
209
+ error: error instanceof Error ? error.message : "Update check failed",
210
+ };
211
+ } finally {
212
+ if (timeout) clearTimeout(timeout);
166
213
  }
167
214
  }
168
215
 
216
+ export async function checkForUpdate(
217
+ currentVersion: string,
218
+ ): Promise<ReleaseInfo | null> {
219
+ const result = await checkForUpdateDetailed(currentVersion);
220
+ return result.kind === "available" ? result.release : null;
221
+ }
222
+
169
223
  export async function performUpdate(
170
224
  release: ReleaseInfo,
171
225
  onProgress: (p: UpdateProgress) => void,
@@ -219,7 +273,9 @@ export async function performUpdate(
219
273
  // Write to temp file
220
274
  const blob = new Blob(chunks);
221
275
  const buffer = await blob.arrayBuffer();
222
- writeFileSync(updatePath, Buffer.from(buffer));
276
+ const downloaded = Buffer.from(buffer);
277
+ const nextBinary = release.compressed ? gunzipSync(downloaded) : downloaded;
278
+ writeFileSync(updatePath, nextBinary);
223
279
  chmodSync(updatePath, 0o755);
224
280
 
225
281
  // Swap binaries
@@ -0,0 +1,86 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import type { AuthUser } from "./api-client";
3
+ import { apiClient } from "./api-client";
4
+
5
+ const originalFetch = globalThis.fetch;
6
+
7
+ const verifiedUser: AuthUser = {
8
+ id: "user-1",
9
+ name: "Test User",
10
+ email: "test@example.com",
11
+ username: "test",
12
+ emailVerified: true,
13
+ image: null,
14
+ createdAt: "2026-03-30T00:00:00.000Z",
15
+ updatedAt: "2026-03-30T00:00:00.000Z",
16
+ };
17
+
18
+ function createResponse(body: unknown, options: { status?: number; cookies?: string[] } = {}): Response {
19
+ const headers = {
20
+ getSetCookie: () => options.cookies ?? [],
21
+ get: (name: string) => {
22
+ if (name.toLowerCase() !== "set-cookie") return null;
23
+ return options.cookies?.[0] ?? null;
24
+ },
25
+ } as Headers;
26
+
27
+ return {
28
+ ok: (options.status ?? 200) >= 200 && (options.status ?? 200) < 300,
29
+ status: options.status ?? 200,
30
+ headers,
31
+ text: async () => JSON.stringify(body),
32
+ } as Response;
33
+ }
34
+
35
+ afterEach(() => {
36
+ globalThis.fetch = originalFetch;
37
+ apiClient.setSessionToken(null);
38
+ apiClient.setWebSocketToken(null);
39
+ });
40
+
41
+ describe("apiClient auth cookies", () => {
42
+ test("captures secure session cookies after login and reuses them on session refresh", async () => {
43
+ const seenCookies: Array<string | null> = [];
44
+
45
+ globalThis.fetch = (async (_input: Request | string | URL, init?: RequestInit) => {
46
+ const headers = new Headers(init?.headers);
47
+ seenCookies.push(headers.get("Cookie"));
48
+
49
+ if (seenCookies.length === 1) {
50
+ return createResponse(
51
+ { token: "ws-token", user: verifiedUser },
52
+ { cookies: ["__Secure-gloomberb.session_token=signed-token.value; Path=/; HttpOnly; Secure; SameSite=Lax"] },
53
+ );
54
+ }
55
+
56
+ return createResponse({ user: verifiedUser });
57
+ }) as typeof fetch;
58
+
59
+ await apiClient.signIn("test@example.com", "password");
60
+ await apiClient.getSession();
61
+
62
+ expect(apiClient.getSessionToken()).toBe("signed-token.value");
63
+ expect(apiClient.getWebSocketToken()).toBe("ws-token");
64
+ expect(seenCookies).toEqual([
65
+ null,
66
+ "__Secure-gloomberb.session_token=signed-token.value",
67
+ ]);
68
+ });
69
+
70
+ test("replays both supported cookie names when restoring a saved session token", async () => {
71
+ const seenCookies: Array<string | null> = [];
72
+ apiClient.setSessionToken("persisted-token.value");
73
+
74
+ globalThis.fetch = (async (_input: Request | string | URL, init?: RequestInit) => {
75
+ const headers = new Headers(init?.headers);
76
+ seenCookies.push(headers.get("Cookie"));
77
+ return createResponse({ user: verifiedUser });
78
+ }) as typeof fetch;
79
+
80
+ await apiClient.getSession();
81
+
82
+ expect(seenCookies).toEqual([
83
+ "__Secure-gloomberb.session_token=persisted-token.value; gloomberb.session_token=persisted-token.value",
84
+ ]);
85
+ });
86
+ });
@@ -3,6 +3,7 @@ import type { InstrumentSearchResult } from "../types/instrument";
3
3
  import { debugLog } from "./debug-log";
4
4
 
5
5
  const DEFAULT_API_URL = "https://api.gloom.sh";
6
+ const SESSION_COOKIE_NAMES = ["__Secure-gloomberb.session_token", "gloomberb.session_token"] as const;
6
7
  const cloudApiLog = debugLog.createLogger("cloud-api");
7
8
 
8
9
  export interface ChatMessage {
@@ -107,6 +108,7 @@ function marketKey(symbol: string, exchange?: string): string {
107
108
 
108
109
  type ChannelListener = (message: ChatMessage) => void;
109
110
  type QuoteListener = (target: QuoteStreamTarget, quote: CloudQuotePayload) => void;
111
+ type SessionCookieName = (typeof SESSION_COOKIE_NAMES)[number];
110
112
 
111
113
  class ApiRequestError extends Error {
112
114
  constructor(message: string, readonly status?: number) {
@@ -117,6 +119,7 @@ class ApiRequestError extends Error {
117
119
 
118
120
  class GloomApiClient {
119
121
  private sessionToken: string | null = null;
122
+ private sessionCookieName: SessionCookieName | null = null;
120
123
  private websocketToken: string | null = null;
121
124
  private currentUser: AuthUser | null = null;
122
125
  private readonly baseUrl: string;
@@ -142,6 +145,9 @@ class GloomApiClient {
142
145
  }
143
146
 
144
147
  setSessionToken(token: string | null): void {
148
+ if (this.sessionToken !== token) {
149
+ this.sessionCookieName = null;
150
+ }
145
151
  this.sessionToken = token;
146
152
  if (!token) {
147
153
  this.websocketToken = null;
@@ -195,22 +201,41 @@ class GloomApiClient {
195
201
 
196
202
  private extractSessionCookie(res: Response): void {
197
203
  const setCookie = res.headers.getSetCookie?.() ?? [];
204
+ const fallbackHeader = res.headers.get("set-cookie");
205
+ if (fallbackHeader) {
206
+ setCookie.push(fallbackHeader);
207
+ }
198
208
  for (const cookie of setCookie) {
199
- const match = cookie.match(/gloomberb\.session_token=([^;]+)/);
200
- if (match) {
209
+ for (const cookieName of SESSION_COOKIE_NAMES) {
210
+ const escapedCookieName = cookieName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
211
+ const match = cookie.match(new RegExp(`${escapedCookieName}=([^;]+)`));
212
+ if (!match) continue;
201
213
  this.sessionToken = match[1] ?? null;
214
+ this.sessionCookieName = cookieName;
215
+ return;
202
216
  }
203
217
  }
204
218
  }
205
219
 
220
+ private buildSessionCookieHeader(): string | null {
221
+ if (!this.sessionToken) return null;
222
+ const cookieNames = this.sessionCookieName ? [this.sessionCookieName] : SESSION_COOKIE_NAMES;
223
+ return cookieNames.map((cookieName) => `${cookieName}=${this.sessionToken}`).join("; ");
224
+ }
225
+
226
+ private setSessionCookieHeader(headers: Headers): void {
227
+ const cookieHeader = this.buildSessionCookieHeader();
228
+ if (cookieHeader) {
229
+ headers.set("Cookie", cookieHeader);
230
+ }
231
+ }
232
+
206
233
  private async request<T>(path: string, options?: RequestInit): Promise<T> {
207
234
  const headers = new Headers(options?.headers);
208
235
  if (!headers.has("Content-Type") && options?.method && options.method !== "GET") {
209
236
  headers.set("Content-Type", "application/json");
210
237
  }
211
- if (this.sessionToken) {
212
- headers.set("Cookie", `gloomberb.session_token=${this.sessionToken}`);
213
- }
238
+ this.setSessionCookieHeader(headers);
214
239
  headers.set("Origin", this.baseUrl);
215
240
 
216
241
  const res = await fetch(`${this.baseUrl}${path}`, {
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const VERSION = "0.3.1";
1
+ export const VERSION = "0.3.3";