@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,228 @@
1
+ import type { Hono } from "hono";
2
+ import type { CliLauncher } from "../cli-launcher.js";
3
+ import type { WsBridge } from "../ws-bridge.js";
4
+ import type { TerminalManager } from "../terminal-manager.js";
5
+ import { getUsageLimits } from "../usage-limits.js";
6
+ import {
7
+ getUpdateState,
8
+ checkForUpdate,
9
+ isUpdateAvailable,
10
+ setUpdateInProgress,
11
+ } from "../update-checker.js";
12
+ import { refreshServiceDefinition } from "../service.js";
13
+ import { getSettings } from "../settings-manager.js";
14
+ import { imagePullManager } from "../image-pull-manager.js";
15
+
16
+ export function registerSystemRoutes(
17
+ api: Hono,
18
+ deps: {
19
+ launcher: CliLauncher;
20
+ wsBridge: WsBridge;
21
+ terminalManager: TerminalManager;
22
+ updateCheckStaleMs: number;
23
+ },
24
+ ): void {
25
+ api.get("/usage-limits", async (c) => {
26
+ const limits = await getUsageLimits();
27
+ return c.json(limits);
28
+ });
29
+
30
+ api.get("/sessions/:id/usage-limits", async (c) => {
31
+ const sessionId = c.req.param("id");
32
+ const session = deps.wsBridge.getSession(sessionId);
33
+ const empty = { five_hour: null, seven_day: null, extra_usage: null };
34
+
35
+ if (session?.backendType === "codex") {
36
+ const rl = deps.wsBridge.getCodexRateLimits(sessionId);
37
+ if (!rl) return c.json(empty);
38
+ const toEpochMs = (value: number): number => (
39
+ value > 0 && value < 1_000_000_000_000 ? value * 1000 : value
40
+ );
41
+ const mapLimit = (l: { usedPercent: number; windowDurationMins: number; resetsAt: number } | null) => {
42
+ if (!l) return null;
43
+ const resetsAtMs = toEpochMs(l.resetsAt);
44
+ return {
45
+ utilization: l.usedPercent,
46
+ resets_at: resetsAtMs ? new Date(resetsAtMs).toISOString() : null,
47
+ };
48
+ };
49
+ return c.json({
50
+ five_hour: mapLimit(rl.primary),
51
+ seven_day: mapLimit(rl.secondary),
52
+ extra_usage: null,
53
+ });
54
+ }
55
+
56
+ const limits = await getUsageLimits();
57
+ return c.json(limits);
58
+ });
59
+
60
+ api.get("/update-check", async (c) => {
61
+ const initialState = getUpdateState();
62
+ const needsRefresh =
63
+ initialState.lastChecked === 0
64
+ || Date.now() - initialState.lastChecked > deps.updateCheckStaleMs;
65
+ if (needsRefresh) {
66
+ await checkForUpdate();
67
+ }
68
+
69
+ const state = getUpdateState();
70
+ return c.json({
71
+ currentVersion: state.currentVersion,
72
+ latestVersion: state.latestVersion,
73
+ updateAvailable: isUpdateAvailable(),
74
+ isServiceMode: state.isServiceMode,
75
+ updateInProgress: state.updateInProgress,
76
+ lastChecked: state.lastChecked,
77
+ channel: state.channel,
78
+ });
79
+ });
80
+
81
+ api.post("/update-check", async (c) => {
82
+ await checkForUpdate();
83
+ const state = getUpdateState();
84
+ return c.json({
85
+ currentVersion: state.currentVersion,
86
+ latestVersion: state.latestVersion,
87
+ updateAvailable: isUpdateAvailable(),
88
+ isServiceMode: state.isServiceMode,
89
+ updateInProgress: state.updateInProgress,
90
+ lastChecked: state.lastChecked,
91
+ channel: state.channel,
92
+ });
93
+ });
94
+
95
+ api.post("/update", async (c) => {
96
+ const state = getUpdateState();
97
+ if (!state.isServiceMode) {
98
+ return c.json(
99
+ { error: "Update & restart is only available in service mode" },
100
+ 400,
101
+ );
102
+ }
103
+ if (!isUpdateAvailable()) {
104
+ return c.json({ error: "No update available" }, 400);
105
+ }
106
+ if (state.updateInProgress) {
107
+ return c.json({ error: "Update already in progress" }, 409);
108
+ }
109
+
110
+ setUpdateInProgress(true);
111
+
112
+ setTimeout(async () => {
113
+ try {
114
+ console.log(
115
+ `[update] Updating @hellcoder/companion to ${state.latestVersion}...`,
116
+ );
117
+ const proc = Bun.spawn(
118
+ ["bun", "install", "-g", `@hellcoder/companion@${state.latestVersion}`],
119
+ { stdout: "pipe", stderr: "pipe" },
120
+ );
121
+ const exitCode = await proc.exited;
122
+ if (exitCode !== 0) {
123
+ const stderr = await new Response(proc.stderr).text();
124
+ console.error(
125
+ `[update] bun install failed (code ${exitCode}):`,
126
+ stderr,
127
+ );
128
+ setUpdateInProgress(false);
129
+ return;
130
+ }
131
+
132
+ // Re-pull Docker image if auto-update is enabled
133
+ if (getSettings().dockerAutoUpdate) {
134
+ try {
135
+ console.log("[update] Re-pulling Docker image (dockerAutoUpdate enabled)...");
136
+ imagePullManager.pull("the-companion:latest");
137
+ const ready = await imagePullManager.waitForReady("the-companion:latest", 120_000);
138
+ if (ready) {
139
+ console.log("[update] Docker image re-pull complete.");
140
+ } else {
141
+ console.warn("[update] Docker image re-pull failed or timed out, continuing with restart.");
142
+ }
143
+ } catch (err) {
144
+ console.warn("[update] Docker image re-pull error, continuing:", err);
145
+ }
146
+ }
147
+
148
+ try {
149
+ refreshServiceDefinition();
150
+ console.log("[update] Service definition refreshed.");
151
+ } catch (err) {
152
+ console.warn("[update] Failed to refresh service definition:", err);
153
+ }
154
+
155
+ console.log("[update] Update successful, restarting service...");
156
+
157
+ const isLinux = process.platform === "linux";
158
+ const uid = typeof process.getuid === "function" ? process.getuid() : undefined;
159
+ const restartCmd = isLinux
160
+ ? ["systemctl", "--user", "restart", "the-companion.service"]
161
+ : uid !== undefined
162
+ ? ["launchctl", "kickstart", "-k", `gui/${uid}/sh.thecompanion.app`]
163
+ : ["launchctl", "kickstart", "-k", "sh.thecompanion.app"];
164
+
165
+ Bun.spawn(restartCmd, {
166
+ stdout: "ignore",
167
+ stderr: "ignore",
168
+ stdin: "ignore",
169
+ env: isLinux
170
+ ? {
171
+ ...process.env,
172
+ XDG_RUNTIME_DIR:
173
+ process.env.XDG_RUNTIME_DIR ||
174
+ `/run/user/${uid ?? 1000}`,
175
+ }
176
+ : undefined,
177
+ });
178
+
179
+ setTimeout(() => process.exit(0), 500);
180
+ } catch (err) {
181
+ console.error("[update] Update failed:", err);
182
+ setUpdateInProgress(false);
183
+ }
184
+ }, 100);
185
+
186
+ return c.json({
187
+ ok: true,
188
+ message: "Update started. Server will restart shortly.",
189
+ });
190
+ });
191
+
192
+ api.get("/terminal", (c) => {
193
+ const terminalId = c.req.query("terminalId");
194
+ const info = deps.terminalManager.getInfo(terminalId || undefined);
195
+ if (!info) return c.json({ active: false });
196
+ return c.json({ active: true, terminalId: info.id, cwd: info.cwd });
197
+ });
198
+
199
+ api.post("/terminal/spawn", async (c) => {
200
+ const body = await c.req.json<{ cwd: string; cols?: number; rows?: number; containerId?: string }>();
201
+ if (!body.cwd) return c.json({ error: "cwd is required" }, 400);
202
+ const terminalId = deps.terminalManager.spawn(body.cwd, body.cols, body.rows, {
203
+ containerId: body.containerId,
204
+ });
205
+ return c.json({ terminalId });
206
+ });
207
+
208
+ api.post("/terminal/kill", async (c) => {
209
+ const body = await c.req.json<{ terminalId?: string }>().catch(() => undefined);
210
+ const terminalId = body?.terminalId?.trim();
211
+ if (!terminalId) return c.json({ error: "terminalId is required" }, 400);
212
+ deps.terminalManager.kill(terminalId);
213
+ return c.json({ ok: true });
214
+ });
215
+
216
+ api.post("/sessions/:id/message", async (c) => {
217
+ const id = c.req.param("id");
218
+ const session = deps.launcher.getSession(id);
219
+ if (!session) return c.json({ error: "Session not found" }, 404);
220
+ if (!deps.launcher.isAlive(id)) return c.json({ error: "Session is not running" }, 400);
221
+ const body = await c.req.json().catch(() => ({}));
222
+ if (typeof body.content !== "string" || !body.content.trim()) {
223
+ return c.json({ error: "content is required" }, 400);
224
+ }
225
+ deps.wsBridge.injectUserMessage(id, body.content);
226
+ return c.json({ ok: true, sessionId: id });
227
+ });
228
+ }
@@ -0,0 +1,176 @@
1
+ import { vi, describe, it, expect, beforeEach } from "vitest";
2
+
3
+ vi.mock("../tailscale-manager.js", () => ({
4
+ getTailscaleStatus: vi.fn(),
5
+ startFunnel: vi.fn(),
6
+ stopFunnel: vi.fn(),
7
+ }));
8
+
9
+ import { Hono } from "hono";
10
+ import { getTailscaleStatus, startFunnel, stopFunnel } from "../tailscale-manager.js";
11
+ import { registerTailscaleRoutes } from "./tailscale-routes.js";
12
+
13
+ const mockGetStatus = vi.mocked(getTailscaleStatus);
14
+ const mockStartFunnel = vi.mocked(startFunnel);
15
+ const mockStopFunnel = vi.mocked(stopFunnel);
16
+
17
+ const PORT = 3456;
18
+
19
+ function createApp() {
20
+ const api = new Hono();
21
+ registerTailscaleRoutes(api, PORT);
22
+ return api;
23
+ }
24
+
25
+ beforeEach(() => {
26
+ vi.clearAllMocks();
27
+ });
28
+
29
+ describe("GET /tailscale/status", () => {
30
+ it("returns the Tailscale status", async () => {
31
+ const status = {
32
+ installed: true,
33
+ binaryPath: "/usr/bin/tailscale",
34
+ connected: true,
35
+ dnsName: "my-machine.ts.net",
36
+ funnelActive: false,
37
+ funnelUrl: null,
38
+ error: null,
39
+ };
40
+ mockGetStatus.mockResolvedValue(status);
41
+
42
+ const app = createApp();
43
+ const res = await app.request("/tailscale/status");
44
+
45
+ expect(res.status).toBe(200);
46
+ const body = await res.json();
47
+ expect(body.installed).toBe(true);
48
+ expect(body.connected).toBe(true);
49
+ expect(body.dnsName).toBe("my-machine.ts.net");
50
+ expect(mockGetStatus).toHaveBeenCalledWith(PORT);
51
+ });
52
+
53
+ it("returns installed=false when Tailscale is not found", async () => {
54
+ mockGetStatus.mockResolvedValue({
55
+ installed: false,
56
+ binaryPath: null,
57
+ connected: false,
58
+ dnsName: null,
59
+ funnelActive: false,
60
+ funnelUrl: null,
61
+ error: null,
62
+ });
63
+
64
+ const app = createApp();
65
+ const res = await app.request("/tailscale/status");
66
+
67
+ expect(res.status).toBe(200);
68
+ const body = await res.json();
69
+ expect(body.installed).toBe(false);
70
+ });
71
+ });
72
+
73
+ describe("POST /tailscale/funnel/start", () => {
74
+ it("returns 200 on success", async () => {
75
+ mockStartFunnel.mockResolvedValue({
76
+ installed: true,
77
+ binaryPath: "/usr/bin/tailscale",
78
+ connected: true,
79
+ dnsName: "my-machine.ts.net",
80
+ funnelActive: true,
81
+ funnelUrl: "https://my-machine.ts.net",
82
+ error: null,
83
+ });
84
+
85
+ const app = createApp();
86
+ const res = await app.request("/tailscale/funnel/start", { method: "POST" });
87
+
88
+ expect(res.status).toBe(200);
89
+ const body = await res.json();
90
+ expect(body.funnelActive).toBe(true);
91
+ expect(body.funnelUrl).toBe("https://my-machine.ts.net");
92
+ expect(mockStartFunnel).toHaveBeenCalledWith(PORT);
93
+ });
94
+
95
+ // Routes now always return 200 — error is in the body
96
+ it("returns 200 with error in body when an error occurs", async () => {
97
+ mockStartFunnel.mockResolvedValue({
98
+ installed: true,
99
+ binaryPath: "/usr/bin/tailscale",
100
+ connected: false,
101
+ dnsName: null,
102
+ funnelActive: false,
103
+ funnelUrl: null,
104
+ error: "Tailscale is not connected",
105
+ });
106
+
107
+ const app = createApp();
108
+ const res = await app.request("/tailscale/funnel/start", { method: "POST" });
109
+
110
+ expect(res.status).toBe(200);
111
+ const body = await res.json();
112
+ expect(body.error).toBe("Tailscale is not connected");
113
+ });
114
+
115
+ it("returns 200 with needsOperatorMode on permission error", async () => {
116
+ mockStartFunnel.mockResolvedValue({
117
+ installed: true,
118
+ binaryPath: "/usr/bin/tailscale",
119
+ connected: true,
120
+ dnsName: "my-machine.ts.net",
121
+ funnelActive: false,
122
+ funnelUrl: null,
123
+ error: "Tailscale requires operator mode on Linux to manage Funnel.",
124
+ needsOperatorMode: true,
125
+ });
126
+
127
+ const app = createApp();
128
+ const res = await app.request("/tailscale/funnel/start", { method: "POST" });
129
+
130
+ expect(res.status).toBe(200);
131
+ const body = await res.json();
132
+ expect(body.needsOperatorMode).toBe(true);
133
+ expect(body.error).toContain("operator mode");
134
+ });
135
+ });
136
+
137
+ describe("POST /tailscale/funnel/stop", () => {
138
+ it("returns 200 on success", async () => {
139
+ mockStopFunnel.mockResolvedValue({
140
+ installed: true,
141
+ binaryPath: "/usr/bin/tailscale",
142
+ connected: true,
143
+ dnsName: "my-machine.ts.net",
144
+ funnelActive: false,
145
+ funnelUrl: null,
146
+ error: null,
147
+ });
148
+
149
+ const app = createApp();
150
+ const res = await app.request("/tailscale/funnel/stop", { method: "POST" });
151
+
152
+ expect(res.status).toBe(200);
153
+ const body = await res.json();
154
+ expect(body.funnelActive).toBe(false);
155
+ expect(mockStopFunnel).toHaveBeenCalledWith(PORT);
156
+ });
157
+
158
+ it("returns 200 with error in body when stop fails", async () => {
159
+ mockStopFunnel.mockResolvedValue({
160
+ installed: true,
161
+ binaryPath: "/usr/bin/tailscale",
162
+ connected: true,
163
+ dnsName: null,
164
+ funnelActive: false,
165
+ funnelUrl: null,
166
+ error: "Failed to stop Funnel: command failed",
167
+ });
168
+
169
+ const app = createApp();
170
+ const res = await app.request("/tailscale/funnel/stop", { method: "POST" });
171
+
172
+ expect(res.status).toBe(200);
173
+ const body = await res.json();
174
+ expect(body.error).toContain("Failed to stop Funnel");
175
+ });
176
+ });
@@ -0,0 +1,22 @@
1
+ import type { Hono } from "hono";
2
+ import { getTailscaleStatus, startFunnel, stopFunnel } from "../tailscale-manager.js";
3
+
4
+ export function registerTailscaleRoutes(api: Hono, port: number): void {
5
+ api.get("/tailscale/status", async (c) => {
6
+ const status = await getTailscaleStatus(port);
7
+ return c.json(status);
8
+ });
9
+
10
+ // Always return 200 — the `error` and `needsOperatorMode` fields in the body
11
+ // signal failures. This lets the frontend receive the full structured status
12
+ // instead of the generic post() helper throwing and losing context.
13
+ api.post("/tailscale/funnel/start", async (c) => {
14
+ const status = await startFunnel(port);
15
+ return c.json(status);
16
+ });
17
+
18
+ api.post("/tailscale/funnel/stop", async (c) => {
19
+ const status = await stopFunnel(port);
20
+ return c.json(status);
21
+ });
22
+ }