@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,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
|
+
});
|