gitspace 0.2.0-rc.1

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 (318) hide show
  1. package/.claude/settings.local.json +21 -0
  2. package/.gitspace/bundle.json +50 -0
  3. package/.gitspace/select/01-status.sh +40 -0
  4. package/.gitspace/setup/01-install-deps.sh +12 -0
  5. package/.gitspace/setup/02-typecheck.sh +16 -0
  6. package/AGENTS.md +439 -0
  7. package/CLAUDE.md +1 -0
  8. package/LICENSE +25 -0
  9. package/README.md +607 -0
  10. package/bin/gssh +62 -0
  11. package/bun.lock +647 -0
  12. package/docs/CONNECTION.md +623 -0
  13. package/docs/GATEWAY-WORKER.md +319 -0
  14. package/docs/GETTING-STARTED.md +448 -0
  15. package/docs/GITSPACE-PLATFORM.md +1819 -0
  16. package/docs/INFRASTRUCTURE.md +1347 -0
  17. package/docs/PROTOCOL.md +619 -0
  18. package/docs/QUICKSTART.md +174 -0
  19. package/docs/RELAY.md +327 -0
  20. package/docs/REMOTE-DESIGN.md +549 -0
  21. package/docs/ROADMAP.md +564 -0
  22. package/docs/SITE_DOCS_FIGMA_MAKE.md +1167 -0
  23. package/docs/STACK-DESIGN.md +588 -0
  24. package/docs/UNIFIED_ARCHITECTURE.md +292 -0
  25. package/experiments/pty-benchmark.ts +148 -0
  26. package/experiments/pty-latency.ts +100 -0
  27. package/experiments/router/client.ts +199 -0
  28. package/experiments/router/protocol.ts +74 -0
  29. package/experiments/router/router.ts +217 -0
  30. package/experiments/router/session.ts +180 -0
  31. package/experiments/router/test.ts +133 -0
  32. package/experiments/socket-bandwidth.ts +77 -0
  33. package/homebrew/gitspace.rb +45 -0
  34. package/landing-page/ATTRIBUTIONS.md +3 -0
  35. package/landing-page/README.md +11 -0
  36. package/landing-page/bun.lock +801 -0
  37. package/landing-page/guidelines/Guidelines.md +61 -0
  38. package/landing-page/index.html +37 -0
  39. package/landing-page/package.json +90 -0
  40. package/landing-page/postcss.config.mjs +15 -0
  41. package/landing-page/public/_redirects +1 -0
  42. package/landing-page/public/favicon.png +0 -0
  43. package/landing-page/src/app/App.tsx +53 -0
  44. package/landing-page/src/app/components/figma/ImageWithFallback.tsx +27 -0
  45. package/landing-page/src/app/components/ui/accordion.tsx +66 -0
  46. package/landing-page/src/app/components/ui/alert-dialog.tsx +157 -0
  47. package/landing-page/src/app/components/ui/alert.tsx +66 -0
  48. package/landing-page/src/app/components/ui/aspect-ratio.tsx +11 -0
  49. package/landing-page/src/app/components/ui/avatar.tsx +53 -0
  50. package/landing-page/src/app/components/ui/badge.tsx +46 -0
  51. package/landing-page/src/app/components/ui/breadcrumb.tsx +109 -0
  52. package/landing-page/src/app/components/ui/button.tsx +57 -0
  53. package/landing-page/src/app/components/ui/calendar.tsx +75 -0
  54. package/landing-page/src/app/components/ui/card.tsx +92 -0
  55. package/landing-page/src/app/components/ui/carousel.tsx +241 -0
  56. package/landing-page/src/app/components/ui/chart.tsx +353 -0
  57. package/landing-page/src/app/components/ui/checkbox.tsx +32 -0
  58. package/landing-page/src/app/components/ui/collapsible.tsx +33 -0
  59. package/landing-page/src/app/components/ui/command.tsx +177 -0
  60. package/landing-page/src/app/components/ui/context-menu.tsx +252 -0
  61. package/landing-page/src/app/components/ui/dialog.tsx +135 -0
  62. package/landing-page/src/app/components/ui/drawer.tsx +132 -0
  63. package/landing-page/src/app/components/ui/dropdown-menu.tsx +257 -0
  64. package/landing-page/src/app/components/ui/form.tsx +168 -0
  65. package/landing-page/src/app/components/ui/hover-card.tsx +44 -0
  66. package/landing-page/src/app/components/ui/input-otp.tsx +77 -0
  67. package/landing-page/src/app/components/ui/input.tsx +21 -0
  68. package/landing-page/src/app/components/ui/label.tsx +24 -0
  69. package/landing-page/src/app/components/ui/menubar.tsx +276 -0
  70. package/landing-page/src/app/components/ui/navigation-menu.tsx +168 -0
  71. package/landing-page/src/app/components/ui/pagination.tsx +127 -0
  72. package/landing-page/src/app/components/ui/popover.tsx +48 -0
  73. package/landing-page/src/app/components/ui/progress.tsx +31 -0
  74. package/landing-page/src/app/components/ui/radio-group.tsx +45 -0
  75. package/landing-page/src/app/components/ui/resizable.tsx +56 -0
  76. package/landing-page/src/app/components/ui/scroll-area.tsx +58 -0
  77. package/landing-page/src/app/components/ui/select.tsx +189 -0
  78. package/landing-page/src/app/components/ui/separator.tsx +28 -0
  79. package/landing-page/src/app/components/ui/sheet.tsx +139 -0
  80. package/landing-page/src/app/components/ui/sidebar.tsx +726 -0
  81. package/landing-page/src/app/components/ui/skeleton.tsx +13 -0
  82. package/landing-page/src/app/components/ui/slider.tsx +63 -0
  83. package/landing-page/src/app/components/ui/sonner.tsx +25 -0
  84. package/landing-page/src/app/components/ui/switch.tsx +31 -0
  85. package/landing-page/src/app/components/ui/table.tsx +116 -0
  86. package/landing-page/src/app/components/ui/tabs.tsx +66 -0
  87. package/landing-page/src/app/components/ui/textarea.tsx +18 -0
  88. package/landing-page/src/app/components/ui/toggle-group.tsx +73 -0
  89. package/landing-page/src/app/components/ui/toggle.tsx +47 -0
  90. package/landing-page/src/app/components/ui/tooltip.tsx +61 -0
  91. package/landing-page/src/app/components/ui/use-mobile.ts +21 -0
  92. package/landing-page/src/app/components/ui/utils.ts +6 -0
  93. package/landing-page/src/components/docs/DocsContent.tsx +718 -0
  94. package/landing-page/src/components/docs/DocsSidebar.tsx +84 -0
  95. package/landing-page/src/components/landing/CTA.tsx +59 -0
  96. package/landing-page/src/components/landing/Comparison.tsx +84 -0
  97. package/landing-page/src/components/landing/FaultyTerminal.tsx +424 -0
  98. package/landing-page/src/components/landing/Features.tsx +201 -0
  99. package/landing-page/src/components/landing/Hero.tsx +142 -0
  100. package/landing-page/src/components/landing/Pricing.tsx +140 -0
  101. package/landing-page/src/components/landing/Roadmap.tsx +86 -0
  102. package/landing-page/src/components/landing/Security.tsx +81 -0
  103. package/landing-page/src/components/landing/TerminalWindow.tsx +27 -0
  104. package/landing-page/src/components/landing/UseCases.tsx +55 -0
  105. package/landing-page/src/components/landing/Workflow.tsx +101 -0
  106. package/landing-page/src/components/layout/DashboardNavbar.tsx +37 -0
  107. package/landing-page/src/components/layout/Footer.tsx +55 -0
  108. package/landing-page/src/components/layout/LandingNavbar.tsx +82 -0
  109. package/landing-page/src/components/ui/badge.tsx +39 -0
  110. package/landing-page/src/components/ui/breadcrumb.tsx +115 -0
  111. package/landing-page/src/components/ui/button.tsx +57 -0
  112. package/landing-page/src/components/ui/card.tsx +79 -0
  113. package/landing-page/src/components/ui/mock-terminal.tsx +68 -0
  114. package/landing-page/src/components/ui/separator.tsx +28 -0
  115. package/landing-page/src/lib/utils.ts +6 -0
  116. package/landing-page/src/main.tsx +10 -0
  117. package/landing-page/src/pages/Dashboard.tsx +133 -0
  118. package/landing-page/src/pages/DocsPage.tsx +79 -0
  119. package/landing-page/src/pages/LandingPage.tsx +31 -0
  120. package/landing-page/src/pages/TerminalView.tsx +106 -0
  121. package/landing-page/src/styles/fonts.css +0 -0
  122. package/landing-page/src/styles/index.css +3 -0
  123. package/landing-page/src/styles/tailwind.css +4 -0
  124. package/landing-page/src/styles/theme.css +181 -0
  125. package/landing-page/vite.config.ts +19 -0
  126. package/npm/darwin-arm64/bin/gssh +0 -0
  127. package/npm/darwin-arm64/package.json +20 -0
  128. package/package.json +74 -0
  129. package/scripts/build.ts +284 -0
  130. package/scripts/release.ts +140 -0
  131. package/src/__tests__/test-utils.ts +298 -0
  132. package/src/commands/__tests__/serve-messages.test.ts +190 -0
  133. package/src/commands/access.ts +298 -0
  134. package/src/commands/add.ts +452 -0
  135. package/src/commands/auth.ts +364 -0
  136. package/src/commands/connect.ts +287 -0
  137. package/src/commands/directory.ts +16 -0
  138. package/src/commands/host.ts +396 -0
  139. package/src/commands/identity.ts +184 -0
  140. package/src/commands/list.ts +200 -0
  141. package/src/commands/relay.ts +315 -0
  142. package/src/commands/remove.ts +241 -0
  143. package/src/commands/serve.ts +1493 -0
  144. package/src/commands/share.ts +456 -0
  145. package/src/commands/status.ts +125 -0
  146. package/src/commands/switch.ts +353 -0
  147. package/src/commands/tmux.ts +317 -0
  148. package/src/core/__tests__/access.test.ts +240 -0
  149. package/src/core/access.ts +277 -0
  150. package/src/core/bundle.ts +342 -0
  151. package/src/core/config.ts +510 -0
  152. package/src/core/git.ts +317 -0
  153. package/src/core/github.ts +151 -0
  154. package/src/core/identity.ts +631 -0
  155. package/src/core/linear.ts +225 -0
  156. package/src/core/shell.ts +161 -0
  157. package/src/core/trusted-relays.ts +315 -0
  158. package/src/index.ts +821 -0
  159. package/src/lib/remote-session/index.ts +7 -0
  160. package/src/lib/remote-session/protocol.ts +267 -0
  161. package/src/lib/remote-session/session-handler.ts +581 -0
  162. package/src/lib/remote-session/workspace-scanner.ts +167 -0
  163. package/src/lib/tmux-lite/README.md +81 -0
  164. package/src/lib/tmux-lite/cli.ts +796 -0
  165. package/src/lib/tmux-lite/crypto/__tests__/helpers/handshake-runner.ts +349 -0
  166. package/src/lib/tmux-lite/crypto/__tests__/helpers/mock-relay.ts +291 -0
  167. package/src/lib/tmux-lite/crypto/__tests__/helpers/test-identities.ts +142 -0
  168. package/src/lib/tmux-lite/crypto/__tests__/integration/authorization.integration.test.ts +339 -0
  169. package/src/lib/tmux-lite/crypto/__tests__/integration/e2e-communication.integration.test.ts +477 -0
  170. package/src/lib/tmux-lite/crypto/__tests__/integration/error-handling.integration.test.ts +499 -0
  171. package/src/lib/tmux-lite/crypto/__tests__/integration/handshake.integration.test.ts +371 -0
  172. package/src/lib/tmux-lite/crypto/__tests__/integration/security.integration.test.ts +573 -0
  173. package/src/lib/tmux-lite/crypto/access-control.test.ts +512 -0
  174. package/src/lib/tmux-lite/crypto/access-control.ts +320 -0
  175. package/src/lib/tmux-lite/crypto/frames.test.ts +262 -0
  176. package/src/lib/tmux-lite/crypto/frames.ts +141 -0
  177. package/src/lib/tmux-lite/crypto/handshake.ts +894 -0
  178. package/src/lib/tmux-lite/crypto/identity.test.ts +220 -0
  179. package/src/lib/tmux-lite/crypto/identity.ts +286 -0
  180. package/src/lib/tmux-lite/crypto/index.ts +51 -0
  181. package/src/lib/tmux-lite/crypto/invites.test.ts +381 -0
  182. package/src/lib/tmux-lite/crypto/invites.ts +215 -0
  183. package/src/lib/tmux-lite/crypto/keyexchange.ts +435 -0
  184. package/src/lib/tmux-lite/crypto/keys.test.ts +58 -0
  185. package/src/lib/tmux-lite/crypto/keys.ts +47 -0
  186. package/src/lib/tmux-lite/crypto/secretbox.test.ts +169 -0
  187. package/src/lib/tmux-lite/crypto/secretbox.ts +124 -0
  188. package/src/lib/tmux-lite/handshake-handler.ts +451 -0
  189. package/src/lib/tmux-lite/protocol.test.ts +307 -0
  190. package/src/lib/tmux-lite/protocol.ts +266 -0
  191. package/src/lib/tmux-lite/relay-client.ts +506 -0
  192. package/src/lib/tmux-lite/server.ts +1250 -0
  193. package/src/lib/tmux-lite/shell-integration.sh +37 -0
  194. package/src/lib/tmux-lite/terminal-queries.test.ts +54 -0
  195. package/src/lib/tmux-lite/terminal-queries.ts +49 -0
  196. package/src/relay/__tests__/e2e-flow.test.ts +1284 -0
  197. package/src/relay/__tests__/helpers/auth.ts +354 -0
  198. package/src/relay/__tests__/helpers/ports.ts +51 -0
  199. package/src/relay/__tests__/protocol-validation.test.ts +265 -0
  200. package/src/relay/authorization.ts +303 -0
  201. package/src/relay/embedded-assets.generated.d.ts +15 -0
  202. package/src/relay/identity.ts +352 -0
  203. package/src/relay/index.ts +57 -0
  204. package/src/relay/pipes.test.ts +427 -0
  205. package/src/relay/pipes.ts +195 -0
  206. package/src/relay/protocol.ts +804 -0
  207. package/src/relay/registries.test.ts +437 -0
  208. package/src/relay/registries.ts +593 -0
  209. package/src/relay/server.test.ts +1323 -0
  210. package/src/relay/server.ts +1092 -0
  211. package/src/relay/signing.ts +238 -0
  212. package/src/relay/types.ts +69 -0
  213. package/src/serve/client-session-manager.ts +622 -0
  214. package/src/serve/daemon.ts +497 -0
  215. package/src/serve/pty-session.ts +236 -0
  216. package/src/serve/types.ts +169 -0
  217. package/src/shared/components/Flow.tsx +453 -0
  218. package/src/shared/components/Flow.tui.tsx +343 -0
  219. package/src/shared/components/Flow.web.tsx +442 -0
  220. package/src/shared/components/Inbox.tsx +446 -0
  221. package/src/shared/components/Inbox.tui.tsx +262 -0
  222. package/src/shared/components/Inbox.web.tsx +329 -0
  223. package/src/shared/components/MachineList.tsx +187 -0
  224. package/src/shared/components/MachineList.tui.tsx +161 -0
  225. package/src/shared/components/MachineList.web.tsx +210 -0
  226. package/src/shared/components/ProjectList.tsx +176 -0
  227. package/src/shared/components/ProjectList.tui.tsx +109 -0
  228. package/src/shared/components/ProjectList.web.tsx +143 -0
  229. package/src/shared/components/SpacesBrowser.tsx +332 -0
  230. package/src/shared/components/SpacesBrowser.tui.tsx +163 -0
  231. package/src/shared/components/SpacesBrowser.web.tsx +221 -0
  232. package/src/shared/components/index.ts +103 -0
  233. package/src/shared/hooks/index.ts +16 -0
  234. package/src/shared/hooks/useNavigation.ts +226 -0
  235. package/src/shared/index.ts +122 -0
  236. package/src/shared/providers/LocalMachineProvider.ts +425 -0
  237. package/src/shared/providers/MachineProvider.ts +165 -0
  238. package/src/shared/providers/RemoteMachineProvider.ts +444 -0
  239. package/src/shared/providers/index.ts +26 -0
  240. package/src/shared/types.ts +145 -0
  241. package/src/tui/adapters.ts +120 -0
  242. package/src/tui/app.tsx +1816 -0
  243. package/src/tui/components/Terminal.tsx +580 -0
  244. package/src/tui/hooks/index.ts +35 -0
  245. package/src/tui/hooks/useAppState.ts +314 -0
  246. package/src/tui/hooks/useDaemonStatus.ts +174 -0
  247. package/src/tui/hooks/useInboxTUI.ts +113 -0
  248. package/src/tui/hooks/useRemoteMachines.ts +209 -0
  249. package/src/tui/index.ts +24 -0
  250. package/src/tui/state.ts +299 -0
  251. package/src/tui/terminal-bracketed-paste.test.ts +45 -0
  252. package/src/tui/terminal-bracketed-paste.ts +47 -0
  253. package/src/types/bundle.ts +112 -0
  254. package/src/types/config.ts +89 -0
  255. package/src/types/errors.ts +206 -0
  256. package/src/types/identity.ts +284 -0
  257. package/src/types/workspace-fuzzy.ts +49 -0
  258. package/src/types/workspace.ts +151 -0
  259. package/src/utils/bun-socket-writer.ts +80 -0
  260. package/src/utils/deps.ts +127 -0
  261. package/src/utils/fuzzy-match.ts +125 -0
  262. package/src/utils/logger.ts +127 -0
  263. package/src/utils/markdown.ts +254 -0
  264. package/src/utils/onboarding.ts +229 -0
  265. package/src/utils/prompts.ts +114 -0
  266. package/src/utils/run-commands.ts +112 -0
  267. package/src/utils/run-scripts.ts +142 -0
  268. package/src/utils/sanitize.ts +98 -0
  269. package/src/utils/secrets.ts +122 -0
  270. package/src/utils/shell-escape.ts +40 -0
  271. package/src/utils/utf8.ts +79 -0
  272. package/src/utils/workspace-state.ts +47 -0
  273. package/src/web/README.md +73 -0
  274. package/src/web/bun.lock +575 -0
  275. package/src/web/eslint.config.js +23 -0
  276. package/src/web/index.html +16 -0
  277. package/src/web/package.json +37 -0
  278. package/src/web/public/vite.svg +1 -0
  279. package/src/web/src/App.tsx +604 -0
  280. package/src/web/src/assets/react.svg +1 -0
  281. package/src/web/src/components/Terminal.tsx +207 -0
  282. package/src/web/src/hooks/useRelayConnection.ts +224 -0
  283. package/src/web/src/hooks/useTerminal.ts +699 -0
  284. package/src/web/src/index.css +55 -0
  285. package/src/web/src/lib/crypto/__tests__/web-terminal.test.ts +1158 -0
  286. package/src/web/src/lib/crypto/frames.ts +205 -0
  287. package/src/web/src/lib/crypto/handshake.ts +396 -0
  288. package/src/web/src/lib/crypto/identity.ts +128 -0
  289. package/src/web/src/lib/crypto/keyexchange.ts +246 -0
  290. package/src/web/src/lib/crypto/relay-signing.ts +53 -0
  291. package/src/web/src/lib/invite.ts +58 -0
  292. package/src/web/src/lib/storage/identity-store.ts +94 -0
  293. package/src/web/src/main.tsx +10 -0
  294. package/src/web/src/types/identity.ts +45 -0
  295. package/src/web/tsconfig.app.json +28 -0
  296. package/src/web/tsconfig.json +7 -0
  297. package/src/web/tsconfig.node.json +26 -0
  298. package/src/web/vite.config.ts +31 -0
  299. package/todo-security.md +92 -0
  300. package/tsconfig.json +23 -0
  301. package/worker/.wrangler/state/v3/d1/miniflare-D1DatabaseObject/12b7107e435bf1b9a8713a7f320472a63e543104d633d89a26f8d21f4e4ef182.sqlite +0 -0
  302. package/worker/.wrangler/state/v3/d1/miniflare-D1DatabaseObject/12b7107e435bf1b9a8713a7f320472a63e543104d633d89a26f8d21f4e4ef182.sqlite-shm +0 -0
  303. package/worker/.wrangler/state/v3/d1/miniflare-D1DatabaseObject/12b7107e435bf1b9a8713a7f320472a63e543104d633d89a26f8d21f4e4ef182.sqlite-wal +0 -0
  304. package/worker/.wrangler/state/v3/d1/miniflare-D1DatabaseObject/1a1ac3db1ab86ecf712f90322868a9aabc2c7dc9fe2dfbe94f9b075096276b0f.sqlite +0 -0
  305. package/worker/.wrangler/state/v3/d1/miniflare-D1DatabaseObject/1a1ac3db1ab86ecf712f90322868a9aabc2c7dc9fe2dfbe94f9b075096276b0f.sqlite-shm +0 -0
  306. package/worker/.wrangler/state/v3/d1/miniflare-D1DatabaseObject/1a1ac3db1ab86ecf712f90322868a9aabc2c7dc9fe2dfbe94f9b075096276b0f.sqlite-wal +0 -0
  307. package/worker/bun.lock +237 -0
  308. package/worker/package.json +22 -0
  309. package/worker/schema.sql +96 -0
  310. package/worker/src/handlers/auth.ts +451 -0
  311. package/worker/src/handlers/subdomains.ts +376 -0
  312. package/worker/src/handlers/user.ts +98 -0
  313. package/worker/src/index.ts +70 -0
  314. package/worker/src/middleware/auth.ts +152 -0
  315. package/worker/src/services/cloudflare.ts +609 -0
  316. package/worker/src/types.ts +96 -0
  317. package/worker/tsconfig.json +15 -0
  318. package/worker/wrangler.toml +26 -0
@@ -0,0 +1,1493 @@
1
+ /**
2
+ * Serve command implementation
3
+ *
4
+ * Handles 'gssh serve' to start a machine-side daemon that accepts
5
+ * remote connections, authenticates clients via X3DH, and spawns PTY sessions.
6
+ *
7
+ * Also handles gitspace.sh hosting via Cloudflare Tunnels when configured.
8
+ *
9
+ * Supports daemon mode with start/stop/status subcommands.
10
+ */
11
+
12
+ import { watch, appendFileSync, existsSync, writeFileSync } from 'fs';
13
+ import { spawn, type Subprocess } from 'bun';
14
+ import { logger } from '../utils/logger.js';
15
+ import { promptPassword, promptConfirm } from '../utils/prompts.js';
16
+ import { getSecret } from '../utils/secrets.js';
17
+ import {
18
+ isRelayTrusted,
19
+ addTrustedRelay,
20
+ getTrustedRelay,
21
+ isLocalhost,
22
+ computeRelayFingerprint,
23
+ type RelayTrustStatus,
24
+ } from '../core/trusted-relays.js';
25
+ import {
26
+ loadKeypair,
27
+ keypairExists,
28
+ readMachineIdentity,
29
+ getPublicKeyWithoutPassword,
30
+ writeRelayConfig,
31
+ clearRelayConfig,
32
+ } from '../core/identity.js';
33
+ import { readAccessList, getAccessListPath } from '../core/access.js';
34
+ import { AccessControlList } from '../lib/tmux-lite/crypto/access-control.js';
35
+ import { ClientSessionManager } from '../serve/client-session-manager.js';
36
+ import type { ServeEventHandler } from '../serve/types.js';
37
+ import type { AccessEntry } from '../types/identity.js';
38
+ import {
39
+ NoIdentityError,
40
+ SpacesError,
41
+ } from '../types/errors.js';
42
+ import { readHostConfig } from './host.js';
43
+ import { createRelayServer } from '../relay/server.js';
44
+ import { generateRelayIdentity } from '../relay/identity.js';
45
+ import { signMessage } from '../relay/signing.js';
46
+ import { PROTOCOL_VERSION } from '../relay/protocol.js';
47
+ import { ed25519 } from '@noble/curves/ed25519.js';
48
+ import {
49
+ isServeRunning,
50
+ getServePid,
51
+ writeServePid,
52
+ cleanupServeFiles,
53
+ startStatusServer,
54
+ stopStatusServer,
55
+ setDaemonState,
56
+ updateDaemonState,
57
+ queryServeStatus,
58
+ sendShutdownCommand,
59
+ getServeLogFile,
60
+ setAccessCommandHandler,
61
+ type StatusResponse,
62
+ } from '../serve/daemon.js';
63
+
64
+ /** Package version for daemon status */
65
+ const PACKAGE_VERSION = '1.0.0';
66
+
67
+ /** Default relay URL */
68
+ const DEFAULT_RELAY_URL = 'wss://relay.gitspace.sh';
69
+
70
+ /** Local relay port for gitspace.sh hosting */
71
+ const LOCAL_RELAY_PORT = 4480;
72
+
73
+ /** Cloudflared process reference */
74
+ let cloudflaredProcess: Subprocess | null = null;
75
+ let cloudflaredSubdomain: string | null = null;
76
+ let cloudflaredRestartAttempts = 0;
77
+ const MAX_CLOUDFLARED_RESTARTS = 5;
78
+ const CLOUDFLARED_RESTART_DELAY = 5000;
79
+
80
+ // ============================================================================
81
+ // Helper Functions
82
+ // ============================================================================
83
+
84
+ /**
85
+ * Validate that an access entry has required keys
86
+ * @param entry - Access entry to validate
87
+ * @param logLabel - Label for logging if validation fails
88
+ * @returns true if valid, false if missing required keys
89
+ */
90
+ function isValidAccessEntry(entry: AccessEntry, logLabel?: string): boolean {
91
+ const label = logLabel || entry.label || entry.identityId.substring(0, 12) + '...';
92
+
93
+ if (!entry.keyExchangePublicKey || entry.keyExchangePublicKey.length === 0) {
94
+ logger.warning(`Skipping access entry with missing keyExchangePublicKey: ${label}`);
95
+ return false;
96
+ }
97
+ if (!entry.signingPublicKey || entry.signingPublicKey.length === 0) {
98
+ logger.warning(`Skipping access entry with missing signingPublicKey: ${label}`);
99
+ return false;
100
+ }
101
+ return true;
102
+ }
103
+
104
+ /**
105
+ * Create a ServeEventHandler that logs events
106
+ * @param sessionManager - Session manager for tracking active sessions
107
+ * @param isLocalRelay - Whether this is for a local relay (affects log messages)
108
+ * @returns Event handler function
109
+ */
110
+ function createEventHandler(
111
+ sessionManager: ClientSessionManager,
112
+ isLocalRelay: boolean
113
+ ): ServeEventHandler {
114
+ const relayName = isLocalRelay ? 'local relay' : 'relay';
115
+
116
+ return (event) => {
117
+ const timestamp = new Date().toLocaleTimeString();
118
+
119
+ switch (event.type) {
120
+ case 'client_connected':
121
+ logger.dim(`[${timestamp}] Client ${event.connectionId.substring(0, 12)}... connecting`);
122
+ break;
123
+
124
+ case 'client_authenticated':
125
+ logger.success(`[${timestamp}] Client ${event.identityId.substring(0, 12)}... authenticated`);
126
+ logger.dim(` Access: ${event.accessType === 'full' ? 'Full access' : `Session invite${event.sessionId ? ` (${event.sessionId})` : ''}`}`);
127
+ updateSessionDisplay(sessionManager);
128
+ break;
129
+
130
+ case 'client_disconnected':
131
+ logger.dim(`[${timestamp}] Client ${event.connectionId.substring(0, 12)}... disconnected: ${event.reason}`);
132
+ updateSessionDisplay(sessionManager);
133
+ break;
134
+
135
+ case 'relay_connected':
136
+ logger.success(isLocalRelay ? 'Machine registered with local relay' : 'Connected to relay');
137
+ break;
138
+
139
+ case 'relay_disconnected':
140
+ logger.warning(`Disconnected from ${relayName}: ${event.code} ${event.reason}`);
141
+ break;
142
+
143
+ case 'relay_reconnecting':
144
+ logger.dim(`Reconnecting to ${relayName} (attempt ${event.attempt})...`);
145
+ break;
146
+
147
+ case 'error':
148
+ logger.error(`Error${event.connectionId ? ` (${event.connectionId.substring(0, 12)}...)` : ''}: ${event.error.message}`);
149
+ break;
150
+ }
151
+ };
152
+ }
153
+
154
+ /**
155
+ * Result of relay trust verification
156
+ */
157
+ type RelayTrustResult =
158
+ | { trusted: true }
159
+ | { trusted: false; reason: string };
160
+
161
+ /**
162
+ * Verify and establish trust with a relay
163
+ *
164
+ * @param relayUrl - The relay URL
165
+ * @param relayPublicKey - The relay's public key
166
+ * @param relayFingerprint - The relay's fingerprint
167
+ * @param relayLabel - Optional relay label
168
+ * @param explicitPubkey - Optional explicit public key to trust
169
+ * @returns Result indicating if trust was established
170
+ */
171
+ async function verifyRelayTrust(
172
+ relayUrl: string,
173
+ relayPublicKey: string,
174
+ relayFingerprint: string,
175
+ relayLabel: string | undefined,
176
+ explicitPubkey?: string
177
+ ): Promise<RelayTrustResult> {
178
+ const trustStatus = isRelayTrusted(relayUrl, relayPublicKey);
179
+
180
+ if (trustStatus === 'mismatch') {
181
+ // SECURITY: Relay key changed - HARD FAIL
182
+ logger.log('');
183
+ logger.error('SECURITY WARNING: Relay public key mismatch!');
184
+ logger.error(`Expected: ${getTrustedRelay(relayUrl)?.fingerprint}`);
185
+ logger.error(`Received: ${relayFingerprint}`);
186
+ logger.log('');
187
+ logger.error('The relay identity has changed. This could indicate a man-in-the-middle attack.');
188
+ logger.error('If this is expected, remove the old trust with: gssh relay untrust ' + relayUrl);
189
+ return { trusted: false, reason: 'Relay identity mismatch - possible security threat' };
190
+ }
191
+
192
+ if (trustStatus === 'unknown') {
193
+ // Unknown relay - check if explicit trust was provided or auto-trust localhost
194
+ if (isLocalhost(relayUrl)) {
195
+ // Localhost auto-trust
196
+ console.log('[serve] Localhost relay - auto-trusting');
197
+ addTrustedRelay(relayUrl, relayPublicKey, relayLabel);
198
+ } else if (explicitPubkey) {
199
+ // Explicit trust provided via --relay-pubkey
200
+ if (explicitPubkey === relayPublicKey) {
201
+ console.log('[serve] Explicit trust match - trusting relay');
202
+ addTrustedRelay(relayUrl, relayPublicKey, relayLabel);
203
+ } else {
204
+ logger.error('Relay public key does not match --relay-pubkey');
205
+ logger.error(`Expected: ${computeRelayFingerprint(explicitPubkey)}`);
206
+ logger.error(`Received: ${relayFingerprint}`);
207
+ return { trusted: false, reason: 'Relay public key does not match --relay-pubkey' };
208
+ }
209
+ } else {
210
+ // Unknown remote relay - prompt for confirmation
211
+ logger.log('');
212
+ logger.bold('Unknown Relay');
213
+ logger.log(` URL: ${relayUrl}`);
214
+ logger.log(` Fingerprint: ${relayFingerprint}`);
215
+ if (relayLabel) {
216
+ logger.log(` Label: ${relayLabel}`);
217
+ }
218
+ logger.log('');
219
+
220
+ // Ask for confirmation
221
+ const shouldTrust = await promptConfirm('Trust this relay?');
222
+
223
+ if (!shouldTrust) {
224
+ logger.info('Relay not trusted, aborting connection');
225
+ return { trusted: false, reason: 'User declined to trust relay' };
226
+ }
227
+
228
+ // Save trust
229
+ addTrustedRelay(relayUrl, relayPublicKey, relayLabel);
230
+ logger.success('Relay trusted and saved');
231
+ }
232
+ }
233
+
234
+ return { trusted: true };
235
+ }
236
+
237
+ /**
238
+ * Sign a challenge and create registration message
239
+ *
240
+ * @param challenge - Base64 challenge from relay
241
+ * @param signingPrivateKey - Private key for signing
242
+ * @param machineId - Machine ID
243
+ * @param publicIdentity - Public identity info
244
+ * @returns Signed message data or null on error
245
+ */
246
+ function signChallengeAndCreateRegistration(
247
+ challenge: string,
248
+ signingPrivateKey: Uint8Array,
249
+ machineId: string,
250
+ publicIdentity: PublicIdentity
251
+ ): { challengeResponse: string; message: object } | null {
252
+ try {
253
+ const nonceBytes = new Uint8Array(Buffer.from(challenge, 'base64'));
254
+ const signature = ed25519.sign(nonceBytes, signingPrivateKey);
255
+ const challengeResponse = Buffer.from(signature).toString('base64');
256
+
257
+ return {
258
+ challengeResponse,
259
+ message: {
260
+ type: 'register_machine',
261
+ machineId,
262
+ signingKey: publicIdentity.signingPublicKey,
263
+ keyExchangeKey: publicIdentity.keyExchangePublicKey,
264
+ label: publicIdentity.label,
265
+ protocolVersion: PROTOCOL_VERSION,
266
+ challengeResponse,
267
+ },
268
+ };
269
+ } catch (err) {
270
+ logger.error(`Failed to sign challenge: ${err instanceof Error ? err.message : String(err)}`);
271
+ return null;
272
+ }
273
+ }
274
+
275
+ /**
276
+ * Create a data message for sending to a client via relay
277
+ */
278
+ function createDataMessage(connectionId: string, data: Uint8Array | Buffer): string {
279
+ return JSON.stringify({
280
+ type: 'data',
281
+ connectionId,
282
+ data: Buffer.from(data).toString('base64'),
283
+ });
284
+ }
285
+
286
+ /**
287
+ * Create a send callback for a client connection
288
+ */
289
+ function createSendCallback(
290
+ ws: WebSocket,
291
+ connectionId: string
292
+ ): (data: Uint8Array | Buffer) => void {
293
+ return (sendData) => {
294
+ ws.send(createDataMessage(connectionId, sendData));
295
+ };
296
+ }
297
+
298
+ // ============================================================================
299
+ // Cloudflared Management
300
+ // ============================================================================
301
+
302
+ /**
303
+ * Check if cloudflared is installed
304
+ */
305
+ async function isCloudflaredInstalled(): Promise<boolean> {
306
+ try {
307
+ const proc = spawn(['which', 'cloudflared'], { stdout: 'pipe', stderr: 'pipe' });
308
+ const exitCode = await proc.exited;
309
+ return exitCode === 0;
310
+ } catch {
311
+ return false;
312
+ }
313
+ }
314
+
315
+ /**
316
+ * Start cloudflared tunnel for a subdomain
317
+ *
318
+ * @param subdomain - The subdomain to tunnel (e.g., 'brad' for brad.gitspace.sh)
319
+ * @returns true if started successfully
320
+ */
321
+ async function startCloudflared(subdomain: string): Promise<boolean> {
322
+ // Get tunnel token from keychain
323
+ const tunnelToken = await getSecret(`TUNNEL_TOKEN_${subdomain}`);
324
+ if (!tunnelToken) {
325
+ logger.warning(`No tunnel token found for ${subdomain}.gitspace.sh`);
326
+ logger.dim('Run: gssh host reserve ' + subdomain + ' (to get token)');
327
+ return false;
328
+ }
329
+
330
+ // Check if cloudflared is installed
331
+ if (!await isCloudflaredInstalled()) {
332
+ logger.warning('cloudflared is not installed');
333
+ logger.dim('Install: brew install cloudflared (macOS) or see https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/');
334
+ return false;
335
+ }
336
+
337
+ cloudflaredSubdomain = subdomain;
338
+
339
+ // Start cloudflared with tunnel token via TUNNEL_TOKEN env var to avoid argv exposure
340
+ logger.info(`Starting tunnel for ${subdomain}.gitspace.sh...`);
341
+
342
+ try {
343
+ cloudflaredProcess = spawn(['cloudflared', 'tunnel', 'run'], {
344
+ env: { ...process.env, TUNNEL_TOKEN: tunnelToken },
345
+ stdin: 'ignore',
346
+ stdout: 'pipe',
347
+ stderr: 'pipe',
348
+ });
349
+
350
+ // Handle cloudflared output
351
+ handleCloudflaredOutput(cloudflaredProcess);
352
+
353
+ // Monitor process exit
354
+ cloudflaredProcess.exited.then((exitCode) => {
355
+ if (exitCode !== 0 && cloudflaredSubdomain) {
356
+ logger.warning(`cloudflared exited with code ${exitCode}`);
357
+ handleCloudflaredCrash();
358
+ }
359
+ });
360
+
361
+ logger.success(`Tunnel active: https://${subdomain}.gitspace.sh`);
362
+ logger.dim(` Wildcard: https://*.${subdomain}.gitspace.sh`);
363
+ return true;
364
+ } catch (error) {
365
+ logger.error(`Failed to start cloudflared: ${error instanceof Error ? error.message : String(error)}`);
366
+ return false;
367
+ }
368
+ }
369
+
370
+ /**
371
+ * Handle cloudflared stdout/stderr
372
+ */
373
+ function handleCloudflaredOutput(proc: Subprocess): void {
374
+ // Read stdout
375
+ const stdout = proc.stdout;
376
+ if (stdout && typeof stdout !== 'number') {
377
+ (async () => {
378
+ const reader = stdout.getReader();
379
+ try {
380
+ while (true) {
381
+ const { done, value } = await reader.read();
382
+ if (done) break;
383
+ const text = new TextDecoder().decode(value);
384
+ // Only log important messages, skip routine output
385
+ if (text.includes('ERR') || text.includes('error') || text.includes('failed')) {
386
+ logger.dim(`[cloudflared] ${text.trim()}`);
387
+ }
388
+ }
389
+ } catch {
390
+ // Stream closed
391
+ }
392
+ })();
393
+ }
394
+
395
+ // Read stderr
396
+ const stderr = proc.stderr;
397
+ if (stderr && typeof stderr !== 'number') {
398
+ (async () => {
399
+ const reader = stderr.getReader();
400
+ try {
401
+ while (true) {
402
+ const { done, value } = await reader.read();
403
+ if (done) break;
404
+ const text = new TextDecoder().decode(value);
405
+ // cloudflared logs most output to stderr
406
+ if (text.includes('ERR') || text.includes('error') || text.includes('failed')) {
407
+ logger.warning(`[cloudflared] ${text.trim()}`);
408
+ }
409
+ }
410
+ } catch {
411
+ // Stream closed
412
+ }
413
+ })();
414
+ }
415
+ }
416
+
417
+ /**
418
+ * Handle cloudflared crash and restart
419
+ */
420
+ function handleCloudflaredCrash(): void {
421
+ if (!cloudflaredSubdomain) return;
422
+
423
+ cloudflaredRestartAttempts++;
424
+
425
+ if (cloudflaredRestartAttempts > MAX_CLOUDFLARED_RESTARTS) {
426
+ logger.error(`cloudflared crashed ${MAX_CLOUDFLARED_RESTARTS} times, giving up`);
427
+ logger.dim('Check your tunnel token or network connection');
428
+ cloudflaredSubdomain = null;
429
+ return;
430
+ }
431
+
432
+ logger.info(`Restarting cloudflared (attempt ${cloudflaredRestartAttempts}/${MAX_CLOUDFLARED_RESTARTS})...`);
433
+
434
+ setTimeout(async () => {
435
+ if (cloudflaredSubdomain) {
436
+ await startCloudflared(cloudflaredSubdomain);
437
+ }
438
+ }, CLOUDFLARED_RESTART_DELAY);
439
+ }
440
+
441
+ /**
442
+ * Stop cloudflared process
443
+ */
444
+ function stopCloudflared(): void {
445
+ if (cloudflaredProcess) {
446
+ logger.dim('Stopping cloudflared...');
447
+ cloudflaredProcess.kill();
448
+ cloudflaredProcess = null;
449
+ cloudflaredSubdomain = null;
450
+ }
451
+ }
452
+
453
+ // ============================================================================
454
+ // Serve Command
455
+ // ============================================================================
456
+
457
+ /**
458
+ * Start the serve daemon
459
+ *
460
+ * @param options - Command options
461
+ */
462
+ export async function serve(options: {
463
+ relay?: string;
464
+ relayPubkey?: string;
465
+ } = {}): Promise<void> {
466
+ // Step 1: Load machine identity
467
+ if (!keypairExists()) {
468
+ throw new NoIdentityError();
469
+ }
470
+
471
+ const password = await promptPassword('Enter password to unlock identity:');
472
+ if (!password) {
473
+ logger.info('Cancelled');
474
+ return;
475
+ }
476
+
477
+ const identity = await loadKeypair(password);
478
+ if (!identity) {
479
+ throw new SpacesError(
480
+ 'Failed to unlock identity. Check your password.',
481
+ 'USER_ERROR',
482
+ 1
483
+ );
484
+ }
485
+
486
+ // Extract signing private key for challenge-response
487
+ // Ed25519 secret key is 64 bytes, but ed25519.sign() expects the 32-byte seed
488
+ const signingPrivateKey = identity.signing.secretKey.slice(0, 32);
489
+
490
+ // Get public identity for registration
491
+ const publicIdentity = getPublicKeyWithoutPassword();
492
+ if (!publicIdentity) {
493
+ throw new SpacesError(
494
+ 'Failed to read public identity',
495
+ 'SYSTEM_ERROR',
496
+ 2
497
+ );
498
+ }
499
+
500
+ // Step 2: Load access control list
501
+ const accessList = new AccessControlList();
502
+ const entries = readAccessList();
503
+ accessList.import(entries);
504
+
505
+ // Step 3: Display info
506
+ const machineIdentity = readMachineIdentity();
507
+ const machineId = machineIdentity?.machineId ?? identity.id;
508
+ const relayUrl = options.relay ?? DEFAULT_RELAY_URL;
509
+
510
+ logger.log('');
511
+ logger.bold('Machine Identity:');
512
+ logger.log(` ID: ${machineId}`);
513
+ logger.log(` Relay: ${relayUrl}`);
514
+ logger.log('');
515
+ logger.dim(`Access list: ${entries.length} authorized ${entries.length === 1 ? 'client' : 'clients'}`);
516
+ logger.log('');
517
+
518
+ // Step 3b: Check for gitspace.sh hosting
519
+ const hostConfig = readHostConfig();
520
+ let localRelayServer: ReturnType<typeof createRelayServer> | null = null;
521
+ let localRelayIdentity: ReturnType<typeof generateRelayIdentity> | null = null;
522
+
523
+ if (hostConfig?.subdomain) {
524
+ logger.bold('gitspace.sh Hosting:');
525
+
526
+ // Generate an ephemeral identity for local relay
527
+ localRelayIdentity = generateRelayIdentity('local-relay');
528
+
529
+ // Start local relay server with this machine pre-authorized
530
+ try {
531
+ localRelayServer = createRelayServer({
532
+ port: LOCAL_RELAY_PORT,
533
+ bind: '127.0.0.1', // Only listen locally, cloudflared handles external
534
+ identity: localRelayIdentity,
535
+ preAuthorizedMachines: [publicIdentity.signingPublicKey],
536
+ });
537
+ logger.success(`Local relay started on port ${LOCAL_RELAY_PORT}`);
538
+ } catch (error) {
539
+ logger.error(`Failed to start local relay: ${error instanceof Error ? error.message : String(error)}`);
540
+ throw new SpacesError('Failed to start local relay server', 'SYSTEM_ERROR', 2);
541
+ }
542
+
543
+ // Start cloudflared tunnel
544
+ const tunnelStarted = await startCloudflared(hostConfig.subdomain);
545
+ if (tunnelStarted) {
546
+ logger.log('');
547
+ logger.dim(`Web terminal: https://${hostConfig.subdomain}.gitspace.sh`);
548
+ logger.log('');
549
+ } else {
550
+ // Stop local relay if tunnel failed
551
+ localRelayServer.stop();
552
+ localRelayServer = null;
553
+ logger.dim(' Hosting not active (tunnel token missing)');
554
+ logger.log('');
555
+ }
556
+ }
557
+
558
+ // If gitspace.sh hosting is active, connect to local relay instead of external
559
+ if (localRelayServer && localRelayIdentity) {
560
+ // For local relay, we auto-authorize this machine (it's the same machine running both)
561
+ // The relay identity was generated above; machine authenticates via challenge-response
562
+ const localRelayUrl = `ws://127.0.0.1:${LOCAL_RELAY_PORT}/ws`;
563
+
564
+ // Step 4: Create session manager for local relay
565
+ const sessionManager = new ClientSessionManager({
566
+ relay: localRelayUrl,
567
+ identity,
568
+ accessList,
569
+ });
570
+
571
+ // Initialize session manager (starts tmux-lite server)
572
+ await sessionManager.initialize();
573
+
574
+ // Set up event handling
575
+ const eventHandler = createEventHandler(sessionManager, true);
576
+ sessionManager.onEvent(eventHandler);
577
+
578
+ // Connect to local relay (no token needed - uses challenge-response auth)
579
+ logger.info('Registering with local relay...');
580
+ try {
581
+ await connectToRelay(localRelayUrl, machineId, publicIdentity, sessionManager, eventHandler, accessList, signingPrivateKey);
582
+ } catch (error) {
583
+ logger.error(`Failed to register with local relay: ${error instanceof Error ? error.message : String(error)}`);
584
+ localRelayServer.stop();
585
+ stopCloudflared();
586
+ throw new SpacesError('Failed to register with local relay', 'SYSTEM_ERROR', 2);
587
+ }
588
+
589
+ logger.log('');
590
+ logger.dim('Waiting for connections via gitspace.sh... (Ctrl+C to stop)');
591
+ logger.log('');
592
+
593
+ // Set up shutdown handler for gitspace.sh mode
594
+ const shutdown = () => {
595
+ logger.log('');
596
+ logger.info('Shutting down...');
597
+ stopCloudflared();
598
+ sessionManager.cleanup();
599
+ localRelayServer?.stop();
600
+ process.exit(0);
601
+ };
602
+ process.on('SIGINT', shutdown);
603
+ process.on('SIGTERM', shutdown);
604
+
605
+ // Keep process alive
606
+ await new Promise(() => {});
607
+ return;
608
+ }
609
+
610
+ // Step 4: Create session manager
611
+ const sessionManager = new ClientSessionManager({
612
+ relay: relayUrl,
613
+ identity,
614
+ accessList,
615
+ });
616
+
617
+ // Initialize session manager (starts tmux-lite server)
618
+ await sessionManager.initialize();
619
+
620
+ // Set up event handling
621
+ const eventHandler = createEventHandler(sessionManager, false);
622
+ sessionManager.onEvent(eventHandler);
623
+
624
+ // Step 5: Connect to relay
625
+ logger.info('Connecting to relay...');
626
+
627
+ try {
628
+ await connectToRelay(relayUrl, machineId, publicIdentity, sessionManager, eventHandler, accessList, signingPrivateKey, options.relayPubkey);
629
+
630
+ // Save relay config for share command
631
+ writeRelayConfig({
632
+ relayUrl,
633
+ machineId,
634
+ savedAt: Date.now(),
635
+ });
636
+ logger.dim('Relay config saved');
637
+ } catch (error) {
638
+ throw new SpacesError(
639
+ `Failed to connect to relay: ${error instanceof Error ? error.message : String(error)}`,
640
+ 'SYSTEM_ERROR',
641
+ 2
642
+ );
643
+ }
644
+
645
+ logger.log('');
646
+ logger.dim('Waiting for connections... (Ctrl+C to stop)');
647
+ logger.log('');
648
+
649
+ // Step 6: Handle shutdown
650
+ setupShutdownHandlers(sessionManager);
651
+
652
+ // Keep process alive
653
+ await new Promise(() => {
654
+ // Never resolves - process stays alive until shutdown
655
+ });
656
+ }
657
+
658
+ /**
659
+ * Public identity type for registration
660
+ */
661
+ interface PublicIdentity {
662
+ id: string;
663
+ signingPublicKey: string;
664
+ keyExchangePublicKey: string;
665
+ label?: string;
666
+ }
667
+
668
+ /**
669
+ * Connect to relay WebSocket with protocol message support
670
+ */
671
+ async function connectToRelay(
672
+ relayUrl: string,
673
+ machineId: string,
674
+ publicIdentity: PublicIdentity,
675
+ sessionManager: ClientSessionManager,
676
+ eventHandler: ServeEventHandler,
677
+ accessList: AccessControlList,
678
+ signingPrivateKey?: Uint8Array,
679
+ relayPubkey?: string
680
+ ): Promise<void> {
681
+ // Build WebSocket URL with machine role (no token in URL - auth via challenge-response)
682
+ const url = new URL(relayUrl);
683
+ url.searchParams.set('role', 'machine');
684
+
685
+ // Track current entries for diffing
686
+ let currentEntries = readAccessList();
687
+ let accessWatcher: ReturnType<typeof watch> | null = null;
688
+
689
+ return new Promise((resolve, reject) => {
690
+ let reconnectAttempts = 0;
691
+ const maxReconnectAttempts = 10;
692
+ const baseReconnectDelay = 1000;
693
+ const maxReconnectDelay = 30000;
694
+ let resolved = false;
695
+ let currentWs: WebSocket | null = null;
696
+
697
+ // Decode public key for message signing
698
+ const signingPublicKey = signingPrivateKey
699
+ ? new Uint8Array(Buffer.from(publicIdentity.signingPublicKey, 'base64'))
700
+ : null;
701
+
702
+ // Helper to sign and send a message
703
+ const signAndSend = (ws: WebSocket, msg: object) => {
704
+ if (signingPrivateKey && signingPublicKey) {
705
+ const signed = signMessage(msg, signingPrivateKey, signingPublicKey);
706
+ ws.send(JSON.stringify(signed));
707
+ } else {
708
+ ws.send(JSON.stringify(msg));
709
+ }
710
+ };
711
+
712
+ // Watch access list file for changes
713
+ const startAccessWatcher = () => {
714
+ const accessPath = getAccessListPath();
715
+
716
+ // Create empty access list if it doesn't exist (watcher requires file to exist)
717
+ if (!existsSync(accessPath)) {
718
+ writeFileSync(accessPath, '[]', 'utf-8');
719
+ }
720
+
721
+ // Debounce to avoid multiple triggers
722
+ let debounceTimeout: ReturnType<typeof setTimeout> | null = null;
723
+
724
+ accessWatcher = watch(accessPath, (eventType) => {
725
+ if (eventType !== 'change') return;
726
+
727
+ // Debounce
728
+ if (debounceTimeout) clearTimeout(debounceTimeout);
729
+ debounceTimeout = setTimeout(() => {
730
+ syncAccessList();
731
+ }, 100);
732
+ });
733
+
734
+ logger.dim('Watching access list for changes');
735
+ };
736
+
737
+ // Sync access list changes to relay
738
+ const syncAccessList = () => {
739
+ if (!currentWs || currentWs.readyState !== WebSocket.OPEN) return;
740
+
741
+ try {
742
+ const newEntries = readAccessList();
743
+
744
+ // Find added entries
745
+ const added = newEntries.filter(
746
+ (newEntry) => !currentEntries.find((e) => e.identityId === newEntry.identityId)
747
+ );
748
+
749
+ // Find removed entries
750
+ const removed = currentEntries.filter(
751
+ (oldEntry) => !newEntries.find((e) => e.identityId === oldEntry.identityId)
752
+ );
753
+
754
+ // Send authorize messages for new entries (signed)
755
+ for (const entry of added) {
756
+ // Validate entry before sending - skip entries with missing keys
757
+ if (!isValidAccessEntry(entry)) {
758
+ continue;
759
+ }
760
+
761
+ signAndSend(currentWs, {
762
+ type: 'authorize_client',
763
+ machineId,
764
+ clientIdentityId: entry.identityId,
765
+ signingKey: entry.signingPublicKey,
766
+ keyExchangeKey: entry.keyExchangePublicKey,
767
+ accessType: entry.accessType,
768
+ sessionId: entry.sessionId,
769
+ });
770
+ logger.success(`Access granted: ${entry.label || entry.identityId.substring(0, 12)}...`);
771
+
772
+ // Also update local access list
773
+ accessList.addEntry({
774
+ id: entry.identityId,
775
+ signingPublicKey: entry.signingPublicKey,
776
+ keyExchangePublicKey: entry.keyExchangePublicKey,
777
+ }, entry.accessType, entry.sessionId);
778
+ }
779
+
780
+ // Send revoke messages for removed entries (signed)
781
+ for (const entry of removed) {
782
+ signAndSend(currentWs, {
783
+ type: 'revoke_client',
784
+ machineId,
785
+ clientIdentityId: entry.identityId,
786
+ });
787
+ logger.warning(`Access revoked: ${entry.label || entry.identityId.substring(0, 12)}...`);
788
+
789
+ // Also update local access list
790
+ accessList.removeEntry(entry.identityId);
791
+ }
792
+
793
+ // Update current entries
794
+ currentEntries = newEntries;
795
+ } catch (error) {
796
+ logger.error(`Failed to sync access list: ${error instanceof Error ? error.message : String(error)}`);
797
+ }
798
+ };
799
+
800
+ const connect = () => {
801
+ console.log(`[serve] Connecting to relay: ${url.toString()}`);
802
+ const ws = new WebSocket(url.toString());
803
+ ws.binaryType = 'arraybuffer';
804
+ currentWs = ws;
805
+
806
+ ws.onopen = () => {
807
+ console.log('[serve] WebSocket connected, waiting for relay identity...');
808
+ reconnectAttempts = 0;
809
+ // Don't send register_machine yet - wait for relay_identity message
810
+ };
811
+
812
+ ws.onclose = (event) => {
813
+ console.log(`[serve] WebSocket closed: code=${event.code} reason=${event.reason || 'none'}`);
814
+ eventHandler({
815
+ type: 'relay_disconnected',
816
+ code: event.code,
817
+ reason: event.reason || 'Connection closed',
818
+ });
819
+
820
+ // Clear relay config on disconnect
821
+ clearRelayConfig();
822
+
823
+ // Attempt reconnection
824
+ if (reconnectAttempts < maxReconnectAttempts) {
825
+ reconnectAttempts++;
826
+ const delay = Math.min(
827
+ baseReconnectDelay * Math.pow(2, reconnectAttempts - 1) + Math.random() * 1000,
828
+ maxReconnectDelay
829
+ );
830
+ eventHandler({ type: 'relay_reconnecting', attempt: reconnectAttempts });
831
+ setTimeout(connect, delay);
832
+ }
833
+ };
834
+
835
+ ws.onerror = (err) => {
836
+ console.log('[serve] WebSocket error:', err);
837
+ // Only reject on initial connection
838
+ if (!resolved && reconnectAttempts === 0) {
839
+ reject(new Error('WebSocket connection failed'));
840
+ }
841
+ };
842
+
843
+ ws.onmessage = async (event) => {
844
+ try {
845
+ // Parse message
846
+ const data = event.data;
847
+ let msg: any;
848
+
849
+ if (typeof data === 'string') {
850
+ msg = JSON.parse(data);
851
+ } else {
852
+ const str = new TextDecoder().decode(data as ArrayBuffer);
853
+ try {
854
+ msg = JSON.parse(str);
855
+ } catch {
856
+ logger.warning('Received binary data without JSON envelope');
857
+ return;
858
+ }
859
+ }
860
+
861
+ // Handle protocol messages
862
+ switch (msg.type) {
863
+ case 'relay_identity': {
864
+ // Relay is identifying itself and providing a challenge
865
+ const { publicKey: relayPublicKey, fingerprint: relayFingerprint, label: relayLabel, challenge } = msg;
866
+
867
+ console.log(`[serve] Received relay identity: ${relayFingerprint}${relayLabel ? ` (${relayLabel})` : ''}`);
868
+
869
+ // Step 1: Verify relay trust
870
+ const trustResult = await verifyRelayTrust(
871
+ relayUrl,
872
+ relayPublicKey,
873
+ relayFingerprint,
874
+ relayLabel,
875
+ relayPubkey
876
+ );
877
+
878
+ if (!trustResult.trusted) {
879
+ ws.close(1008, trustResult.reason);
880
+ if (!resolved) {
881
+ reject(new Error(trustResult.reason));
882
+ }
883
+ return;
884
+ }
885
+
886
+ // Step 2: Sign the challenge and send register_machine
887
+ if (!signingPrivateKey) {
888
+ logger.error('No signing key available for challenge-response');
889
+ ws.close(1008, 'No signing key');
890
+ return;
891
+ }
892
+
893
+ const registration = signChallengeAndCreateRegistration(
894
+ challenge,
895
+ signingPrivateKey,
896
+ machineId,
897
+ publicIdentity
898
+ );
899
+
900
+ if (!registration) {
901
+ ws.close(1008, 'Challenge signing failed');
902
+ return;
903
+ }
904
+
905
+ signAndSend(ws, registration.message);
906
+ console.log('[serve] Sent register_machine with challenge response');
907
+ break;
908
+ }
909
+
910
+ case 'registered':
911
+ // Machine registered successfully
912
+ eventHandler({ type: 'relay_connected' });
913
+
914
+ // Send initial access list entries to relay (signed)
915
+ for (const entry of currentEntries) {
916
+ // Validate entry before sending - skip entries with missing keys
917
+ if (!isValidAccessEntry(entry)) {
918
+ continue;
919
+ }
920
+
921
+ signAndSend(ws, {
922
+ type: 'authorize_client',
923
+ machineId,
924
+ clientIdentityId: entry.identityId,
925
+ signingKey: entry.signingPublicKey,
926
+ keyExchangeKey: entry.keyExchangePublicKey,
927
+ accessType: entry.accessType,
928
+ sessionId: entry.sessionId,
929
+ });
930
+ logger.dim(`Synced access: ${entry.label || entry.identityId.substring(0, 12)}...`);
931
+ }
932
+
933
+ // Start watching access list for changes
934
+ if (!accessWatcher) {
935
+ startAccessWatcher();
936
+ }
937
+
938
+ // Register access command handler for CLI commands
939
+ setAccessCommandHandler({
940
+ async addAccess(entry) {
941
+ if (!currentWs || currentWs.readyState !== WebSocket.OPEN) {
942
+ return { success: false, error: 'Not connected to relay' };
943
+ }
944
+ try {
945
+ signAndSend(currentWs, {
946
+ type: 'add_global_access',
947
+ clientIdentityId: entry.clientIdentityId,
948
+ signingKey: entry.signingKey,
949
+ keyExchangeKey: entry.keyExchangeKey,
950
+ label: entry.label,
951
+ accessType: entry.accessType,
952
+ sessionId: entry.sessionId,
953
+ });
954
+ return { success: true };
955
+ } catch (err) {
956
+ return { success: false, error: err instanceof Error ? err.message : 'Send failed' };
957
+ }
958
+ },
959
+ async removeAccess(clientIdentityId) {
960
+ if (!currentWs || currentWs.readyState !== WebSocket.OPEN) {
961
+ return { success: false, error: 'Not connected to relay' };
962
+ }
963
+ try {
964
+ signAndSend(currentWs, {
965
+ type: 'remove_global_access',
966
+ clientIdentityId,
967
+ });
968
+ return { success: true };
969
+ } catch (err) {
970
+ return { success: false, error: err instanceof Error ? err.message : 'Send failed' };
971
+ }
972
+ },
973
+ });
974
+
975
+ if (!resolved) {
976
+ resolved = true;
977
+ resolve();
978
+ }
979
+ break;
980
+
981
+ case 'client_connected':
982
+ // New client connection
983
+ sessionManager.handleConnect(msg.connectionId);
984
+ // Set up send callback for this connection
985
+ sessionManager.setSendCallback(msg.connectionId, createSendCallback(ws, msg.connectionId));
986
+ break;
987
+
988
+ case 'client_disconnected':
989
+ // Client disconnected
990
+ sessionManager.handleDisconnect(msg.connectionId, msg.reason || 'Client disconnected');
991
+ break;
992
+
993
+ case 'data':
994
+ // Data from client - connectionId tells us which client
995
+ if (msg.data && msg.connectionId) {
996
+ const messageData = Buffer.from(msg.data, 'base64');
997
+
998
+ // Ensure send callback is set
999
+ if (!sessionManager.getSession(msg.connectionId)) {
1000
+ sessionManager.setSendCallback(msg.connectionId, createSendCallback(ws, msg.connectionId));
1001
+ }
1002
+
1003
+ const response = await sessionManager.handleMessage(
1004
+ msg.connectionId,
1005
+ messageData
1006
+ );
1007
+
1008
+ if (response) {
1009
+ ws.send(createDataMessage(msg.connectionId, response));
1010
+ }
1011
+ }
1012
+ break;
1013
+
1014
+ case 'error':
1015
+ logger.error(`Relay error: ${msg.message} (${msg.code})`);
1016
+ if (!resolved) {
1017
+ reject(new Error(msg.message));
1018
+ }
1019
+ break;
1020
+
1021
+ case 'access_list':
1022
+ // Full access list from relay - sync to local access list
1023
+ logger.dim(`Received ${msg.entries?.length || 0} access entries from relay`);
1024
+ if (msg.entries && Array.isArray(msg.entries)) {
1025
+ for (const entry of msg.entries) {
1026
+ accessList.addEntry({
1027
+ id: entry.clientIdentityId,
1028
+ signingPublicKey: entry.signingKey,
1029
+ keyExchangePublicKey: entry.keyExchangeKey,
1030
+ }, entry.accessType === 'full' ? 'full' : 'session-invite', entry.sessionId);
1031
+ }
1032
+ }
1033
+ break;
1034
+
1035
+ case 'access_update':
1036
+ // Incremental access update from relay
1037
+ if (msg.added && Array.isArray(msg.added)) {
1038
+ for (const entry of msg.added) {
1039
+ accessList.addEntry({
1040
+ id: entry.clientIdentityId,
1041
+ signingPublicKey: entry.signingKey,
1042
+ keyExchangePublicKey: entry.keyExchangeKey,
1043
+ }, entry.accessType === 'full' ? 'full' : 'session-invite', entry.sessionId);
1044
+ logger.success(`Access granted (from relay): ${entry.label || entry.clientIdentityId.substring(0, 12)}...`);
1045
+ }
1046
+ }
1047
+ if (msg.removed && Array.isArray(msg.removed)) {
1048
+ for (const clientId of msg.removed) {
1049
+ accessList.removeEntry(clientId);
1050
+ logger.warning(`Access revoked (from relay): ${clientId.substring(0, 12)}...`);
1051
+ }
1052
+ }
1053
+ break;
1054
+
1055
+ case 'client_authorized':
1056
+ // Acknowledgment that client authorization was registered with relay
1057
+ // No action needed - the authorization was already applied locally
1058
+ break;
1059
+
1060
+ case 'client_revoked':
1061
+ // Acknowledgment that client revocation was registered with relay
1062
+ // No action needed - the revocation was already applied locally
1063
+ break;
1064
+
1065
+ default:
1066
+ logger.dim(`Unknown message type: ${msg.type}`);
1067
+ }
1068
+ } catch (error) {
1069
+ logger.error(`Message handling error: ${error instanceof Error ? error.message : String(error)}`);
1070
+ }
1071
+ };
1072
+ };
1073
+
1074
+ connect();
1075
+ });
1076
+ }
1077
+
1078
+ /**
1079
+ * Update session display
1080
+ */
1081
+ function updateSessionDisplay(sessionManager: ClientSessionManager): void {
1082
+ const count = sessionManager.establishedSessionCount;
1083
+ if (count > 0) {
1084
+ logger.dim(`Active sessions: ${count}`);
1085
+ }
1086
+ }
1087
+
1088
+ /**
1089
+ * Set up shutdown handlers
1090
+ */
1091
+ function setupShutdownHandlers(sessionManager: ClientSessionManager, isDaemon: boolean = false): void {
1092
+ const shutdown = () => {
1093
+ logger.log('');
1094
+ logger.info('Shutting down...');
1095
+
1096
+ // Stop cloudflared if running
1097
+ stopCloudflared();
1098
+
1099
+ clearRelayConfig();
1100
+ sessionManager.cleanup();
1101
+
1102
+ // Clean up daemon files if in daemon mode
1103
+ if (isDaemon) {
1104
+ stopStatusServer();
1105
+ cleanupServeFiles();
1106
+ }
1107
+
1108
+ process.exit(0);
1109
+ };
1110
+
1111
+ process.on('SIGINT', shutdown);
1112
+ process.on('SIGTERM', shutdown);
1113
+ }
1114
+
1115
+ // ============================================================================
1116
+ // Daemon Commands
1117
+ // ============================================================================
1118
+
1119
+ /**
1120
+ * Format uptime in human-readable format
1121
+ */
1122
+ function formatUptime(seconds: number): string {
1123
+ if (seconds < 60) return `${seconds}s`;
1124
+ if (seconds < 3600) return `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
1125
+ const hours = Math.floor(seconds / 3600);
1126
+ const mins = Math.floor((seconds % 3600) / 60);
1127
+ return `${hours}h ${mins}m`;
1128
+ }
1129
+
1130
+ /**
1131
+ * Start serve daemon
1132
+ */
1133
+ export async function serveStart(options: {
1134
+ relay?: string;
1135
+ relayPubkey?: string;
1136
+ passwordStdin?: boolean;
1137
+ foreground?: boolean;
1138
+ } = {}): Promise<void> {
1139
+ // Check if already running
1140
+ if (isServeRunning()) {
1141
+ const pid = getServePid();
1142
+ logger.info(`serve daemon already running${pid ? ` (pid ${pid})` : ''}`);
1143
+ return;
1144
+ }
1145
+
1146
+ // Load identity (need password)
1147
+ if (!keypairExists()) {
1148
+ throw new NoIdentityError();
1149
+ }
1150
+
1151
+ let password: string | null = null;
1152
+
1153
+ if (options.passwordStdin) {
1154
+ // Read password from stdin
1155
+ const reader = process.stdin;
1156
+ const chunks: Buffer[] = [];
1157
+
1158
+ const onData = (chunk: Buffer) => chunks.push(chunk);
1159
+ reader.on('data', onData);
1160
+
1161
+ await new Promise<void>((resolve, reject) => {
1162
+ const timeoutId = setTimeout(() => reject(new Error('Timeout reading password from stdin')), 10000);
1163
+ const onEnd = () => {
1164
+ clearTimeout(timeoutId);
1165
+ resolve();
1166
+ };
1167
+ const onError = (err: Error) => {
1168
+ clearTimeout(timeoutId);
1169
+ reject(err);
1170
+ };
1171
+ reader.once('end', onEnd);
1172
+ reader.once('error', onError);
1173
+ });
1174
+
1175
+ // Clean up stdin to allow process to exit
1176
+ reader.removeListener('data', onData);
1177
+ reader.pause();
1178
+
1179
+ password = Buffer.concat(chunks).toString().trim();
1180
+ if (!password) {
1181
+ throw new SpacesError('No password provided via stdin', 'USER_ERROR', 1);
1182
+ }
1183
+ } else {
1184
+ // Interactive prompt
1185
+ password = await promptPassword('Enter password to unlock identity:');
1186
+ if (!password) {
1187
+ logger.info('Cancelled');
1188
+ return;
1189
+ }
1190
+ }
1191
+
1192
+ // Validate password by loading keypair
1193
+ const identity = await loadKeypair(password);
1194
+ if (!identity) {
1195
+ throw new SpacesError(
1196
+ 'Failed to unlock identity. Check your password.',
1197
+ 'USER_ERROR',
1198
+ 1
1199
+ );
1200
+ }
1201
+
1202
+ // Extract signing private key for challenge-response
1203
+ const signingPrivateKey = identity.signing.secretKey.slice(0, 32);
1204
+
1205
+ // If not foreground mode, fork to background
1206
+ if (!options.foreground) {
1207
+ logger.log('Starting serve daemon...');
1208
+
1209
+ // Build args for background process
1210
+ const args = [process.argv[1], 'serve', 'start', '--foreground'];
1211
+ if (options.relay) args.push('--relay', options.relay);
1212
+ if (options.relayPubkey) args.push('--relay-pubkey', options.relayPubkey);
1213
+ args.push('--password-stdin');
1214
+
1215
+ // Write output to log file for debugging
1216
+ const logFile = getServeLogFile();
1217
+ const { ensureServeDaemonDir } = await import('../serve/daemon.js');
1218
+ ensureServeDaemonDir();
1219
+
1220
+ // Truncate log file at start
1221
+ await Bun.write(logFile, `[${new Date().toISOString()}] Starting serve daemon...\n`);
1222
+
1223
+ const child = spawn({
1224
+ cmd: ['bun', ...args],
1225
+ stdin: 'pipe',
1226
+ stdout: Bun.file(logFile),
1227
+ stderr: Bun.file(logFile),
1228
+ env: process.env,
1229
+ });
1230
+
1231
+ // Send password via stdin
1232
+ child.stdin.write(password);
1233
+ child.stdin.end();
1234
+
1235
+ // Wait a bit for process to start
1236
+ await Bun.sleep(1000);
1237
+
1238
+ // Check if it started
1239
+ if (isServeRunning()) {
1240
+ const pid = getServePid();
1241
+ logger.success(`serve daemon started${pid ? ` (pid ${pid})` : ''}`);
1242
+ // Force exit since inquirer prompts may keep event loop alive
1243
+ process.exit(0);
1244
+ } else {
1245
+ // Read log file for error message
1246
+ const logContent = await Bun.file(logFile).text();
1247
+ logger.error('Daemon log:');
1248
+ logger.log(logContent);
1249
+ throw new SpacesError('Failed to start serve daemon. Check log above for details.', 'SYSTEM_ERROR', 2);
1250
+ }
1251
+ }
1252
+
1253
+ // Foreground/daemon mode - write PID and start status server
1254
+ writeServePid(process.pid);
1255
+ startStatusServer();
1256
+
1257
+ // Get public identity for registration
1258
+ const publicIdentity = getPublicKeyWithoutPassword();
1259
+ if (!publicIdentity) {
1260
+ cleanupServeFiles();
1261
+ throw new SpacesError('Failed to read public identity', 'SYSTEM_ERROR', 2);
1262
+ }
1263
+
1264
+ // Load access control list
1265
+ const accessList = new AccessControlList();
1266
+ const entries = readAccessList();
1267
+ accessList.import(entries);
1268
+
1269
+ // Get config
1270
+ const machineIdentity = readMachineIdentity();
1271
+ const machineId = machineIdentity?.machineId ?? identity.id;
1272
+ const relayUrl = options.relay ?? DEFAULT_RELAY_URL;
1273
+
1274
+ // Check for gitspace.sh hosting
1275
+ const hostConfig = readHostConfig();
1276
+ let localRelayServer: ReturnType<typeof createRelayServer> | null = null;
1277
+ let localRelayIdentity: ReturnType<typeof generateRelayIdentity> | null = null;
1278
+ let effectiveRelayUrl = relayUrl;
1279
+
1280
+ // Initialize daemon state
1281
+ setDaemonState({
1282
+ version: PACKAGE_VERSION,
1283
+ startTime: Date.now(),
1284
+ relay: {
1285
+ url: relayUrl,
1286
+ status: 'connecting',
1287
+ },
1288
+ clients: 0,
1289
+ hosting: hostConfig?.subdomain ? {
1290
+ subdomain: hostConfig.subdomain,
1291
+ tunnelActive: false,
1292
+ } : undefined,
1293
+ });
1294
+
1295
+ if (hostConfig?.subdomain) {
1296
+ // Generate an ephemeral identity for local relay
1297
+ localRelayIdentity = generateRelayIdentity('local-relay');
1298
+
1299
+ // Start local relay server with this machine pre-authorized
1300
+ try {
1301
+ localRelayServer = createRelayServer({
1302
+ port: LOCAL_RELAY_PORT,
1303
+ bind: '127.0.0.1',
1304
+ identity: localRelayIdentity,
1305
+ preAuthorizedMachines: [publicIdentity.signingPublicKey],
1306
+ });
1307
+ logger.success(`Local relay started on port ${LOCAL_RELAY_PORT}`);
1308
+ } catch (error) {
1309
+ cleanupServeFiles();
1310
+ throw new SpacesError('Failed to start local relay server', 'SYSTEM_ERROR', 2);
1311
+ }
1312
+
1313
+ // Start cloudflared tunnel
1314
+ const tunnelStarted = await startCloudflared(hostConfig.subdomain);
1315
+ if (tunnelStarted) {
1316
+ updateDaemonState({
1317
+ hosting: {
1318
+ subdomain: hostConfig.subdomain,
1319
+ tunnelActive: true,
1320
+ },
1321
+ });
1322
+ }
1323
+
1324
+ // Use local relay (machine will authenticate via challenge-response)
1325
+ effectiveRelayUrl = `ws://127.0.0.1:${LOCAL_RELAY_PORT}/ws`;
1326
+ }
1327
+
1328
+ // Create session manager
1329
+ const sessionManager = new ClientSessionManager({
1330
+ relay: effectiveRelayUrl,
1331
+ identity,
1332
+ accessList,
1333
+ });
1334
+
1335
+ // Initialize session manager (starts tmux-lite server)
1336
+ await sessionManager.initialize();
1337
+
1338
+ // Event handler - update daemon state
1339
+ const eventHandler: ServeEventHandler = (event) => {
1340
+ switch (event.type) {
1341
+ case 'relay_connected':
1342
+ updateDaemonState({ relay: { url: effectiveRelayUrl, status: 'connected' } });
1343
+ break;
1344
+ case 'relay_disconnected':
1345
+ updateDaemonState({ relay: { url: effectiveRelayUrl, status: 'disconnected' } });
1346
+ break;
1347
+ case 'relay_reconnecting':
1348
+ updateDaemonState({ relay: { url: effectiveRelayUrl, status: 'reconnecting' } });
1349
+ break;
1350
+ case 'client_authenticated':
1351
+ case 'client_disconnected':
1352
+ updateDaemonState({ clients: sessionManager.establishedSessionCount });
1353
+ break;
1354
+ }
1355
+ };
1356
+
1357
+ sessionManager.onEvent(eventHandler);
1358
+
1359
+ // Connect to relay
1360
+ try {
1361
+ await connectToRelay(effectiveRelayUrl, machineId, publicIdentity, sessionManager, eventHandler, accessList, signingPrivateKey, options.relayPubkey);
1362
+ updateDaemonState({ relay: { url: effectiveRelayUrl, status: 'connected' } });
1363
+ } catch (error) {
1364
+ localRelayServer?.stop();
1365
+ stopCloudflared();
1366
+ cleanupServeFiles();
1367
+ throw new SpacesError(
1368
+ `Failed to connect to relay: ${error instanceof Error ? error.message : String(error)}`,
1369
+ 'SYSTEM_ERROR',
1370
+ 2
1371
+ );
1372
+ }
1373
+
1374
+ // Save relay config for share/access commands
1375
+ writeRelayConfig({
1376
+ relayUrl,
1377
+ machineId,
1378
+ savedAt: Date.now(),
1379
+ });
1380
+
1381
+ // Set up shutdown handlers with daemon cleanup
1382
+ setupShutdownHandlers(sessionManager, true);
1383
+
1384
+ // Keep process alive
1385
+ await new Promise(() => {});
1386
+ }
1387
+
1388
+ /**
1389
+ * Stop serve daemon
1390
+ */
1391
+ export async function serveStop(): Promise<void> {
1392
+ if (!isServeRunning()) {
1393
+ logger.info('serve daemon not running');
1394
+ return;
1395
+ }
1396
+
1397
+ logger.log('Stopping serve daemon...');
1398
+
1399
+ // Try graceful shutdown via socket first
1400
+ const success = await sendShutdownCommand();
1401
+
1402
+ if (success) {
1403
+ // Wait for process to exit
1404
+ await Bun.sleep(1000);
1405
+
1406
+ if (!isServeRunning()) {
1407
+ logger.success('serve daemon stopped');
1408
+ return;
1409
+ }
1410
+ }
1411
+
1412
+ // Fallback: send SIGTERM directly
1413
+ const pid = getServePid();
1414
+ if (pid) {
1415
+ try {
1416
+ process.kill(pid, 'SIGTERM');
1417
+ await Bun.sleep(1000);
1418
+
1419
+ if (!isServeRunning()) {
1420
+ logger.success('serve daemon stopped');
1421
+ return;
1422
+ }
1423
+
1424
+ // Force kill
1425
+ process.kill(pid, 'SIGKILL');
1426
+ cleanupServeFiles();
1427
+ logger.success('serve daemon stopped (forced)');
1428
+ } catch {
1429
+ cleanupServeFiles();
1430
+ logger.success('serve daemon stopped');
1431
+ }
1432
+ }
1433
+ }
1434
+
1435
+ /**
1436
+ * Show serve daemon status
1437
+ */
1438
+ export async function serveStatus(): Promise<void> {
1439
+ // Build status output
1440
+ const box = (lines: string[]) => {
1441
+ const width = 44;
1442
+ const top = '┌─ serve daemon ' + '─'.repeat(width - 16) + '┐';
1443
+ const bottom = '└' + '─'.repeat(width) + '┘';
1444
+ const padded = lines.map((l) => {
1445
+ const visible = l.replace(/\x1b\[[0-9;]*m/g, ''); // Strip ANSI for length calc
1446
+ const padding = width - visible.length;
1447
+ return '│ ' + l + ' '.repeat(Math.max(0, padding - 1)) + '│';
1448
+ });
1449
+ return [top, ...padded, bottom].join('\n');
1450
+ };
1451
+
1452
+ if (!isServeRunning()) {
1453
+ const lines = [
1454
+ 'Status: \x1b[90m○ not running\x1b[0m',
1455
+ '',
1456
+ 'Run: \x1b[36mgssh serve start\x1b[0m',
1457
+ ];
1458
+ logger.log(box(lines));
1459
+ return;
1460
+ }
1461
+
1462
+ // Query daemon for status
1463
+ const status = await queryServeStatus();
1464
+
1465
+ if (status) {
1466
+ const statusIcon = status.relay.status === 'connected' ? '\x1b[32m●\x1b[0m' : '\x1b[33m●\x1b[0m';
1467
+ const relayStatus = status.relay.status === 'connected' ? 'connected' : status.relay.status;
1468
+
1469
+ const lines = [
1470
+ `Status: ${statusIcon} running (pid ${status.pid})`,
1471
+ `Version: ${status.version}`,
1472
+ `Relay: ${status.relay.url}`,
1473
+ ` ${relayStatus}`,
1474
+ `Clients: ${status.clients} active`,
1475
+ `Uptime: ${formatUptime(status.uptime)}`,
1476
+ ];
1477
+
1478
+ if (status.hosting) {
1479
+ const tunnelIcon = status.hosting.tunnelActive ? '\x1b[32m●\x1b[0m' : '\x1b[31m●\x1b[0m';
1480
+ lines.push(`Hosting: ${tunnelIcon} ${status.hosting.subdomain}.gitspace.sh`);
1481
+ }
1482
+
1483
+ logger.log(box(lines));
1484
+ } else {
1485
+ // Fallback if status query fails
1486
+ const pid = getServePid();
1487
+ const lines = [
1488
+ `Status: \x1b[32m●\x1b[0m running${pid ? ` (pid ${pid})` : ''}`,
1489
+ `Version: ${PACKAGE_VERSION}`,
1490
+ ];
1491
+ logger.log(box(lines));
1492
+ }
1493
+ }