@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.
Files changed (242) hide show
  1. package/bin/cli.ts +168 -0
  2. package/bin/ctl.ts +528 -0
  3. package/bin/generate-token.ts +28 -0
  4. package/dist/apple-touch-icon.png +0 -0
  5. package/dist/assets/AgentsPage-DCFhrJ28.js +13 -0
  6. package/dist/assets/CronManager-EGwLJONv.js +1 -0
  7. package/dist/assets/IntegrationsPage-CTMRnbQS.js +1 -0
  8. package/dist/assets/LinearOAuthSettingsPage-CgQFMIgr.js +1 -0
  9. package/dist/assets/LinearSettingsPage-C9nok1qi.js +1 -0
  10. package/dist/assets/Playground-BV3k0RbV.js +109 -0
  11. package/dist/assets/PromptsPage-CFojqNKP.js +4 -0
  12. package/dist/assets/RunsPage-DUJ1QUSa.js +1 -0
  13. package/dist/assets/SandboxManager-CrVQ-VU_.js +8 -0
  14. package/dist/assets/SettingsPage-D1fPCL19.js +1 -0
  15. package/dist/assets/TailscalePage-D06cyvyC.js +1 -0
  16. package/dist/assets/index-BhUa1e6X.css +1 -0
  17. package/dist/assets/index-DkqeP-R9.js +134 -0
  18. package/dist/assets/sw-register-BibwRdvC.js +1 -0
  19. package/dist/assets/workbox-window.prod.es5-BIl4cyR9.js +2 -0
  20. package/dist/favicon.svg +8 -0
  21. package/dist/fonts/MesloLGSNerdFontMono-Bold.woff2 +0 -0
  22. package/dist/fonts/MesloLGSNerdFontMono-Regular.woff2 +0 -0
  23. package/dist/icon-192.png +0 -0
  24. package/dist/icon-512.png +0 -0
  25. package/dist/index.html +20 -0
  26. package/dist/logo-codex.svg +14 -0
  27. package/dist/logo-docker.svg +4 -0
  28. package/dist/logo.svg +14 -0
  29. package/dist/manifest.json +24 -0
  30. package/dist/sw.js +2 -0
  31. package/package.json +104 -0
  32. package/server/agent-cron-migrator.test.ts +610 -0
  33. package/server/agent-cron-migrator.ts +85 -0
  34. package/server/agent-executor.test.ts +1108 -0
  35. package/server/agent-executor.ts +346 -0
  36. package/server/agent-store.test.ts +588 -0
  37. package/server/agent-store.ts +185 -0
  38. package/server/agent-types.ts +138 -0
  39. package/server/ai-validation-settings.test.ts +128 -0
  40. package/server/ai-validation-settings.ts +35 -0
  41. package/server/ai-validator.test.ts +387 -0
  42. package/server/ai-validator.ts +271 -0
  43. package/server/auth-manager.test.ts +83 -0
  44. package/server/auth-manager.ts +150 -0
  45. package/server/auto-namer.test.ts +252 -0
  46. package/server/auto-namer.ts +78 -0
  47. package/server/backend-adapter.test.ts +38 -0
  48. package/server/backend-adapter.ts +54 -0
  49. package/server/cache-headers.test.ts +98 -0
  50. package/server/cache-headers.ts +61 -0
  51. package/server/claude-adapter.test.ts +1363 -0
  52. package/server/claude-adapter.ts +889 -0
  53. package/server/claude-container-auth.test.ts +44 -0
  54. package/server/claude-container-auth.ts +30 -0
  55. package/server/claude-protocol-contract.test.ts +71 -0
  56. package/server/claude-protocol-drift.test.ts +78 -0
  57. package/server/claude-session-discovery.test.ts +132 -0
  58. package/server/claude-session-discovery.ts +157 -0
  59. package/server/claude-session-history.test.ts +158 -0
  60. package/server/claude-session-history.ts +410 -0
  61. package/server/cli-launcher.test.ts +1343 -0
  62. package/server/cli-launcher.ts +1298 -0
  63. package/server/cli.test.ts +16 -0
  64. package/server/codex-adapter.test.ts +5545 -0
  65. package/server/codex-adapter.ts +3062 -0
  66. package/server/codex-container-auth.test.ts +50 -0
  67. package/server/codex-container-auth.ts +24 -0
  68. package/server/codex-home.test.ts +61 -0
  69. package/server/codex-home.ts +26 -0
  70. package/server/codex-protocol-contract.test.ts +96 -0
  71. package/server/codex-protocol-drift.test.ts +123 -0
  72. package/server/codex-ws-proxy.cjs +226 -0
  73. package/server/commands-discovery.test.ts +179 -0
  74. package/server/commands-discovery.ts +81 -0
  75. package/server/constants.ts +7 -0
  76. package/server/container-manager.test.ts +1211 -0
  77. package/server/container-manager.ts +1053 -0
  78. package/server/cron-scheduler.test.ts +957 -0
  79. package/server/cron-scheduler.ts +243 -0
  80. package/server/cron-store.test.ts +422 -0
  81. package/server/cron-store.ts +148 -0
  82. package/server/cron-types.ts +63 -0
  83. package/server/env-manager.test.ts +268 -0
  84. package/server/env-manager.ts +161 -0
  85. package/server/event-bus-types.ts +64 -0
  86. package/server/event-bus.test.ts +244 -0
  87. package/server/event-bus.ts +124 -0
  88. package/server/execution-store.test.ts +307 -0
  89. package/server/execution-store.ts +170 -0
  90. package/server/fs-utils.ts +15 -0
  91. package/server/git-utils.test.ts +938 -0
  92. package/server/git-utils.ts +421 -0
  93. package/server/github-pr.test.ts +498 -0
  94. package/server/github-pr.ts +379 -0
  95. package/server/image-pull-manager.test.ts +303 -0
  96. package/server/image-pull-manager.ts +279 -0
  97. package/server/index.ts +396 -0
  98. package/server/linear-agent-bridge.test.ts +1157 -0
  99. package/server/linear-agent-bridge.ts +629 -0
  100. package/server/linear-agent.test.ts +473 -0
  101. package/server/linear-agent.ts +479 -0
  102. package/server/linear-cache.test.ts +136 -0
  103. package/server/linear-cache.ts +113 -0
  104. package/server/linear-connections.test.ts +350 -0
  105. package/server/linear-connections.ts +231 -0
  106. package/server/linear-credential-migration.test.ts +337 -0
  107. package/server/linear-credential-migration.ts +63 -0
  108. package/server/linear-oauth-connections-migration.test.ts +268 -0
  109. package/server/linear-oauth-connections.test.ts +365 -0
  110. package/server/linear-oauth-connections.ts +294 -0
  111. package/server/linear-project-manager.test.ts +162 -0
  112. package/server/linear-project-manager.ts +111 -0
  113. package/server/linear-prompt-builder.test.ts +74 -0
  114. package/server/linear-prompt-builder.ts +61 -0
  115. package/server/linear-staging.test.ts +276 -0
  116. package/server/linear-staging.ts +142 -0
  117. package/server/logger.test.ts +393 -0
  118. package/server/logger.ts +259 -0
  119. package/server/metrics-collector.test.ts +413 -0
  120. package/server/metrics-collector.ts +350 -0
  121. package/server/metrics-types.ts +108 -0
  122. package/server/middleware/managed-auth.test.ts +264 -0
  123. package/server/middleware/managed-auth.ts +195 -0
  124. package/server/novnc-proxy.test.ts +333 -0
  125. package/server/novnc-proxy.ts +99 -0
  126. package/server/path-resolver.test.ts +552 -0
  127. package/server/path-resolver.ts +186 -0
  128. package/server/paths.test.ts +31 -0
  129. package/server/paths.ts +11 -0
  130. package/server/pr-poller.test.ts +191 -0
  131. package/server/pr-poller.ts +162 -0
  132. package/server/prompt-manager.test.ts +211 -0
  133. package/server/prompt-manager.ts +211 -0
  134. package/server/protocol/claude-upstream/README.md +19 -0
  135. package/server/protocol/claude-upstream/sdk.d.ts.txt +1943 -0
  136. package/server/protocol/codex-upstream/ClientNotification.ts.txt +5 -0
  137. package/server/protocol/codex-upstream/ClientRequest.ts.txt +60 -0
  138. package/server/protocol/codex-upstream/README.md +18 -0
  139. package/server/protocol/codex-upstream/ServerNotification.ts.txt +41 -0
  140. package/server/protocol/codex-upstream/ServerRequest.ts.txt +16 -0
  141. package/server/protocol/codex-upstream/v2/DynamicToolCallParams.ts.txt +6 -0
  142. package/server/protocol/codex-upstream/v2/DynamicToolCallResponse.ts.txt +6 -0
  143. package/server/protocol-monitor.ts +50 -0
  144. package/server/recorder.test.ts +454 -0
  145. package/server/recorder.ts +374 -0
  146. package/server/recording-hub/compat-validator.test.ts +150 -0
  147. package/server/recording-hub/compat-validator.ts +284 -0
  148. package/server/recording-hub/diagnostics.test.ts +140 -0
  149. package/server/recording-hub/diagnostics.ts +299 -0
  150. package/server/recording-hub/hub-config.test.ts +44 -0
  151. package/server/recording-hub/hub-config.ts +19 -0
  152. package/server/recording-hub/hub-routes.test.ts +417 -0
  153. package/server/recording-hub/hub-routes.ts +236 -0
  154. package/server/recording-hub/hub-store.test.ts +262 -0
  155. package/server/recording-hub/hub-store.ts +265 -0
  156. package/server/recording-hub/replay-adapter.test.ts +294 -0
  157. package/server/recording-hub/replay-adapter.ts +207 -0
  158. package/server/relay-client.test.ts +337 -0
  159. package/server/relay-client.ts +320 -0
  160. package/server/replay.test.ts +200 -0
  161. package/server/replay.ts +78 -0
  162. package/server/routes/agent-routes.test.ts +1400 -0
  163. package/server/routes/agent-routes.ts +409 -0
  164. package/server/routes/cron-routes.test.ts +881 -0
  165. package/server/routes/cron-routes.ts +103 -0
  166. package/server/routes/env-routes.test.ts +383 -0
  167. package/server/routes/env-routes.ts +95 -0
  168. package/server/routes/fs-routes.test.ts +1198 -0
  169. package/server/routes/fs-routes.ts +605 -0
  170. package/server/routes/git-routes.test.ts +813 -0
  171. package/server/routes/git-routes.ts +97 -0
  172. package/server/routes/linear-agent-routes.test.ts +721 -0
  173. package/server/routes/linear-agent-routes.ts +304 -0
  174. package/server/routes/linear-connection-routes.test.ts +927 -0
  175. package/server/routes/linear-connection-routes.ts +244 -0
  176. package/server/routes/linear-oauth-connection-routes.test.ts +406 -0
  177. package/server/routes/linear-oauth-connection-routes.ts +129 -0
  178. package/server/routes/linear-routes.test.ts +1510 -0
  179. package/server/routes/linear-routes.ts +953 -0
  180. package/server/routes/metrics-routes.test.ts +103 -0
  181. package/server/routes/metrics-routes.ts +13 -0
  182. package/server/routes/prompt-routes.ts +67 -0
  183. package/server/routes/sandbox-routes.test.ts +513 -0
  184. package/server/routes/sandbox-routes.ts +127 -0
  185. package/server/routes/settings-routes.ts +270 -0
  186. package/server/routes/skills-routes.test.ts +690 -0
  187. package/server/routes/skills-routes.ts +100 -0
  188. package/server/routes/system-routes.test.ts +637 -0
  189. package/server/routes/system-routes.ts +228 -0
  190. package/server/routes/tailscale-routes.test.ts +176 -0
  191. package/server/routes/tailscale-routes.ts +22 -0
  192. package/server/routes.test.ts +4655 -0
  193. package/server/routes.ts +1277 -0
  194. package/server/sandbox-manager.test.ts +378 -0
  195. package/server/sandbox-manager.ts +168 -0
  196. package/server/service.test.ts +1419 -0
  197. package/server/service.ts +718 -0
  198. package/server/session-creation-service.test.ts +661 -0
  199. package/server/session-creation-service.ts +473 -0
  200. package/server/session-git-info.ts +104 -0
  201. package/server/session-linear-issues.test.ts +118 -0
  202. package/server/session-linear-issues.ts +88 -0
  203. package/server/session-names.test.ts +94 -0
  204. package/server/session-names.ts +67 -0
  205. package/server/session-orchestrator.test.ts +1784 -0
  206. package/server/session-orchestrator.ts +973 -0
  207. package/server/session-state-machine.test.ts +606 -0
  208. package/server/session-state-machine.ts +207 -0
  209. package/server/session-store.test.ts +290 -0
  210. package/server/session-store.ts +146 -0
  211. package/server/session-types.ts +509 -0
  212. package/server/settings-manager.test.ts +275 -0
  213. package/server/settings-manager.ts +173 -0
  214. package/server/tailscale-manager.test.ts +553 -0
  215. package/server/tailscale-manager.ts +451 -0
  216. package/server/terminal-manager.ts +240 -0
  217. package/server/update-checker.test.ts +306 -0
  218. package/server/update-checker.ts +197 -0
  219. package/server/usage-limits.test.ts +536 -0
  220. package/server/usage-limits.ts +225 -0
  221. package/server/worktree-tracker.test.ts +243 -0
  222. package/server/worktree-tracker.ts +84 -0
  223. package/server/ws-auth.test.ts +59 -0
  224. package/server/ws-auth.ts +41 -0
  225. package/server/ws-bridge-browser-ingest.test.ts +272 -0
  226. package/server/ws-bridge-browser-ingest.ts +72 -0
  227. package/server/ws-bridge-browser.ts +112 -0
  228. package/server/ws-bridge-cli-ingest.test.ts +302 -0
  229. package/server/ws-bridge-cli-ingest.ts +81 -0
  230. package/server/ws-bridge-codex.test.ts +1837 -0
  231. package/server/ws-bridge-codex.ts +266 -0
  232. package/server/ws-bridge-controls.test.ts +124 -0
  233. package/server/ws-bridge-controls.ts +20 -0
  234. package/server/ws-bridge-persist.test.ts +296 -0
  235. package/server/ws-bridge-persist.ts +66 -0
  236. package/server/ws-bridge-publish.test.ts +234 -0
  237. package/server/ws-bridge-publish.ts +79 -0
  238. package/server/ws-bridge-replay.test.ts +44 -0
  239. package/server/ws-bridge-replay.ts +61 -0
  240. package/server/ws-bridge-types.ts +106 -0
  241. package/server/ws-bridge.test.ts +4777 -0
  242. package/server/ws-bridge.ts +1279 -0
@@ -0,0 +1,142 @@
1
+ // ─── Linear OAuth Staging Slots ──────────────────────────────────────────────
2
+ // Temporary, file-backed credential storage for the Linear agent wizard.
3
+ // Each wizard invocation gets a unique staging slot so multiple wizards
4
+ // (or multiple agents) never collide. Slots are automatically cleaned up
5
+ // after 30 minutes.
6
+
7
+ import {
8
+ mkdirSync,
9
+ readdirSync,
10
+ readFileSync,
11
+ writeFileSync,
12
+ unlinkSync,
13
+ } from "node:fs";
14
+ import { join } from "node:path";
15
+ import { randomBytes } from "node:crypto";
16
+ import { COMPANION_HOME } from "./paths.js";
17
+
18
+ // ─── Types ──────────────────────────────────────────────────────────────────
19
+
20
+ export interface StagingSlot {
21
+ id: string;
22
+ clientId: string;
23
+ clientSecret: string;
24
+ webhookSecret: string;
25
+ accessToken: string;
26
+ refreshToken: string;
27
+ createdAt: number;
28
+ }
29
+
30
+ // ─── Constants ──────────────────────────────────────────────────────────────
31
+
32
+ const STAGING_DIR = join(COMPANION_HOME, "staging");
33
+ const SLOT_TTL_MS = 30 * 60 * 1000; // 30 minutes
34
+
35
+ function ensureDir(): void {
36
+ mkdirSync(STAGING_DIR, { recursive: true });
37
+ }
38
+
39
+ function slotPath(id: string): string {
40
+ if (!/^[0-9a-f]{32}$/.test(id)) {
41
+ throw new Error(`Invalid staging slot ID: ${id}`);
42
+ }
43
+ return join(STAGING_DIR, `${id}.json`);
44
+ }
45
+
46
+ // ─── Public API ─────────────────────────────────────────────────────────────
47
+
48
+ /** Create a new staging slot with the given credentials. Returns the slot ID. */
49
+ export function createSlot(creds: {
50
+ clientId: string;
51
+ clientSecret: string;
52
+ webhookSecret: string;
53
+ }): string {
54
+ ensureDir();
55
+ pruneExpired();
56
+
57
+ const id = randomBytes(16).toString("hex");
58
+ const slot: StagingSlot = {
59
+ id,
60
+ clientId: creds.clientId,
61
+ clientSecret: creds.clientSecret,
62
+ webhookSecret: creds.webhookSecret,
63
+ accessToken: "",
64
+ refreshToken: "",
65
+ createdAt: Date.now(),
66
+ };
67
+
68
+ writeFileSync(slotPath(id), JSON.stringify(slot, null, 2), { mode: 0o600 });
69
+ return id;
70
+ }
71
+
72
+ /** Retrieve a staging slot by ID. Returns null if not found or expired. */
73
+ export function getSlot(id: string): StagingSlot | null {
74
+ ensureDir();
75
+ try {
76
+ const raw = readFileSync(slotPath(id), "utf-8");
77
+ const slot = JSON.parse(raw) as StagingSlot;
78
+ if (Date.now() - slot.createdAt > SLOT_TTL_MS) {
79
+ try { unlinkSync(slotPath(id)); } catch { /* ok */ }
80
+ return null;
81
+ }
82
+ return slot;
83
+ } catch {
84
+ return null;
85
+ }
86
+ }
87
+
88
+ /** Update a staging slot's OAuth tokens (after the OAuth callback). */
89
+ export function updateSlotTokens(
90
+ id: string,
91
+ tokens: { accessToken: string; refreshToken: string },
92
+ ): boolean {
93
+ const slot = getSlot(id);
94
+ if (!slot) return false;
95
+
96
+ slot.accessToken = tokens.accessToken;
97
+ slot.refreshToken = tokens.refreshToken;
98
+
99
+ writeFileSync(slotPath(id), JSON.stringify(slot, null, 2), { mode: 0o600 });
100
+ return true;
101
+ }
102
+
103
+ /** Retrieve and delete a staging slot (one-time consume). */
104
+ export function consumeSlot(id: string): StagingSlot | null {
105
+ const slot = getSlot(id);
106
+ if (!slot) return null;
107
+ try { unlinkSync(slotPath(id)); } catch { /* ok */ }
108
+ return slot;
109
+ }
110
+
111
+ /** Delete a staging slot. */
112
+ export function deleteSlot(id: string): boolean {
113
+ try {
114
+ unlinkSync(slotPath(id));
115
+ return true;
116
+ } catch {
117
+ return false;
118
+ }
119
+ }
120
+
121
+ /** Remove all expired staging slots. Called on create and on server start. */
122
+ export function pruneExpired(): void {
123
+ ensureDir();
124
+ try {
125
+ const now = Date.now();
126
+ for (const file of readdirSync(STAGING_DIR)) {
127
+ if (!file.endsWith(".json")) continue;
128
+ try {
129
+ const raw = readFileSync(join(STAGING_DIR, file), "utf-8");
130
+ const slot = JSON.parse(raw) as StagingSlot;
131
+ if (now - slot.createdAt > SLOT_TTL_MS) {
132
+ unlinkSync(join(STAGING_DIR, file));
133
+ }
134
+ } catch {
135
+ // Remove corrupt files
136
+ try { unlinkSync(join(STAGING_DIR, file)); } catch { /* ok */ }
137
+ }
138
+ }
139
+ } catch {
140
+ // Directory doesn't exist yet, nothing to prune
141
+ }
142
+ }
@@ -0,0 +1,393 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import { mkdirSync, writeFileSync, readFileSync, readdirSync, rmSync, utimesSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { tmpdir } from "node:os";
5
+ import { randomBytes } from "node:crypto";
6
+
7
+ describe("logger", () => {
8
+ let log: typeof import("./logger.js").log;
9
+ const originalEnv = process.env.COMPANION_LOG_FORMAT;
10
+
11
+ beforeEach(() => {
12
+ vi.resetModules();
13
+ });
14
+
15
+ afterEach(() => {
16
+ if (originalEnv === undefined) {
17
+ delete process.env.COMPANION_LOG_FORMAT;
18
+ } else {
19
+ process.env.COMPANION_LOG_FORMAT = originalEnv;
20
+ }
21
+ });
22
+
23
+ describe("human-readable format (default)", () => {
24
+ beforeEach(async () => {
25
+ delete process.env.COMPANION_LOG_FORMAT;
26
+ const mod = await import("./logger.js");
27
+ log = mod.log;
28
+ });
29
+
30
+ it("formats info messages with bracket prefix", () => {
31
+ const spy = vi.spyOn(console, "log").mockImplementation(() => {});
32
+ log.info("ws-bridge", "Browser connected", { sessionId: "abc-123", browsers: 3 });
33
+ expect(spy).toHaveBeenCalledOnce();
34
+ const output = spy.mock.calls[0][0] as string;
35
+ expect(output).toContain("[ws-bridge]");
36
+ expect(output).toContain("Browser connected");
37
+ expect(output).toContain("sessionId=abc-123");
38
+ expect(output).toContain("browsers=3");
39
+ spy.mockRestore();
40
+ });
41
+
42
+ it("formats warn messages", () => {
43
+ const spy = vi.spyOn(console, "warn").mockImplementation(() => {});
44
+ log.warn("orchestrator", "Relaunch limit reached", { sessionId: "s1" });
45
+ expect(spy).toHaveBeenCalledOnce();
46
+ const output = spy.mock.calls[0][0] as string;
47
+ expect(output).toContain("[orchestrator]");
48
+ expect(output).toContain("Relaunch limit reached");
49
+ spy.mockRestore();
50
+ });
51
+
52
+ it("formats error messages", () => {
53
+ const spy = vi.spyOn(console, "error").mockImplementation(() => {});
54
+ log.error("cli-launcher", "Process crashed", { exitCode: 1 });
55
+ expect(spy).toHaveBeenCalledOnce();
56
+ const output = spy.mock.calls[0][0] as string;
57
+ expect(output).toContain("[cli-launcher]");
58
+ expect(output).toContain("exitCode=1");
59
+ spy.mockRestore();
60
+ });
61
+
62
+ it("handles messages without data", () => {
63
+ const spy = vi.spyOn(console, "log").mockImplementation(() => {});
64
+ log.info("server", "Started");
65
+ expect(spy).toHaveBeenCalledOnce();
66
+ const output = spy.mock.calls[0][0] as string;
67
+ expect(output).toBe("[server] Started");
68
+ spy.mockRestore();
69
+ });
70
+ });
71
+
72
+ describe("JSON format (COMPANION_LOG_FORMAT=json)", () => {
73
+ beforeEach(async () => {
74
+ process.env.COMPANION_LOG_FORMAT = "json";
75
+ const mod = await import("./logger.js");
76
+ log = mod.log;
77
+ });
78
+
79
+ it("outputs valid JSON with required fields", () => {
80
+ const spy = vi.spyOn(console, "log").mockImplementation(() => {});
81
+ log.info("ws-bridge", "CLI connected", { sessionId: "s1" });
82
+ expect(spy).toHaveBeenCalledOnce();
83
+ const output = spy.mock.calls[0][0] as string;
84
+ const parsed = JSON.parse(output);
85
+ expect(parsed.level).toBe("info");
86
+ expect(parsed.module).toBe("ws-bridge");
87
+ expect(parsed.msg).toBe("CLI connected");
88
+ expect(parsed.sessionId).toBe("s1");
89
+ expect(parsed.ts).toBeDefined();
90
+ spy.mockRestore();
91
+ });
92
+
93
+ it("core metadata fields cannot be overwritten by caller data", () => {
94
+ // Caller-supplied keys with names matching core fields should not
95
+ // overwrite ts, level, module, or msg.
96
+ const spy = vi.spyOn(console, "log").mockImplementation(() => {});
97
+ log.info("real-module", "real message", {
98
+ level: "error" as any,
99
+ module: "evil",
100
+ msg: "overwritten",
101
+ ts: "tampered",
102
+ });
103
+ expect(spy).toHaveBeenCalledOnce();
104
+ const parsed = JSON.parse(spy.mock.calls[0][0] as string);
105
+ expect(parsed.level).toBe("info");
106
+ expect(parsed.module).toBe("real-module");
107
+ expect(parsed.msg).toBe("real message");
108
+ expect(parsed.ts).not.toBe("tampered");
109
+ spy.mockRestore();
110
+ });
111
+ });
112
+ });
113
+
114
+ describe("LogFileWriter", () => {
115
+ let LogFileWriter: typeof import("./logger.js").LogFileWriter;
116
+ let tmpDir: string;
117
+
118
+ beforeEach(async () => {
119
+ vi.resetModules();
120
+ // Create a unique temp directory for each test to avoid cross-contamination
121
+ tmpDir = join(tmpdir(), `companion-log-test-${randomBytes(4).toString("hex")}`);
122
+ mkdirSync(tmpDir, { recursive: true });
123
+ const mod = await import("./logger.js");
124
+ LogFileWriter = mod.LogFileWriter;
125
+ });
126
+
127
+ afterEach(() => {
128
+ // Clean up temp directory
129
+ try {
130
+ rmSync(tmpDir, { recursive: true, force: true });
131
+ } catch {
132
+ // ignore
133
+ }
134
+ });
135
+
136
+ it("creates a log file in the specified directory", () => {
137
+ // Verify that constructing a LogFileWriter creates a .log file
138
+ const writer = new LogFileWriter({ logsDir: tmpDir, maxLines: 1_000_000 });
139
+ try {
140
+ const files = readdirSync(tmpDir).filter((f) => f.endsWith(".log"));
141
+ expect(files).toHaveLength(1);
142
+ expect(writer.filePath).toContain(tmpDir);
143
+ expect(writer.filePath).toMatch(/\.log$/);
144
+ } finally {
145
+ writer.close();
146
+ }
147
+ });
148
+
149
+ it("writes log lines to the file", () => {
150
+ // Write multiple lines and verify they appear in the file
151
+ const writer = new LogFileWriter({ logsDir: tmpDir, maxLines: 1_000_000 });
152
+ try {
153
+ writer.write("[server] Line one");
154
+ writer.write("[server] Line two");
155
+ writer.write("[server] Line three");
156
+
157
+ const content = readFileSync(writer.filePath, "utf-8");
158
+ const lines = content.split("\n").filter(Boolean);
159
+ expect(lines).toHaveLength(3);
160
+ expect(lines[0]).toBe("[server] Line one");
161
+ expect(lines[1]).toBe("[server] Line two");
162
+ expect(lines[2]).toBe("[server] Line three");
163
+ } finally {
164
+ writer.close();
165
+ }
166
+ });
167
+
168
+ it("includes PID in the filename for uniqueness across server runs", () => {
169
+ const writer = new LogFileWriter({ logsDir: tmpDir, maxLines: 1_000_000 });
170
+ try {
171
+ // Filename format: companion_{iso-timestamp}_{pid}.log
172
+ const filename = writer.filePath.split("/").pop()!;
173
+ expect(filename).toContain(`_${process.pid}.log`);
174
+ expect(filename).toMatch(/^companion_/);
175
+ } finally {
176
+ writer.close();
177
+ }
178
+ });
179
+
180
+ it("exposes logsDir and maxLines for status reporting", () => {
181
+ const writer = new LogFileWriter({ logsDir: tmpDir, maxLines: 42 });
182
+ try {
183
+ expect(writer.getLogsDir()).toBe(tmpDir);
184
+ expect(writer.getMaxLines()).toBe(42);
185
+ } finally {
186
+ writer.close();
187
+ }
188
+ });
189
+
190
+ describe("rotation", () => {
191
+ it("deletes oldest log files when total lines exceed maxLines", () => {
192
+ // Pre-create two old log files with known line counts and distinct mtimes
193
+ // so rotation deletes the oldest first.
194
+ const oldFile1 = join(tmpDir, "companion_2020-01-01T00-00-00_1.log");
195
+ const oldFile2 = join(tmpDir, "companion_2020-06-01T00-00-00_2.log");
196
+ writeFileSync(oldFile1, "line1\nline2\nline3\nline4\nline5\n");
197
+ writeFileSync(oldFile2, "line1\nline2\nline3\nline4\nline5\n");
198
+
199
+ // Set explicit mtimes: oldFile1 is oldest, oldFile2 is newer
200
+ const past1 = new Date("2020-01-01");
201
+ const past2 = new Date("2020-06-01");
202
+ utimesSync(oldFile1, past1, past1);
203
+ utimesSync(oldFile2, past2, past2);
204
+
205
+ // maxLines = 8: total is 5 + 5 = 10 lines > 8, so oldest file (oldFile1)
206
+ // gets deleted bringing total to 5 which is <= 8.
207
+ const writer = new LogFileWriter({ logsDir: tmpDir, maxLines: 8 });
208
+ try {
209
+ // Initial cleanup is deferred — run it explicitly for the test
210
+ writer.cleanup();
211
+ const files = readdirSync(tmpDir).filter((f) => f.endsWith(".log"));
212
+ // oldFile1 should have been deleted by cleanup, oldFile2 and current remain
213
+ expect(files).toHaveLength(2);
214
+ // The oldest file should be gone
215
+ expect(files.some((f) => f.includes("2020-01-01"))).toBe(false);
216
+ // The newer old file should still exist
217
+ expect(files.some((f) => f.includes("2020-06-01"))).toBe(true);
218
+ } finally {
219
+ writer.close();
220
+ }
221
+ });
222
+
223
+ it("does not delete the current log file during cleanup", () => {
224
+ // Pre-create one old file that puts us over the limit, with an old mtime
225
+ const oldFile = join(tmpDir, "companion_2020-01-01T00-00-00_1.log");
226
+ writeFileSync(oldFile, "line1\nline2\nline3\n");
227
+ utimesSync(oldFile, new Date("2020-01-01"), new Date("2020-01-01"));
228
+
229
+ // maxLines = 2 means we're over limit but the current file must survive
230
+ const writer = new LogFileWriter({ logsDir: tmpDir, maxLines: 2 });
231
+ try {
232
+ writer.write("current line 1");
233
+ writer.write("current line 2");
234
+ writer.write("current line 3");
235
+
236
+ // Force another cleanup pass
237
+ const deleted = writer.cleanup();
238
+ expect(deleted).toBeGreaterThanOrEqual(0);
239
+
240
+ // Current file must still exist and be writable
241
+ writer.write("still works");
242
+ const content = readFileSync(writer.filePath, "utf-8");
243
+ expect(content).toContain("still works");
244
+ } finally {
245
+ writer.close();
246
+ }
247
+ });
248
+
249
+ it("returns the number of files deleted during cleanup", () => {
250
+ // Create 3 old files with 5 lines each = 15 lines total, with distinct mtimes
251
+ for (let i = 0; i < 3; i++) {
252
+ const f = join(tmpDir, `companion_2020-0${i + 1}-01T00-00-00_${i}.log`);
253
+ writeFileSync(f, "a\nb\nc\nd\ne\n");
254
+ const past = new Date(`2020-0${i + 1}-01`);
255
+ utimesSync(f, past, past);
256
+ }
257
+
258
+ // maxLines = 5 means we need to delete at least 2 old files
259
+ const writer = new LogFileWriter({ logsDir: tmpDir, maxLines: 5 });
260
+ try {
261
+ // Initial cleanup is deferred — run it explicitly for the test
262
+ writer.cleanup();
263
+ const files = readdirSync(tmpDir).filter((f) => f.endsWith(".log"));
264
+ // At most the newest old file + current file should remain
265
+ expect(files.length).toBeLessThanOrEqual(2);
266
+ } finally {
267
+ writer.close();
268
+ }
269
+ });
270
+ });
271
+
272
+ describe("isEnabled", () => {
273
+ const origLogFile = process.env.COMPANION_LOG_FILE;
274
+
275
+ afterEach(() => {
276
+ if (origLogFile === undefined) {
277
+ delete process.env.COMPANION_LOG_FILE;
278
+ } else {
279
+ process.env.COMPANION_LOG_FILE = origLogFile;
280
+ }
281
+ });
282
+
283
+ it("returns true by default (no env var set)", async () => {
284
+ delete process.env.COMPANION_LOG_FILE;
285
+ vi.resetModules();
286
+ const mod = await import("./logger.js");
287
+ expect(mod.LogFileWriter.isEnabled()).toBe(true);
288
+ });
289
+
290
+ it("returns false when COMPANION_LOG_FILE=0", async () => {
291
+ process.env.COMPANION_LOG_FILE = "0";
292
+ vi.resetModules();
293
+ const mod = await import("./logger.js");
294
+ expect(mod.LogFileWriter.isEnabled()).toBe(false);
295
+ });
296
+
297
+ it("returns false when COMPANION_LOG_FILE=false", async () => {
298
+ process.env.COMPANION_LOG_FILE = "false";
299
+ vi.resetModules();
300
+ const mod = await import("./logger.js");
301
+ expect(mod.LogFileWriter.isEnabled()).toBe(false);
302
+ });
303
+ });
304
+ });
305
+
306
+ describe("initLogFile / closeLogFile", () => {
307
+ let initLogFile: typeof import("./logger.js").initLogFile;
308
+ let closeLogFile: typeof import("./logger.js").closeLogFile;
309
+ let log: typeof import("./logger.js").log;
310
+ let tmpDir: string;
311
+
312
+ const origLogFile = process.env.COMPANION_LOG_FILE;
313
+ const origLogFormat = process.env.COMPANION_LOG_FORMAT;
314
+
315
+ beforeEach(async () => {
316
+ vi.resetModules();
317
+ tmpDir = join(tmpdir(), `companion-log-init-${randomBytes(4).toString("hex")}`);
318
+ mkdirSync(tmpDir, { recursive: true });
319
+ delete process.env.COMPANION_LOG_FILE;
320
+ delete process.env.COMPANION_LOG_FORMAT;
321
+ const mod = await import("./logger.js");
322
+ initLogFile = mod.initLogFile;
323
+ closeLogFile = mod.closeLogFile;
324
+ log = mod.log;
325
+ });
326
+
327
+ afterEach(() => {
328
+ closeLogFile();
329
+ try {
330
+ rmSync(tmpDir, { recursive: true, force: true });
331
+ } catch {
332
+ // ignore
333
+ }
334
+ if (origLogFile === undefined) {
335
+ delete process.env.COMPANION_LOG_FILE;
336
+ } else {
337
+ process.env.COMPANION_LOG_FILE = origLogFile;
338
+ }
339
+ if (origLogFormat === undefined) {
340
+ delete process.env.COMPANION_LOG_FORMAT;
341
+ } else {
342
+ process.env.COMPANION_LOG_FORMAT = origLogFormat;
343
+ }
344
+ });
345
+
346
+ it("tees log output to file after initialization", () => {
347
+ // Initialize the log file writer, then verify that log.info writes to both
348
+ // console and the log file
349
+ const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
350
+ const writer = initLogFile({ logsDir: tmpDir });
351
+ expect(writer).not.toBeNull();
352
+
353
+ log.info("test-module", "Hello world");
354
+
355
+ // Console should have been called
356
+ expect(consoleSpy).toHaveBeenCalledOnce();
357
+
358
+ // File should contain the same line
359
+ const content = readFileSync(writer!.filePath, "utf-8");
360
+ expect(content).toContain("[test-module] Hello world");
361
+
362
+ consoleSpy.mockRestore();
363
+ });
364
+
365
+ it("returns null when disabled via env var", async () => {
366
+ process.env.COMPANION_LOG_FILE = "0";
367
+ vi.resetModules();
368
+ const mod = await import("./logger.js");
369
+ const writer = mod.initLogFile({ logsDir: tmpDir });
370
+ expect(writer).toBeNull();
371
+ });
372
+
373
+ it("stops writing to file after closeLogFile()", () => {
374
+ const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
375
+ const writer = initLogFile({ logsDir: tmpDir });
376
+ expect(writer).not.toBeNull();
377
+
378
+ log.info("mod", "before close");
379
+ closeLogFile();
380
+ log.info("mod", "after close");
381
+
382
+ // Console gets both calls
383
+ expect(consoleSpy).toHaveBeenCalledTimes(2);
384
+
385
+ // File should only have the first line (closeLogFile nulls out the writer
386
+ // so subsequent writes are no-ops to the file)
387
+ const content = readFileSync(writer!.filePath, "utf-8");
388
+ expect(content).toContain("before close");
389
+ expect(content).not.toContain("after close");
390
+
391
+ consoleSpy.mockRestore();
392
+ });
393
+ });