@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,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PATH discovery and binary resolution for service environments.
|
|
3
|
+
*
|
|
4
|
+
* When The Companion runs as a macOS launchd or Linux systemd service, it inherits
|
|
5
|
+
* a restricted PATH that omits directories from version managers (nvm, fnm, volta,
|
|
6
|
+
* mise, etc.) and user-local installs (~/.local/bin, ~/.cargo/bin). This module
|
|
7
|
+
* captures the user's real shell PATH at runtime and provides binary resolution
|
|
8
|
+
* that works regardless of how the server was started.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { execSync } from "node:child_process";
|
|
12
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
13
|
+
import { homedir } from "node:os";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Capture the user's full interactive shell PATH by spawning a login shell.
|
|
18
|
+
* This picks up all version manager initializations (nvm, fnm, volta, mise, etc.).
|
|
19
|
+
* Falls back to probing common directories if shell sourcing fails.
|
|
20
|
+
*/
|
|
21
|
+
export function captureUserShellPath(): string {
|
|
22
|
+
try {
|
|
23
|
+
const shell = process.env.SHELL || "/bin/bash";
|
|
24
|
+
const captured = execSync(
|
|
25
|
+
`${shell} -lic 'echo "___PATH_START___$PATH___PATH_END___"'`,
|
|
26
|
+
{
|
|
27
|
+
encoding: "utf-8",
|
|
28
|
+
timeout: 10_000,
|
|
29
|
+
env: { HOME: homedir(), USER: process.env.USER, SHELL: shell },
|
|
30
|
+
},
|
|
31
|
+
);
|
|
32
|
+
const match = captured.match(/___PATH_START___(.+)___PATH_END___/);
|
|
33
|
+
if (match?.[1]) {
|
|
34
|
+
return match[1];
|
|
35
|
+
}
|
|
36
|
+
} catch {
|
|
37
|
+
// Shell sourcing failed (timeout, compinit prompt, etc.)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return buildFallbackPath();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Build a PATH by probing common binary installation directories.
|
|
45
|
+
* Used as fallback when shell-sourcing fails.
|
|
46
|
+
*/
|
|
47
|
+
export function buildFallbackPath(): string {
|
|
48
|
+
const home = homedir();
|
|
49
|
+
const candidates = [
|
|
50
|
+
// Standard system paths
|
|
51
|
+
"/opt/homebrew/bin",
|
|
52
|
+
"/opt/homebrew/sbin",
|
|
53
|
+
"/usr/local/bin",
|
|
54
|
+
"/usr/bin",
|
|
55
|
+
"/bin",
|
|
56
|
+
"/usr/sbin",
|
|
57
|
+
"/sbin",
|
|
58
|
+
// Bun
|
|
59
|
+
join(home, ".bun", "bin"),
|
|
60
|
+
// Claude CLI / user-local installs
|
|
61
|
+
join(home, ".local", "bin"),
|
|
62
|
+
// Cargo / Rust
|
|
63
|
+
join(home, ".cargo", "bin"),
|
|
64
|
+
// Volta (Node version manager)
|
|
65
|
+
join(home, ".volta", "bin"),
|
|
66
|
+
// mise (formerly rtx)
|
|
67
|
+
join(home, ".local", "share", "mise", "shims"),
|
|
68
|
+
// pyenv
|
|
69
|
+
join(home, ".pyenv", "bin"),
|
|
70
|
+
join(home, ".pyenv", "shims"),
|
|
71
|
+
// Go
|
|
72
|
+
join(home, "go", "bin"),
|
|
73
|
+
"/usr/local/go/bin",
|
|
74
|
+
// Deno
|
|
75
|
+
join(home, ".deno", "bin"),
|
|
76
|
+
];
|
|
77
|
+
|
|
78
|
+
// Probe nvm-managed node versions
|
|
79
|
+
const nvmDir = process.env.NVM_DIR || join(home, ".nvm");
|
|
80
|
+
const nvmVersionsDir = join(nvmDir, "versions", "node");
|
|
81
|
+
if (existsSync(nvmVersionsDir)) {
|
|
82
|
+
try {
|
|
83
|
+
for (const v of readdirSync(nvmVersionsDir)) {
|
|
84
|
+
candidates.push(join(nvmVersionsDir, v, "bin"));
|
|
85
|
+
}
|
|
86
|
+
} catch { /* ignore */ }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// fnm (Fast Node Manager) — versions stored in fnm multishell or XDG data
|
|
90
|
+
const fnmDir = join(home, "Library", "Application Support", "fnm", "node-versions");
|
|
91
|
+
if (existsSync(fnmDir)) {
|
|
92
|
+
try {
|
|
93
|
+
for (const v of readdirSync(fnmDir)) {
|
|
94
|
+
candidates.push(join(fnmDir, v, "installation", "bin"));
|
|
95
|
+
}
|
|
96
|
+
} catch { /* ignore */ }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const pathSep = process.platform === "win32" ? ";" : ":";
|
|
100
|
+
return [...new Set(candidates.filter((dir) => existsSync(dir)))].join(pathSep);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ─── Enriched PATH (cached) ───────────────────────────────────────────────────
|
|
104
|
+
|
|
105
|
+
let _cachedPath: string | null = null;
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Returns an enriched PATH that merges the user's shell PATH (or probed common
|
|
109
|
+
* directories) with the current process PATH. Deduplicates entries.
|
|
110
|
+
* Result is cached after the first call.
|
|
111
|
+
*/
|
|
112
|
+
export function getEnrichedPath(): string {
|
|
113
|
+
if (_cachedPath) return _cachedPath;
|
|
114
|
+
|
|
115
|
+
const currentPath = process.env.PATH || "";
|
|
116
|
+
const userPath = captureUserShellPath();
|
|
117
|
+
const pathSep = process.platform === "win32" ? ";" : ":";
|
|
118
|
+
|
|
119
|
+
// Merge: user shell PATH first (takes precedence), then current process PATH
|
|
120
|
+
const allDirs = [...userPath.split(pathSep), ...currentPath.split(pathSep)];
|
|
121
|
+
const seen = new Set<string>();
|
|
122
|
+
const deduped: string[] = [];
|
|
123
|
+
for (const dir of allDirs) {
|
|
124
|
+
if (dir && !seen.has(dir)) {
|
|
125
|
+
seen.add(dir);
|
|
126
|
+
deduped.push(dir);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
_cachedPath = deduped.join(pathSep);
|
|
131
|
+
return _cachedPath;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Reset the cached PATH (for testing). */
|
|
135
|
+
export function _resetPathCache(): void {
|
|
136
|
+
_cachedPath = null;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ─── Binary resolution ────────────────────────────────────────────────────────
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Resolve a binary name to an absolute path using the enriched PATH.
|
|
143
|
+
* Returns null if the binary is not found anywhere.
|
|
144
|
+
*/
|
|
145
|
+
export function resolveBinary(name: string): string | null {
|
|
146
|
+
if (name.startsWith("/")) {
|
|
147
|
+
return existsSync(name) ? name : null;
|
|
148
|
+
}
|
|
149
|
+
// On Windows, also accept absolute paths like C:\... or D:\...
|
|
150
|
+
if (process.platform === "win32" && /^[a-zA-Z]:[/\\]/.test(name)) {
|
|
151
|
+
return existsSync(name) ? name : null;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const sanitized = name.replace(/[^a-zA-Z0-9._@/-]/g, "");
|
|
155
|
+
const enrichedPath = getEnrichedPath();
|
|
156
|
+
|
|
157
|
+
// Try `where` first on Windows (returns native Win32 paths), then `which` as fallback
|
|
158
|
+
const commands = process.platform === "win32" ? ["where", "which"] : ["which"];
|
|
159
|
+
for (const cmd of commands) {
|
|
160
|
+
try {
|
|
161
|
+
const result = execSync(`${cmd} ${sanitized}`, {
|
|
162
|
+
encoding: "utf-8",
|
|
163
|
+
timeout: 5_000,
|
|
164
|
+
env: { ...process.env, PATH: enrichedPath },
|
|
165
|
+
}).trim();
|
|
166
|
+
if (!result) continue;
|
|
167
|
+
// `where` on Windows may return multiple lines; prefer .cmd for Bun.spawn compatibility
|
|
168
|
+
if (cmd === "where") {
|
|
169
|
+
const lines = result.split(/\r?\n/).filter(Boolean);
|
|
170
|
+
return lines.find(l => l.endsWith(".cmd")) || lines[0];
|
|
171
|
+
}
|
|
172
|
+
return result;
|
|
173
|
+
} catch {
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Returns a PATH string suitable for embedding in service definitions
|
|
182
|
+
* (plist/systemd unit). Captures the user's shell PATH at install time.
|
|
183
|
+
*/
|
|
184
|
+
export function getServicePath(): string {
|
|
185
|
+
return getEnrichedPath();
|
|
186
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { describe, it, expect, afterEach } from "vitest";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
|
|
5
|
+
describe("paths", () => {
|
|
6
|
+
const originalEnv = process.env.COMPANION_HOME;
|
|
7
|
+
|
|
8
|
+
afterEach(() => {
|
|
9
|
+
// Restore original env
|
|
10
|
+
if (originalEnv === undefined) {
|
|
11
|
+
delete process.env.COMPANION_HOME;
|
|
12
|
+
} else {
|
|
13
|
+
process.env.COMPANION_HOME = originalEnv;
|
|
14
|
+
}
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("defaults to ~/.companion/ when COMPANION_HOME is not set", async () => {
|
|
18
|
+
delete process.env.COMPANION_HOME;
|
|
19
|
+
// Dynamic import to pick up env change (module is already cached, so we
|
|
20
|
+
// test the value computed at import time — which uses the env at startup)
|
|
21
|
+
const { COMPANION_HOME } = await import("./paths.js");
|
|
22
|
+
// When env var is unset at module load time, it should be ~/.companion
|
|
23
|
+
expect(COMPANION_HOME).toBe(join(homedir(), ".companion"));
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("exports a string path", async () => {
|
|
27
|
+
const { COMPANION_HOME } = await import("./paths.js");
|
|
28
|
+
expect(typeof COMPANION_HOME).toBe("string");
|
|
29
|
+
expect(COMPANION_HOME.length).toBeGreaterThan(0);
|
|
30
|
+
});
|
|
31
|
+
});
|
package/server/paths.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Base directory for all Companion configuration and state.
|
|
6
|
+
* Defaults to ~/.companion/ for self-hosted installs.
|
|
7
|
+
* Override with COMPANION_HOME env var for managed deployments
|
|
8
|
+
* (e.g. COMPANION_HOME=/data/companion on Fly.io volumes).
|
|
9
|
+
*/
|
|
10
|
+
export const COMPANION_HOME =
|
|
11
|
+
process.env.COMPANION_HOME || join(homedir(), ".companion");
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
|
|
3
|
+
// ─── Mocks ──────────────────────────────────────────────────────────────────
|
|
4
|
+
|
|
5
|
+
const mockFetchPRInfoAsync = vi.hoisted(() => vi.fn());
|
|
6
|
+
const mockComputeAdaptiveTTL = vi.hoisted(() => vi.fn());
|
|
7
|
+
|
|
8
|
+
vi.mock("./github-pr.js", () => ({
|
|
9
|
+
fetchPRInfoAsync: mockFetchPRInfoAsync,
|
|
10
|
+
computeAdaptiveTTL: mockComputeAdaptiveTTL,
|
|
11
|
+
}));
|
|
12
|
+
|
|
13
|
+
import { PRPoller } from "./pr-poller.js";
|
|
14
|
+
import type { GitHubPRInfo } from "./github-pr.js";
|
|
15
|
+
|
|
16
|
+
function makeMockBridge() {
|
|
17
|
+
return {
|
|
18
|
+
broadcastToSession: vi.fn(),
|
|
19
|
+
} as any;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function makePR(overrides?: Partial<GitHubPRInfo>): GitHubPRInfo {
|
|
23
|
+
return {
|
|
24
|
+
number: 42,
|
|
25
|
+
title: "test pr",
|
|
26
|
+
url: "https://github.com/org/repo/pull/42",
|
|
27
|
+
state: "OPEN",
|
|
28
|
+
isDraft: false,
|
|
29
|
+
reviewDecision: null,
|
|
30
|
+
additions: 10,
|
|
31
|
+
deletions: 5,
|
|
32
|
+
changedFiles: 2,
|
|
33
|
+
checks: [],
|
|
34
|
+
checksSummary: { total: 0, success: 0, failure: 0, pending: 0 },
|
|
35
|
+
reviewThreads: { total: 0, resolved: 0, unresolved: 0 },
|
|
36
|
+
...overrides,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Flush microtasks so async callbacks in the poller can settle. */
|
|
41
|
+
async function flushMicrotasks() {
|
|
42
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ─── Tests ──────────────────────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
describe("PRPoller", () => {
|
|
48
|
+
let poller: PRPoller;
|
|
49
|
+
let bridge: ReturnType<typeof makeMockBridge>;
|
|
50
|
+
|
|
51
|
+
beforeEach(() => {
|
|
52
|
+
mockFetchPRInfoAsync.mockReset();
|
|
53
|
+
mockComputeAdaptiveTTL.mockReset();
|
|
54
|
+
mockComputeAdaptiveTTL.mockReturnValue(30_000);
|
|
55
|
+
bridge = makeMockBridge();
|
|
56
|
+
poller = new PRPoller(bridge);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
afterEach(() => {
|
|
60
|
+
poller.destroy();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("returns null on initial watch (no cached data)", () => {
|
|
64
|
+
mockFetchPRInfoAsync.mockResolvedValue(null);
|
|
65
|
+
const result = poller.watch("s1", "/repo", "main");
|
|
66
|
+
expect(result).toBeNull();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("fetches and broadcasts PR data after watch", async () => {
|
|
70
|
+
const pr = makePR();
|
|
71
|
+
mockFetchPRInfoAsync.mockResolvedValue(pr);
|
|
72
|
+
|
|
73
|
+
poller.watch("s1", "/repo", "feat/test");
|
|
74
|
+
|
|
75
|
+
// Let the async fetch settle
|
|
76
|
+
await flushMicrotasks();
|
|
77
|
+
|
|
78
|
+
expect(mockFetchPRInfoAsync).toHaveBeenCalledWith("/repo", "feat/test");
|
|
79
|
+
expect(bridge.broadcastToSession).toHaveBeenCalledWith("s1", {
|
|
80
|
+
type: "pr_status_update",
|
|
81
|
+
pr,
|
|
82
|
+
available: true,
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("shares one timer across multiple sessions watching the same branch", async () => {
|
|
87
|
+
const pr = makePR();
|
|
88
|
+
mockFetchPRInfoAsync.mockResolvedValue(pr);
|
|
89
|
+
|
|
90
|
+
poller.watch("s1", "/repo", "main");
|
|
91
|
+
poller.watch("s2", "/repo", "main");
|
|
92
|
+
|
|
93
|
+
await flushMicrotasks();
|
|
94
|
+
|
|
95
|
+
// Should only have fetched once (shared timer)
|
|
96
|
+
expect(mockFetchPRInfoAsync).toHaveBeenCalledTimes(1);
|
|
97
|
+
// But should broadcast to both sessions
|
|
98
|
+
expect(bridge.broadcastToSession).toHaveBeenCalledWith("s1", expect.objectContaining({ type: "pr_status_update" }));
|
|
99
|
+
expect(bridge.broadcastToSession).toHaveBeenCalledWith("s2", expect.objectContaining({ type: "pr_status_update" }));
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("returns cached data on subsequent watch calls", async () => {
|
|
103
|
+
const pr = makePR();
|
|
104
|
+
mockFetchPRInfoAsync.mockResolvedValue(pr);
|
|
105
|
+
|
|
106
|
+
poller.watch("s1", "/repo", "main");
|
|
107
|
+
await flushMicrotasks();
|
|
108
|
+
|
|
109
|
+
// Second session watches the same branch — should get cached data
|
|
110
|
+
const cached = poller.watch("s2", "/repo", "main");
|
|
111
|
+
expect(cached).toEqual(pr);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("cleans up when last session unwatches", async () => {
|
|
115
|
+
mockFetchPRInfoAsync.mockResolvedValue(makePR());
|
|
116
|
+
|
|
117
|
+
poller.watch("s1", "/repo", "main");
|
|
118
|
+
poller.watch("s2", "/repo", "main");
|
|
119
|
+
await flushMicrotasks();
|
|
120
|
+
|
|
121
|
+
poller.unwatch("s1");
|
|
122
|
+
// Still one session watching — cache should remain
|
|
123
|
+
expect(poller.getCached("/repo", "main")).not.toBeNull();
|
|
124
|
+
|
|
125
|
+
poller.unwatch("s2");
|
|
126
|
+
// No sessions left — should be cleaned up
|
|
127
|
+
expect(poller.getCached("/repo", "main")).toBeNull();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("getCached returns null for unknown branches", () => {
|
|
131
|
+
expect(poller.getCached("/repo", "nonexistent")).toBeNull();
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("uses computeAdaptiveTTL for scheduling", async () => {
|
|
135
|
+
const pr = makePR({ checksSummary: { total: 3, success: 1, failure: 0, pending: 2 } });
|
|
136
|
+
mockFetchPRInfoAsync.mockResolvedValue(pr);
|
|
137
|
+
mockComputeAdaptiveTTL.mockReturnValue(10_000);
|
|
138
|
+
|
|
139
|
+
poller.watch("s1", "/repo", "feat/ci");
|
|
140
|
+
await flushMicrotasks();
|
|
141
|
+
|
|
142
|
+
expect(mockComputeAdaptiveTTL).toHaveBeenCalledWith(pr);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("handles session switching branches (unwatches old, watches new)", async () => {
|
|
146
|
+
mockFetchPRInfoAsync.mockResolvedValue(makePR());
|
|
147
|
+
|
|
148
|
+
poller.watch("s1", "/repo", "branch-a");
|
|
149
|
+
await flushMicrotasks();
|
|
150
|
+
|
|
151
|
+
// Same session now watches a different branch
|
|
152
|
+
poller.watch("s1", "/repo", "branch-b");
|
|
153
|
+
await flushMicrotasks();
|
|
154
|
+
|
|
155
|
+
// Old branch should have been cleaned up (only session was s1)
|
|
156
|
+
expect(poller.getCached("/repo", "branch-a")).toBeNull();
|
|
157
|
+
expect(poller.getCached("/repo", "branch-b")).not.toBeNull();
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("handles fetch errors gracefully", async () => {
|
|
161
|
+
mockFetchPRInfoAsync.mockRejectedValue(new Error("network error"));
|
|
162
|
+
|
|
163
|
+
poller.watch("s1", "/repo", "main");
|
|
164
|
+
await flushMicrotasks();
|
|
165
|
+
|
|
166
|
+
// Should not throw, should not broadcast (no data on error)
|
|
167
|
+
expect(bridge.broadcastToSession).not.toHaveBeenCalled();
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("prevents concurrent fetches for the same key", async () => {
|
|
171
|
+
// Create a fetch that takes time to resolve
|
|
172
|
+
let resolveFirst: (value: GitHubPRInfo) => void;
|
|
173
|
+
mockFetchPRInfoAsync.mockReturnValueOnce(
|
|
174
|
+
new Promise<GitHubPRInfo>((r) => { resolveFirst = r; }),
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
poller.watch("s1", "/repo", "main");
|
|
178
|
+
// Try to trigger another fetch immediately (e.g., from a second session)
|
|
179
|
+
poller.watch("s2", "/repo", "main");
|
|
180
|
+
|
|
181
|
+
// Only one fetch should have started
|
|
182
|
+
expect(mockFetchPRInfoAsync).toHaveBeenCalledTimes(1);
|
|
183
|
+
|
|
184
|
+
// Resolve the first fetch
|
|
185
|
+
resolveFirst!(makePR());
|
|
186
|
+
await flushMicrotasks();
|
|
187
|
+
|
|
188
|
+
// Now broadcast should have gone to both sessions
|
|
189
|
+
expect(bridge.broadcastToSession).toHaveBeenCalledTimes(2);
|
|
190
|
+
});
|
|
191
|
+
});
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { fetchPRInfoAsync, computeAdaptiveTTL, type GitHubPRInfo } from "./github-pr.js";
|
|
2
|
+
import type { WsBridge } from "./ws-bridge.js";
|
|
3
|
+
|
|
4
|
+
// ─── Types ───────────────────────────────────────────────────────────────────
|
|
5
|
+
|
|
6
|
+
interface WatchedPR {
|
|
7
|
+
cwd: string;
|
|
8
|
+
branch: string;
|
|
9
|
+
/** Sessions interested in this PR (same cwd:branch may be shared) */
|
|
10
|
+
sessionIds: Set<string>;
|
|
11
|
+
lastData: GitHubPRInfo | null;
|
|
12
|
+
timer: ReturnType<typeof setTimeout> | null;
|
|
13
|
+
lastFetchTime: number;
|
|
14
|
+
currentInterval: number;
|
|
15
|
+
fetching: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// ─── PR Poller ───────────────────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Server-side poller that fetches GitHub PR status at adaptive intervals
|
|
22
|
+
* and pushes updates to browsers via WebSocket.
|
|
23
|
+
*
|
|
24
|
+
* One timer per unique cwd:branch — shared across sessions on the same branch.
|
|
25
|
+
*/
|
|
26
|
+
export class PRPoller {
|
|
27
|
+
private watched = new Map<string, WatchedPR>();
|
|
28
|
+
private wsBridge: WsBridge;
|
|
29
|
+
/** Reverse index: sessionId → cwd:branch key (a session can only watch one PR at a time) */
|
|
30
|
+
private sessionToKey = new Map<string, string>();
|
|
31
|
+
|
|
32
|
+
constructor(wsBridge: WsBridge) {
|
|
33
|
+
this.wsBridge = wsBridge;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Start watching a PR for a session.
|
|
38
|
+
* Returns cached data immediately if available.
|
|
39
|
+
* Triggers an async fetch if cache is stale or missing.
|
|
40
|
+
*/
|
|
41
|
+
watch(sessionId: string, cwd: string, branch: string): GitHubPRInfo | null {
|
|
42
|
+
const key = `${cwd}:${branch}`;
|
|
43
|
+
|
|
44
|
+
// If this session was watching a different PR, unregister from the old one
|
|
45
|
+
const prevKey = this.sessionToKey.get(sessionId);
|
|
46
|
+
if (prevKey && prevKey !== key) {
|
|
47
|
+
this.unwatchKey(sessionId, prevKey);
|
|
48
|
+
}
|
|
49
|
+
this.sessionToKey.set(sessionId, key);
|
|
50
|
+
|
|
51
|
+
const existing = this.watched.get(key);
|
|
52
|
+
if (existing) {
|
|
53
|
+
existing.sessionIds.add(sessionId);
|
|
54
|
+
// If cache is stale, trigger a refresh
|
|
55
|
+
if (Date.now() - existing.lastFetchTime > existing.currentInterval) {
|
|
56
|
+
this.fetchAndBroadcast(key);
|
|
57
|
+
}
|
|
58
|
+
return existing.lastData;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// New watch — create entry and fetch immediately
|
|
62
|
+
const entry: WatchedPR = {
|
|
63
|
+
cwd,
|
|
64
|
+
branch,
|
|
65
|
+
sessionIds: new Set([sessionId]),
|
|
66
|
+
lastData: null,
|
|
67
|
+
timer: null,
|
|
68
|
+
lastFetchTime: 0,
|
|
69
|
+
currentInterval: 10_000, // start aggressive for fast initial load
|
|
70
|
+
fetching: false,
|
|
71
|
+
};
|
|
72
|
+
this.watched.set(key, entry);
|
|
73
|
+
this.fetchAndBroadcast(key);
|
|
74
|
+
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Stop watching for a specific session. */
|
|
79
|
+
unwatch(sessionId: string): void {
|
|
80
|
+
const key = this.sessionToKey.get(sessionId);
|
|
81
|
+
if (!key) return;
|
|
82
|
+
this.sessionToKey.delete(sessionId);
|
|
83
|
+
this.unwatchKey(sessionId, key);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Get current cached data for a cwd:branch pair (for REST fallback). */
|
|
87
|
+
getCached(cwd: string, branch: string): { available: boolean; pr: GitHubPRInfo | null } | null {
|
|
88
|
+
const key = `${cwd}:${branch}`;
|
|
89
|
+
const entry = this.watched.get(key);
|
|
90
|
+
if (!entry) return null;
|
|
91
|
+
return { available: true, pr: entry.lastData };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Stop all timers (for testing / cleanup). */
|
|
95
|
+
destroy(): void {
|
|
96
|
+
for (const entry of this.watched.values()) {
|
|
97
|
+
if (entry.timer) clearTimeout(entry.timer);
|
|
98
|
+
}
|
|
99
|
+
this.watched.clear();
|
|
100
|
+
this.sessionToKey.clear();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ─── Internal ──────────────────────────────────────────────────────────
|
|
104
|
+
|
|
105
|
+
private unwatchKey(sessionId: string, key: string): void {
|
|
106
|
+
const entry = this.watched.get(key);
|
|
107
|
+
if (!entry) return;
|
|
108
|
+
entry.sessionIds.delete(sessionId);
|
|
109
|
+
if (entry.sessionIds.size === 0) {
|
|
110
|
+
if (entry.timer) clearTimeout(entry.timer);
|
|
111
|
+
this.watched.delete(key);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
private async fetchAndBroadcast(key: string): Promise<void> {
|
|
116
|
+
const entry = this.watched.get(key);
|
|
117
|
+
if (!entry) return;
|
|
118
|
+
|
|
119
|
+
// Prevent concurrent fetches for the same key
|
|
120
|
+
if (entry.fetching) return;
|
|
121
|
+
entry.fetching = true;
|
|
122
|
+
|
|
123
|
+
// Clear existing timer (will be rescheduled after fetch)
|
|
124
|
+
if (entry.timer) {
|
|
125
|
+
clearTimeout(entry.timer);
|
|
126
|
+
entry.timer = null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
try {
|
|
130
|
+
const prInfo = await fetchPRInfoAsync(entry.cwd, entry.branch);
|
|
131
|
+
// Re-check entry still exists (may have been unwatched during async fetch)
|
|
132
|
+
const current = this.watched.get(key);
|
|
133
|
+
if (!current) return;
|
|
134
|
+
|
|
135
|
+
current.lastData = prInfo;
|
|
136
|
+
current.lastFetchTime = Date.now();
|
|
137
|
+
current.currentInterval = computeAdaptiveTTL(prInfo);
|
|
138
|
+
|
|
139
|
+
// Push to all sessions watching this PR
|
|
140
|
+
for (const sessionId of current.sessionIds) {
|
|
141
|
+
this.wsBridge.broadcastToSession(sessionId, {
|
|
142
|
+
type: "pr_status_update",
|
|
143
|
+
pr: prInfo,
|
|
144
|
+
available: true,
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
} catch {
|
|
148
|
+
// On error, use a moderate interval
|
|
149
|
+
const current = this.watched.get(key);
|
|
150
|
+
if (current) {
|
|
151
|
+
current.currentInterval = 30_000;
|
|
152
|
+
}
|
|
153
|
+
} finally {
|
|
154
|
+
const current = this.watched.get(key);
|
|
155
|
+
if (current) {
|
|
156
|
+
current.fetching = false;
|
|
157
|
+
// Schedule next fetch
|
|
158
|
+
current.timer = setTimeout(() => this.fetchAndBroadcast(key), current.currentInterval);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|