@hellcoder/companion 0.96.0
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/bin/cli.ts +168 -0
- package/bin/ctl.ts +528 -0
- package/bin/generate-token.ts +28 -0
- package/dist/apple-touch-icon.png +0 -0
- package/dist/assets/AgentsPage-DCFhrJ28.js +13 -0
- package/dist/assets/CronManager-EGwLJONv.js +1 -0
- package/dist/assets/IntegrationsPage-CTMRnbQS.js +1 -0
- package/dist/assets/LinearOAuthSettingsPage-CgQFMIgr.js +1 -0
- package/dist/assets/LinearSettingsPage-C9nok1qi.js +1 -0
- package/dist/assets/Playground-BV3k0RbV.js +109 -0
- package/dist/assets/PromptsPage-CFojqNKP.js +4 -0
- package/dist/assets/RunsPage-DUJ1QUSa.js +1 -0
- package/dist/assets/SandboxManager-CrVQ-VU_.js +8 -0
- package/dist/assets/SettingsPage-D1fPCL19.js +1 -0
- package/dist/assets/TailscalePage-D06cyvyC.js +1 -0
- package/dist/assets/index-BhUa1e6X.css +1 -0
- package/dist/assets/index-DkqeP-R9.js +134 -0
- package/dist/assets/sw-register-BibwRdvC.js +1 -0
- package/dist/assets/workbox-window.prod.es5-BIl4cyR9.js +2 -0
- package/dist/favicon.svg +8 -0
- package/dist/fonts/MesloLGSNerdFontMono-Bold.woff2 +0 -0
- package/dist/fonts/MesloLGSNerdFontMono-Regular.woff2 +0 -0
- package/dist/icon-192.png +0 -0
- package/dist/icon-512.png +0 -0
- package/dist/index.html +20 -0
- package/dist/logo-codex.svg +14 -0
- package/dist/logo-docker.svg +4 -0
- package/dist/logo.svg +14 -0
- package/dist/manifest.json +24 -0
- package/dist/sw.js +2 -0
- package/package.json +104 -0
- package/server/agent-cron-migrator.test.ts +610 -0
- package/server/agent-cron-migrator.ts +85 -0
- package/server/agent-executor.test.ts +1108 -0
- package/server/agent-executor.ts +346 -0
- package/server/agent-store.test.ts +588 -0
- package/server/agent-store.ts +185 -0
- package/server/agent-types.ts +138 -0
- package/server/ai-validation-settings.test.ts +128 -0
- package/server/ai-validation-settings.ts +35 -0
- package/server/ai-validator.test.ts +387 -0
- package/server/ai-validator.ts +271 -0
- package/server/auth-manager.test.ts +83 -0
- package/server/auth-manager.ts +150 -0
- package/server/auto-namer.test.ts +252 -0
- package/server/auto-namer.ts +78 -0
- package/server/backend-adapter.test.ts +38 -0
- package/server/backend-adapter.ts +54 -0
- package/server/cache-headers.test.ts +98 -0
- package/server/cache-headers.ts +61 -0
- package/server/claude-adapter.test.ts +1363 -0
- package/server/claude-adapter.ts +889 -0
- package/server/claude-container-auth.test.ts +44 -0
- package/server/claude-container-auth.ts +30 -0
- package/server/claude-protocol-contract.test.ts +71 -0
- package/server/claude-protocol-drift.test.ts +78 -0
- package/server/claude-session-discovery.test.ts +132 -0
- package/server/claude-session-discovery.ts +157 -0
- package/server/claude-session-history.test.ts +158 -0
- package/server/claude-session-history.ts +410 -0
- package/server/cli-launcher.test.ts +1343 -0
- package/server/cli-launcher.ts +1298 -0
- package/server/cli.test.ts +16 -0
- package/server/codex-adapter.test.ts +5545 -0
- package/server/codex-adapter.ts +3062 -0
- package/server/codex-container-auth.test.ts +50 -0
- package/server/codex-container-auth.ts +24 -0
- package/server/codex-home.test.ts +61 -0
- package/server/codex-home.ts +26 -0
- package/server/codex-protocol-contract.test.ts +96 -0
- package/server/codex-protocol-drift.test.ts +123 -0
- package/server/codex-ws-proxy.cjs +226 -0
- package/server/commands-discovery.test.ts +179 -0
- package/server/commands-discovery.ts +81 -0
- package/server/constants.ts +7 -0
- package/server/container-manager.test.ts +1211 -0
- package/server/container-manager.ts +1053 -0
- package/server/cron-scheduler.test.ts +957 -0
- package/server/cron-scheduler.ts +243 -0
- package/server/cron-store.test.ts +422 -0
- package/server/cron-store.ts +148 -0
- package/server/cron-types.ts +63 -0
- package/server/env-manager.test.ts +268 -0
- package/server/env-manager.ts +161 -0
- package/server/event-bus-types.ts +64 -0
- package/server/event-bus.test.ts +244 -0
- package/server/event-bus.ts +124 -0
- package/server/execution-store.test.ts +307 -0
- package/server/execution-store.ts +170 -0
- package/server/fs-utils.ts +15 -0
- package/server/git-utils.test.ts +938 -0
- package/server/git-utils.ts +421 -0
- package/server/github-pr.test.ts +498 -0
- package/server/github-pr.ts +379 -0
- package/server/image-pull-manager.test.ts +303 -0
- package/server/image-pull-manager.ts +279 -0
- package/server/index.ts +396 -0
- package/server/linear-agent-bridge.test.ts +1157 -0
- package/server/linear-agent-bridge.ts +629 -0
- package/server/linear-agent.test.ts +473 -0
- package/server/linear-agent.ts +479 -0
- package/server/linear-cache.test.ts +136 -0
- package/server/linear-cache.ts +113 -0
- package/server/linear-connections.test.ts +350 -0
- package/server/linear-connections.ts +231 -0
- package/server/linear-credential-migration.test.ts +337 -0
- package/server/linear-credential-migration.ts +63 -0
- package/server/linear-oauth-connections-migration.test.ts +268 -0
- package/server/linear-oauth-connections.test.ts +365 -0
- package/server/linear-oauth-connections.ts +294 -0
- package/server/linear-project-manager.test.ts +162 -0
- package/server/linear-project-manager.ts +111 -0
- package/server/linear-prompt-builder.test.ts +74 -0
- package/server/linear-prompt-builder.ts +61 -0
- package/server/linear-staging.test.ts +276 -0
- package/server/linear-staging.ts +142 -0
- package/server/logger.test.ts +393 -0
- package/server/logger.ts +259 -0
- package/server/metrics-collector.test.ts +413 -0
- package/server/metrics-collector.ts +350 -0
- package/server/metrics-types.ts +108 -0
- package/server/middleware/managed-auth.test.ts +264 -0
- package/server/middleware/managed-auth.ts +195 -0
- package/server/novnc-proxy.test.ts +333 -0
- package/server/novnc-proxy.ts +99 -0
- package/server/path-resolver.test.ts +552 -0
- package/server/path-resolver.ts +186 -0
- package/server/paths.test.ts +31 -0
- package/server/paths.ts +11 -0
- package/server/pr-poller.test.ts +191 -0
- package/server/pr-poller.ts +162 -0
- package/server/prompt-manager.test.ts +211 -0
- package/server/prompt-manager.ts +211 -0
- package/server/protocol/claude-upstream/README.md +19 -0
- package/server/protocol/claude-upstream/sdk.d.ts.txt +1943 -0
- package/server/protocol/codex-upstream/ClientNotification.ts.txt +5 -0
- package/server/protocol/codex-upstream/ClientRequest.ts.txt +60 -0
- package/server/protocol/codex-upstream/README.md +18 -0
- package/server/protocol/codex-upstream/ServerNotification.ts.txt +41 -0
- package/server/protocol/codex-upstream/ServerRequest.ts.txt +16 -0
- package/server/protocol/codex-upstream/v2/DynamicToolCallParams.ts.txt +6 -0
- package/server/protocol/codex-upstream/v2/DynamicToolCallResponse.ts.txt +6 -0
- package/server/protocol-monitor.ts +50 -0
- package/server/recorder.test.ts +454 -0
- package/server/recorder.ts +374 -0
- package/server/recording-hub/compat-validator.test.ts +150 -0
- package/server/recording-hub/compat-validator.ts +284 -0
- package/server/recording-hub/diagnostics.test.ts +140 -0
- package/server/recording-hub/diagnostics.ts +299 -0
- package/server/recording-hub/hub-config.test.ts +44 -0
- package/server/recording-hub/hub-config.ts +19 -0
- package/server/recording-hub/hub-routes.test.ts +417 -0
- package/server/recording-hub/hub-routes.ts +236 -0
- package/server/recording-hub/hub-store.test.ts +262 -0
- package/server/recording-hub/hub-store.ts +265 -0
- package/server/recording-hub/replay-adapter.test.ts +294 -0
- package/server/recording-hub/replay-adapter.ts +207 -0
- package/server/relay-client.test.ts +337 -0
- package/server/relay-client.ts +320 -0
- package/server/replay.test.ts +200 -0
- package/server/replay.ts +78 -0
- package/server/routes/agent-routes.test.ts +1400 -0
- package/server/routes/agent-routes.ts +409 -0
- package/server/routes/cron-routes.test.ts +881 -0
- package/server/routes/cron-routes.ts +103 -0
- package/server/routes/env-routes.test.ts +383 -0
- package/server/routes/env-routes.ts +95 -0
- package/server/routes/fs-routes.test.ts +1198 -0
- package/server/routes/fs-routes.ts +605 -0
- package/server/routes/git-routes.test.ts +813 -0
- package/server/routes/git-routes.ts +97 -0
- package/server/routes/linear-agent-routes.test.ts +721 -0
- package/server/routes/linear-agent-routes.ts +304 -0
- package/server/routes/linear-connection-routes.test.ts +927 -0
- package/server/routes/linear-connection-routes.ts +244 -0
- package/server/routes/linear-oauth-connection-routes.test.ts +406 -0
- package/server/routes/linear-oauth-connection-routes.ts +129 -0
- package/server/routes/linear-routes.test.ts +1510 -0
- package/server/routes/linear-routes.ts +953 -0
- package/server/routes/metrics-routes.test.ts +103 -0
- package/server/routes/metrics-routes.ts +13 -0
- package/server/routes/prompt-routes.ts +67 -0
- package/server/routes/sandbox-routes.test.ts +513 -0
- package/server/routes/sandbox-routes.ts +127 -0
- package/server/routes/settings-routes.ts +270 -0
- package/server/routes/skills-routes.test.ts +690 -0
- package/server/routes/skills-routes.ts +100 -0
- package/server/routes/system-routes.test.ts +637 -0
- package/server/routes/system-routes.ts +228 -0
- package/server/routes/tailscale-routes.test.ts +176 -0
- package/server/routes/tailscale-routes.ts +22 -0
- package/server/routes.test.ts +4655 -0
- package/server/routes.ts +1277 -0
- package/server/sandbox-manager.test.ts +378 -0
- package/server/sandbox-manager.ts +168 -0
- package/server/service.test.ts +1419 -0
- package/server/service.ts +718 -0
- package/server/session-creation-service.test.ts +661 -0
- package/server/session-creation-service.ts +473 -0
- package/server/session-git-info.ts +104 -0
- package/server/session-linear-issues.test.ts +118 -0
- package/server/session-linear-issues.ts +88 -0
- package/server/session-names.test.ts +94 -0
- package/server/session-names.ts +67 -0
- package/server/session-orchestrator.test.ts +1784 -0
- package/server/session-orchestrator.ts +973 -0
- package/server/session-state-machine.test.ts +606 -0
- package/server/session-state-machine.ts +207 -0
- package/server/session-store.test.ts +290 -0
- package/server/session-store.ts +146 -0
- package/server/session-types.ts +509 -0
- package/server/settings-manager.test.ts +275 -0
- package/server/settings-manager.ts +173 -0
- package/server/tailscale-manager.test.ts +553 -0
- package/server/tailscale-manager.ts +451 -0
- package/server/terminal-manager.ts +240 -0
- package/server/update-checker.test.ts +306 -0
- package/server/update-checker.ts +197 -0
- package/server/usage-limits.test.ts +536 -0
- package/server/usage-limits.ts +225 -0
- package/server/worktree-tracker.test.ts +243 -0
- package/server/worktree-tracker.ts +84 -0
- package/server/ws-auth.test.ts +59 -0
- package/server/ws-auth.ts +41 -0
- package/server/ws-bridge-browser-ingest.test.ts +272 -0
- package/server/ws-bridge-browser-ingest.ts +72 -0
- package/server/ws-bridge-browser.ts +112 -0
- package/server/ws-bridge-cli-ingest.test.ts +302 -0
- package/server/ws-bridge-cli-ingest.ts +81 -0
- package/server/ws-bridge-codex.test.ts +1837 -0
- package/server/ws-bridge-codex.ts +266 -0
- package/server/ws-bridge-controls.test.ts +124 -0
- package/server/ws-bridge-controls.ts +20 -0
- package/server/ws-bridge-persist.test.ts +296 -0
- package/server/ws-bridge-persist.ts +66 -0
- package/server/ws-bridge-publish.test.ts +234 -0
- package/server/ws-bridge-publish.ts +79 -0
- package/server/ws-bridge-replay.test.ts +44 -0
- package/server/ws-bridge-replay.ts +61 -0
- package/server/ws-bridge-types.ts +106 -0
- package/server/ws-bridge.test.ts +4777 -0
- package/server/ws-bridge.ts +1279 -0
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for linear-oauth-connections.ts — file-based CRUD store for
|
|
3
|
+
* Linear OAuth connections.
|
|
4
|
+
*
|
|
5
|
+
* Validates:
|
|
6
|
+
* - CRUD: create, read, update, delete
|
|
7
|
+
* - List and lookup operations
|
|
8
|
+
* - findOAuthConnectionByClientId lookup
|
|
9
|
+
* - sanitizeOAuthConnection masks secrets
|
|
10
|
+
* - Auto-derived status from accessToken
|
|
11
|
+
* - _resetForTest clears state
|
|
12
|
+
* - Persistence to disk (write + reload)
|
|
13
|
+
* - Invalid/corrupt JSON file handling
|
|
14
|
+
*/
|
|
15
|
+
import { describe, it, expect, beforeEach, afterAll } from "vitest";
|
|
16
|
+
import { mkdirSync, rmSync, existsSync, writeFileSync, readFileSync } from "node:fs";
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
import { tmpdir } from "node:os";
|
|
19
|
+
|
|
20
|
+
import {
|
|
21
|
+
listOAuthConnections,
|
|
22
|
+
getOAuthConnection,
|
|
23
|
+
findOAuthConnectionByClientId,
|
|
24
|
+
createOAuthConnection,
|
|
25
|
+
updateOAuthConnection,
|
|
26
|
+
deleteOAuthConnection,
|
|
27
|
+
sanitizeOAuthConnection,
|
|
28
|
+
_resetForTest,
|
|
29
|
+
type LinearOAuthConnection,
|
|
30
|
+
} from "./linear-oauth-connections.js";
|
|
31
|
+
|
|
32
|
+
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
const TEST_DIR = join(tmpdir(), `companion-oauth-test-${Date.now()}`);
|
|
35
|
+
const TEST_FILE = join(TEST_DIR, "linear-oauth-connections.json");
|
|
36
|
+
|
|
37
|
+
beforeEach(() => {
|
|
38
|
+
// Reset state and point at a temp file per test
|
|
39
|
+
_resetForTest(TEST_FILE);
|
|
40
|
+
// Ensure clean directory
|
|
41
|
+
if (existsSync(TEST_DIR)) {
|
|
42
|
+
rmSync(TEST_DIR, { recursive: true });
|
|
43
|
+
}
|
|
44
|
+
mkdirSync(TEST_DIR, { recursive: true });
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
afterAll(() => {
|
|
48
|
+
// Clean up temp directory
|
|
49
|
+
if (existsSync(TEST_DIR)) {
|
|
50
|
+
rmSync(TEST_DIR, { recursive: true });
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// =============================================================================
|
|
55
|
+
// Tests
|
|
56
|
+
// =============================================================================
|
|
57
|
+
|
|
58
|
+
describe("linear-oauth-connections", () => {
|
|
59
|
+
// ─── List ─────────────────────────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
it("returns empty array when no connections exist", () => {
|
|
62
|
+
const conns = listOAuthConnections();
|
|
63
|
+
expect(conns).toEqual([]);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// ─── Create ───────────────────────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
it("creates a connection with all required fields", () => {
|
|
69
|
+
const conn = createOAuthConnection({
|
|
70
|
+
name: "My App",
|
|
71
|
+
oauthClientId: "client-123",
|
|
72
|
+
oauthClientSecret: "secret-456",
|
|
73
|
+
webhookSecret: "webhook-789",
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
expect(conn.id).toBeTruthy();
|
|
77
|
+
expect(conn.name).toBe("My App");
|
|
78
|
+
expect(conn.oauthClientId).toBe("client-123");
|
|
79
|
+
expect(conn.oauthClientSecret).toBe("secret-456");
|
|
80
|
+
expect(conn.webhookSecret).toBe("webhook-789");
|
|
81
|
+
expect(conn.accessToken).toBe("");
|
|
82
|
+
expect(conn.refreshToken).toBe("");
|
|
83
|
+
expect(conn.status).toBe("disconnected");
|
|
84
|
+
expect(conn.createdAt).toBeGreaterThan(0);
|
|
85
|
+
expect(conn.updatedAt).toBeGreaterThan(0);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("trims whitespace from inputs", () => {
|
|
89
|
+
const conn = createOAuthConnection({
|
|
90
|
+
name: " Trimmed App ",
|
|
91
|
+
oauthClientId: " cid ",
|
|
92
|
+
oauthClientSecret: " csec ",
|
|
93
|
+
webhookSecret: " wsec ",
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
expect(conn.name).toBe("Trimmed App");
|
|
97
|
+
expect(conn.oauthClientId).toBe("cid");
|
|
98
|
+
expect(conn.oauthClientSecret).toBe("csec");
|
|
99
|
+
expect(conn.webhookSecret).toBe("wsec");
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("sets status to 'connected' when accessToken is provided", () => {
|
|
103
|
+
const conn = createOAuthConnection({
|
|
104
|
+
name: "With Token",
|
|
105
|
+
oauthClientId: "cid",
|
|
106
|
+
oauthClientSecret: "csec",
|
|
107
|
+
webhookSecret: "wsec",
|
|
108
|
+
accessToken: "tok-123",
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
expect(conn.status).toBe("connected");
|
|
112
|
+
expect(conn.accessToken).toBe("tok-123");
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
// ─── Get ──────────────────────────────────────────────────────────────────
|
|
116
|
+
|
|
117
|
+
it("retrieves a connection by ID", () => {
|
|
118
|
+
const created = createOAuthConnection({
|
|
119
|
+
name: "Findable",
|
|
120
|
+
oauthClientId: "cid",
|
|
121
|
+
oauthClientSecret: "csec",
|
|
122
|
+
webhookSecret: "wsec",
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
const found = getOAuthConnection(created.id);
|
|
126
|
+
expect(found).not.toBeNull();
|
|
127
|
+
expect(found!.name).toBe("Findable");
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("returns null for non-existent ID", () => {
|
|
131
|
+
expect(getOAuthConnection("non-existent-id")).toBeNull();
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
// ─── Find by client ID ────────────────────────────────────────────────────
|
|
135
|
+
|
|
136
|
+
it("finds a connection by oauthClientId", () => {
|
|
137
|
+
createOAuthConnection({
|
|
138
|
+
name: "Target",
|
|
139
|
+
oauthClientId: "unique-client-id",
|
|
140
|
+
oauthClientSecret: "csec",
|
|
141
|
+
webhookSecret: "wsec",
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
const found = findOAuthConnectionByClientId("unique-client-id");
|
|
145
|
+
expect(found).not.toBeNull();
|
|
146
|
+
expect(found!.name).toBe("Target");
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("returns null when no matching oauthClientId", () => {
|
|
150
|
+
expect(findOAuthConnectionByClientId("nope")).toBeNull();
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
// ─── Update ───────────────────────────────────────────────────────────────
|
|
154
|
+
|
|
155
|
+
it("updates connection fields", () => {
|
|
156
|
+
const conn = createOAuthConnection({
|
|
157
|
+
name: "Old Name",
|
|
158
|
+
oauthClientId: "cid",
|
|
159
|
+
oauthClientSecret: "csec",
|
|
160
|
+
webhookSecret: "wsec",
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
const updated = updateOAuthConnection(conn.id, { name: "New Name" });
|
|
164
|
+
expect(updated).not.toBeNull();
|
|
165
|
+
expect(updated!.name).toBe("New Name");
|
|
166
|
+
expect(updated!.oauthClientId).toBe("cid"); // unchanged
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("auto-derives connected status when accessToken is set", () => {
|
|
170
|
+
const conn = createOAuthConnection({
|
|
171
|
+
name: "App",
|
|
172
|
+
oauthClientId: "cid",
|
|
173
|
+
oauthClientSecret: "csec",
|
|
174
|
+
webhookSecret: "wsec",
|
|
175
|
+
});
|
|
176
|
+
expect(conn.status).toBe("disconnected");
|
|
177
|
+
|
|
178
|
+
const updated = updateOAuthConnection(conn.id, { accessToken: "tok" });
|
|
179
|
+
expect(updated!.status).toBe("connected");
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("auto-derives disconnected status when accessToken is cleared", () => {
|
|
183
|
+
const conn = createOAuthConnection({
|
|
184
|
+
name: "App",
|
|
185
|
+
oauthClientId: "cid",
|
|
186
|
+
oauthClientSecret: "csec",
|
|
187
|
+
webhookSecret: "wsec",
|
|
188
|
+
accessToken: "tok",
|
|
189
|
+
});
|
|
190
|
+
expect(conn.status).toBe("connected");
|
|
191
|
+
|
|
192
|
+
const updated = updateOAuthConnection(conn.id, { accessToken: "" });
|
|
193
|
+
expect(updated!.status).toBe("disconnected");
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("allows explicit status override", () => {
|
|
197
|
+
const conn = createOAuthConnection({
|
|
198
|
+
name: "App",
|
|
199
|
+
oauthClientId: "cid",
|
|
200
|
+
oauthClientSecret: "csec",
|
|
201
|
+
webhookSecret: "wsec",
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
const updated = updateOAuthConnection(conn.id, { status: "connected" });
|
|
205
|
+
expect(updated!.status).toBe("connected");
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it("returns null when updating non-existent ID", () => {
|
|
209
|
+
expect(updateOAuthConnection("nope", { name: "x" })).toBeNull();
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
// ─── Delete ───────────────────────────────────────────────────────────────
|
|
213
|
+
|
|
214
|
+
it("deletes a connection and returns true", () => {
|
|
215
|
+
const conn = createOAuthConnection({
|
|
216
|
+
name: "Deletable",
|
|
217
|
+
oauthClientId: "cid",
|
|
218
|
+
oauthClientSecret: "csec",
|
|
219
|
+
webhookSecret: "wsec",
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
expect(deleteOAuthConnection(conn.id)).toBe(true);
|
|
223
|
+
expect(getOAuthConnection(conn.id)).toBeNull();
|
|
224
|
+
expect(listOAuthConnections()).toHaveLength(0);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("returns false when deleting non-existent ID", () => {
|
|
228
|
+
expect(deleteOAuthConnection("nope")).toBe(false);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
// ─── List ─────────────────────────────────────────────────────────────────
|
|
232
|
+
|
|
233
|
+
it("lists all connections", () => {
|
|
234
|
+
createOAuthConnection({ name: "A", oauthClientId: "a", oauthClientSecret: "s", webhookSecret: "w" });
|
|
235
|
+
createOAuthConnection({ name: "B", oauthClientId: "b", oauthClientSecret: "s", webhookSecret: "w" });
|
|
236
|
+
|
|
237
|
+
const all = listOAuthConnections();
|
|
238
|
+
expect(all).toHaveLength(2);
|
|
239
|
+
expect(all.map((c) => c.name)).toEqual(["A", "B"]);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it("returns a copy (mutations don't affect store)", () => {
|
|
243
|
+
createOAuthConnection({ name: "Safe", oauthClientId: "cid", oauthClientSecret: "s", webhookSecret: "w" });
|
|
244
|
+
const list = listOAuthConnections();
|
|
245
|
+
list.pop();
|
|
246
|
+
expect(listOAuthConnections()).toHaveLength(1);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
// ─── Sanitize ─────────────────────────────────────────────────────────────
|
|
250
|
+
|
|
251
|
+
it("masks secrets in sanitized output", () => {
|
|
252
|
+
const conn = createOAuthConnection({
|
|
253
|
+
name: "Sensitive",
|
|
254
|
+
oauthClientId: "visible-client-id",
|
|
255
|
+
oauthClientSecret: "super-secret",
|
|
256
|
+
webhookSecret: "wh-secret",
|
|
257
|
+
accessToken: "at-secret",
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
const sanitized = sanitizeOAuthConnection(conn);
|
|
261
|
+
|
|
262
|
+
// Should include these public fields
|
|
263
|
+
expect(sanitized.id).toBe(conn.id);
|
|
264
|
+
expect(sanitized.name).toBe("Sensitive");
|
|
265
|
+
expect(sanitized.oauthClientId).toBe("visible-client-id");
|
|
266
|
+
expect(sanitized.status).toBe("connected");
|
|
267
|
+
expect(sanitized.createdAt).toBe(conn.createdAt);
|
|
268
|
+
expect(sanitized.updatedAt).toBe(conn.updatedAt);
|
|
269
|
+
|
|
270
|
+
// Should have boolean flags instead of actual secrets
|
|
271
|
+
expect(sanitized.hasAccessToken).toBe(true);
|
|
272
|
+
expect(sanitized.hasClientSecret).toBe(true);
|
|
273
|
+
expect(sanitized.hasWebhookSecret).toBe(true);
|
|
274
|
+
|
|
275
|
+
// Should NOT contain the actual secrets
|
|
276
|
+
const raw = sanitized as unknown as Record<string, unknown>;
|
|
277
|
+
expect(raw["oauthClientSecret"]).toBeUndefined();
|
|
278
|
+
expect(raw["webhookSecret"]).toBeUndefined();
|
|
279
|
+
expect(raw["accessToken"]).toBeUndefined();
|
|
280
|
+
expect(raw["refreshToken"]).toBeUndefined();
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it("reports false flags when secrets are empty", () => {
|
|
284
|
+
const conn = createOAuthConnection({
|
|
285
|
+
name: "No Secrets",
|
|
286
|
+
oauthClientId: "cid",
|
|
287
|
+
oauthClientSecret: "csec",
|
|
288
|
+
webhookSecret: "wsec",
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
const sanitized = sanitizeOAuthConnection(conn);
|
|
292
|
+
expect(sanitized.hasAccessToken).toBe(false); // no accessToken provided
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
// ─── Persistence ──────────────────────────────────────────────────────────
|
|
296
|
+
|
|
297
|
+
it("persists to disk and reloads correctly", () => {
|
|
298
|
+
// Create a connection
|
|
299
|
+
const conn = createOAuthConnection({
|
|
300
|
+
name: "Persistent",
|
|
301
|
+
oauthClientId: "persist-cid",
|
|
302
|
+
oauthClientSecret: "persist-csec",
|
|
303
|
+
webhookSecret: "persist-wsec",
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
// Verify file exists
|
|
307
|
+
expect(existsSync(TEST_FILE)).toBe(true);
|
|
308
|
+
|
|
309
|
+
// Reset state (simulates server restart) and reload
|
|
310
|
+
_resetForTest(TEST_FILE);
|
|
311
|
+
const reloaded = listOAuthConnections();
|
|
312
|
+
expect(reloaded).toHaveLength(1);
|
|
313
|
+
expect(reloaded[0].id).toBe(conn.id);
|
|
314
|
+
expect(reloaded[0].name).toBe("Persistent");
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
it("handles corrupt JSON file gracefully", () => {
|
|
318
|
+
// Write invalid JSON to the file
|
|
319
|
+
writeFileSync(TEST_FILE, "not valid json{{{", "utf-8");
|
|
320
|
+
|
|
321
|
+
_resetForTest(TEST_FILE);
|
|
322
|
+
const conns = listOAuthConnections();
|
|
323
|
+
expect(conns).toEqual([]);
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
it("handles non-array JSON file gracefully", () => {
|
|
327
|
+
// Write valid JSON but not an array
|
|
328
|
+
writeFileSync(TEST_FILE, JSON.stringify({ foo: "bar" }), "utf-8");
|
|
329
|
+
|
|
330
|
+
_resetForTest(TEST_FILE);
|
|
331
|
+
const conns = listOAuthConnections();
|
|
332
|
+
expect(conns).toEqual([]);
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
it("filters out malformed entries from JSON file", () => {
|
|
336
|
+
// Write array with mix of valid and invalid entries
|
|
337
|
+
const data = [
|
|
338
|
+
{ id: "valid", oauthClientId: "cid", name: "Valid", oauthClientSecret: "", webhookSecret: "", accessToken: "", refreshToken: "", status: "disconnected", createdAt: 1, updatedAt: 1 },
|
|
339
|
+
{ noId: true }, // missing id
|
|
340
|
+
null, // null entry
|
|
341
|
+
"string entry", // not an object
|
|
342
|
+
];
|
|
343
|
+
writeFileSync(TEST_FILE, JSON.stringify(data), "utf-8");
|
|
344
|
+
|
|
345
|
+
_resetForTest(TEST_FILE);
|
|
346
|
+
const conns = listOAuthConnections();
|
|
347
|
+
expect(conns).toHaveLength(1);
|
|
348
|
+
expect(conns[0].name).toBe("Valid");
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
// ─── _resetForTest ────────────────────────────────────────────────────────
|
|
352
|
+
|
|
353
|
+
it("clears state when _resetForTest is called", () => {
|
|
354
|
+
createOAuthConnection({ name: "Will Reset", oauthClientId: "cid", oauthClientSecret: "s", webhookSecret: "w" });
|
|
355
|
+
expect(listOAuthConnections()).toHaveLength(1);
|
|
356
|
+
|
|
357
|
+
_resetForTest(TEST_FILE);
|
|
358
|
+
// Next list call will attempt to read from TEST_FILE which no longer has this connection
|
|
359
|
+
// But since we created a connection above, the file DOES have it.
|
|
360
|
+
// So reset and use a non-existent file to prove state is cleared
|
|
361
|
+
const emptyFile = join(TEST_DIR, "empty.json");
|
|
362
|
+
_resetForTest(emptyFile);
|
|
363
|
+
expect(listOAuthConnections()).toHaveLength(0);
|
|
364
|
+
});
|
|
365
|
+
});
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
import {
|
|
2
|
+
mkdirSync,
|
|
3
|
+
readFileSync,
|
|
4
|
+
writeFileSync,
|
|
5
|
+
existsSync,
|
|
6
|
+
} from "node:fs";
|
|
7
|
+
import { join, dirname } from "node:path";
|
|
8
|
+
import { randomUUID } from "node:crypto";
|
|
9
|
+
import { COMPANION_HOME } from "./paths.js";
|
|
10
|
+
|
|
11
|
+
// ─── Types ───────────────────────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
export interface LinearOAuthConnection {
|
|
14
|
+
id: string;
|
|
15
|
+
name: string;
|
|
16
|
+
oauthClientId: string;
|
|
17
|
+
oauthClientSecret: string;
|
|
18
|
+
webhookSecret: string;
|
|
19
|
+
accessToken: string;
|
|
20
|
+
refreshToken: string;
|
|
21
|
+
status: "connected" | "disconnected";
|
|
22
|
+
createdAt: number;
|
|
23
|
+
updatedAt: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Sanitized version for API responses (secrets masked). */
|
|
27
|
+
export interface LinearOAuthConnectionSummary {
|
|
28
|
+
id: string;
|
|
29
|
+
name: string;
|
|
30
|
+
oauthClientId: string;
|
|
31
|
+
status: "connected" | "disconnected";
|
|
32
|
+
hasAccessToken: boolean;
|
|
33
|
+
hasClientSecret: boolean;
|
|
34
|
+
hasWebhookSecret: boolean;
|
|
35
|
+
createdAt: number;
|
|
36
|
+
updatedAt: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ─── Paths ───────────────────────────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
const DEFAULT_PATH = join(COMPANION_HOME, "linear-oauth-connections.json");
|
|
42
|
+
|
|
43
|
+
// ─── Store ───────────────────────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
let connections: LinearOAuthConnection[] = [];
|
|
46
|
+
let loaded = false;
|
|
47
|
+
let filePath = DEFAULT_PATH;
|
|
48
|
+
|
|
49
|
+
function ensureLoaded(): void {
|
|
50
|
+
if (loaded) return;
|
|
51
|
+
try {
|
|
52
|
+
if (existsSync(filePath)) {
|
|
53
|
+
const raw = JSON.parse(readFileSync(filePath, "utf-8"));
|
|
54
|
+
if (Array.isArray(raw)) {
|
|
55
|
+
connections = raw.filter(
|
|
56
|
+
(c: unknown): c is LinearOAuthConnection =>
|
|
57
|
+
typeof c === "object" &&
|
|
58
|
+
c !== null &&
|
|
59
|
+
typeof (c as LinearOAuthConnection).id === "string" &&
|
|
60
|
+
typeof (c as LinearOAuthConnection).oauthClientId === "string",
|
|
61
|
+
);
|
|
62
|
+
} else {
|
|
63
|
+
connections = [];
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
} catch {
|
|
67
|
+
connections = [];
|
|
68
|
+
}
|
|
69
|
+
loaded = true;
|
|
70
|
+
|
|
71
|
+
// Auto-migrate from agents with inline credentials + global settings
|
|
72
|
+
migrateFromAgents();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ─── Migration ───────────────────────────────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
interface MigrationSettings {
|
|
78
|
+
linearOAuthClientId: string;
|
|
79
|
+
linearOAuthClientSecret: string;
|
|
80
|
+
linearOAuthWebhookSecret: string;
|
|
81
|
+
linearOAuthAccessToken: string;
|
|
82
|
+
linearOAuthRefreshToken: string;
|
|
83
|
+
[key: string]: unknown;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
interface MigrationDeps {
|
|
87
|
+
listAgents: () => Array<{ id: string; name: string; triggers?: { linear?: Record<string, unknown> } }>;
|
|
88
|
+
updateAgent: (id: string, patch: Record<string, unknown>) => void;
|
|
89
|
+
getSettings: () => MigrationSettings;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* One-time migration: if no OAuth connections exist, extract inline credentials
|
|
94
|
+
* from agents and global settings into standalone OAuth connections.
|
|
95
|
+
* Deduplicates by oauthClientId so multiple agents sharing the same app
|
|
96
|
+
* get a single connection.
|
|
97
|
+
*
|
|
98
|
+
* Accepts optional deps parameter for testability.
|
|
99
|
+
*/
|
|
100
|
+
export function migrateFromAgents(deps?: MigrationDeps): void {
|
|
101
|
+
if (connections.length > 0) return;
|
|
102
|
+
|
|
103
|
+
let resolvedDeps: MigrationDeps;
|
|
104
|
+
if (deps) {
|
|
105
|
+
resolvedDeps = deps;
|
|
106
|
+
} else {
|
|
107
|
+
// Lazy import to avoid circular dependency at module load time
|
|
108
|
+
try {
|
|
109
|
+
const agentStoreModule = require("./agent-store.js") as typeof import("./agent-store.js");
|
|
110
|
+
const settingsModule = require("./settings-manager.js") as typeof import("./settings-manager.js");
|
|
111
|
+
resolvedDeps = {
|
|
112
|
+
listAgents: agentStoreModule.listAgents as MigrationDeps["listAgents"],
|
|
113
|
+
updateAgent: agentStoreModule.updateAgent as MigrationDeps["updateAgent"],
|
|
114
|
+
getSettings: settingsModule.getSettings as unknown as MigrationDeps["getSettings"],
|
|
115
|
+
};
|
|
116
|
+
} catch {
|
|
117
|
+
return; // Can't migrate without dependencies
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const agents = resolvedDeps.listAgents();
|
|
122
|
+
const seenClientIds = new Set<string>();
|
|
123
|
+
|
|
124
|
+
for (const agent of agents) {
|
|
125
|
+
const linear = agent.triggers?.linear;
|
|
126
|
+
const oauthClientId = linear?.oauthClientId as string | undefined;
|
|
127
|
+
if (!oauthClientId || seenClientIds.has(oauthClientId)) continue;
|
|
128
|
+
seenClientIds.add(oauthClientId);
|
|
129
|
+
|
|
130
|
+
const now = Date.now();
|
|
131
|
+
const conn: LinearOAuthConnection = {
|
|
132
|
+
id: randomUUID(),
|
|
133
|
+
name: `${agent.name} OAuth App`,
|
|
134
|
+
oauthClientId,
|
|
135
|
+
oauthClientSecret: (linear?.oauthClientSecret as string) || "",
|
|
136
|
+
webhookSecret: (linear?.webhookSecret as string) || "",
|
|
137
|
+
accessToken: (linear?.accessToken as string) || "",
|
|
138
|
+
refreshToken: (linear?.refreshToken as string) || "",
|
|
139
|
+
status: linear?.accessToken ? "connected" : "disconnected",
|
|
140
|
+
createdAt: now,
|
|
141
|
+
updatedAt: now,
|
|
142
|
+
};
|
|
143
|
+
connections.push(conn);
|
|
144
|
+
|
|
145
|
+
// Update all agents with this clientId to reference the new connection
|
|
146
|
+
for (const a of agents) {
|
|
147
|
+
if ((a.triggers?.linear?.oauthClientId as string) === oauthClientId) {
|
|
148
|
+
resolvedDeps.updateAgent(a.id, {
|
|
149
|
+
triggers: {
|
|
150
|
+
...a.triggers,
|
|
151
|
+
linear: {
|
|
152
|
+
...a.triggers!.linear,
|
|
153
|
+
oauthConnectionId: conn.id,
|
|
154
|
+
},
|
|
155
|
+
},
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Also migrate from global settings if present
|
|
162
|
+
const settings = resolvedDeps.getSettings();
|
|
163
|
+
if (settings.linearOAuthClientId && !seenClientIds.has(settings.linearOAuthClientId)) {
|
|
164
|
+
const now = Date.now();
|
|
165
|
+
connections.push({
|
|
166
|
+
id: randomUUID(),
|
|
167
|
+
name: "Default OAuth App",
|
|
168
|
+
oauthClientId: settings.linearOAuthClientId,
|
|
169
|
+
oauthClientSecret: settings.linearOAuthClientSecret,
|
|
170
|
+
webhookSecret: settings.linearOAuthWebhookSecret,
|
|
171
|
+
accessToken: settings.linearOAuthAccessToken,
|
|
172
|
+
refreshToken: settings.linearOAuthRefreshToken,
|
|
173
|
+
status: settings.linearOAuthAccessToken ? "connected" : "disconnected",
|
|
174
|
+
createdAt: now,
|
|
175
|
+
updatedAt: now,
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (connections.length > 0) {
|
|
180
|
+
persist();
|
|
181
|
+
console.log(`[linear-oauth-connections] Migrated ${connections.length} OAuth connection(s) from agents/settings`);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function persist(): void {
|
|
186
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
187
|
+
writeFileSync(filePath, JSON.stringify(connections, null, 2), "utf-8");
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// ─── Public API ──────────────────────────────────────────────────────────────
|
|
191
|
+
|
|
192
|
+
export function listOAuthConnections(): LinearOAuthConnection[] {
|
|
193
|
+
ensureLoaded();
|
|
194
|
+
return [...connections];
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export function getOAuthConnection(id: string): LinearOAuthConnection | null {
|
|
198
|
+
ensureLoaded();
|
|
199
|
+
return connections.find((c) => c.id === id) ?? null;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Look up an OAuth connection by its Linear OAuth app client ID. */
|
|
203
|
+
export function findOAuthConnectionByClientId(
|
|
204
|
+
oauthClientId: string,
|
|
205
|
+
): LinearOAuthConnection | null {
|
|
206
|
+
ensureLoaded();
|
|
207
|
+
return connections.find((c) => c.oauthClientId === oauthClientId) ?? null;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export function createOAuthConnection(data: {
|
|
211
|
+
name: string;
|
|
212
|
+
oauthClientId: string;
|
|
213
|
+
oauthClientSecret: string;
|
|
214
|
+
webhookSecret: string;
|
|
215
|
+
accessToken?: string;
|
|
216
|
+
refreshToken?: string;
|
|
217
|
+
}): LinearOAuthConnection {
|
|
218
|
+
ensureLoaded();
|
|
219
|
+
const now = Date.now();
|
|
220
|
+
const conn: LinearOAuthConnection = {
|
|
221
|
+
id: randomUUID(),
|
|
222
|
+
name: data.name.trim(),
|
|
223
|
+
oauthClientId: data.oauthClientId.trim(),
|
|
224
|
+
oauthClientSecret: data.oauthClientSecret.trim(),
|
|
225
|
+
webhookSecret: data.webhookSecret.trim(),
|
|
226
|
+
accessToken: data.accessToken?.trim() || "",
|
|
227
|
+
refreshToken: data.refreshToken?.trim() || "",
|
|
228
|
+
status: data.accessToken?.trim() ? "connected" : "disconnected",
|
|
229
|
+
createdAt: now,
|
|
230
|
+
updatedAt: now,
|
|
231
|
+
};
|
|
232
|
+
connections.push(conn);
|
|
233
|
+
persist();
|
|
234
|
+
return conn;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export function updateOAuthConnection(
|
|
238
|
+
id: string,
|
|
239
|
+
patch: Partial<Omit<LinearOAuthConnection, "id" | "createdAt">>,
|
|
240
|
+
): LinearOAuthConnection | null {
|
|
241
|
+
ensureLoaded();
|
|
242
|
+
const conn = connections.find((c) => c.id === id);
|
|
243
|
+
if (!conn) return null;
|
|
244
|
+
|
|
245
|
+
if (patch.name !== undefined) conn.name = patch.name.trim();
|
|
246
|
+
if (patch.oauthClientId !== undefined) conn.oauthClientId = patch.oauthClientId.trim();
|
|
247
|
+
if (patch.oauthClientSecret !== undefined) conn.oauthClientSecret = patch.oauthClientSecret.trim();
|
|
248
|
+
if (patch.webhookSecret !== undefined) conn.webhookSecret = patch.webhookSecret.trim();
|
|
249
|
+
if (patch.accessToken !== undefined) conn.accessToken = patch.accessToken.trim();
|
|
250
|
+
if (patch.refreshToken !== undefined) conn.refreshToken = patch.refreshToken.trim();
|
|
251
|
+
if (patch.status !== undefined) {
|
|
252
|
+
conn.status = patch.status;
|
|
253
|
+
} else if (patch.accessToken !== undefined) {
|
|
254
|
+
// Auto-derive status from accessToken presence
|
|
255
|
+
conn.status = patch.accessToken.trim() ? "connected" : "disconnected";
|
|
256
|
+
}
|
|
257
|
+
conn.updatedAt = Date.now();
|
|
258
|
+
|
|
259
|
+
persist();
|
|
260
|
+
return conn;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export function deleteOAuthConnection(id: string): boolean {
|
|
264
|
+
ensureLoaded();
|
|
265
|
+
const idx = connections.findIndex((c) => c.id === id);
|
|
266
|
+
if (idx === -1) return false;
|
|
267
|
+
connections.splice(idx, 1);
|
|
268
|
+
persist();
|
|
269
|
+
return true;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** Sanitize an OAuth connection for API responses (mask secrets). */
|
|
273
|
+
export function sanitizeOAuthConnection(
|
|
274
|
+
conn: LinearOAuthConnection,
|
|
275
|
+
): LinearOAuthConnectionSummary {
|
|
276
|
+
return {
|
|
277
|
+
id: conn.id,
|
|
278
|
+
name: conn.name,
|
|
279
|
+
oauthClientId: conn.oauthClientId,
|
|
280
|
+
status: conn.status,
|
|
281
|
+
hasAccessToken: !!conn.accessToken,
|
|
282
|
+
hasClientSecret: !!conn.oauthClientSecret,
|
|
283
|
+
hasWebhookSecret: !!conn.webhookSecret,
|
|
284
|
+
createdAt: conn.createdAt,
|
|
285
|
+
updatedAt: conn.updatedAt,
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** Reset internal state and optionally set a custom file path (for testing). */
|
|
290
|
+
export function _resetForTest(customPath?: string): void {
|
|
291
|
+
connections = [];
|
|
292
|
+
loaded = false;
|
|
293
|
+
filePath = customPath || DEFAULT_PATH;
|
|
294
|
+
}
|