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,718 @@
1
+ import {
2
+ mkdirSync,
3
+ writeFileSync,
4
+ unlinkSync,
5
+ existsSync,
6
+ readFileSync,
7
+ } from "node:fs";
8
+ import { join } from "node:path";
9
+ import { homedir } from "node:os";
10
+ import { execSync } from "node:child_process";
11
+ import { DEFAULT_PORT_PROD } from "./constants.js";
12
+ import { getServicePath } from "./path-resolver.js";
13
+ import { HEYHANK_HOME } from "./paths.js";
14
+
15
+ // ─── Shared Constants ───────────────────────────────────────────────────────────
16
+
17
+ const LOG_DIR = join(HEYHANK_HOME, "logs");
18
+ const STDOUT_LOG = join(LOG_DIR, "heyhank.log");
19
+ const STDERR_LOG = join(LOG_DIR, "heyhank.error.log");
20
+
21
+ // ─── macOS (launchd) Constants ──────────────────────────────────────────────────
22
+
23
+ const LABEL = "sh.heyhank.app";
24
+ const OLD_LABEL = "sh.thecompanion.app"; // legacy label for migration
25
+ const PLIST_DIR = join(homedir(), "Library", "LaunchAgents");
26
+ const PLIST_PATH = join(PLIST_DIR, `${LABEL}.plist`);
27
+ const OLD_PLIST_PATH = join(PLIST_DIR, `${OLD_LABEL}.plist`);
28
+
29
+ // ─── Linux (systemd) Constants ──────────────────────────────────────────────────
30
+
31
+ const SYSTEMD_DIR = join(homedir(), ".config", "systemd", "user");
32
+ const UNIT_NAME = "heyhank.service";
33
+ const UNIT_PATH = join(SYSTEMD_DIR, UNIT_NAME);
34
+
35
+ // ─── Platform check ─────────────────────────────────────────────────────────────
36
+
37
+ function ensureSupportedPlatform(): void {
38
+ if (process.platform !== "darwin" && process.platform !== "linux") {
39
+ console.error(
40
+ "Service management is only supported on macOS (launchd) and Linux (systemd).",
41
+ );
42
+ process.exit(1);
43
+ }
44
+ }
45
+
46
+ function isDarwin(): boolean {
47
+ return process.platform === "darwin";
48
+ }
49
+
50
+ function isLinux(): boolean {
51
+ return process.platform === "linux";
52
+ }
53
+
54
+ // ─── Plist generation (macOS) ───────────────────────────────────────────────────
55
+
56
+ interface PlistOptions {
57
+ binPath: string;
58
+ port?: number;
59
+ path?: string;
60
+ }
61
+
62
+ export function generatePlist(opts: PlistOptions): string {
63
+ const port = opts.port ?? DEFAULT_PORT_PROD;
64
+ const home = homedir();
65
+
66
+ return `<?xml version="1.0" encoding="UTF-8"?>
67
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
68
+ <plist version="1.0">
69
+ <dict>
70
+ <key>Label</key>
71
+ <string>${LABEL}</string>
72
+
73
+ <key>ProgramArguments</key>
74
+ <array>
75
+ <string>${opts.binPath}</string>
76
+ <string>start</string>
77
+ <string>--foreground</string>
78
+ </array>
79
+
80
+ <key>WorkingDirectory</key>
81
+ <string>${home}</string>
82
+
83
+ <key>RunAtLoad</key>
84
+ <true/>
85
+
86
+ <key>KeepAlive</key>
87
+ <dict>
88
+ <key>SuccessfulExit</key>
89
+ <false/>
90
+ </dict>
91
+
92
+ <key>StandardOutPath</key>
93
+ <string>${STDOUT_LOG}</string>
94
+
95
+ <key>StandardErrorPath</key>
96
+ <string>${STDERR_LOG}</string>
97
+
98
+ <key>EnvironmentVariables</key>
99
+ <dict>
100
+ <key>NODE_ENV</key>
101
+ <string>production</string>
102
+ <key>PORT</key>
103
+ <string>${port}</string>
104
+ <key>HOME</key>
105
+ <string>${home}</string>
106
+ <key>PATH</key>
107
+ <string>${opts.path || getServicePath()}</string>
108
+ </dict>
109
+
110
+ <key>ProcessType</key>
111
+ <string>Interactive</string>
112
+
113
+ <key>ThrottleInterval</key>
114
+ <integer>5</integer>
115
+ </dict>
116
+ </plist>`;
117
+ }
118
+
119
+ // ─── Systemd unit generation (Linux) ────────────────────────────────────────────
120
+
121
+ interface UnitOptions {
122
+ binPath: string;
123
+ port?: number;
124
+ path?: string;
125
+ }
126
+
127
+ export function generateSystemdUnit(opts: UnitOptions): string {
128
+ const port = opts.port ?? DEFAULT_PORT_PROD;
129
+ const home = homedir();
130
+
131
+ return `[Unit]
132
+ Description=HeyHank - Web UI for Claude Code
133
+ After=network.target
134
+
135
+ [Service]
136
+ Type=simple
137
+ ExecStart=${opts.binPath} start --foreground
138
+ WorkingDirectory=${home}
139
+ Restart=always
140
+ RestartSec=5
141
+ SuccessExitStatus=42
142
+ StandardOutput=append:${STDOUT_LOG}
143
+ StandardError=append:${STDERR_LOG}
144
+ Environment=NODE_ENV=production
145
+ Environment=PORT=${port}
146
+ Environment=HOME=${home}
147
+ Environment=PATH=${opts.path || getServicePath()}
148
+
149
+ [Install]
150
+ WantedBy=default.target
151
+ `;
152
+ }
153
+
154
+ // ─── Binary resolution ──────────────────────────────────────────────────────────
155
+
156
+ function resolveBinPath(): string {
157
+ try {
158
+ const binPath = execSync("which heyhank", { encoding: "utf-8" }).trim();
159
+ if (binPath) return binPath;
160
+ } catch {
161
+ // not found globally
162
+ }
163
+
164
+ console.error("heyhank must be installed globally for service mode.");
165
+ console.error("");
166
+ console.error(" bun install -g heyhank");
167
+ console.error("");
168
+ console.error("Then retry:");
169
+ console.error("");
170
+ console.error(" heyhank install");
171
+ process.exit(1);
172
+ }
173
+
174
+ // ─── macOS helpers ──────────────────────────────────────────────────────────────
175
+
176
+ function unloadLaunchdService(plistPath: string): void {
177
+ try {
178
+ execSync(`launchctl unload -w "${plistPath}"`, { stdio: "pipe" });
179
+ } catch {
180
+ // Service may already be unloaded — that's fine
181
+ }
182
+ }
183
+
184
+ function removePlist(plistPath: string): void {
185
+ try {
186
+ unlinkSync(plistPath);
187
+ } catch {
188
+ // Already gone
189
+ }
190
+ }
191
+
192
+ function migrateLegacyInstallIfNeeded(): void {
193
+ if (!existsSync(OLD_PLIST_PATH)) return;
194
+
195
+ console.log("Found legacy service. Migrating...");
196
+ unloadLaunchdService(OLD_PLIST_PATH);
197
+ removePlist(OLD_PLIST_PATH);
198
+ }
199
+
200
+ function getInstalledLaunchdService():
201
+ | { label: string; plistPath: string }
202
+ | undefined {
203
+ if (existsSync(PLIST_PATH)) return { label: LABEL, plistPath: PLIST_PATH };
204
+ if (existsSync(OLD_PLIST_PATH)) {
205
+ return { label: OLD_LABEL, plistPath: OLD_PLIST_PATH };
206
+ }
207
+ return undefined;
208
+ }
209
+
210
+ // ─── Linux helpers ──────────────────────────────────────────────────────────────
211
+
212
+ function isSystemdUnitInstalled(): boolean {
213
+ return existsSync(UNIT_PATH);
214
+ }
215
+
216
+ function systemctlUser(cmd: string): string {
217
+ const uid = typeof process.getuid === "function" ? process.getuid() : 1000;
218
+ return execSync(`systemctl --user ${cmd}`, {
219
+ encoding: "utf-8",
220
+ stdio: ["pipe", "pipe", "pipe"],
221
+ env: {
222
+ ...process.env,
223
+ XDG_RUNTIME_DIR: process.env.XDG_RUNTIME_DIR || `/run/user/${uid}`,
224
+ },
225
+ });
226
+ }
227
+
228
+ // ─── Install ────────────────────────────────────────────────────────────────────
229
+
230
+ export async function install(opts?: { port?: number }): Promise<void> {
231
+ ensureSupportedPlatform();
232
+
233
+ if (isDarwin()) {
234
+ return installDarwin(opts);
235
+ }
236
+ return installLinux(opts);
237
+ }
238
+
239
+ async function installDarwin(opts?: { port?: number }): Promise<void> {
240
+ migrateLegacyInstallIfNeeded();
241
+
242
+ if (existsSync(PLIST_PATH)) {
243
+ console.error("HeyHank is already installed as a service.");
244
+ console.error("Run 'heyhank uninstall' first to reinstall.");
245
+ process.exit(1);
246
+ }
247
+
248
+ const binPath = resolveBinPath();
249
+ const port = opts?.port ?? DEFAULT_PORT_PROD;
250
+
251
+ // Create log directory
252
+ mkdirSync(LOG_DIR, { recursive: true });
253
+
254
+ // Generate and write plist (capture user's shell PATH at install time)
255
+ const path = getServicePath();
256
+ const plist = generatePlist({ binPath, port, path });
257
+ mkdirSync(PLIST_DIR, { recursive: true });
258
+ writeFileSync(PLIST_PATH, plist, "utf-8");
259
+
260
+ // Load the service
261
+ try {
262
+ execSync(`launchctl load -w "${PLIST_PATH}"`, { stdio: "pipe" });
263
+ } catch (err: unknown) {
264
+ console.error("Failed to load the service with launchctl:");
265
+ console.error(err instanceof Error ? err.message : String(err));
266
+ // Clean up the plist on failure
267
+ try { unlinkSync(PLIST_PATH); } catch { /* ok */ }
268
+ process.exit(1);
269
+ }
270
+
271
+ console.log("HeyHank has been installed as a background service.");
272
+ console.log("");
273
+ console.log(` URL: http://localhost:${port}`);
274
+ console.log(` Logs: ${LOG_DIR}`);
275
+ console.log(` Plist: ${PLIST_PATH}`);
276
+ console.log("");
277
+ console.log("The service will start automatically on login.");
278
+ console.log("Use 'heyhank status' to check if it's running.");
279
+ }
280
+
281
+ async function installLinux(opts?: { port?: number }): Promise<void> {
282
+ if (isSystemdUnitInstalled()) {
283
+ console.error("HeyHank is already installed as a service.");
284
+ console.error("Run 'heyhank uninstall' first to reinstall.");
285
+ process.exit(1);
286
+ }
287
+
288
+ const binPath = resolveBinPath();
289
+ const port = opts?.port ?? DEFAULT_PORT_PROD;
290
+
291
+ // Create log directory
292
+ mkdirSync(LOG_DIR, { recursive: true });
293
+
294
+ // Generate and write systemd unit (capture user's shell PATH at install time)
295
+ const path = getServicePath();
296
+ const unit = generateSystemdUnit({ binPath, port, path });
297
+ mkdirSync(SYSTEMD_DIR, { recursive: true });
298
+ writeFileSync(UNIT_PATH, unit, "utf-8");
299
+
300
+ // Reload systemd and enable + start the service
301
+ try {
302
+ systemctlUser("daemon-reload");
303
+ systemctlUser(`enable --now ${UNIT_NAME}`);
304
+ } catch (err: unknown) {
305
+ console.error("Failed to enable the service with systemctl:");
306
+ console.error(err instanceof Error ? err.message : String(err));
307
+ // Clean up the unit file on failure
308
+ try { unlinkSync(UNIT_PATH); } catch { /* ok */ }
309
+ process.exit(1);
310
+ }
311
+
312
+ // Enable linger so user services survive logout
313
+ try {
314
+ execSync("loginctl enable-linger", { stdio: ["pipe", "pipe", "pipe"] });
315
+ } catch {
316
+ console.warn(
317
+ "Warning: Could not enable linger. The service may stop when you log out.",
318
+ );
319
+ console.warn(" sudo loginctl enable-linger $(whoami)");
320
+ }
321
+
322
+ console.log("HeyHank has been installed as a background service.");
323
+ console.log("");
324
+ console.log(` URL: http://localhost:${port}`);
325
+ console.log(` Logs: ${LOG_DIR}`);
326
+ console.log(` Unit: ${UNIT_PATH}`);
327
+ console.log("");
328
+ console.log("The service will start automatically on login.");
329
+ console.log("Use 'heyhank status' to check if it's running.");
330
+ }
331
+
332
+ // ─── Uninstall ──────────────────────────────────────────────────────────────────
333
+
334
+ export async function uninstall(): Promise<void> {
335
+ ensureSupportedPlatform();
336
+
337
+ if (isDarwin()) {
338
+ return uninstallDarwin();
339
+ }
340
+ return uninstallLinux();
341
+ }
342
+
343
+ async function uninstallDarwin(): Promise<void> {
344
+ const installedService = getInstalledLaunchdService();
345
+ if (!installedService) {
346
+ console.log("HeyHank is not installed as a service.");
347
+ return;
348
+ }
349
+
350
+ unloadLaunchdService(installedService.plistPath);
351
+ removePlist(installedService.plistPath);
352
+
353
+ console.log("HeyHank service has been removed.");
354
+ console.log(`Logs are preserved at ${LOG_DIR}`);
355
+ }
356
+
357
+ async function uninstallLinux(): Promise<void> {
358
+ if (!isSystemdUnitInstalled()) {
359
+ console.log("HeyHank is not installed as a service.");
360
+ return;
361
+ }
362
+
363
+ try {
364
+ systemctlUser(`disable --now ${UNIT_NAME}`);
365
+ } catch {
366
+ // Service may already be stopped — that's fine
367
+ }
368
+
369
+ try {
370
+ unlinkSync(UNIT_PATH);
371
+ } catch {
372
+ // Already gone
373
+ }
374
+
375
+ try {
376
+ systemctlUser("daemon-reload");
377
+ } catch {
378
+ // Best-effort reload
379
+ }
380
+
381
+ console.log("HeyHank service has been removed.");
382
+ console.log(`Logs are preserved at ${LOG_DIR}`);
383
+ }
384
+
385
+ // ─── Stop / Restart ────────────────────────────────────────────────────────────
386
+
387
+ export async function start(): Promise<void> {
388
+ ensureSupportedPlatform();
389
+
390
+ if (isDarwin()) {
391
+ return startDarwin();
392
+ }
393
+ return startLinux();
394
+ }
395
+
396
+ async function startDarwin(): Promise<void> {
397
+ const installedService = getInstalledLaunchdService();
398
+ if (!installedService) {
399
+ console.log("HeyHank is not installed as a service.");
400
+ console.log("Run 'heyhank install' first.");
401
+ return;
402
+ }
403
+
404
+ const uid = typeof process.getuid === "function" ? process.getuid() : undefined;
405
+ const domain = uid !== undefined ? `gui/${uid}` : "gui";
406
+ const domainTarget = uid !== undefined
407
+ ? `gui/${uid}/${installedService.label}`
408
+ : installedService.label;
409
+
410
+ try {
411
+ execSync(`launchctl kickstart -k "${domainTarget}"`, { stdio: "pipe" });
412
+ } catch {
413
+ try {
414
+ execSync(`launchctl bootstrap "${domain}" "${installedService.plistPath}"`, { stdio: "pipe" });
415
+ } catch {
416
+ try {
417
+ execSync(`launchctl load -w "${installedService.plistPath}"`, { stdio: "pipe" });
418
+ } catch (err: unknown) {
419
+ console.error("Failed to start the service with launchctl:");
420
+ console.error(err instanceof Error ? err.message : String(err));
421
+ process.exit(1);
422
+ }
423
+ }
424
+ }
425
+
426
+ console.log("HeyHank service has been started.");
427
+ }
428
+
429
+ async function startLinux(): Promise<void> {
430
+ if (!isSystemdUnitInstalled()) {
431
+ console.log("Service not yet installed. Installing now...");
432
+ await installLinux();
433
+ return; // installLinux uses enable --now which starts the service
434
+ }
435
+
436
+ // Ensure the installed unit file matches the latest template (e.g.
437
+ // SuccessExitStatus=42, Restart=always) so that stale definitions from
438
+ // older versions don't cause restart loops after an auto-update.
439
+ refreshServiceDefinition();
440
+
441
+ try {
442
+ systemctlUser(`start ${UNIT_NAME}`);
443
+ } catch (err: unknown) {
444
+ console.error("Failed to start the service with systemctl:");
445
+ console.error(err instanceof Error ? err.message : String(err));
446
+ process.exit(1);
447
+ }
448
+
449
+ console.log("HeyHank service has been started.");
450
+ }
451
+
452
+ export async function stop(): Promise<void> {
453
+ ensureSupportedPlatform();
454
+
455
+ if (isDarwin()) {
456
+ return stopDarwin();
457
+ }
458
+ return stopLinux();
459
+ }
460
+
461
+ async function stopDarwin(): Promise<void> {
462
+ const installedService = getInstalledLaunchdService();
463
+ if (!installedService) {
464
+ console.log("HeyHank is not installed as a service.");
465
+ return;
466
+ }
467
+
468
+ try {
469
+ const uid = typeof process.getuid === "function" ? process.getuid() : undefined;
470
+ const domainTarget = uid !== undefined
471
+ ? `gui/${uid}/${installedService.label}`
472
+ : installedService.label;
473
+ // `stop` is not enough with KeepAlive=true: launchd can immediately restart it.
474
+ // Booting out unloads the job from launchd while keeping the plist installed.
475
+ execSync(`launchctl bootout "${domainTarget}"`, { stdio: "pipe" });
476
+ } catch {
477
+ // Fallback for environments where bootout/domain targeting is unavailable.
478
+ unloadLaunchdService(installedService.plistPath);
479
+ }
480
+
481
+ console.log("HeyHank service has been stopped.");
482
+ console.log("Run 'heyhank restart' to start it again.");
483
+ }
484
+
485
+ async function stopLinux(): Promise<void> {
486
+ if (!isSystemdUnitInstalled()) {
487
+ console.log("HeyHank is not installed as a service.");
488
+ return;
489
+ }
490
+
491
+ try {
492
+ systemctlUser(`stop ${UNIT_NAME}`);
493
+ } catch (err: unknown) {
494
+ console.error("Failed to stop the service with systemctl:");
495
+ console.error(err instanceof Error ? err.message : String(err));
496
+ process.exit(1);
497
+ }
498
+
499
+ console.log("HeyHank service has been stopped.");
500
+ console.log("Run 'heyhank restart' to start it again.");
501
+ }
502
+
503
+ export async function restart(): Promise<void> {
504
+ ensureSupportedPlatform();
505
+
506
+ if (isDarwin()) {
507
+ return restartDarwin();
508
+ }
509
+ return restartLinux();
510
+ }
511
+
512
+ async function restartDarwin(): Promise<void> {
513
+ const installedService = getInstalledLaunchdService();
514
+ if (!installedService) {
515
+ console.log("HeyHank is not installed as a service.");
516
+ return;
517
+ }
518
+
519
+ const uid = typeof process.getuid === "function" ? process.getuid() : undefined;
520
+ const domainTarget = uid !== undefined
521
+ ? `gui/${uid}/${installedService.label}`
522
+ : installedService.label;
523
+
524
+ try {
525
+ execSync(`launchctl kickstart -k "${domainTarget}"`, { stdio: "pipe" });
526
+ } catch {
527
+ // Fallback for environments where kickstart/domain targeting is unavailable.
528
+ unloadLaunchdService(installedService.plistPath);
529
+ try {
530
+ execSync(`launchctl load -w "${installedService.plistPath}"`, { stdio: "pipe" });
531
+ } catch (err: unknown) {
532
+ console.error("Failed to restart the service with launchctl:");
533
+ console.error(err instanceof Error ? err.message : String(err));
534
+ process.exit(1);
535
+ }
536
+ }
537
+
538
+ console.log("HeyHank service has been restarted.");
539
+ }
540
+
541
+ async function restartLinux(): Promise<void> {
542
+ if (!isSystemdUnitInstalled()) {
543
+ console.log("HeyHank is not installed as a service.");
544
+ return;
545
+ }
546
+
547
+ // Keep the unit file in sync with the latest template before restarting.
548
+ refreshServiceDefinition();
549
+
550
+ try {
551
+ systemctlUser(`restart ${UNIT_NAME}`);
552
+ } catch (err: unknown) {
553
+ console.error("Failed to restart the service with systemctl:");
554
+ console.error(err instanceof Error ? err.message : String(err));
555
+ process.exit(1);
556
+ }
557
+
558
+ console.log("HeyHank service has been restarted.");
559
+ }
560
+
561
+ // ─── Status ─────────────────────────────────────────────────────────────────────
562
+
563
+ export interface ServiceStatus {
564
+ installed: boolean;
565
+ running: boolean;
566
+ pid?: number;
567
+ port?: number;
568
+ }
569
+
570
+ /**
571
+ * Safe check for whether the current process is running as a managed service.
572
+ * Unlike status(), this never calls process.exit() and works on all platforms.
573
+ */
574
+ export function isRunningAsService(): boolean {
575
+ if (isDarwin()) {
576
+ const installedService = getInstalledLaunchdService();
577
+ if (!installedService) return false;
578
+ try {
579
+ const output = execSync(`launchctl list "${installedService.label}"`, {
580
+ encoding: "utf-8",
581
+ stdio: ["pipe", "pipe", "pipe"],
582
+ });
583
+ return /"PID"\s*=\s*\d+/.test(output);
584
+ } catch {
585
+ return false;
586
+ }
587
+ }
588
+
589
+ if (isLinux()) {
590
+ if (!isSystemdUnitInstalled()) return false;
591
+ try {
592
+ const output = systemctlUser(`is-active ${UNIT_NAME}`);
593
+ return output.trim() === "active";
594
+ } catch {
595
+ return false;
596
+ }
597
+ }
598
+
599
+ return false;
600
+ }
601
+
602
+ /**
603
+ * Re-write the service definition (plist or systemd unit) using the current
604
+ * binary path and the latest template, preserving the user's custom port.
605
+ * On Linux this also calls daemon-reload so systemd picks up the changes.
606
+ */
607
+ export function refreshServiceDefinition(): void {
608
+ if (isDarwin()) {
609
+ const installedService = getInstalledLaunchdService();
610
+ if (!installedService) return;
611
+
612
+ let port = DEFAULT_PORT_PROD;
613
+ try {
614
+ const content = readFileSync(installedService.plistPath, "utf-8");
615
+ const portMatch = content.match(/<key>PORT<\/key>\s*<string>(\d+)<\/string>/);
616
+ if (portMatch) port = Number(portMatch[1]);
617
+ } catch { /* use default */ }
618
+
619
+ const binPath = resolveBinPath();
620
+ const path = getServicePath();
621
+ const plist = generatePlist({ binPath, port, path });
622
+ writeFileSync(installedService.plistPath, plist, "utf-8");
623
+ } else if (isLinux()) {
624
+ if (!isSystemdUnitInstalled()) return;
625
+
626
+ let port = DEFAULT_PORT_PROD;
627
+ try {
628
+ const content = readFileSync(UNIT_PATH, "utf-8");
629
+ const portMatch = content.match(/Environment=PORT=(\d+)/);
630
+ if (portMatch) port = Number(portMatch[1]);
631
+ } catch { /* use default */ }
632
+
633
+ const binPath = resolveBinPath();
634
+ const path = getServicePath();
635
+ const unit = generateSystemdUnit({ binPath, port, path });
636
+ writeFileSync(UNIT_PATH, unit, "utf-8");
637
+
638
+ try {
639
+ systemctlUser("daemon-reload");
640
+ } catch { /* best effort */ }
641
+ }
642
+ }
643
+
644
+ export async function status(): Promise<ServiceStatus> {
645
+ ensureSupportedPlatform();
646
+
647
+ if (isDarwin()) {
648
+ return statusDarwin();
649
+ }
650
+ return statusLinux();
651
+ }
652
+
653
+ async function statusDarwin(): Promise<ServiceStatus> {
654
+ const installedService = getInstalledLaunchdService();
655
+ if (!installedService) {
656
+ return { installed: false, running: false };
657
+ }
658
+
659
+ // Read port from the plist
660
+ let port = DEFAULT_PORT_PROD;
661
+ try {
662
+ const plistContent = readFileSync(installedService.plistPath, "utf-8");
663
+ const portMatch = plistContent.match(/<key>PORT<\/key>\s*<string>(\d+)<\/string>/);
664
+ if (portMatch) port = Number(portMatch[1]);
665
+ } catch { /* use default */ }
666
+
667
+ // Check if service is running via launchctl
668
+ try {
669
+ const output = execSync(`launchctl list "${installedService.label}"`, {
670
+ encoding: "utf-8",
671
+ stdio: ["pipe", "pipe", "pipe"],
672
+ });
673
+
674
+ // Parse PID from the launchctl list output
675
+ const pidMatch = output.match(/"PID"\s*=\s*(\d+)/);
676
+ if (pidMatch) {
677
+ return { installed: true, running: true, pid: Number(pidMatch[1]), port };
678
+ }
679
+
680
+ // Service is loaded but not running (no PID)
681
+ return { installed: true, running: false, port };
682
+ } catch {
683
+ // launchctl list fails if service is not loaded
684
+ return { installed: true, running: false, port };
685
+ }
686
+ }
687
+
688
+ async function statusLinux(): Promise<ServiceStatus> {
689
+ if (!isSystemdUnitInstalled()) {
690
+ return { installed: false, running: false };
691
+ }
692
+
693
+ // Read port from the unit file
694
+ let port = DEFAULT_PORT_PROD;
695
+ try {
696
+ const unitContent = readFileSync(UNIT_PATH, "utf-8");
697
+ const portMatch = unitContent.match(/Environment=PORT=(\d+)/);
698
+ if (portMatch) port = Number(portMatch[1]);
699
+ } catch { /* use default */ }
700
+
701
+ // Check if service is running via systemctl
702
+ try {
703
+ const output = systemctlUser(`show ${UNIT_NAME} --property=ActiveState,MainPID --no-pager`);
704
+ const activeMatch = output.match(/ActiveState=(\w+)/);
705
+ const pidMatch = output.match(/MainPID=(\d+)/);
706
+
707
+ const isActive = activeMatch?.[1] === "active";
708
+ const pid = pidMatch ? Number(pidMatch[1]) : undefined;
709
+
710
+ if (isActive && pid && pid > 0) {
711
+ return { installed: true, running: true, pid, port };
712
+ }
713
+
714
+ return { installed: true, running: false, port };
715
+ } catch {
716
+ return { installed: true, running: false, port };
717
+ }
718
+ }