gloomberb 0.9.8 → 0.9.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.
Files changed (177) hide show
  1. package/package.json +1 -1
  2. package/src/api-client/index.test.ts +3 -3
  3. package/src/api-client/index.ts +0 -6
  4. package/src/api-client/request.test.ts +63 -0
  5. package/src/api-client/request.ts +53 -5
  6. package/src/api-client/types.ts +0 -2
  7. package/src/app/pane-runtime/layout-placement.ts +0 -1
  8. package/src/brokers/account-cache.ts +2 -10
  9. package/src/brokers/broker-portfolio-sync.ts +0 -1
  10. package/src/brokers/broker-ticker-sync.ts +20 -16
  11. package/src/brokers/remote-broker-adapter.ts +3 -0
  12. package/src/capabilities/registry.test.ts +25 -0
  13. package/src/capabilities/registry.ts +22 -5
  14. package/src/cli/commands/automation.ts +1 -3
  15. package/src/cli/commands/command-utils.ts +0 -7
  16. package/src/cli/commands/overview.ts +2 -2
  17. package/src/cli/commands/plugins.ts +34 -17
  18. package/src/cli/index.ts +2 -2
  19. package/src/cli.test.ts +0 -157
  20. package/src/components/chart/comparison/renderer.ts +0 -1
  21. package/src/components/chart/comparison-stock-chart/render-output.ts +3 -8
  22. package/src/components/chart/comparison-stock-chart/view.tsx +0 -2
  23. package/src/components/chart/core/index.test.ts +0 -24
  24. package/src/components/chart/core/renderer.ts +0 -4
  25. package/src/components/chart/core/resolution.test.ts +0 -17
  26. package/src/components/chart/core/resolution.ts +0 -26
  27. package/src/components/chart/core/scroll.test.ts +0 -6
  28. package/src/components/chart/core/scroll.ts +0 -22
  29. package/src/components/chart/core/viewport.test.ts +2 -23
  30. package/src/components/chart/core/viewport.ts +0 -27
  31. package/src/components/chart/market-session.ts +1 -1
  32. package/src/components/chart/native/surface/hooks.ts +10 -3
  33. package/src/components/chart/stock-chart/data-runtime.ts +0 -1
  34. package/src/components/chart/stock-chart/viewport/effects.ts +0 -3
  35. package/src/components/command-bar/helpers.test.ts +1 -80
  36. package/src/components/command-bar/helpers.ts +1 -36
  37. package/src/components/command-bar/pane-settings.ts +1 -1
  38. package/src/components/command-bar/panel/runtime.ts +0 -2
  39. package/src/components/command-bar/routes/ticker-search/results.ts +0 -11
  40. package/src/components/command-bar/surface/index.test.tsx +0 -16
  41. package/src/components/command-bar/surface/index.tsx +0 -1
  42. package/src/components/feed-data-table/stack-view.test.tsx +36 -130
  43. package/src/components/feed-data-table/stack-view.tsx +0 -1
  44. package/src/components/layout/shell/index.test.tsx +2 -113
  45. package/src/components/layout/shell/index.tsx +0 -1
  46. package/src/components/layout/shell/menu.ts +0 -1
  47. package/src/components/layout/shell/terminal-pointer-runtime.ts +0 -1
  48. package/src/components/onboarding/onboarding-wizard.tsx +3 -3
  49. package/src/components/onboarding/wizard-keyboard.ts +2 -23
  50. package/src/components/price-selector-dialog.tsx +3 -3
  51. package/src/components/ticker/badge/text.tsx +0 -1
  52. package/src/components/ui/page-stack-view.tsx +1 -1
  53. package/src/components/ui/ui.test.tsx +0 -78
  54. package/src/core/state/app/layout-reducer.ts +0 -1
  55. package/src/data/config/layout.ts +3 -3
  56. package/src/data/resource-store.ts +6 -0
  57. package/src/data/sqlite/cache.test.ts +5 -0
  58. package/src/data/ticker-store.ts +0 -7
  59. package/src/market-data/coordinator/auxiliary.ts +1 -44
  60. package/src/market-data/coordinator/entries.ts +0 -1
  61. package/src/market-data/coordinator/index.test.ts +0 -10
  62. package/src/market-data/coordinator/index.ts +1 -18
  63. package/src/market-data/coordinator/quotes.ts +0 -6
  64. package/src/market-data/coordinator/subscriptions.test.ts +1 -0
  65. package/src/market-data/market/status.ts +19 -1
  66. package/src/market-data/query-store.ts +0 -4
  67. package/src/market-data/quotes/resolution.ts +2 -2
  68. package/src/market-data/request-types.ts +0 -5
  69. package/src/market-data/result-types.ts +0 -11
  70. package/src/market-data/selectors.ts +1 -5
  71. package/src/news/aggregator.ts +5 -1
  72. package/src/pane-settings.test.ts +0 -18
  73. package/src/plugins/builtin/account-management/form-components.tsx +0 -120
  74. package/src/plugins/builtin/account-management/model.ts +3 -27
  75. package/src/plugins/builtin/broker-manager/index.test.tsx +0 -32
  76. package/src/plugins/builtin/buildout/cells.tsx +1 -1
  77. package/src/plugins/builtin/buildout/detail/satellite-images.tsx +1 -1
  78. package/src/plugins/builtin/buildout/detail/ui.tsx +0 -1
  79. package/src/plugins/builtin/buildout/format.ts +2 -11
  80. package/src/plugins/builtin/chat/chat.test.tsx +2 -1
  81. package/src/plugins/builtin/chat/content/index.tsx +1 -2
  82. package/src/plugins/builtin/chat/content/transcript.tsx +0 -1
  83. package/src/plugins/builtin/chat/controller/index.ts +0 -10
  84. package/src/plugins/builtin/chat/controller.test.ts +5 -5
  85. package/src/plugins/builtin/chat/sidebar.test.tsx +15 -5
  86. package/src/plugins/builtin/chat/test-harness.tsx +2 -2
  87. package/src/plugins/builtin/cloud/plugin.tsx +1 -1
  88. package/src/plugins/builtin/cloud-tweets/pane.tsx +0 -1
  89. package/src/plugins/builtin/congress-trades/model.ts +2 -7
  90. package/src/plugins/builtin/congress-trades/pane.tsx +0 -1
  91. package/src/plugins/builtin/earnings/index.tsx +0 -9
  92. package/src/plugins/builtin/econ/index.tsx +1 -5
  93. package/src/plugins/builtin/help/index.tsx +2 -2
  94. package/src/plugins/builtin/holders/pane.tsx +0 -5
  95. package/src/plugins/builtin/insider/index.tsx +1 -8
  96. package/src/plugins/builtin/kelly-sizer/index.test.tsx +0 -26
  97. package/src/plugins/builtin/kelly-sizer/index.tsx +0 -2
  98. package/src/plugins/builtin/market-movers/index.tsx +0 -2
  99. package/src/plugins/builtin/news/index.tsx +0 -1
  100. package/src/plugins/builtin/news/wire/news/table.tsx +0 -11
  101. package/src/plugins/builtin/options/view.test.tsx +1 -38
  102. package/src/plugins/builtin/plugin-groups.ts +17 -3
  103. package/src/plugins/builtin/portfolio-list/column-values.ts +15 -8
  104. package/src/plugins/builtin/portfolio-list/index.test.ts +1 -24
  105. package/src/plugins/builtin/portfolio-list/metrics.test.ts +44 -7
  106. package/src/plugins/builtin/sec/filing-documents.ts +0 -11
  107. package/src/plugins/builtin/sec/index.tsx +0 -2
  108. package/src/plugins/builtin/substack/article-detail.tsx +1 -1
  109. package/src/plugins/builtin/thirteenf/api.ts +0 -15
  110. package/src/plugins/builtin/thirteenf/pane.tsx +1 -3
  111. package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph/content.tsx +1 -1
  112. package/src/plugins/builtin/ticker-detail/financials/tab.tsx +0 -7
  113. package/src/plugins/builtin/ticker-detail/quote-monitor/index.test.tsx +5 -34
  114. package/src/plugins/builtin/world-indices/index.tsx +0 -4
  115. package/src/plugins/builtin/world-indices/model.ts +0 -7
  116. package/src/plugins/ibkr/account-cache.ts +1 -1
  117. package/src/plugins/ibkr/portfolio-performance.ts +1 -1
  118. package/src/plugins/ibkr/trade/utils.ts +1 -7
  119. package/src/plugins/loader.ts +2 -1
  120. package/src/plugins/prediction-markets/detail.test.tsx +0 -34
  121. package/src/plugins/registry/contributions.ts +22 -11
  122. package/src/plugins/registry/index.test.ts +44 -0
  123. package/src/plugins/registry/index.ts +44 -26
  124. package/src/remote/controller.ts +1 -7
  125. package/src/remote/json-patch.ts +15 -1
  126. package/src/remote/server.ts +16 -6
  127. package/src/renderers/electrobun/bun/application-menu/index.ts +1 -1
  128. package/src/renderers/electrobun/bun/core-capabilities.ts +2 -0
  129. package/src/renderers/electrobun/bun/desktop/http-fetch.test.ts +14 -0
  130. package/src/renderers/electrobun/bun/desktop/http-fetch.ts +8 -0
  131. package/src/renderers/electrobun/bun/desktop/initialization.ts +2 -1
  132. package/src/renderers/electrobun/view/cli-pane-shot-entry.tsx +1 -0
  133. package/src/renderers/electrobun/view/data-table/dom.ts +0 -1
  134. package/src/renderers/electrobun/view/host/mouse.ts +0 -11
  135. package/src/renderers/electrobun/view/http-fetch.ts +15 -2
  136. package/src/renderers/electrobun/view/remote/asset-data-client.ts +9 -18
  137. package/src/renderers/electrobun/view/remote/capability-invoker.test.ts +50 -0
  138. package/src/renderers/electrobun/view/remote/capability-invoker.ts +36 -0
  139. package/src/renderers/opentui/host.tsx +11 -3
  140. package/src/renderers/opentui/input-host.tsx +5 -1
  141. package/src/sources/gloomberb-cloud/index.test.ts +0 -72
  142. package/src/sources/gloomberb-cloud/index.ts +1 -30
  143. package/src/sources/gloomberb-cloud/news.ts +0 -12
  144. package/src/sources/provider-router/financial-routes.ts +25 -3
  145. package/src/sources/provider-router/index.test.ts +167 -0
  146. package/src/sources/provider-router/search.ts +5 -0
  147. package/src/sources/provider-router/supplemental.ts +4 -3
  148. package/src/sources/yahoo-finance/history.ts +5 -1
  149. package/src/sources/yahoo-finance/options.ts +59 -14
  150. package/src/sources/yahoo-finance/quote-summary.ts +4 -1
  151. package/src/sources/yahoo-finance.test.ts +40 -21
  152. package/src/sources/yahoo-finance.ts +13 -8
  153. package/src/state/app/context/index.test.tsx +0 -81
  154. package/src/state/app/context/selector.test.tsx +6 -2
  155. package/src/sync/controller.test.ts +31 -0
  156. package/src/sync/controller.ts +21 -17
  157. package/src/test-support/data-provider.ts +0 -1
  158. package/src/ui/host.tsx +2 -1
  159. package/src/ui/index.tsx +1 -0
  160. package/src/updater.test.ts +0 -21
  161. package/src/updater.ts +20 -16
  162. package/src/utils/async-deadline.ts +26 -0
  163. package/src/utils/selection-clipboard.ts +2 -2
  164. package/src/version.ts +1 -1
  165. package/src/components/layout/pane/header.test.tsx +0 -39
  166. package/src/plugins/builtin/chat/channels.test.ts +0 -28
  167. package/src/plugins/builtin/chat/index.tsx +0 -4
  168. package/src/plugins/builtin/cloud-tweets/index.ts +0 -1
  169. package/src/plugins/builtin/earnings/index.test.ts +0 -12
  170. package/src/plugins/builtin/help/index.test.tsx +0 -242
  171. package/src/plugins/builtin/substack/article-detail.test.tsx +0 -10
  172. package/src/renderers/electrobun/bun/application-menu/index.test.ts +0 -21
  173. package/src/renderers/electrobun/bun/window/focus.test.ts +0 -35
  174. package/src/renderers/electrobun/view/host/mouse.test.ts +0 -37
  175. package/src/renderers/electrobun/view/host/text.test.ts +0 -22
  176. package/src/renderers/opentui/dialog-host.test.tsx +0 -44
  177. /package/src/{plugins/ibkr → utils}/hash.ts +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gloomberb",
3
- "version": "0.9.8",
3
+ "version": "0.9.9",
4
4
  "description": "Market research and portfolio tracker for the terminal",
5
5
  "module": "src/index.tsx",
6
6
  "type": "module",
@@ -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",
@@ -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 body = await res.text();
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) {
@@ -6,9 +6,7 @@ import type {
6
6
  HolderData,
7
7
  HolderRecord,
8
8
  OptionsChain,
9
- PricePoint,
10
9
  Quote,
11
- TickerFinancials,
12
10
  } from "../types/financials";
13
11
  import type { SyncSettings, SyncSnapshot } from "../sync/types";
14
12
 
@@ -1,6 +1,5 @@
1
1
  import type { PluginRegistry } from "../../plugins/registry";
2
2
  import {
3
- findDockLeaf,
4
3
  getDockLeafLayouts,
5
4
  getLeafRect,
6
5
  } from "../../plugins/pane-manager";
@@ -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 hashString(JSON.stringify({
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
- ticker.metadata.positions = [...otherPositions, positionEntry];
95
- ticker.metadata.broker_contracts = mergeBrokerContracts(
84
+ const brokerContracts = mergeBrokerContracts(
96
85
  ticker.metadata.broker_contracts ?? [],
97
86
  brokerContract ? [brokerContract] : [],
98
87
  );
99
- if (!ticker.metadata.portfolios.includes(portfolioId)) {
100
- ticker.metadata.portfolios.push(portfolioId);
101
- }
88
+ const portfolios = ticker.metadata.portfolios.includes(portfolioId)
89
+ ? ticker.metadata.portfolios
90
+ : [...ticker.metadata.portfolios, portfolioId];
102
91
 
103
- return ticker;
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
- this.capabilities.set(capability.id, { pluginId, capability });
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
- entry.dispose();
33
- this.subscriptions.delete(subscriptionId);
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()]) this.unsubscribe(subscriptionId);
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 provider = getAiProvider(providerId);
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: (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" }) },
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 { execSync } from "child_process";
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
- function parseGitHubRef(ref: string): { url: string; name: string } {
22
- if (ref.startsWith("https://github.com/")) {
23
- const clean = ref.replace(/\.git$/, "");
24
- const name = clean.split("/").pop()!;
25
- return { url: clean.endsWith(".git") ? ref : `${clean}.git`, name };
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
- if (ref.startsWith("github:")) {
28
- ref = ref.slice(7);
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
- if (ref.includes("/") && !ref.includes("://")) {
31
- const name = ref.split("/").pop()!;
32
- return { url: `https://github.com/${ref}.git`, name };
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
- execSync(`git clone --depth 1 ${url} ${targetDir}`, { stdio: "inherit" });
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
- execSync("bun install", { cwd: targetDir, stdio: "inherit" });
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
- execSync("git pull", { cwd: targetDir, stdio: "inherit" });
141
+ execFileSync("git", ["pull", "--ff-only"], { cwd: targetDir, stdio: "inherit" });
125
142
  const pkgPath = join(targetDir, "package.json");
126
143
  if (existsSync(pkgPath)) {
127
- execSync("bun install", { cwd: targetDir, stdio: "inherit" });
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, buildTickerReport } from "./commands/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, buildTickerReport, searchCandidatesForCli };
219
+ export { buildSearchReport, searchCandidatesForCli };
220
220
 
221
221
  export async function dispatchCli(args: string[], options: DispatchCliOptions = {}): Promise<CliDispatchResult> {
222
222
  let parsed;