heyhank 0.1.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 (199) hide show
  1. package/README.md +40 -0
  2. package/bin/cli.ts +168 -0
  3. package/bin/ctl.ts +528 -0
  4. package/bin/generate-token.ts +28 -0
  5. package/dist/apple-touch-icon.png +0 -0
  6. package/dist/assets/AgentsPage-BPhirnCe.js +7 -0
  7. package/dist/assets/AssistantPage-DJ-cMQfb.js +1 -0
  8. package/dist/assets/CronManager-DDbz-yiT.js +1 -0
  9. package/dist/assets/HelpPage-DMfkzERp.js +1 -0
  10. package/dist/assets/IntegrationsPage-CrOitCmJ.js +1 -0
  11. package/dist/assets/MediaPage-CE5rdvkC.js +1 -0
  12. package/dist/assets/PlatformDashboard-Do6F0O2p.js +1 -0
  13. package/dist/assets/Playground-Fc5cdc5p.js +109 -0
  14. package/dist/assets/ProcessPanel-CslEiZkI.js +2 -0
  15. package/dist/assets/PromptsPage-D2EhsdNO.js +4 -0
  16. package/dist/assets/RunsPage-C5BZF5Rx.js +1 -0
  17. package/dist/assets/SandboxManager-a1AVI5q2.js +8 -0
  18. package/dist/assets/SettingsPage-DirhjQrJ.js +51 -0
  19. package/dist/assets/SocialMediaPage-DBuM28vD.js +1 -0
  20. package/dist/assets/TailscalePage-CHiFhZXF.js +1 -0
  21. package/dist/assets/TelephonyPage-x0VV0fOo.js +1 -0
  22. package/dist/assets/TerminalPage-Drwyrnfd.js +1 -0
  23. package/dist/assets/gemini-audio-t-TSU-To.js +17 -0
  24. package/dist/assets/gemini-live-client-C7rqAW7G.js +166 -0
  25. package/dist/assets/index-C8M_PUmX.css +32 -0
  26. package/dist/assets/index-CEqZnThB.js +204 -0
  27. package/dist/assets/sw-register-LSSpj6RU.js +1 -0
  28. package/dist/assets/time-ago-B6r_l9u1.js +1 -0
  29. package/dist/assets/workbox-window.prod.es5-BIl4cyR9.js +2 -0
  30. package/dist/favicon-32-original.png +0 -0
  31. package/dist/favicon-32.png +0 -0
  32. package/dist/favicon.ico +0 -0
  33. package/dist/favicon.svg +8 -0
  34. package/dist/fonts/MesloLGSNerdFontMono-Bold.woff2 +0 -0
  35. package/dist/fonts/MesloLGSNerdFontMono-Regular.woff2 +0 -0
  36. package/dist/heyhank-mascot-poster.png +0 -0
  37. package/dist/heyhank-mascot.mp4 +0 -0
  38. package/dist/heyhank-mascot.webm +0 -0
  39. package/dist/icon-192-original.png +0 -0
  40. package/dist/icon-192.png +0 -0
  41. package/dist/icon-512-original.png +0 -0
  42. package/dist/icon-512.png +0 -0
  43. package/dist/index.html +21 -0
  44. package/dist/logo-192.png +0 -0
  45. package/dist/logo-512.png +0 -0
  46. package/dist/logo-codex.svg +14 -0
  47. package/dist/logo-docker.svg +4 -0
  48. package/dist/logo-original.png +0 -0
  49. package/dist/logo.png +0 -0
  50. package/dist/logo.svg +14 -0
  51. package/dist/manifest.json +24 -0
  52. package/dist/push-sw.js +34 -0
  53. package/dist/sw.js +1 -0
  54. package/dist/workbox-d2a0910a.js +1 -0
  55. package/package.json +109 -0
  56. package/server/agent-cron-migrator.ts +85 -0
  57. package/server/agent-executor.ts +357 -0
  58. package/server/agent-store.ts +185 -0
  59. package/server/agent-timeout.ts +107 -0
  60. package/server/agent-types.ts +122 -0
  61. package/server/ai-validation-settings.ts +37 -0
  62. package/server/ai-validator.ts +181 -0
  63. package/server/anthropic-provider-migration.ts +48 -0
  64. package/server/assistant-store.ts +272 -0
  65. package/server/auth-manager.ts +150 -0
  66. package/server/auto-approve.ts +153 -0
  67. package/server/auto-namer.ts +36 -0
  68. package/server/backend-adapter.ts +54 -0
  69. package/server/cache-headers.ts +61 -0
  70. package/server/calendar-service.ts +434 -0
  71. package/server/claude-adapter.ts +889 -0
  72. package/server/claude-container-auth.ts +30 -0
  73. package/server/claude-session-discovery.ts +157 -0
  74. package/server/claude-session-history.ts +410 -0
  75. package/server/cli-launcher.ts +1303 -0
  76. package/server/codex-adapter.ts +3027 -0
  77. package/server/codex-container-auth.ts +24 -0
  78. package/server/codex-home.ts +27 -0
  79. package/server/codex-ws-proxy.cjs +226 -0
  80. package/server/commands-discovery.ts +81 -0
  81. package/server/constants.ts +7 -0
  82. package/server/container-manager.ts +1053 -0
  83. package/server/cost-tracker.ts +222 -0
  84. package/server/cron-scheduler.ts +243 -0
  85. package/server/cron-store.ts +148 -0
  86. package/server/cron-types.ts +63 -0
  87. package/server/email-service.ts +354 -0
  88. package/server/env-manager.ts +161 -0
  89. package/server/event-bus-types.ts +75 -0
  90. package/server/event-bus.ts +124 -0
  91. package/server/execution-store.ts +170 -0
  92. package/server/federation/node-connection.ts +190 -0
  93. package/server/federation/node-manager.ts +366 -0
  94. package/server/federation/node-store.ts +86 -0
  95. package/server/federation/node-types.ts +121 -0
  96. package/server/fs-utils.ts +15 -0
  97. package/server/git-utils.ts +421 -0
  98. package/server/github-pr.ts +379 -0
  99. package/server/google-media.ts +342 -0
  100. package/server/image-pull-manager.ts +279 -0
  101. package/server/index.ts +491 -0
  102. package/server/internal-ai.ts +237 -0
  103. package/server/kill-switch.ts +99 -0
  104. package/server/llm-providers.ts +342 -0
  105. package/server/logger.ts +259 -0
  106. package/server/mcp-registry.ts +401 -0
  107. package/server/message-bus.ts +271 -0
  108. package/server/message-delivery.ts +128 -0
  109. package/server/metrics-collector.ts +350 -0
  110. package/server/metrics-types.ts +108 -0
  111. package/server/middleware/managed-auth.ts +195 -0
  112. package/server/novnc-proxy.ts +99 -0
  113. package/server/path-resolver.ts +186 -0
  114. package/server/paths.ts +13 -0
  115. package/server/pr-poller.ts +162 -0
  116. package/server/prompt-manager.ts +211 -0
  117. package/server/protocol/claude-upstream/README.md +19 -0
  118. package/server/protocol/claude-upstream/sdk.d.ts.txt +1943 -0
  119. package/server/protocol/codex-upstream/ClientNotification.ts.txt +5 -0
  120. package/server/protocol/codex-upstream/ClientRequest.ts.txt +60 -0
  121. package/server/protocol/codex-upstream/README.md +18 -0
  122. package/server/protocol/codex-upstream/ServerNotification.ts.txt +41 -0
  123. package/server/protocol/codex-upstream/ServerRequest.ts.txt +16 -0
  124. package/server/protocol/codex-upstream/v2/DynamicToolCallParams.ts.txt +6 -0
  125. package/server/protocol/codex-upstream/v2/DynamicToolCallResponse.ts.txt +6 -0
  126. package/server/protocol-monitor.ts +50 -0
  127. package/server/provider-manager.ts +111 -0
  128. package/server/provider-registry.ts +393 -0
  129. package/server/push-notifications.ts +221 -0
  130. package/server/recorder.ts +374 -0
  131. package/server/recording-hub/compat-validator.ts +284 -0
  132. package/server/recording-hub/diagnostics.ts +299 -0
  133. package/server/recording-hub/hub-config.ts +19 -0
  134. package/server/recording-hub/hub-routes.ts +236 -0
  135. package/server/recording-hub/hub-store.ts +265 -0
  136. package/server/recording-hub/replay-adapter.ts +207 -0
  137. package/server/relay-client.ts +320 -0
  138. package/server/reminder-scheduler.ts +38 -0
  139. package/server/replay.ts +78 -0
  140. package/server/routes/agent-routes.ts +264 -0
  141. package/server/routes/assistant-routes.ts +90 -0
  142. package/server/routes/cron-routes.ts +103 -0
  143. package/server/routes/env-routes.ts +95 -0
  144. package/server/routes/federation-routes.ts +76 -0
  145. package/server/routes/fs-routes.ts +622 -0
  146. package/server/routes/git-routes.ts +97 -0
  147. package/server/routes/llm-routes.ts +166 -0
  148. package/server/routes/media-routes.ts +135 -0
  149. package/server/routes/metrics-routes.ts +13 -0
  150. package/server/routes/platform-routes.ts +1379 -0
  151. package/server/routes/prompt-routes.ts +67 -0
  152. package/server/routes/provider-routes.ts +109 -0
  153. package/server/routes/sandbox-routes.ts +127 -0
  154. package/server/routes/settings-routes.ts +285 -0
  155. package/server/routes/skills-routes.ts +100 -0
  156. package/server/routes/socialmedia-routes.ts +208 -0
  157. package/server/routes/system-routes.ts +228 -0
  158. package/server/routes/tailscale-routes.ts +22 -0
  159. package/server/routes/telephony-routes.ts +259 -0
  160. package/server/routes.ts +1379 -0
  161. package/server/sandbox-manager.ts +168 -0
  162. package/server/service.ts +718 -0
  163. package/server/session-creation-service.ts +457 -0
  164. package/server/session-git-info.ts +104 -0
  165. package/server/session-names.ts +67 -0
  166. package/server/session-orchestrator.ts +824 -0
  167. package/server/session-state-machine.ts +207 -0
  168. package/server/session-store.ts +146 -0
  169. package/server/session-types.ts +511 -0
  170. package/server/settings-manager.ts +149 -0
  171. package/server/shared-context.ts +157 -0
  172. package/server/socialmedia/adapter.ts +15 -0
  173. package/server/socialmedia/adapters/ayrshare-adapter.ts +169 -0
  174. package/server/socialmedia/adapters/buffer-adapter.ts +299 -0
  175. package/server/socialmedia/adapters/postiz-adapter.ts +298 -0
  176. package/server/socialmedia/manager.ts +227 -0
  177. package/server/socialmedia/store.ts +98 -0
  178. package/server/socialmedia/types.ts +89 -0
  179. package/server/tailscale-manager.ts +451 -0
  180. package/server/telephony/audio-bridge.ts +331 -0
  181. package/server/telephony/call-manager.ts +457 -0
  182. package/server/telephony/call-types.ts +108 -0
  183. package/server/telephony/telephony-store.ts +119 -0
  184. package/server/terminal-manager.ts +240 -0
  185. package/server/update-checker.ts +192 -0
  186. package/server/usage-limits.ts +225 -0
  187. package/server/web-push.d.ts +51 -0
  188. package/server/worktree-tracker.ts +84 -0
  189. package/server/ws-auth.ts +41 -0
  190. package/server/ws-bridge-browser-ingest.ts +72 -0
  191. package/server/ws-bridge-browser.ts +112 -0
  192. package/server/ws-bridge-cli-ingest.ts +81 -0
  193. package/server/ws-bridge-codex.ts +266 -0
  194. package/server/ws-bridge-controls.ts +20 -0
  195. package/server/ws-bridge-persist.ts +66 -0
  196. package/server/ws-bridge-publish.ts +79 -0
  197. package/server/ws-bridge-replay.ts +61 -0
  198. package/server/ws-bridge-types.ts +121 -0
  199. package/server/ws-bridge.ts +1240 -0
@@ -0,0 +1,89 @@
1
+ // Social Media Types
2
+
3
+ export type SocialBackendId = "postiz" | "ayrshare" | "buffer";
4
+
5
+ export type SocialPlatform = "twitter" | "instagram" | "linkedin" | "facebook" | "tiktok" | "threads";
6
+
7
+ export interface SocialBackendConfig {
8
+ url?: string; // For Postiz (self-hosted URL)
9
+ apiKey: string;
10
+ }
11
+
12
+ export interface SocialProfile {
13
+ id: string;
14
+ platform: SocialPlatform;
15
+ name: string;
16
+ picture?: string | null;
17
+ }
18
+
19
+ export interface CreatePostInput {
20
+ text: string;
21
+ platforms: SocialPlatform[];
22
+ scheduledAt?: string | null;
23
+ mediaUrls?: string[];
24
+ // Rich post fields (Buffer-style)
25
+ title?: string;
26
+ firstComment?: string;
27
+ videoUrl?: string;
28
+ thumbnailUrl?: string;
29
+ isDraft?: boolean;
30
+ }
31
+
32
+ export interface SocialPost {
33
+ id: string;
34
+ text: string;
35
+ platforms: SocialPlatform[];
36
+ scheduledAt?: string | null;
37
+ mediaUrls: string[];
38
+ status: "published" | "scheduled" | "failed" | "draft";
39
+ backendId: SocialBackendId | null;
40
+ backendPostId?: string | null;
41
+ backendData?: unknown;
42
+ createdAt: string;
43
+ updatedAt: string;
44
+ // Rich post fields
45
+ title?: string;
46
+ firstComment?: string;
47
+ videoUrl?: string;
48
+ thumbnailUrl?: string;
49
+ createdBy?: "user" | "gemini" | "agent";
50
+ }
51
+
52
+ export interface PostAnalytics {
53
+ impressions: number;
54
+ likes: number;
55
+ shares: number;
56
+ comments: number;
57
+ }
58
+
59
+ export interface AccountAnalytics {
60
+ followers: number;
61
+ following: number;
62
+ posts: number;
63
+ }
64
+
65
+ export interface SocialComment {
66
+ id: string;
67
+ author: string;
68
+ text: string;
69
+ createdAt?: string;
70
+ likes?: number;
71
+ }
72
+
73
+ export interface SocialMediaSettings {
74
+ backend: SocialBackendId | null;
75
+ backends: Partial<Record<SocialBackendId, SocialBackendConfig>>;
76
+ defaultPlatforms: SocialPlatform[];
77
+ }
78
+
79
+ export interface ListPostsOpts {
80
+ status?: string;
81
+ platform?: string;
82
+ limit?: number;
83
+ }
84
+
85
+ export const DEFAULT_SOCIAL_SETTINGS: SocialMediaSettings = {
86
+ backend: null,
87
+ backends: {},
88
+ defaultPlatforms: [],
89
+ };
@@ -0,0 +1,451 @@
1
+ /**
2
+ * Tailscale CLI wrapper for Funnel integration.
3
+ *
4
+ * Detects the `tailscale` binary, checks connection status, and manages
5
+ * Tailscale Funnel to expose HeyHank over HTTPS. Persists funnel
6
+ * state to ~/.heyhank/tailscale-state.json for restoration across
7
+ * server restarts.
8
+ */
9
+
10
+ import { spawnSync, spawn } from "node:child_process";
11
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync } from "node:fs";
12
+ import { dirname, join } from "node:path";
13
+ import { homedir } from "node:os";
14
+ import { resolveBinary } from "./path-resolver.js";
15
+ import { getSettings, updateSettings } from "./settings-manager.js";
16
+
17
+ // ── Types ───────────────────────────────────────────────────────────────────
18
+
19
+ /** Keep in sync with web/src/api.ts TailscaleStatus */
20
+ export interface TailscaleStatus {
21
+ /** Whether the `tailscale` binary was found on PATH */
22
+ installed: boolean;
23
+ /** Resolved absolute path to the binary, or null */
24
+ binaryPath: string | null;
25
+ /** Whether Tailscale is connected to a tailnet */
26
+ connected: boolean;
27
+ /** Machine DNS name, e.g. "my-machine.tail1234.ts.net" */
28
+ dnsName: string | null;
29
+ /** Whether Funnel is currently active for our port */
30
+ funnelActive: boolean;
31
+ /** HTTPS Funnel URL when active, e.g. "https://my-machine.tail1234.ts.net" */
32
+ funnelUrl: string | null;
33
+ /** Error message if the last operation failed */
34
+ error: string | null;
35
+ /** True when on Linux and Tailscale operator mode is not configured */
36
+ needsOperatorMode?: boolean;
37
+ /** Non-blocking warning (e.g. DNS not resolving publicly) */
38
+ warning?: string;
39
+ }
40
+
41
+ interface PersistedFunnelState {
42
+ wasActive: boolean;
43
+ port: number;
44
+ funnelUrl: string;
45
+ activatedAt: number;
46
+ }
47
+
48
+ // ── Internal state ──────────────────────────────────────────────────────────
49
+
50
+ const STATE_PATH = join(homedir(), ".heyhank", "tailscale-state.json");
51
+ const CMD_TIMEOUT = 15_000;
52
+ const BINARY_CACHE_TTL = 60_000; // 1 minute — allows detecting install/uninstall without restart
53
+
54
+ let cachedBinaryPath: string | null | undefined; // undefined = not yet checked
55
+ let binaryCacheTime = 0;
56
+
57
+ // ── Helpers ─────────────────────────────────────────────────────────────────
58
+
59
+ function findBinary(): string | null {
60
+ if (cachedBinaryPath !== undefined && Date.now() - binaryCacheTime < BINARY_CACHE_TTL) {
61
+ return cachedBinaryPath;
62
+ }
63
+ cachedBinaryPath = resolveBinary("tailscale");
64
+ binaryCacheTime = Date.now();
65
+ return cachedBinaryPath;
66
+ }
67
+
68
+ /**
69
+ * Run a command asynchronously using spawn with explicit argument array
70
+ * (no shell interpolation — eliminates command injection).
71
+ */
72
+ function execAsync(binary: string, args: string[]): Promise<string> {
73
+ return new Promise((resolve, reject) => {
74
+ const proc = spawn(binary, args, {
75
+ stdio: ["pipe", "pipe", "pipe"],
76
+ timeout: CMD_TIMEOUT,
77
+ });
78
+
79
+ let stdout = "";
80
+ let stderr = "";
81
+
82
+ proc.stdout.on("data", (data: Buffer) => { stdout += data.toString(); });
83
+ proc.stderr.on("data", (data: Buffer) => { stderr += data.toString(); });
84
+
85
+ proc.on("error", (err) => reject(err));
86
+ proc.on("close", (code) => {
87
+ if (code === 0) {
88
+ resolve(stdout.trim());
89
+ } else {
90
+ reject(new Error(stderr.trim() || `Process exited with code ${code}`));
91
+ }
92
+ });
93
+ });
94
+ }
95
+
96
+ /**
97
+ * Check if operator mode is needed but not configured (Linux only).
98
+ * On macOS the Tailscale GUI app handles permissions, so this is a no-op.
99
+ */
100
+ async function checkNeedsOperatorMode(binary: string): Promise<boolean> {
101
+ if (process.platform !== "linux") return false;
102
+ try {
103
+ const output = await execAsync(binary, ["debug", "prefs"]);
104
+ const prefs = JSON.parse(output);
105
+ return !prefs.OperatorUser;
106
+ } catch {
107
+ return false; // Can't determine — assume ok
108
+ }
109
+ }
110
+
111
+ /**
112
+ * Check if a hostname resolves via public DNS (Google 8.8.8.8).
113
+ * We explicitly use a public resolver to avoid Tailscale's MagicDNS
114
+ * returning private CGNAT addresses (100.64.x.x) for .ts.net hostnames.
115
+ */
116
+ async function checkFunnelDnsResolves(hostname: string): Promise<boolean> {
117
+ try {
118
+ const { Resolver } = await import("node:dns/promises");
119
+ const resolver = new Resolver();
120
+ resolver.setServers(["8.8.8.8"]);
121
+ const addresses = await resolver.resolve4(hostname);
122
+ return addresses.length > 0;
123
+ } catch {
124
+ return false;
125
+ }
126
+ }
127
+
128
+ function loadPersistedState(): PersistedFunnelState | null {
129
+ try {
130
+ if (!existsSync(STATE_PATH)) return null;
131
+ const raw = JSON.parse(readFileSync(STATE_PATH, "utf-8")) as PersistedFunnelState;
132
+ if (raw && typeof raw.wasActive === "boolean" && typeof raw.port === "number") {
133
+ return raw;
134
+ }
135
+ return null;
136
+ } catch {
137
+ return null;
138
+ }
139
+ }
140
+
141
+ function persistState(state: PersistedFunnelState): void {
142
+ try {
143
+ mkdirSync(dirname(STATE_PATH), { recursive: true });
144
+ writeFileSync(STATE_PATH, JSON.stringify(state, null, 2), "utf-8");
145
+ } catch (err) {
146
+ console.warn("[tailscale] Failed to persist state:", err);
147
+ }
148
+ }
149
+
150
+ function clearPersistedState(): void {
151
+ try {
152
+ if (existsSync(STATE_PATH)) unlinkSync(STATE_PATH);
153
+ } catch {
154
+ // best-effort
155
+ }
156
+ }
157
+
158
+ /**
159
+ * Parse `tailscale status --json` to get connection state and DNS name.
160
+ */
161
+ async function parseConnectionStatus(binary: string): Promise<{ connected: boolean; dnsName: string | null }> {
162
+ try {
163
+ const output = await execAsync(binary, ["status", "--json"]);
164
+ const status = JSON.parse(output) as {
165
+ BackendState?: string;
166
+ Self?: { DNSName?: string };
167
+ };
168
+
169
+ const backendState = status.BackendState ?? "";
170
+ const connected = backendState === "Running";
171
+ let dnsName: string | null = null;
172
+
173
+ if (connected && status.Self?.DNSName) {
174
+ // DNSName typically ends with a trailing dot — strip it
175
+ dnsName = status.Self.DNSName.replace(/\.$/, "");
176
+ }
177
+
178
+ return { connected, dnsName };
179
+ } catch {
180
+ return { connected: false, dnsName: null };
181
+ }
182
+ }
183
+
184
+ /**
185
+ * Parse `tailscale serve status --json` to determine if a given port is being
186
+ * funneled. The output looks like:
187
+ * {
188
+ * "Web": { "machine.ts.net:443": { "Handlers": { "/": { "Proxy": "http://127.0.0.1:PORT" } } } },
189
+ * "AllowFunnel": { "machine.ts.net:443": true }
190
+ * }
191
+ */
192
+ async function parseFunnelStatus(binary: string, port: number): Promise<{ active: boolean; funnelUrl: string | null }> {
193
+ try {
194
+ const output = await execAsync(binary, ["serve", "status", "--json"]);
195
+ const config = JSON.parse(output) as {
196
+ Web?: Record<string, { Handlers?: Record<string, { Proxy?: string }> }>;
197
+ AllowFunnel?: Record<string, boolean>;
198
+ };
199
+
200
+ if (!config.Web || !config.AllowFunnel) {
201
+ return { active: false, funnelUrl: null };
202
+ }
203
+
204
+ // Match port precisely: the Proxy URL ends with ":PORT" (no trailing path beyond optional /)
205
+ const portSuffix = `:${port}`;
206
+ for (const [hostPort, isFunnel] of Object.entries(config.AllowFunnel)) {
207
+ if (!isFunnel) continue;
208
+ const handlers = config.Web[hostPort]?.Handlers;
209
+ if (!handlers) continue;
210
+ for (const handler of Object.values(handlers)) {
211
+ if (!handler.Proxy) continue;
212
+ // Exact port match: URL ends with ":PORT" or ":PORT/"
213
+ if (handler.Proxy.endsWith(portSuffix) || handler.Proxy.endsWith(`${portSuffix}/`)) {
214
+ // Extract the hostname from "machine.ts.net:443"
215
+ const hostname = hostPort.split(":")[0];
216
+ return { active: true, funnelUrl: `https://${hostname}` };
217
+ }
218
+ }
219
+ }
220
+
221
+ return { active: false, funnelUrl: null };
222
+ } catch {
223
+ return { active: false, funnelUrl: null };
224
+ }
225
+ }
226
+
227
+ // ── Public API ──────────────────────────────────────────────────────────────
228
+
229
+ /**
230
+ * Get full Tailscale status: binary availability, connection, and funnel state.
231
+ */
232
+ export async function getTailscaleStatus(port: number): Promise<TailscaleStatus> {
233
+ const binary = findBinary();
234
+ if (!binary) {
235
+ return {
236
+ installed: false,
237
+ binaryPath: null,
238
+ connected: false,
239
+ dnsName: null,
240
+ funnelActive: false,
241
+ funnelUrl: null,
242
+ error: null,
243
+ };
244
+ }
245
+
246
+ const { connected, dnsName } = await parseConnectionStatus(binary);
247
+ if (!connected) {
248
+ return {
249
+ installed: true,
250
+ binaryPath: binary,
251
+ connected: false,
252
+ dnsName: null,
253
+ funnelActive: false,
254
+ funnelUrl: null,
255
+ error: null,
256
+ };
257
+ }
258
+
259
+ const { active, funnelUrl } = await parseFunnelStatus(binary, port);
260
+ const needsOperatorMode = !active ? await checkNeedsOperatorMode(binary) : undefined;
261
+
262
+ // If funnel is active, check if the URL actually resolves publicly
263
+ let warning: string | undefined;
264
+ if (active && dnsName) {
265
+ const dnsOk = await checkFunnelDnsResolves(dnsName);
266
+ if (!dnsOk) {
267
+ warning = "DNS for this hostname is not resolving publicly. Ensure Funnel is enabled in your Tailscale admin console (admin.tailscale.com \u2192 Access Controls \u2192 nodeAttrs). DNS propagation can take up to 10 minutes on first use.";
268
+ }
269
+ }
270
+
271
+ return {
272
+ installed: true,
273
+ binaryPath: binary,
274
+ connected: true,
275
+ dnsName,
276
+ funnelActive: active,
277
+ funnelUrl,
278
+ error: null,
279
+ ...(needsOperatorMode && { needsOperatorMode }),
280
+ ...(warning && { warning }),
281
+ };
282
+ }
283
+
284
+ /**
285
+ * Start Tailscale Funnel for the given port.
286
+ * Automatically updates publicUrl in settings and persists funnel state.
287
+ */
288
+ export async function startFunnel(port: number): Promise<TailscaleStatus> {
289
+ const binary = findBinary();
290
+ if (!binary) {
291
+ return { installed: false, binaryPath: null, connected: false, dnsName: null, funnelActive: false, funnelUrl: null, error: "Tailscale is not installed" };
292
+ }
293
+
294
+ const { connected, dnsName } = await parseConnectionStatus(binary);
295
+ if (!connected) {
296
+ return { installed: true, binaryPath: binary, connected: false, dnsName: null, funnelActive: false, funnelUrl: null, error: "Tailscale is not connected. Run `tailscale up` to connect." };
297
+ }
298
+
299
+ try {
300
+ await execAsync(binary, ["funnel", "--bg", String(port)]);
301
+ } catch (err: unknown) {
302
+ const message = err instanceof Error ? err.message : String(err);
303
+ const isPermissionError = process.platform === "linux" && /permission|sudo|access denied/i.test(message);
304
+ return {
305
+ installed: true, binaryPath: binary, connected: true, dnsName,
306
+ funnelActive: false, funnelUrl: null,
307
+ error: isPermissionError
308
+ ? "Tailscale requires operator mode on Linux to manage Funnel."
309
+ : `Failed to start Funnel: ${message}`,
310
+ ...(isPermissionError && { needsOperatorMode: true }),
311
+ };
312
+ }
313
+
314
+ // Verify it's running and get the URL
315
+ const { active, funnelUrl } = await parseFunnelStatus(binary, port);
316
+
317
+ // DNS reachability is NOT checked here — it takes seconds to minutes for
318
+ // Tailscale to provision public DNS records after first enablement.
319
+ // The check runs in getTailscaleStatus() on subsequent polls instead.
320
+
321
+ if (!active || !funnelUrl) {
322
+ // Funnel command succeeded but we can't detect it yet — construct URL from DNS name
323
+ const constructedUrl = dnsName ? `https://${dnsName}` : null;
324
+ if (constructedUrl) {
325
+ updateSettings({ publicUrl: constructedUrl });
326
+ persistState({ wasActive: true, port, funnelUrl: constructedUrl, activatedAt: Date.now() });
327
+ return { installed: true, binaryPath: binary, connected: true, dnsName, funnelActive: true, funnelUrl: constructedUrl, error: null };
328
+ }
329
+ return { installed: true, binaryPath: binary, connected: true, dnsName, funnelActive: false, funnelUrl: null, error: "Funnel started but could not determine URL" };
330
+ }
331
+
332
+ updateSettings({ publicUrl: funnelUrl });
333
+ persistState({ wasActive: true, port, funnelUrl, activatedAt: Date.now() });
334
+ console.log(`[tailscale] Funnel started: ${funnelUrl} → localhost:${port}`);
335
+
336
+ return { installed: true, binaryPath: binary, connected: true, dnsName, funnelActive: true, funnelUrl, error: null };
337
+ }
338
+
339
+ /**
340
+ * Stop Tailscale Funnel for the given port.
341
+ * Clears publicUrl if it still matches the Funnel URL.
342
+ */
343
+ export async function stopFunnel(port: number): Promise<TailscaleStatus> {
344
+ const binary = findBinary();
345
+ if (!binary) {
346
+ return { installed: false, binaryPath: null, connected: false, dnsName: null, funnelActive: false, funnelUrl: null, error: "Tailscale is not installed" };
347
+ }
348
+
349
+ // Read persisted URL before stopping
350
+ const persisted = loadPersistedState();
351
+ const previousUrl = persisted?.funnelUrl ?? null;
352
+
353
+ try {
354
+ await execAsync(binary, ["funnel", String(port), "off"]);
355
+ } catch (err: unknown) {
356
+ const message = err instanceof Error ? err.message : String(err);
357
+ // Re-query actual state — funnel is likely still running after a failed stop
358
+ const { connected, dnsName } = await parseConnectionStatus(binary).catch(() => ({ connected: true, dnsName: null as string | null }));
359
+ const { active, funnelUrl } = await parseFunnelStatus(binary, port).catch(() => ({ active: true, funnelUrl: null as string | null }));
360
+ return { installed: true, binaryPath: binary, connected, dnsName, funnelActive: active, funnelUrl, error: `Failed to stop Funnel: ${message}` };
361
+ }
362
+
363
+ clearPersistedState();
364
+
365
+ // Clear publicUrl only if it matches the Funnel URL (don't overwrite manual URL)
366
+ if (previousUrl) {
367
+ const currentPublicUrl = getSettings().publicUrl;
368
+ if (currentPublicUrl === previousUrl) {
369
+ updateSettings({ publicUrl: "" });
370
+ }
371
+ }
372
+
373
+ console.log(`[tailscale] Funnel stopped for port ${port}`);
374
+
375
+ const { connected, dnsName } = await parseConnectionStatus(binary);
376
+ return { installed: true, binaryPath: binary, connected, dnsName, funnelActive: false, funnelUrl: null, error: null };
377
+ }
378
+
379
+ /**
380
+ * Check if Tailscale Funnel was previously active and verify it's still running.
381
+ * Called on server startup to keep publicUrl in sync.
382
+ */
383
+ export async function restoreIfNeeded(port: number): Promise<void> {
384
+ const persisted = loadPersistedState();
385
+ if (!persisted?.wasActive) return;
386
+
387
+ const binary = findBinary();
388
+ if (!binary) {
389
+ console.log("[tailscale] Binary not found, clearing persisted funnel state");
390
+ clearPersistedState();
391
+ return;
392
+ }
393
+
394
+ const { connected } = await parseConnectionStatus(binary);
395
+ if (!connected) {
396
+ console.log("[tailscale] Not connected, clearing persisted funnel state");
397
+ clearPersistedState();
398
+ return;
399
+ }
400
+
401
+ // Check if funnel is still active (--bg makes it a daemon, so it should survive restarts)
402
+ const { active, funnelUrl } = await parseFunnelStatus(binary, port);
403
+ if (active && funnelUrl) {
404
+ console.log(`[tailscale] Funnel still active: ${funnelUrl}`);
405
+ // Ensure publicUrl is in sync
406
+ const currentPublicUrl = getSettings().publicUrl;
407
+ if (currentPublicUrl !== funnelUrl) {
408
+ updateSettings({ publicUrl: funnelUrl });
409
+ console.log(`[tailscale] Updated publicUrl to match active Funnel: ${funnelUrl}`);
410
+ }
411
+ } else {
412
+ console.log("[tailscale] Funnel no longer active, clearing persisted state");
413
+ clearPersistedState();
414
+ // Clear publicUrl if it still points to the old Funnel URL
415
+ const currentPublicUrl = getSettings().publicUrl;
416
+ if (persisted.funnelUrl && currentPublicUrl === persisted.funnelUrl) {
417
+ updateSettings({ publicUrl: "" });
418
+ }
419
+ }
420
+ }
421
+
422
+ /**
423
+ * Best-effort cleanup on server shutdown. Uses spawnSync since process.exit follows.
424
+ * By default, leaves Funnel running (it's a system daemon).
425
+ * Set HEYHANK_TAILSCALE_CLEANUP_ON_EXIT=1 to stop on shutdown.
426
+ */
427
+ export function cleanup(port: number): void {
428
+ const shouldCleanup = (process.env.HEYHANK_TAILSCALE_CLEANUP_ON_EXIT || process.env.COMPANION_TAILSCALE_CLEANUP_ON_EXIT) === "1";
429
+ if (!shouldCleanup) return;
430
+
431
+ const binary = findBinary();
432
+ if (!binary) return;
433
+
434
+ try {
435
+ spawnSync(binary, ["funnel", String(port), "off"], {
436
+ encoding: "utf-8",
437
+ timeout: 5_000,
438
+ stdio: ["pipe", "pipe", "pipe"],
439
+ });
440
+ clearPersistedState();
441
+ console.log(`[tailscale] Funnel stopped on shutdown for port ${port}`);
442
+ } catch {
443
+ // best-effort
444
+ }
445
+ }
446
+
447
+ /** Reset cached state for testing. */
448
+ export function _resetForTest(): void {
449
+ cachedBinaryPath = undefined;
450
+ binaryCacheTime = 0;
451
+ }