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/package.json +1 -1
- package/src/app.tsx +41 -6
- package/src/components/command-bar/command-bar.test.tsx +22 -0
- package/src/components/command-bar/command-bar.tsx +19 -1
- package/src/components/command-bar/command-registry.ts +8 -0
- package/src/components/layout/header.tsx +24 -2
- package/src/components/layout/shell.test.tsx +26 -0
- package/src/components/markdown-text.tsx +218 -0
- package/src/data/config-store.test.ts +39 -2
- package/src/data/config-store.ts +40 -2
- package/src/pane-settings.test.ts +3 -19
- package/src/plugins/builtin/ai/ask-ai-detail-tab.tsx +17 -5
- package/src/plugins/builtin/portfolio-list/metrics.ts +38 -14
- package/src/plugins/builtin/portfolio-list/settings.ts +1 -11
- package/src/plugins/builtin/portfolio-metrics.test.ts +71 -0
- package/src/state/app-context.test.tsx +30 -0
- package/src/state/app-context.tsx +15 -3
- package/src/types/config.ts +12 -2
- package/src/updater.test.ts +121 -1
- package/src/updater.ts +75 -19
- package/src/utils/api-client.test.ts +86 -0
- package/src/utils/api-client.ts +30 -5
- package/src/version.ts +1 -1
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
|
|
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
|
|
151
|
+
export async function checkForUpdateDetailed(
|
|
127
152
|
currentVersion: string,
|
|
128
|
-
): Promise<
|
|
153
|
+
): Promise<UpdateCheckResult> {
|
|
129
154
|
const updateAction = detectUpdateAction();
|
|
130
|
-
if (!updateAction)
|
|
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
|
-
|
|
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)
|
|
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)
|
|
180
|
+
if (compareSemver(version, currentVersion) <= 0) {
|
|
181
|
+
return { kind: "current" };
|
|
182
|
+
}
|
|
152
183
|
|
|
153
|
-
const
|
|
154
|
-
|
|
155
|
-
|
|
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
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
});
|
package/src/utils/api-client.ts
CHANGED
|
@@ -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
|
|
200
|
-
|
|
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
|
-
|
|
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
|
+
export const VERSION = "0.3.3";
|