@eiei114/pi-sub-core 1.5.1

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 (52) hide show
  1. package/CHANGELOG.md +190 -0
  2. package/README.md +178 -0
  3. package/index.ts +540 -0
  4. package/package.json +35 -0
  5. package/src/cache.ts +546 -0
  6. package/src/config.ts +35 -0
  7. package/src/dependencies.ts +37 -0
  8. package/src/errors.ts +71 -0
  9. package/src/paths.ts +55 -0
  10. package/src/provider.ts +66 -0
  11. package/src/providers/detection.ts +51 -0
  12. package/src/providers/impl/anthropic.ts +174 -0
  13. package/src/providers/impl/antigravity.ts +226 -0
  14. package/src/providers/impl/codex.ts +186 -0
  15. package/src/providers/impl/copilot.ts +176 -0
  16. package/src/providers/impl/gemini.ts +130 -0
  17. package/src/providers/impl/kiro.ts +92 -0
  18. package/src/providers/impl/zai.ts +120 -0
  19. package/src/providers/index.ts +5 -0
  20. package/src/providers/metadata.ts +16 -0
  21. package/src/providers/registry.ts +54 -0
  22. package/src/providers/settings.ts +109 -0
  23. package/src/providers/status.ts +25 -0
  24. package/src/settings/behavior.ts +58 -0
  25. package/src/settings/menu.ts +83 -0
  26. package/src/settings/tools.ts +38 -0
  27. package/src/settings/ui.ts +450 -0
  28. package/src/settings-types.ts +95 -0
  29. package/src/settings-ui.ts +1 -0
  30. package/src/settings.ts +137 -0
  31. package/src/status.ts +245 -0
  32. package/src/storage/lock.ts +150 -0
  33. package/src/storage.ts +61 -0
  34. package/src/types.ts +33 -0
  35. package/src/ui/keybindings.ts +92 -0
  36. package/src/ui/settings-list.ts +290 -0
  37. package/src/usage/controller.ts +250 -0
  38. package/src/usage/fetch.ts +215 -0
  39. package/src/usage/types.ts +5 -0
  40. package/src/utils.ts +158 -0
  41. package/test/all.test.ts +9 -0
  42. package/test/cache.test.ts +157 -0
  43. package/test/controller.test.ts +101 -0
  44. package/test/detection.test.ts +24 -0
  45. package/test/extension.test.ts +233 -0
  46. package/test/helpers.ts +48 -0
  47. package/test/keybindings.test.ts +59 -0
  48. package/test/lock.test.ts +49 -0
  49. package/test/prioritize.test.ts +81 -0
  50. package/test/providers.test.ts +385 -0
  51. package/test/status.test.ts +70 -0
  52. package/tsconfig.json +5 -0
package/src/status.ts ADDED
@@ -0,0 +1,245 @@
1
+ /**
2
+ * Status polling for providers
3
+ */
4
+
5
+ import type { Dependencies, ProviderName, ProviderStatus, StatusIndicator } from "./types.js";
6
+ import type { ProviderStatusConfig } from "./providers/metadata.js";
7
+ import { GOOGLE_STATUS_URL, GEMINI_PRODUCT_ID, API_TIMEOUT_MS } from "./config.js";
8
+ import { PROVIDER_METADATA } from "./providers/metadata.js";
9
+ import { createTimeoutController } from "./utils.js";
10
+
11
+ type StatusPageStatusConfig = Extract<ProviderStatusConfig, { type: "statuspage" }>;
12
+
13
+ interface StatusPageSummary {
14
+ status?: {
15
+ indicator?: string;
16
+ description?: string;
17
+ };
18
+ components?: Array<{
19
+ id?: string;
20
+ name?: string;
21
+ status?: string;
22
+ }>;
23
+ }
24
+
25
+ interface StatusPageStatus {
26
+ indicator?: string;
27
+ description?: string;
28
+ }
29
+
30
+ interface StatusPageResponse {
31
+ status?: StatusPageStatus;
32
+ }
33
+
34
+ function toSummaryUrl(url: string): string {
35
+ if (url.endsWith("/summary.json")) return url;
36
+ if (url.endsWith("/status.json")) {
37
+ return `${url.slice(0, -"/status.json".length)}/summary.json`;
38
+ }
39
+ if (!url.endsWith("/")) return `${url}/summary.json`;
40
+ return `${url}summary.json`;
41
+ }
42
+
43
+ function isComponentMatch(component: { id?: string; name?: string }, config?: StatusPageStatusConfig): boolean {
44
+ if (!config?.component) return false;
45
+
46
+ if (config.component.id && component.id) {
47
+ return component.id === config.component.id;
48
+ }
49
+
50
+ if (config.component.name && component.name) {
51
+ return component.name.trim().toLowerCase() === config.component.name.trim().toLowerCase();
52
+ }
53
+
54
+ return false;
55
+ }
56
+
57
+ function mapStatusIndicator(indicator?: string): StatusIndicator {
58
+ switch (indicator) {
59
+ case "none":
60
+ return "none";
61
+ case "minor":
62
+ return "minor";
63
+ case "major":
64
+ return "major";
65
+ case "critical":
66
+ return "critical";
67
+ case "maintenance":
68
+ return "maintenance";
69
+ default:
70
+ return "unknown";
71
+ }
72
+ }
73
+
74
+ function mapComponentStatus(indicator?: string): StatusIndicator {
75
+ switch ((indicator || "").toLowerCase()) {
76
+ case "operational":
77
+ return "none";
78
+ case "under_maintenance":
79
+ return "maintenance";
80
+ case "degraded_performance":
81
+ return "minor";
82
+ case "partial_outage":
83
+ return "major";
84
+ case "major_outage":
85
+ return "critical";
86
+ default:
87
+ return "unknown";
88
+ }
89
+ }
90
+
91
+ function formatComponentLabel(rawStatus?: string): string {
92
+ switch ((rawStatus || "").toLowerCase()) {
93
+ case "operational":
94
+ return "Operational";
95
+ case "under_maintenance":
96
+ return "Under maintenance";
97
+ case "degraded_performance":
98
+ return "Degraded performance";
99
+ case "partial_outage":
100
+ return "Partial outage";
101
+ case "major_outage":
102
+ return "Major outage";
103
+ default:
104
+ return rawStatus ? rawStatus.replace(/_/g, " ") : "Unknown";
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Fetch status from a standard statuspage.io API
110
+ */
111
+ async function fetchStatuspageStatus(
112
+ url: string,
113
+ deps: Dependencies,
114
+ config?: StatusPageStatusConfig
115
+ ): Promise<ProviderStatus> {
116
+ const { controller, clear } = createTimeoutController(API_TIMEOUT_MS);
117
+
118
+ try {
119
+ const fetchUrl = config?.component ? toSummaryUrl(url) : url;
120
+ const res = await deps.fetch(fetchUrl, { signal: controller.signal });
121
+ clear();
122
+
123
+ if (!res.ok) {
124
+ return { indicator: "unknown" };
125
+ }
126
+
127
+ if (!config?.component) {
128
+ const data = (await res.json()) as StatusPageResponse;
129
+ const indicator = mapStatusIndicator(data.status?.indicator);
130
+ return { indicator, description: data.status?.description };
131
+ }
132
+
133
+ const data = (await res.json()) as StatusPageSummary;
134
+ const summaryIndicator = mapStatusIndicator(data.status?.indicator);
135
+ const component = (data.components ?? []).find((entry) => isComponentMatch(entry, config));
136
+ if (component) {
137
+ const componentIndicator = mapComponentStatus(component.status);
138
+ const componentDescription =
139
+ componentIndicator === "none"
140
+ ? undefined
141
+ : `${component.name ?? "Component"}: ${formatComponentLabel(component.status)}`;
142
+ return {
143
+ indicator: componentIndicator,
144
+ description: componentDescription,
145
+ };
146
+ }
147
+
148
+ return {
149
+ indicator: summaryIndicator,
150
+ description: data.status?.description,
151
+ };
152
+ } catch {
153
+ clear();
154
+ return { indicator: "unknown" };
155
+ }
156
+ }
157
+
158
+ /**
159
+ * Fetch Gemini status from Google Workspace status API
160
+ */
161
+ async function fetchGeminiStatus(deps: Dependencies): Promise<ProviderStatus> {
162
+ const { controller, clear } = createTimeoutController(API_TIMEOUT_MS);
163
+
164
+ try {
165
+ const res = await deps.fetch(GOOGLE_STATUS_URL, { signal: controller.signal });
166
+ clear();
167
+
168
+ if (!res.ok) return { indicator: "unknown" };
169
+
170
+ const incidents = (await res.json()) as Array<{
171
+ end?: string;
172
+ currently_affected_products?: Array<{ id: string }>;
173
+ affected_products?: Array<{ id: string }>;
174
+ most_recent_update?: { status?: string };
175
+ status_impact?: string;
176
+ external_desc?: string;
177
+ }>;
178
+
179
+ const activeIncidents = incidents.filter((inc) => {
180
+ if (inc.end) return false;
181
+ const affected = inc.currently_affected_products || inc.affected_products || [];
182
+ return affected.some((p) => p.id === GEMINI_PRODUCT_ID);
183
+ });
184
+
185
+ if (activeIncidents.length === 0) {
186
+ return { indicator: "none" };
187
+ }
188
+
189
+ let worstIndicator: StatusIndicator = "minor";
190
+ let description: string | undefined;
191
+
192
+ for (const inc of activeIncidents) {
193
+ const status = inc.most_recent_update?.status || inc.status_impact;
194
+ if (status === "SERVICE_OUTAGE") {
195
+ worstIndicator = "critical";
196
+ description = inc.external_desc;
197
+ } else if (status === "SERVICE_DISRUPTION" && worstIndicator !== "critical") {
198
+ worstIndicator = "major";
199
+ description = inc.external_desc;
200
+ }
201
+ }
202
+
203
+ return { indicator: worstIndicator, description };
204
+ } catch {
205
+ clear();
206
+ return { indicator: "unknown" };
207
+ }
208
+ }
209
+
210
+ /**
211
+ * Fetch status for a provider
212
+ */
213
+ export async function fetchProviderStatus(provider: ProviderName, deps: Dependencies): Promise<ProviderStatus> {
214
+ const statusConfig = PROVIDER_METADATA[provider]?.status;
215
+ if (!statusConfig) {
216
+ return { indicator: "none" };
217
+ }
218
+
219
+ if (statusConfig.type === "google-workspace") {
220
+ return fetchGeminiStatus(deps);
221
+ }
222
+
223
+ return fetchStatuspageStatus(statusConfig.url, deps, statusConfig);
224
+ }
225
+
226
+ /**
227
+ * Get emoji for a status indicator
228
+ */
229
+ export function getStatusEmoji(status?: ProviderStatus): string {
230
+ if (!status) return "";
231
+ switch (status.indicator) {
232
+ case "none":
233
+ return "✅";
234
+ case "minor":
235
+ return "⚠️";
236
+ case "major":
237
+ return "🟠";
238
+ case "critical":
239
+ return "🔴";
240
+ case "maintenance":
241
+ return "🔧";
242
+ default:
243
+ return "";
244
+ }
245
+ }
@@ -0,0 +1,150 @@
1
+ /**
2
+ * File lock helpers for storage-backed locks.
3
+ */
4
+
5
+ import { getStorage } from "../storage.js";
6
+
7
+ interface LockRecord {
8
+ acquiredAt: number;
9
+ token?: string;
10
+ }
11
+
12
+ export function tryAcquireFileLock(lockPath: string, staleAfterMs: number): string | null {
13
+ const storage = getStorage();
14
+ const token = createLockToken();
15
+ if (tryCreateLock(storage, lockPath, token)) {
16
+ return token;
17
+ }
18
+
19
+ const observed = readLockRecord(storage, lockPath);
20
+ if (!observed) {
21
+ return null;
22
+ }
23
+ if (!isLockStale(observed.acquiredAt, staleAfterMs)) {
24
+ return null;
25
+ }
26
+ if (!removeObservedStaleLock(storage, lockPath, observed)) {
27
+ return null;
28
+ }
29
+ if (tryCreateLock(storage, lockPath, token)) {
30
+ return token;
31
+ }
32
+
33
+ return null;
34
+ }
35
+
36
+ export function releaseFileLock(lockPath: string, token?: string): void {
37
+ const storage = getStorage();
38
+ try {
39
+ if (!storage.exists(lockPath)) {
40
+ return;
41
+ }
42
+ if (token) {
43
+ const current = readLockRecord(storage, lockPath);
44
+ if (!current?.token || current.token !== token) {
45
+ return;
46
+ }
47
+ }
48
+ storage.removeFile(lockPath);
49
+ } catch {
50
+ // Ignore
51
+ }
52
+ }
53
+
54
+ export async function waitForLockRelease(
55
+ lockPath: string,
56
+ maxWaitMs: number,
57
+ pollMs: number = 100
58
+ ): Promise<boolean> {
59
+ const storage = getStorage();
60
+ const startTime = Date.now();
61
+
62
+ while (Date.now() - startTime < maxWaitMs) {
63
+ await new Promise((resolve) => setTimeout(resolve, pollMs));
64
+ if (!storage.exists(lockPath)) {
65
+ return true;
66
+ }
67
+ }
68
+
69
+ return false;
70
+ }
71
+
72
+ function tryCreateLock(storage: ReturnType<typeof getStorage>, lockPath: string, token: string): boolean {
73
+ try {
74
+ return storage.writeFileExclusive(lockPath, serializeLockRecord({ token, acquiredAt: Date.now() }));
75
+ } catch {
76
+ return false;
77
+ }
78
+ }
79
+
80
+ function removeObservedStaleLock(
81
+ storage: ReturnType<typeof getStorage>,
82
+ lockPath: string,
83
+ observed: LockRecord
84
+ ): boolean {
85
+ try {
86
+ const current = readLockRecord(storage, lockPath);
87
+ if (!current) {
88
+ return false;
89
+ }
90
+ if (current.acquiredAt !== observed.acquiredAt) {
91
+ return false;
92
+ }
93
+ if (current.token !== observed.token) {
94
+ return false;
95
+ }
96
+ storage.removeFile(lockPath);
97
+ return !storage.exists(lockPath);
98
+ } catch {
99
+ return false;
100
+ }
101
+ }
102
+
103
+ function readLockRecord(storage: ReturnType<typeof getStorage>, lockPath: string): LockRecord | null {
104
+ try {
105
+ if (!storage.exists(lockPath)) {
106
+ return null;
107
+ }
108
+ const lockContent = storage.readFile(lockPath) ?? "";
109
+ return parseLockRecord(lockContent);
110
+ } catch {
111
+ return null;
112
+ }
113
+ }
114
+
115
+ function parseLockRecord(lockContent: string): LockRecord | null {
116
+ const trimmed = lockContent.trim();
117
+ if (!trimmed) return null;
118
+
119
+ const asTimestamp = parseInt(trimmed, 10);
120
+ if (Number.isFinite(asTimestamp) && asTimestamp > 0) {
121
+ return { acquiredAt: asTimestamp };
122
+ }
123
+
124
+ try {
125
+ const parsed = JSON.parse(trimmed) as { token?: unknown; acquiredAt?: unknown; createdAt?: unknown };
126
+ const acquiredAt = parsed.acquiredAt ?? parsed.createdAt;
127
+ if (typeof acquiredAt !== "number" || !Number.isFinite(acquiredAt) || acquiredAt <= 0) {
128
+ return null;
129
+ }
130
+ const token = typeof parsed.token === "string" && parsed.token ? parsed.token : undefined;
131
+ return { acquiredAt, token };
132
+ } catch {
133
+ return null;
134
+ }
135
+ }
136
+
137
+ function serializeLockRecord(record: LockRecord): string {
138
+ return JSON.stringify(record);
139
+ }
140
+
141
+ function isLockStale(acquiredAt: number, staleAfterMs: number): boolean {
142
+ if (staleAfterMs <= 0) {
143
+ return true;
144
+ }
145
+ return Date.now() - acquiredAt > staleAfterMs;
146
+ }
147
+
148
+ function createLockToken(): string {
149
+ return `${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`;
150
+ }
package/src/storage.ts ADDED
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Storage abstraction for settings and cache persistence.
3
+ */
4
+
5
+ import * as fs from "node:fs";
6
+ import * as path from "node:path";
7
+
8
+ export interface StorageAdapter {
9
+ readFile(path: string): string | undefined;
10
+ writeFile(path: string, contents: string): void;
11
+ writeFileExclusive(path: string, contents: string): boolean;
12
+ exists(path: string): boolean;
13
+ removeFile(path: string): void;
14
+ ensureDir(path: string): void;
15
+ }
16
+
17
+ export function createFsStorage(): StorageAdapter {
18
+ return {
19
+ readFile(filePath: string): string | undefined {
20
+ try {
21
+ return fs.readFileSync(filePath, "utf-8");
22
+ } catch {
23
+ return undefined;
24
+ }
25
+ },
26
+ writeFile(filePath: string, contents: string): void {
27
+ fs.writeFileSync(filePath, contents, "utf-8");
28
+ },
29
+ writeFileExclusive(filePath: string, contents: string): boolean {
30
+ try {
31
+ fs.writeFileSync(filePath, contents, { flag: "wx" });
32
+ return true;
33
+ } catch {
34
+ return false;
35
+ }
36
+ },
37
+ exists(filePath: string): boolean {
38
+ return fs.existsSync(filePath);
39
+ },
40
+ removeFile(filePath: string): void {
41
+ try {
42
+ fs.unlinkSync(filePath);
43
+ } catch {
44
+ // Ignore remove errors
45
+ }
46
+ },
47
+ ensureDir(dirPath: string): void {
48
+ fs.mkdirSync(path.resolve(dirPath), { recursive: true });
49
+ },
50
+ };
51
+ }
52
+
53
+ let activeStorage: StorageAdapter = createFsStorage();
54
+
55
+ export function getStorage(): StorageAdapter {
56
+ return activeStorage;
57
+ }
58
+
59
+ export function setStorage(storage: StorageAdapter): void {
60
+ activeStorage = storage;
61
+ }
package/src/types.ts ADDED
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Core types for the sub-bar extension
3
+ */
4
+
5
+ import type { ExecFileSyncOptionsWithStringEncoding } from "node:child_process";
6
+
7
+ export type {
8
+ ProviderName,
9
+ StatusIndicator,
10
+ ProviderStatus,
11
+ RateWindow,
12
+ UsageSnapshot,
13
+ UsageError,
14
+ UsageErrorCode,
15
+ ProviderUsageEntry,
16
+ SubCoreState,
17
+ SubCoreEvents,
18
+ } from "@eiei114/pi-sub-shared";
19
+
20
+ export { PROVIDERS } from "@eiei114/pi-sub-shared";
21
+
22
+ /**
23
+ * Dependencies that can be injected for testing
24
+ */
25
+ export interface Dependencies {
26
+ fetch: typeof globalThis.fetch;
27
+ readFile: (path: string) => string | undefined;
28
+ fileExists: (path: string) => boolean;
29
+ // Use static commands/args only (no user-controlled input).
30
+ execFileSync: (file: string, args: string[], options?: ExecFileSyncOptionsWithStringEncoding) => string;
31
+ homedir: () => string;
32
+ env: NodeJS.ProcessEnv;
33
+ }
@@ -0,0 +1,92 @@
1
+ import * as PiTui from "@mariozechner/pi-tui";
2
+
3
+ export type SettingsListAction =
4
+ | "selectUp"
5
+ | "selectDown"
6
+ | "cursorLeft"
7
+ | "cursorRight"
8
+ | "selectConfirm"
9
+ | "selectCancel";
10
+
11
+ export interface SettingsKeybindings {
12
+ matches(data: string, action: SettingsListAction): boolean;
13
+ }
14
+
15
+ interface CompatibleApi {
16
+ getEditorKeybindings?: () => { matches(data: string, action: string): boolean };
17
+ getKeybindings?: () => { matches(data: string, action: string): boolean };
18
+ matchesKey?: (data: string, key: string) => boolean;
19
+ }
20
+
21
+ const LEGACY_ACTION_MAP: Record<SettingsListAction, string> = {
22
+ selectUp: "tui.select.up",
23
+ selectDown: "tui.select.down",
24
+ cursorLeft: "tui.editor.cursorLeft",
25
+ cursorRight: "tui.editor.cursorRight",
26
+ selectConfirm: "tui.select.confirm",
27
+ selectCancel: "tui.select.cancel",
28
+ };
29
+
30
+ const DEFAULT_ACTION_KEYS: Record<SettingsListAction, string | string[]> = {
31
+ selectUp: "up",
32
+ selectDown: "down",
33
+ cursorLeft: ["left", "ctrl+b"],
34
+ cursorRight: ["right", "ctrl+f"],
35
+ selectConfirm: "enter",
36
+ selectCancel: ["escape", "ctrl+c"],
37
+ };
38
+
39
+ function matchesKeyWithFallback(
40
+ data: string,
41
+ key: string,
42
+ matchesKey?: (data: string, key: string) => boolean,
43
+ ): boolean {
44
+ if (matchesKey) {
45
+ return matchesKey(data, key);
46
+ }
47
+
48
+ if (key === "enter") return data === "\r" || data === "\n";
49
+ if (key === "escape") return data === "\u001b";
50
+ if (key === "up") return data === "\u001b[A";
51
+ if (key === "down") return data === "\u001b[B";
52
+ if (key === "left") return data === "\u001b[D";
53
+ if (key === "right") return data === "\u001b[C";
54
+ return data === key;
55
+ }
56
+
57
+ function matchesDefaultAction(
58
+ data: string,
59
+ action: SettingsListAction,
60
+ matchesKey?: (data: string, key: string) => boolean,
61
+ ): boolean {
62
+ const keys = DEFAULT_ACTION_KEYS[action];
63
+ const list = Array.isArray(keys) ? keys : [keys];
64
+ return list.some((key) => matchesKeyWithFallback(data, key, matchesKey));
65
+ }
66
+
67
+ export function createSettingsKeybindings(api: CompatibleApi): SettingsKeybindings {
68
+ const editor = api.getEditorKeybindings?.();
69
+ if (editor && typeof editor.matches === "function") {
70
+ return {
71
+ matches: (data, action) => editor.matches(data, action),
72
+ };
73
+ }
74
+
75
+ const legacy = api.getKeybindings?.();
76
+ if (legacy && typeof legacy.matches === "function") {
77
+ return {
78
+ matches: (data, action) => {
79
+ const legacyAction = LEGACY_ACTION_MAP[action];
80
+ return legacy.matches(data, legacyAction);
81
+ },
82
+ };
83
+ }
84
+
85
+ return {
86
+ matches: (data, action) => matchesDefaultAction(data, action, api.matchesKey),
87
+ };
88
+ }
89
+
90
+ export function getSettingsKeybindings(): SettingsKeybindings {
91
+ return createSettingsKeybindings(PiTui as CompatibleApi);
92
+ }