gloomberb 0.9.8 → 0.9.10
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/api-client/index.test.ts +3 -3
- package/src/api-client/index.ts +0 -6
- package/src/api-client/request.test.ts +63 -0
- package/src/api-client/request.ts +53 -5
- package/src/api-client/types.ts +0 -2
- package/src/app/pane-runtime/layout-placement.ts +0 -1
- package/src/app.tsx +1 -0
- package/src/brokers/account-cache.ts +2 -10
- package/src/brokers/broker-portfolio-sync.ts +0 -1
- package/src/brokers/broker-ticker-sync.ts +20 -16
- package/src/brokers/remote-broker-adapter.ts +3 -0
- package/src/capabilities/registry.test.ts +25 -0
- package/src/capabilities/registry.ts +22 -5
- package/src/cli/commands/automation.ts +1 -3
- package/src/cli/commands/command-utils.ts +0 -7
- package/src/cli/commands/overview.ts +2 -2
- package/src/cli/commands/plugins.ts +34 -17
- package/src/cli/index.ts +2 -2
- package/src/cli.test.ts +0 -157
- package/src/components/chart/comparison/renderer.ts +0 -1
- package/src/components/chart/comparison-stock-chart/render-output.ts +3 -8
- package/src/components/chart/comparison-stock-chart/view.tsx +0 -2
- package/src/components/chart/core/index.test.ts +0 -24
- package/src/components/chart/core/renderer.ts +0 -4
- package/src/components/chart/core/resolution.test.ts +0 -17
- package/src/components/chart/core/resolution.ts +0 -26
- package/src/components/chart/core/scroll.test.ts +0 -6
- package/src/components/chart/core/scroll.ts +0 -22
- package/src/components/chart/core/viewport.test.ts +2 -23
- package/src/components/chart/core/viewport.ts +0 -27
- package/src/components/chart/market-session.ts +1 -1
- package/src/components/chart/native/surface/hooks.ts +10 -3
- package/src/components/chart/stock-chart/data-runtime.ts +0 -1
- package/src/components/chart/stock-chart/viewport/effects.ts +0 -3
- package/src/components/command-bar/helpers.test.ts +1 -80
- package/src/components/command-bar/helpers.ts +1 -36
- package/src/components/command-bar/pane-settings.ts +1 -1
- package/src/components/command-bar/panel/runtime.ts +0 -2
- package/src/components/command-bar/routes/ticker-search/results.ts +0 -11
- package/src/components/command-bar/surface/index.test.tsx +0 -16
- package/src/components/command-bar/surface/index.tsx +0 -1
- package/src/components/feed-data-table/stack-view.test.tsx +36 -130
- package/src/components/feed-data-table/stack-view.tsx +0 -1
- package/src/components/layout/shell/index.test.tsx +2 -113
- package/src/components/layout/shell/index.tsx +0 -1
- package/src/components/layout/shell/menu.ts +0 -1
- package/src/components/layout/shell/terminal-pointer-runtime.ts +0 -1
- package/src/components/onboarding/onboarding-wizard.tsx +3 -3
- package/src/components/onboarding/wizard-keyboard.ts +2 -23
- package/src/components/price-selector-dialog.tsx +3 -3
- package/src/components/ticker/badge/text.tsx +0 -1
- package/src/components/ui/page-stack-view.tsx +1 -1
- package/src/components/ui/ui.test.tsx +0 -78
- package/src/core/state/app/layout-reducer.ts +0 -1
- package/src/data/config/layout.ts +3 -3
- package/src/data/resource-store.ts +6 -0
- package/src/data/sqlite/cache.test.ts +5 -0
- package/src/data/ticker-store.ts +0 -7
- package/src/market-data/coordinator/auxiliary.ts +1 -44
- package/src/market-data/coordinator/entries.ts +0 -1
- package/src/market-data/coordinator/index.test.ts +0 -10
- package/src/market-data/coordinator/index.ts +1 -18
- package/src/market-data/coordinator/quotes.ts +0 -6
- package/src/market-data/coordinator/subscriptions.test.ts +1 -0
- package/src/market-data/market/status.ts +19 -1
- package/src/market-data/query-store.ts +0 -4
- package/src/market-data/quotes/resolution.ts +2 -2
- package/src/market-data/request-types.ts +0 -5
- package/src/market-data/result-types.ts +0 -11
- package/src/market-data/selectors.ts +1 -5
- package/src/news/aggregator.ts +5 -1
- package/src/pane-settings.test.ts +0 -18
- package/src/plugins/builtin/account-management/form-components.tsx +0 -120
- package/src/plugins/builtin/account-management/model.ts +3 -27
- package/src/plugins/builtin/broker-manager/index.test.tsx +0 -32
- package/src/plugins/builtin/buildout/cells.tsx +1 -1
- package/src/plugins/builtin/buildout/detail/satellite-images.tsx +1 -1
- package/src/plugins/builtin/buildout/detail/ui.tsx +0 -1
- package/src/plugins/builtin/buildout/format.ts +2 -11
- package/src/plugins/builtin/chat/chat.test.tsx +2 -1
- package/src/plugins/builtin/chat/content/index.tsx +1 -2
- package/src/plugins/builtin/chat/content/transcript.tsx +0 -1
- package/src/plugins/builtin/chat/controller/index.ts +0 -10
- package/src/plugins/builtin/chat/controller.test.ts +5 -5
- package/src/plugins/builtin/chat/sidebar.test.tsx +15 -5
- package/src/plugins/builtin/chat/test-harness.tsx +2 -2
- package/src/plugins/builtin/cloud/plugin.tsx +1 -1
- package/src/plugins/builtin/cloud-tweets/pane.tsx +0 -1
- package/src/plugins/builtin/congress-trades/model.ts +2 -7
- package/src/plugins/builtin/congress-trades/pane.tsx +0 -1
- package/src/plugins/builtin/earnings/index.tsx +0 -9
- package/src/plugins/builtin/econ/index.tsx +1 -5
- package/src/plugins/builtin/help/index.tsx +2 -2
- package/src/plugins/builtin/holders/pane.tsx +0 -5
- package/src/plugins/builtin/insider/index.tsx +1 -8
- package/src/plugins/builtin/kelly-sizer/index.test.tsx +0 -26
- package/src/plugins/builtin/kelly-sizer/index.tsx +0 -2
- package/src/plugins/builtin/market-movers/index.tsx +0 -2
- package/src/plugins/builtin/news/index.tsx +0 -1
- package/src/plugins/builtin/news/wire/news/table.tsx +0 -11
- package/src/plugins/builtin/options/view.test.tsx +1 -38
- package/src/plugins/builtin/plugin-groups.ts +17 -3
- package/src/plugins/builtin/portfolio-list/column-values.ts +15 -8
- package/src/plugins/builtin/portfolio-list/index.test.ts +1 -24
- package/src/plugins/builtin/portfolio-list/metrics.test.ts +44 -7
- package/src/plugins/builtin/sec/filing-documents.ts +0 -11
- package/src/plugins/builtin/sec/index.tsx +0 -2
- package/src/plugins/builtin/substack/article-detail.tsx +1 -1
- package/src/plugins/builtin/thirteenf/api.ts +0 -15
- package/src/plugins/builtin/thirteenf/pane.tsx +1 -3
- package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph/content.tsx +1 -1
- package/src/plugins/builtin/ticker-detail/financials/tab.tsx +0 -7
- package/src/plugins/builtin/ticker-detail/quote-monitor/index.test.tsx +5 -34
- package/src/plugins/builtin/world-indices/index.tsx +0 -4
- package/src/plugins/builtin/world-indices/model.ts +0 -7
- package/src/plugins/ibkr/account-cache.ts +1 -1
- package/src/plugins/ibkr/portfolio-performance.ts +1 -1
- package/src/plugins/ibkr/trade/utils.ts +1 -7
- package/src/plugins/loader.ts +2 -1
- package/src/plugins/prediction-markets/detail.test.tsx +0 -34
- package/src/plugins/registry/contributions.ts +22 -11
- package/src/plugins/registry/index.test.ts +44 -0
- package/src/plugins/registry/index.ts +44 -26
- package/src/remote/controller.ts +1 -7
- package/src/remote/json-patch.ts +15 -1
- package/src/remote/server.ts +16 -6
- package/src/renderers/electrobun/bun/application-menu/index.ts +1 -1
- package/src/renderers/electrobun/bun/core-capabilities.ts +2 -0
- package/src/renderers/electrobun/bun/desktop/http-fetch.test.ts +14 -0
- package/src/renderers/electrobun/bun/desktop/http-fetch.ts +8 -0
- package/src/renderers/electrobun/bun/desktop/initialization.ts +2 -1
- package/src/renderers/electrobun/view/cli-pane-shot-entry.tsx +1 -0
- package/src/renderers/electrobun/view/data-table/dom.ts +0 -1
- package/src/renderers/electrobun/view/host/mouse.ts +0 -11
- package/src/renderers/electrobun/view/http-fetch.ts +15 -2
- package/src/renderers/electrobun/view/remote/asset-data-client.ts +9 -18
- package/src/renderers/electrobun/view/remote/capability-invoker.test.ts +50 -0
- package/src/renderers/electrobun/view/remote/capability-invoker.ts +36 -0
- package/src/renderers/opentui/host.tsx +11 -3
- package/src/renderers/opentui/input-host.tsx +5 -1
- package/src/sources/gloomberb-cloud/index.test.ts +0 -72
- package/src/sources/gloomberb-cloud/index.ts +1 -30
- package/src/sources/gloomberb-cloud/news.ts +0 -12
- package/src/sources/provider-router/financial-routes.ts +25 -3
- package/src/sources/provider-router/index.test.ts +167 -0
- package/src/sources/provider-router/search.ts +5 -0
- package/src/sources/provider-router/supplemental.ts +4 -3
- package/src/sources/yahoo-finance/history.ts +5 -1
- package/src/sources/yahoo-finance/options.ts +59 -14
- package/src/sources/yahoo-finance/quote-summary.ts +4 -1
- package/src/sources/yahoo-finance.test.ts +40 -21
- package/src/sources/yahoo-finance.ts +13 -8
- package/src/state/app/context/index.test.tsx +0 -81
- package/src/state/app/context/selector.test.tsx +6 -2
- package/src/sync/controller.test.ts +151 -0
- package/src/sync/controller.ts +92 -57
- package/src/sync/core-contributors.ts +45 -21
- package/src/sync/react.ts +6 -8
- package/src/sync/types.ts +3 -0
- package/src/test-support/data-provider.ts +0 -1
- package/src/ui/host.tsx +2 -1
- package/src/ui/index.tsx +1 -0
- package/src/updater.test.ts +0 -21
- package/src/updater.ts +20 -16
- package/src/utils/async-deadline.ts +26 -0
- package/src/utils/selection-clipboard.ts +2 -2
- package/src/version.ts +1 -1
- package/src/components/layout/pane/header.test.tsx +0 -39
- package/src/plugins/builtin/chat/channels.test.ts +0 -28
- package/src/plugins/builtin/chat/index.tsx +0 -4
- package/src/plugins/builtin/cloud-tweets/index.ts +0 -1
- package/src/plugins/builtin/earnings/index.test.ts +0 -12
- package/src/plugins/builtin/help/index.test.tsx +0 -242
- package/src/plugins/builtin/substack/article-detail.test.tsx +0 -10
- package/src/renderers/electrobun/bun/application-menu/index.test.ts +0 -21
- package/src/renderers/electrobun/bun/window/focus.test.ts +0 -35
- package/src/renderers/electrobun/view/host/mouse.test.ts +0 -37
- package/src/renderers/electrobun/view/host/text.test.ts +0 -22
- package/src/renderers/opentui/dialog-host.test.tsx +0 -44
- /package/src/{plugins/ibkr → utils}/hash.ts +0 -0
package/package.json
CHANGED
|
@@ -415,7 +415,7 @@ describe("apiClient chat timestamps", () => {
|
|
|
415
415
|
seenCreatedAts.push(message.createdAt);
|
|
416
416
|
});
|
|
417
417
|
|
|
418
|
-
await (apiClient as any).handleSocketMessage(JSON.stringify({
|
|
418
|
+
await (apiClient as any).socket.handleSocketMessage(JSON.stringify({
|
|
419
419
|
type: "chat.message",
|
|
420
420
|
channelId: "everyone",
|
|
421
421
|
data: {
|
|
@@ -531,11 +531,11 @@ describe("apiClient chat timestamps", () => {
|
|
|
531
531
|
seenNotifications.push(`${notification.id}:${notification.message.createdAt}`);
|
|
532
532
|
});
|
|
533
533
|
|
|
534
|
-
await (apiClient as any).handleSocketMessage(JSON.stringify({
|
|
534
|
+
await (apiClient as any).socket.handleSocketMessage(JSON.stringify({
|
|
535
535
|
type: "chat.presence",
|
|
536
536
|
onlineCount: 5,
|
|
537
537
|
}));
|
|
538
|
-
await (apiClient as any).handleSocketMessage(JSON.stringify({
|
|
538
|
+
await (apiClient as any).socket.handleSocketMessage(JSON.stringify({
|
|
539
539
|
type: "chat.notification",
|
|
540
540
|
data: {
|
|
541
541
|
id: "n1",
|
package/src/api-client/index.ts
CHANGED
|
@@ -39,8 +39,6 @@ import type {
|
|
|
39
39
|
CloudCongressHousePayload,
|
|
40
40
|
CloudNewsPayload,
|
|
41
41
|
CloudNewsListResponse,
|
|
42
|
-
CloudTweetPayload,
|
|
43
|
-
CloudTweetQueryType,
|
|
44
42
|
CloudTweetSearchResponse,
|
|
45
43
|
CloudMarketResponse,
|
|
46
44
|
CloudMarketBatchTarget,
|
|
@@ -158,10 +156,6 @@ class GloomApiClient {
|
|
|
158
156
|
return this.transport.getSocketAuthToken();
|
|
159
157
|
}
|
|
160
158
|
|
|
161
|
-
private async handleSocketMessage(raw: string): Promise<void> {
|
|
162
|
-
await this.socket.handleSocketMessage(raw);
|
|
163
|
-
}
|
|
164
|
-
|
|
165
159
|
async ensureVerifiedSession(): Promise<AuthUser | null> {
|
|
166
160
|
return this.auth.ensureVerifiedSession();
|
|
167
161
|
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { CloudApiRequestTransport } from "./request";
|
|
3
|
+
|
|
4
|
+
function responseWithBody(body: () => Promise<string>): Response {
|
|
5
|
+
return {
|
|
6
|
+
ok: true,
|
|
7
|
+
status: 200,
|
|
8
|
+
headers: new Headers(),
|
|
9
|
+
text: body,
|
|
10
|
+
} as Response;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
describe("CloudApiRequestTransport market deadlines", () => {
|
|
14
|
+
test("aborts when response headers never arrive", async () => {
|
|
15
|
+
let signal: AbortSignal | null | undefined;
|
|
16
|
+
const transport = new CloudApiRequestTransport({
|
|
17
|
+
marketRequestTimeoutMs: 10,
|
|
18
|
+
fetchTransport: async (_url, init) => {
|
|
19
|
+
signal = init?.signal;
|
|
20
|
+
return new Promise<Response>(() => {});
|
|
21
|
+
},
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
await expect(transport.request("/market/quote?symbol=AAPL")).rejects.toThrow(
|
|
25
|
+
"Cloud market request timed out after 10ms",
|
|
26
|
+
);
|
|
27
|
+
expect(signal?.aborted).toBe(true);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("keeps the deadline active while reading the response body", async () => {
|
|
31
|
+
let signal: AbortSignal | null | undefined;
|
|
32
|
+
const transport = new CloudApiRequestTransport({
|
|
33
|
+
marketRequestTimeoutMs: 10,
|
|
34
|
+
fetchTransport: async (_url, init) => {
|
|
35
|
+
signal = init?.signal;
|
|
36
|
+
return responseWithBody(async () => new Promise<string>(() => {}));
|
|
37
|
+
},
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
await expect(transport.request("/market/history?symbol=AAPL")).rejects.toThrow(
|
|
41
|
+
"Cloud market request timed out after 10ms",
|
|
42
|
+
);
|
|
43
|
+
expect(signal?.aborted).toBe(true);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("preserves a caller abort instead of replacing it with the market deadline", async () => {
|
|
47
|
+
let fetchCalls = 0;
|
|
48
|
+
const controller = new AbortController();
|
|
49
|
+
controller.abort(new Error("cancelled by caller"));
|
|
50
|
+
const transport = new CloudApiRequestTransport({
|
|
51
|
+
marketRequestTimeoutMs: 100,
|
|
52
|
+
fetchTransport: async () => {
|
|
53
|
+
fetchCalls += 1;
|
|
54
|
+
return responseWithBody(async () => "{}");
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
await expect(transport.request("/market/quote?symbol=AAPL", {
|
|
59
|
+
signal: controller.signal,
|
|
60
|
+
})).rejects.toThrow("cancelled by caller");
|
|
61
|
+
expect(fetchCalls).toBe(0);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { httpFetch } from "../utils/http-transport";
|
|
2
|
+
import { withDeadline } from "../utils/async-deadline";
|
|
2
3
|
import { ApiRequestError, parseApiErrorMessage } from "./errors";
|
|
3
4
|
|
|
4
5
|
const DEFAULT_API_URL = "https://api.gloom.sh";
|
|
6
|
+
const DEFAULT_MARKET_REQUEST_TIMEOUT_MS = 10_000;
|
|
5
7
|
const SESSION_COOKIE_NAMES = ["__Secure-gloomberb.session_token", "gloomberb.session_token"] as const;
|
|
6
8
|
|
|
7
9
|
type CloudApiResponse = Pick<Response, "ok" | "status" | "headers" | "text">;
|
|
@@ -21,13 +23,28 @@ function getCloudApiBaseUrl(): string {
|
|
|
21
23
|
return process.env.GLOOMBERB_API_URL ?? DEFAULT_API_URL;
|
|
22
24
|
}
|
|
23
25
|
|
|
26
|
+
function throwIfRequestAborted(signal: AbortSignal | null | undefined): void {
|
|
27
|
+
if (!signal?.aborted) return;
|
|
28
|
+
throw signal.reason ?? new DOMException("The operation was aborted.", "AbortError");
|
|
29
|
+
}
|
|
30
|
+
|
|
24
31
|
export class CloudApiRequestTransport {
|
|
25
32
|
private sessionToken: string | null = null;
|
|
26
33
|
private sessionCookieName: SessionCookieName | null = null;
|
|
27
34
|
private websocketToken: string | null = null;
|
|
35
|
+
private readonly fetchTransport: CloudApiFetchTransport | null;
|
|
36
|
+
private readonly marketRequestTimeoutMs: number;
|
|
28
37
|
|
|
29
38
|
readonly baseUrl = getCloudApiBaseUrl();
|
|
30
39
|
|
|
40
|
+
constructor(options: {
|
|
41
|
+
fetchTransport?: CloudApiFetchTransport;
|
|
42
|
+
marketRequestTimeoutMs?: number;
|
|
43
|
+
} = {}) {
|
|
44
|
+
this.fetchTransport = options.fetchTransport ?? null;
|
|
45
|
+
this.marketRequestTimeoutMs = options.marketRequestTimeoutMs ?? DEFAULT_MARKET_REQUEST_TIMEOUT_MS;
|
|
46
|
+
}
|
|
47
|
+
|
|
31
48
|
getSessionToken(): string | null {
|
|
32
49
|
return this.sessionToken;
|
|
33
50
|
}
|
|
@@ -61,6 +78,37 @@ export class CloudApiRequestTransport {
|
|
|
61
78
|
}
|
|
62
79
|
|
|
63
80
|
async request<T>(path: string, options?: RequestInit): Promise<T> {
|
|
81
|
+
if (!path.startsWith("/market/")) {
|
|
82
|
+
return this.performRequest<T>(path, options);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const controller = new AbortController();
|
|
86
|
+
const callerSignal = options?.signal;
|
|
87
|
+
const abortFromCaller = () => controller.abort(callerSignal?.reason);
|
|
88
|
+
if (callerSignal?.aborted) {
|
|
89
|
+
abortFromCaller();
|
|
90
|
+
} else {
|
|
91
|
+
callerSignal?.addEventListener("abort", abortFromCaller, { once: true });
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
try {
|
|
95
|
+
const request = this.performRequest<T>(path, {
|
|
96
|
+
...options,
|
|
97
|
+
signal: controller.signal,
|
|
98
|
+
});
|
|
99
|
+
return await withDeadline(
|
|
100
|
+
request,
|
|
101
|
+
this.marketRequestTimeoutMs,
|
|
102
|
+
`Cloud market request timed out after ${this.marketRequestTimeoutMs}ms: ${path}`,
|
|
103
|
+
(error) => controller.abort(error),
|
|
104
|
+
);
|
|
105
|
+
} finally {
|
|
106
|
+
callerSignal?.removeEventListener("abort", abortFromCaller);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
private async performRequest<T>(path: string, options?: RequestInit): Promise<T> {
|
|
111
|
+
throwIfRequestAborted(options?.signal);
|
|
64
112
|
const headers = new Headers(options?.headers);
|
|
65
113
|
if (!headers.has("Content-Type") && options?.method && options.method !== "GET") {
|
|
66
114
|
headers.set("Content-Type", "application/json");
|
|
@@ -68,21 +116,21 @@ export class CloudApiRequestTransport {
|
|
|
68
116
|
this.setSessionCookieHeader(headers);
|
|
69
117
|
headers.set("Origin", this.baseUrl);
|
|
70
118
|
|
|
71
|
-
const res = await cloudApiFetchTransport(`${this.baseUrl}${path}`, {
|
|
119
|
+
const res = await (this.fetchTransport ?? cloudApiFetchTransport)(`${this.baseUrl}${path}`, {
|
|
72
120
|
...options,
|
|
73
121
|
headers,
|
|
74
122
|
credentials: "include",
|
|
75
123
|
});
|
|
76
|
-
|
|
124
|
+
throwIfRequestAborted(options?.signal);
|
|
77
125
|
this.extractSessionCookie(res);
|
|
126
|
+
const text = await res.text();
|
|
127
|
+
throwIfRequestAborted(options?.signal);
|
|
78
128
|
|
|
79
129
|
if (!res.ok) {
|
|
80
|
-
const
|
|
81
|
-
const msg = parseApiErrorMessage(body);
|
|
130
|
+
const msg = parseApiErrorMessage(text);
|
|
82
131
|
throw new ApiRequestError(msg, res.status);
|
|
83
132
|
}
|
|
84
133
|
|
|
85
|
-
const text = await res.text();
|
|
86
134
|
if (!text) return undefined as T;
|
|
87
135
|
const parsed = JSON.parse(text) as T & { token?: string };
|
|
88
136
|
if (typeof parsed?.token === "string" && parsed.token.length > 0) {
|
package/src/api-client/types.ts
CHANGED
package/src/app.tsx
CHANGED
|
@@ -3,6 +3,7 @@ import type { BrokerAdapter } from "../types/broker";
|
|
|
3
3
|
import type { BrokerInstanceConfig } from "../types/config";
|
|
4
4
|
import type { CachePolicy } from "../types/persistence";
|
|
5
5
|
import type { BrokerAccount } from "../types/trading";
|
|
6
|
+
import { fnv1aHashString } from "../utils/hash";
|
|
6
7
|
|
|
7
8
|
const BROKER_ACCOUNT_SNAPSHOT_KIND = "account-snapshot";
|
|
8
9
|
const BROKER_ACCOUNT_SNAPSHOT_SCHEMA_VERSION = 1;
|
|
@@ -16,15 +17,6 @@ interface PersistedBrokerAccountSnapshot {
|
|
|
16
17
|
brokerType?: string;
|
|
17
18
|
}
|
|
18
19
|
|
|
19
|
-
function hashString(input: string): string {
|
|
20
|
-
let hash = 0x811c9dc5;
|
|
21
|
-
for (let index = 0; index < input.length; index += 1) {
|
|
22
|
-
hash ^= input.charCodeAt(index);
|
|
23
|
-
hash = Math.imul(hash, 0x01000193);
|
|
24
|
-
}
|
|
25
|
-
return `fnv1a:${(hash >>> 0).toString(16).padStart(8, "0")}`;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
20
|
function brokerAccountNamespace(instance: BrokerInstanceConfig): string {
|
|
29
21
|
return `plugin:${instance.brokerType}`;
|
|
30
22
|
}
|
|
@@ -36,7 +28,7 @@ export function getBrokerAccountCacheSourceKey(
|
|
|
36
28
|
if (broker?.getAccountCacheSourceKey) {
|
|
37
29
|
return broker.getAccountCacheSourceKey(instance);
|
|
38
30
|
}
|
|
39
|
-
return
|
|
31
|
+
return fnv1aHashString(JSON.stringify({
|
|
40
32
|
brokerType: instance.brokerType,
|
|
41
33
|
config: broker?.toConfigValues?.(instance) ?? instance.config,
|
|
42
34
|
}));
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import type { AppConfig, BrokerInstanceConfig } from "../types/config";
|
|
2
|
-
import type { BrokerContractRef } from "../types/instrument";
|
|
3
2
|
import type { Portfolio, TickerPosition, TickerRecord } from "../types/ticker";
|
|
4
3
|
import { buildBrokerPortfolioId, getBrokerInstance, isBrokerPortfolioId } from "../utils/broker-instances";
|
|
5
4
|
|
|
@@ -78,29 +78,33 @@ function updateExistingTicker(
|
|
|
78
78
|
positionEntry: TickerPosition,
|
|
79
79
|
brokerContract?: BrokerContractRef,
|
|
80
80
|
): TickerRecord {
|
|
81
|
-
if (position.name && ticker.metadata.name === ticker.metadata.ticker) {
|
|
82
|
-
ticker.metadata.name = position.name;
|
|
83
|
-
}
|
|
84
|
-
if (position.assetCategory && !ticker.metadata.assetCategory) {
|
|
85
|
-
ticker.metadata.assetCategory = position.assetCategory;
|
|
86
|
-
}
|
|
87
|
-
if (position.isin && !ticker.metadata.isin) {
|
|
88
|
-
ticker.metadata.isin = position.isin;
|
|
89
|
-
}
|
|
90
|
-
|
|
91
81
|
const otherPositions = ticker.metadata.positions.filter(
|
|
92
82
|
(entry) => !(entry.portfolio === portfolioId && entry.broker === instance.brokerType),
|
|
93
83
|
);
|
|
94
|
-
|
|
95
|
-
ticker.metadata.broker_contracts = mergeBrokerContracts(
|
|
84
|
+
const brokerContracts = mergeBrokerContracts(
|
|
96
85
|
ticker.metadata.broker_contracts ?? [],
|
|
97
86
|
brokerContract ? [brokerContract] : [],
|
|
98
87
|
);
|
|
99
|
-
|
|
100
|
-
ticker.metadata.portfolios
|
|
101
|
-
|
|
88
|
+
const portfolios = ticker.metadata.portfolios.includes(portfolioId)
|
|
89
|
+
? ticker.metadata.portfolios
|
|
90
|
+
: [...ticker.metadata.portfolios, portfolioId];
|
|
102
91
|
|
|
103
|
-
return
|
|
92
|
+
return {
|
|
93
|
+
...ticker,
|
|
94
|
+
metadata: {
|
|
95
|
+
...ticker.metadata,
|
|
96
|
+
name: position.name && ticker.metadata.name === ticker.metadata.ticker
|
|
97
|
+
? position.name
|
|
98
|
+
: ticker.metadata.name,
|
|
99
|
+
assetCategory: position.assetCategory && !ticker.metadata.assetCategory
|
|
100
|
+
? position.assetCategory
|
|
101
|
+
: ticker.metadata.assetCategory,
|
|
102
|
+
isin: position.isin && !ticker.metadata.isin ? position.isin : ticker.metadata.isin,
|
|
103
|
+
positions: [...otherPositions, positionEntry],
|
|
104
|
+
broker_contracts: brokerContracts,
|
|
105
|
+
portfolios,
|
|
106
|
+
},
|
|
107
|
+
};
|
|
104
108
|
}
|
|
105
109
|
|
|
106
110
|
export async function upsertBrokerPositionTicker({
|
|
@@ -44,6 +44,9 @@ export function createRemoteBrokerAdapter(adapter: BrokerAdapter): BrokerAdapter
|
|
|
44
44
|
...adapter,
|
|
45
45
|
validate: (instance) => invoke<boolean>(instance, "validate"),
|
|
46
46
|
importPositions: (instance) => invoke(instance, "importPositions"),
|
|
47
|
+
importPortfolioSnapshot: adapter.importPortfolioSnapshot
|
|
48
|
+
? (instance) => invoke(instance, "importPortfolioSnapshot")
|
|
49
|
+
: undefined,
|
|
47
50
|
connect: (instance) => invoke<void>(instance, "connect"),
|
|
48
51
|
disconnect: (instance) => invoke<void>(instance, "disconnect"),
|
|
49
52
|
getStatus: (instance) => getBrokerRemoteClient()?.getStatus(instance.id) ?? adapter.getStatus?.(instance) ?? disconnectedStatus(),
|
|
@@ -47,6 +47,10 @@ describe("CapabilityRegistry", () => {
|
|
|
47
47
|
|
|
48
48
|
dispose();
|
|
49
49
|
expect(registry.list()).toEqual([]);
|
|
50
|
+
|
|
51
|
+
registry.register("plugin-b", testCapability());
|
|
52
|
+
dispose();
|
|
53
|
+
expect(registry.list()).toHaveLength(1);
|
|
50
54
|
});
|
|
51
55
|
|
|
52
56
|
test("filters disabled plugins and disabled capabilities", () => {
|
|
@@ -134,4 +138,25 @@ describe("CapabilityRegistry", () => {
|
|
|
134
138
|
expect(disposed).toBe(2);
|
|
135
139
|
expect(registry.list()).toEqual([]);
|
|
136
140
|
});
|
|
141
|
+
|
|
142
|
+
test("disposes subscriptions that finish after their capability is removed", async () => {
|
|
143
|
+
const registry = new CapabilityRegistry();
|
|
144
|
+
let finishSubscribe!: (dispose: () => void) => void;
|
|
145
|
+
let disposed = 0;
|
|
146
|
+
const disposeCapability = registry.register("plugin-a", testCapability({
|
|
147
|
+
operations: {
|
|
148
|
+
ticks: {
|
|
149
|
+
kind: "stream",
|
|
150
|
+
subscribe: () => new Promise((resolve) => { finishSubscribe = resolve; }),
|
|
151
|
+
},
|
|
152
|
+
},
|
|
153
|
+
}));
|
|
154
|
+
|
|
155
|
+
const subscribing = registry.subscribe("plugin-service.test", "ticks", {}, () => {});
|
|
156
|
+
disposeCapability();
|
|
157
|
+
finishSubscribe(() => { disposed += 1; });
|
|
158
|
+
|
|
159
|
+
await expect(subscribing).rejects.toThrow("not available");
|
|
160
|
+
expect(disposed).toBe(1);
|
|
161
|
+
});
|
|
137
162
|
});
|
|
@@ -24,15 +24,22 @@ export class CapabilityRegistry {
|
|
|
24
24
|
if (existing) {
|
|
25
25
|
throw new Error(`Capability "${capability.id}" already registered by plugin "${existing.pluginId}".`);
|
|
26
26
|
}
|
|
27
|
-
|
|
27
|
+
const registration = { pluginId, capability };
|
|
28
|
+
this.capabilities.set(capability.id, registration);
|
|
28
29
|
return () => {
|
|
30
|
+
if (this.capabilities.get(capability.id) !== registration) return;
|
|
29
31
|
this.capabilities.delete(capability.id);
|
|
32
|
+
let firstError: unknown;
|
|
30
33
|
for (const [subscriptionId, entry] of this.subscriptions) {
|
|
31
34
|
if (entry.capabilityId === capability.id) {
|
|
32
|
-
|
|
33
|
-
|
|
35
|
+
try {
|
|
36
|
+
this.unsubscribe(subscriptionId);
|
|
37
|
+
} catch (error) {
|
|
38
|
+
firstError ??= error;
|
|
39
|
+
}
|
|
34
40
|
}
|
|
35
41
|
}
|
|
42
|
+
if (firstError) throw firstError;
|
|
36
43
|
};
|
|
37
44
|
}
|
|
38
45
|
|
|
@@ -97,6 +104,10 @@ export class CapabilityRegistry {
|
|
|
97
104
|
const subscriptionId = options.subscriptionId ?? `${capabilityId}:${operationId}:${this.nextSubscriptionId++}`;
|
|
98
105
|
this.unsubscribe(subscriptionId);
|
|
99
106
|
const dispose = await operation.subscribe(input, emit, { capability, operationId });
|
|
107
|
+
if (this.capabilities.get(capabilityId)?.capability !== capability) {
|
|
108
|
+
dispose();
|
|
109
|
+
throw new Error(`Capability "${capabilityId}" is not available.`);
|
|
110
|
+
}
|
|
100
111
|
this.subscriptions.set(subscriptionId, { capabilityId, dispose });
|
|
101
112
|
return subscriptionId;
|
|
102
113
|
}
|
|
@@ -104,12 +115,18 @@ export class CapabilityRegistry {
|
|
|
104
115
|
unsubscribe(subscriptionId: string): void {
|
|
105
116
|
const entry = this.subscriptions.get(subscriptionId);
|
|
106
117
|
if (!entry) return;
|
|
107
|
-
entry.dispose();
|
|
108
118
|
this.subscriptions.delete(subscriptionId);
|
|
119
|
+
entry.dispose();
|
|
109
120
|
}
|
|
110
121
|
|
|
111
122
|
destroy(): void {
|
|
112
|
-
for (const subscriptionId of [...this.subscriptions.keys()])
|
|
123
|
+
for (const subscriptionId of [...this.subscriptions.keys()]) {
|
|
124
|
+
try {
|
|
125
|
+
this.unsubscribe(subscriptionId);
|
|
126
|
+
} catch {
|
|
127
|
+
// Continue releasing the remaining subscriptions.
|
|
128
|
+
}
|
|
129
|
+
}
|
|
113
130
|
this.capabilities.clear();
|
|
114
131
|
}
|
|
115
132
|
|
|
@@ -102,9 +102,7 @@ export const aiCliCommand: CliCommandDef = {
|
|
|
102
102
|
if (action === "ask") {
|
|
103
103
|
const rawArgs = args.slice(1);
|
|
104
104
|
const providerId = takeOption(rawArgs, "--provider") ?? detectProviders().find((provider) => provider.available)?.id;
|
|
105
|
-
const
|
|
106
|
-
if (!provider) ctx.fail("Unknown AI provider.");
|
|
107
|
-
const selectedProvider = provider;
|
|
105
|
+
const selectedProvider = getAiProvider(providerId) ?? ctx.fail("Unknown AI provider.");
|
|
108
106
|
if (!selectedProvider.available) ctx.fail(`${selectedProvider.name} is not installed or not available in PATH.`);
|
|
109
107
|
const prompt = rawArgs.join(" ").trim();
|
|
110
108
|
if (!prompt) ctx.fail("Usage: gloomberb ai ask [--provider id] <prompt>");
|
|
@@ -15,13 +15,6 @@ export function takeOption(args: string[], name: string): string | undefined {
|
|
|
15
15
|
return value;
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
-
export function takeFlag(args: string[], name: string): boolean {
|
|
19
|
-
const index = args.indexOf(name);
|
|
20
|
-
if (index < 0) return false;
|
|
21
|
-
args.splice(index, 1);
|
|
22
|
-
return true;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
18
|
export function parseJsonPayload(value: string | undefined, ctx: CliCommandContext): unknown {
|
|
26
19
|
if (!value) return {};
|
|
27
20
|
try {
|
|
@@ -211,8 +211,8 @@ async function runCorrelation(args: string[], ctx: Parameters<CliCommandDef["exe
|
|
|
211
211
|
|
|
212
212
|
export const overviewCliCommands: CliCommandDef[] = [
|
|
213
213
|
{ name: "movers", description: "Fetch gainers, losers, active, or trending market movers", help: { usage: ["movers [gainers|losers|active|trending]"] }, execute: runMoverCommand },
|
|
214
|
-
{ name: "indices", description: "Fetch major US index quotes", execute: (
|
|
215
|
-
{ name: "sectors", description: "Fetch SPDR sector ETF quotes", execute: (
|
|
214
|
+
{ name: "indices", description: "Fetch major US index quotes", execute: (_args, ctx) => runQuoteBasket([...MARKET_SUMMARY_SYMBOLS], ctx, { group: "indices" }) },
|
|
215
|
+
{ name: "sectors", description: "Fetch SPDR sector ETF quotes", execute: (_args, ctx) => runQuoteBasket(SECTOR_ETFS, ctx, { group: "sectors" }) },
|
|
216
216
|
{ name: "fear-greed", description: "Fetch CNN Fear & Greed gauge data", execute: runFearGreed },
|
|
217
217
|
{ name: "econ", description: "Fetch economic calendar events", help: { usage: ["econ [--country US|G7|EU|all] [--impact high|medium|low|all]"] }, execute: runEcon },
|
|
218
218
|
{ name: "fred", description: "Fetch a FRED series through the configured cloud session", help: { usage: ["fred <series-id> [--start yyyy-mm-dd]"] }, execute: runFred },
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { join } from "path";
|
|
2
2
|
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync } from "fs";
|
|
3
|
-
import {
|
|
3
|
+
import { execFileSync } from "child_process";
|
|
4
4
|
import { getPluginsDir } from "../../plugins/loader";
|
|
5
5
|
import {
|
|
6
6
|
cliStyles,
|
|
@@ -18,18 +18,34 @@ function ensurePluginsDir() {
|
|
|
18
18
|
}
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
21
|
+
const GITHUB_SEGMENT_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
22
|
+
|
|
23
|
+
function validatePluginDirectoryName(name: string): string {
|
|
24
|
+
if (!GITHUB_SEGMENT_PATTERN.test(name)) {
|
|
25
|
+
throw new Error(`Invalid plugin name: ${name}.`);
|
|
26
26
|
}
|
|
27
|
-
|
|
28
|
-
|
|
27
|
+
return name;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function parseGitHubRef(rawRef: string): { url: string; name: string } {
|
|
31
|
+
const ref = rawRef.startsWith("github:") ? rawRef.slice("github:".length) : rawRef;
|
|
32
|
+
let segments: string[];
|
|
33
|
+
if (ref.startsWith("https://")) {
|
|
34
|
+
const parsed = new URL(ref);
|
|
35
|
+
if (parsed.hostname !== "github.com" || parsed.port || parsed.username || parsed.password || parsed.search || parsed.hash) {
|
|
36
|
+
throw new Error(`Invalid plugin reference: ${rawRef}. Use user/repo or a GitHub URL.`);
|
|
37
|
+
}
|
|
38
|
+
segments = parsed.pathname.split("/").filter(Boolean);
|
|
39
|
+
} else if (!ref.includes("://")) {
|
|
40
|
+
segments = ref.split("/");
|
|
41
|
+
} else {
|
|
42
|
+
segments = [];
|
|
29
43
|
}
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
44
|
+
const [owner, rawRepo] = segments;
|
|
45
|
+
const repo = rawRepo?.replace(/\.git$/, "");
|
|
46
|
+
if (segments.length === 2 && owner && GITHUB_SEGMENT_PATTERN.test(owner) && repo) {
|
|
47
|
+
const name = validatePluginDirectoryName(repo);
|
|
48
|
+
return { url: `https://github.com/${owner}/${name}.git`, name };
|
|
33
49
|
}
|
|
34
50
|
throw new Error(`Invalid plugin reference: ${ref}. Use user/repo or a GitHub URL.`);
|
|
35
51
|
}
|
|
@@ -47,8 +63,9 @@ export async function installPlugin(ref: string) {
|
|
|
47
63
|
console.log(cliStyles.muted(url));
|
|
48
64
|
|
|
49
65
|
try {
|
|
50
|
-
|
|
66
|
+
execFileSync("git", ["clone", "--depth", "1", url, targetDir], { stdio: "inherit" });
|
|
51
67
|
} catch {
|
|
68
|
+
rmSync(targetDir, { recursive: true, force: true });
|
|
52
69
|
fail(`Failed to clone ${url}.`);
|
|
53
70
|
}
|
|
54
71
|
|
|
@@ -56,7 +73,7 @@ export async function installPlugin(ref: string) {
|
|
|
56
73
|
if (existsSync(pkgPath)) {
|
|
57
74
|
console.log(cliStyles.muted("Installing plugin dependencies..."));
|
|
58
75
|
try {
|
|
59
|
-
|
|
76
|
+
execFileSync("bun", ["install"], { cwd: targetDir, stdio: "inherit" });
|
|
60
77
|
} catch {
|
|
61
78
|
console.error(cliStyles.warning("Warning: failed to install plugin dependencies."));
|
|
62
79
|
}
|
|
@@ -92,7 +109,7 @@ export async function installPlugin(ref: string) {
|
|
|
92
109
|
}
|
|
93
110
|
|
|
94
111
|
export async function removePlugin(name: string) {
|
|
95
|
-
const targetDir = join(PLUGINS_DIR, name);
|
|
112
|
+
const targetDir = join(PLUGINS_DIR, validatePluginDirectoryName(name));
|
|
96
113
|
if (!existsSync(targetDir)) {
|
|
97
114
|
fail(`Plugin "${name}" was not found.`, PLUGINS_DIR);
|
|
98
115
|
}
|
|
@@ -103,7 +120,7 @@ export async function removePlugin(name: string) {
|
|
|
103
120
|
export async function updatePlugins(name?: string) {
|
|
104
121
|
ensurePluginsDir();
|
|
105
122
|
const dirs = name
|
|
106
|
-
? [name]
|
|
123
|
+
? [validatePluginDirectoryName(name)]
|
|
107
124
|
: readdirSync(PLUGINS_DIR, { withFileTypes: true })
|
|
108
125
|
.filter((entry) => entry.isDirectory())
|
|
109
126
|
.map((entry) => entry.name);
|
|
@@ -121,10 +138,10 @@ export async function updatePlugins(name?: string) {
|
|
|
121
138
|
}
|
|
122
139
|
console.log(cliStyles.accent(`Updating ${dir}...`));
|
|
123
140
|
try {
|
|
124
|
-
|
|
141
|
+
execFileSync("git", ["pull", "--ff-only"], { cwd: targetDir, stdio: "inherit" });
|
|
125
142
|
const pkgPath = join(targetDir, "package.json");
|
|
126
143
|
if (existsSync(pkgPath)) {
|
|
127
|
-
|
|
144
|
+
execFileSync("bun", ["install"], { cwd: targetDir, stdio: "inherit" });
|
|
128
145
|
}
|
|
129
146
|
} catch {
|
|
130
147
|
console.error(cliStyles.danger(`Failed to update ${dir}.`));
|
package/src/cli/index.ts
CHANGED
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
import { fail, inferCliErrorOptions, printCliError } from "./errors";
|
|
15
15
|
import { setCliColorEnabledOverride } from "../utils/cli-output";
|
|
16
16
|
import { search, searchCandidatesForCli, buildSearchReport } from "./commands/search";
|
|
17
|
-
import { ticker
|
|
17
|
+
import { ticker } from "./commands/ticker";
|
|
18
18
|
import { apiCliCommand } from "./commands/api";
|
|
19
19
|
import { marketDataCliCommands } from "./commands/market";
|
|
20
20
|
import { overviewCliCommands } from "./commands/overview";
|
|
@@ -216,7 +216,7 @@ async function createRegistry(options: DispatchCliOptions = {}): Promise<CliComm
|
|
|
216
216
|
return registry;
|
|
217
217
|
}
|
|
218
218
|
|
|
219
|
-
export { buildSearchReport,
|
|
219
|
+
export { buildSearchReport, searchCandidatesForCli };
|
|
220
220
|
|
|
221
221
|
export async function dispatchCli(args: string[], options: DispatchCliOptions = {}): Promise<CliDispatchResult> {
|
|
222
222
|
let parsed;
|