gitspace 0.2.0-rc.2 → 0.2.0-rc.21

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 (316) hide show
  1. package/README.md +68 -38
  2. package/package.json +36 -25
  3. package/.claude/settings.local.json +0 -21
  4. package/.gitspace/bundle.json +0 -50
  5. package/.gitspace/select/01-status.sh +0 -40
  6. package/.gitspace/setup/01-install-deps.sh +0 -12
  7. package/.gitspace/setup/02-typecheck.sh +0 -16
  8. package/AGENTS.md +0 -439
  9. package/CLAUDE.md +0 -1
  10. package/bun.lock +0 -647
  11. package/docs/CONNECTION.md +0 -623
  12. package/docs/GATEWAY-WORKER.md +0 -319
  13. package/docs/GETTING-STARTED.md +0 -448
  14. package/docs/GITSPACE-PLATFORM.md +0 -1819
  15. package/docs/INFRASTRUCTURE.md +0 -1347
  16. package/docs/PROTOCOL.md +0 -619
  17. package/docs/QUICKSTART.md +0 -174
  18. package/docs/RELAY.md +0 -327
  19. package/docs/REMOTE-DESIGN.md +0 -549
  20. package/docs/ROADMAP.md +0 -564
  21. package/docs/SITE_DOCS_FIGMA_MAKE.md +0 -1167
  22. package/docs/STACK-DESIGN.md +0 -588
  23. package/docs/UNIFIED_ARCHITECTURE.md +0 -292
  24. package/experiments/pty-benchmark.ts +0 -148
  25. package/experiments/pty-latency.ts +0 -100
  26. package/experiments/router/client.ts +0 -199
  27. package/experiments/router/protocol.ts +0 -74
  28. package/experiments/router/router.ts +0 -217
  29. package/experiments/router/session.ts +0 -180
  30. package/experiments/router/test.ts +0 -133
  31. package/experiments/socket-bandwidth.ts +0 -77
  32. package/homebrew/gitspace.rb +0 -45
  33. package/landing-page/ATTRIBUTIONS.md +0 -3
  34. package/landing-page/README.md +0 -11
  35. package/landing-page/bun.lock +0 -801
  36. package/landing-page/guidelines/Guidelines.md +0 -61
  37. package/landing-page/index.html +0 -37
  38. package/landing-page/package.json +0 -90
  39. package/landing-page/postcss.config.mjs +0 -15
  40. package/landing-page/public/_redirects +0 -1
  41. package/landing-page/public/favicon.png +0 -0
  42. package/landing-page/src/app/App.tsx +0 -53
  43. package/landing-page/src/app/components/figma/ImageWithFallback.tsx +0 -27
  44. package/landing-page/src/app/components/ui/accordion.tsx +0 -66
  45. package/landing-page/src/app/components/ui/alert-dialog.tsx +0 -157
  46. package/landing-page/src/app/components/ui/alert.tsx +0 -66
  47. package/landing-page/src/app/components/ui/aspect-ratio.tsx +0 -11
  48. package/landing-page/src/app/components/ui/avatar.tsx +0 -53
  49. package/landing-page/src/app/components/ui/badge.tsx +0 -46
  50. package/landing-page/src/app/components/ui/breadcrumb.tsx +0 -109
  51. package/landing-page/src/app/components/ui/button.tsx +0 -57
  52. package/landing-page/src/app/components/ui/calendar.tsx +0 -75
  53. package/landing-page/src/app/components/ui/card.tsx +0 -92
  54. package/landing-page/src/app/components/ui/carousel.tsx +0 -241
  55. package/landing-page/src/app/components/ui/chart.tsx +0 -353
  56. package/landing-page/src/app/components/ui/checkbox.tsx +0 -32
  57. package/landing-page/src/app/components/ui/collapsible.tsx +0 -33
  58. package/landing-page/src/app/components/ui/command.tsx +0 -177
  59. package/landing-page/src/app/components/ui/context-menu.tsx +0 -252
  60. package/landing-page/src/app/components/ui/dialog.tsx +0 -135
  61. package/landing-page/src/app/components/ui/drawer.tsx +0 -132
  62. package/landing-page/src/app/components/ui/dropdown-menu.tsx +0 -257
  63. package/landing-page/src/app/components/ui/form.tsx +0 -168
  64. package/landing-page/src/app/components/ui/hover-card.tsx +0 -44
  65. package/landing-page/src/app/components/ui/input-otp.tsx +0 -77
  66. package/landing-page/src/app/components/ui/input.tsx +0 -21
  67. package/landing-page/src/app/components/ui/label.tsx +0 -24
  68. package/landing-page/src/app/components/ui/menubar.tsx +0 -276
  69. package/landing-page/src/app/components/ui/navigation-menu.tsx +0 -168
  70. package/landing-page/src/app/components/ui/pagination.tsx +0 -127
  71. package/landing-page/src/app/components/ui/popover.tsx +0 -48
  72. package/landing-page/src/app/components/ui/progress.tsx +0 -31
  73. package/landing-page/src/app/components/ui/radio-group.tsx +0 -45
  74. package/landing-page/src/app/components/ui/resizable.tsx +0 -56
  75. package/landing-page/src/app/components/ui/scroll-area.tsx +0 -58
  76. package/landing-page/src/app/components/ui/select.tsx +0 -189
  77. package/landing-page/src/app/components/ui/separator.tsx +0 -28
  78. package/landing-page/src/app/components/ui/sheet.tsx +0 -139
  79. package/landing-page/src/app/components/ui/sidebar.tsx +0 -726
  80. package/landing-page/src/app/components/ui/skeleton.tsx +0 -13
  81. package/landing-page/src/app/components/ui/slider.tsx +0 -63
  82. package/landing-page/src/app/components/ui/sonner.tsx +0 -25
  83. package/landing-page/src/app/components/ui/switch.tsx +0 -31
  84. package/landing-page/src/app/components/ui/table.tsx +0 -116
  85. package/landing-page/src/app/components/ui/tabs.tsx +0 -66
  86. package/landing-page/src/app/components/ui/textarea.tsx +0 -18
  87. package/landing-page/src/app/components/ui/toggle-group.tsx +0 -73
  88. package/landing-page/src/app/components/ui/toggle.tsx +0 -47
  89. package/landing-page/src/app/components/ui/tooltip.tsx +0 -61
  90. package/landing-page/src/app/components/ui/use-mobile.ts +0 -21
  91. package/landing-page/src/app/components/ui/utils.ts +0 -6
  92. package/landing-page/src/components/docs/DocsContent.tsx +0 -718
  93. package/landing-page/src/components/docs/DocsSidebar.tsx +0 -84
  94. package/landing-page/src/components/landing/CTA.tsx +0 -59
  95. package/landing-page/src/components/landing/Comparison.tsx +0 -84
  96. package/landing-page/src/components/landing/FaultyTerminal.tsx +0 -424
  97. package/landing-page/src/components/landing/Features.tsx +0 -201
  98. package/landing-page/src/components/landing/Hero.tsx +0 -142
  99. package/landing-page/src/components/landing/Pricing.tsx +0 -140
  100. package/landing-page/src/components/landing/Roadmap.tsx +0 -86
  101. package/landing-page/src/components/landing/Security.tsx +0 -81
  102. package/landing-page/src/components/landing/TerminalWindow.tsx +0 -27
  103. package/landing-page/src/components/landing/UseCases.tsx +0 -55
  104. package/landing-page/src/components/landing/Workflow.tsx +0 -101
  105. package/landing-page/src/components/layout/DashboardNavbar.tsx +0 -37
  106. package/landing-page/src/components/layout/Footer.tsx +0 -55
  107. package/landing-page/src/components/layout/LandingNavbar.tsx +0 -82
  108. package/landing-page/src/components/ui/badge.tsx +0 -39
  109. package/landing-page/src/components/ui/breadcrumb.tsx +0 -115
  110. package/landing-page/src/components/ui/button.tsx +0 -57
  111. package/landing-page/src/components/ui/card.tsx +0 -79
  112. package/landing-page/src/components/ui/mock-terminal.tsx +0 -68
  113. package/landing-page/src/components/ui/separator.tsx +0 -28
  114. package/landing-page/src/lib/utils.ts +0 -6
  115. package/landing-page/src/main.tsx +0 -10
  116. package/landing-page/src/pages/Dashboard.tsx +0 -133
  117. package/landing-page/src/pages/DocsPage.tsx +0 -79
  118. package/landing-page/src/pages/LandingPage.tsx +0 -31
  119. package/landing-page/src/pages/TerminalView.tsx +0 -106
  120. package/landing-page/src/styles/fonts.css +0 -0
  121. package/landing-page/src/styles/index.css +0 -3
  122. package/landing-page/src/styles/tailwind.css +0 -4
  123. package/landing-page/src/styles/theme.css +0 -181
  124. package/landing-page/vite.config.ts +0 -19
  125. package/npm/darwin-arm64/bin/gssh +0 -0
  126. package/npm/darwin-arm64/package.json +0 -20
  127. package/scripts/build.ts +0 -298
  128. package/scripts/release.ts +0 -140
  129. package/src/__tests__/test-utils.ts +0 -298
  130. package/src/commands/__tests__/serve-messages.test.ts +0 -190
  131. package/src/commands/access.ts +0 -298
  132. package/src/commands/add.ts +0 -452
  133. package/src/commands/auth.ts +0 -364
  134. package/src/commands/connect.ts +0 -287
  135. package/src/commands/directory.ts +0 -16
  136. package/src/commands/host.ts +0 -396
  137. package/src/commands/identity.ts +0 -184
  138. package/src/commands/list.ts +0 -200
  139. package/src/commands/relay.ts +0 -315
  140. package/src/commands/remove.ts +0 -241
  141. package/src/commands/serve.ts +0 -1493
  142. package/src/commands/share.ts +0 -456
  143. package/src/commands/status.ts +0 -125
  144. package/src/commands/switch.ts +0 -353
  145. package/src/commands/tmux.ts +0 -317
  146. package/src/core/__tests__/access.test.ts +0 -240
  147. package/src/core/access.ts +0 -277
  148. package/src/core/bundle.ts +0 -342
  149. package/src/core/config.ts +0 -510
  150. package/src/core/git.ts +0 -317
  151. package/src/core/github.ts +0 -151
  152. package/src/core/identity.ts +0 -631
  153. package/src/core/linear.ts +0 -225
  154. package/src/core/shell.ts +0 -161
  155. package/src/core/trusted-relays.ts +0 -315
  156. package/src/index.ts +0 -810
  157. package/src/lib/remote-session/index.ts +0 -7
  158. package/src/lib/remote-session/protocol.ts +0 -267
  159. package/src/lib/remote-session/session-handler.ts +0 -581
  160. package/src/lib/remote-session/workspace-scanner.ts +0 -167
  161. package/src/lib/tmux-lite/README.md +0 -81
  162. package/src/lib/tmux-lite/cli.ts +0 -796
  163. package/src/lib/tmux-lite/crypto/__tests__/helpers/handshake-runner.ts +0 -349
  164. package/src/lib/tmux-lite/crypto/__tests__/helpers/mock-relay.ts +0 -291
  165. package/src/lib/tmux-lite/crypto/__tests__/helpers/test-identities.ts +0 -142
  166. package/src/lib/tmux-lite/crypto/__tests__/integration/authorization.integration.test.ts +0 -339
  167. package/src/lib/tmux-lite/crypto/__tests__/integration/e2e-communication.integration.test.ts +0 -477
  168. package/src/lib/tmux-lite/crypto/__tests__/integration/error-handling.integration.test.ts +0 -499
  169. package/src/lib/tmux-lite/crypto/__tests__/integration/handshake.integration.test.ts +0 -371
  170. package/src/lib/tmux-lite/crypto/__tests__/integration/security.integration.test.ts +0 -573
  171. package/src/lib/tmux-lite/crypto/access-control.test.ts +0 -512
  172. package/src/lib/tmux-lite/crypto/access-control.ts +0 -320
  173. package/src/lib/tmux-lite/crypto/frames.test.ts +0 -262
  174. package/src/lib/tmux-lite/crypto/frames.ts +0 -141
  175. package/src/lib/tmux-lite/crypto/handshake.ts +0 -894
  176. package/src/lib/tmux-lite/crypto/identity.test.ts +0 -220
  177. package/src/lib/tmux-lite/crypto/identity.ts +0 -286
  178. package/src/lib/tmux-lite/crypto/index.ts +0 -51
  179. package/src/lib/tmux-lite/crypto/invites.test.ts +0 -381
  180. package/src/lib/tmux-lite/crypto/invites.ts +0 -215
  181. package/src/lib/tmux-lite/crypto/keyexchange.ts +0 -435
  182. package/src/lib/tmux-lite/crypto/keys.test.ts +0 -58
  183. package/src/lib/tmux-lite/crypto/keys.ts +0 -47
  184. package/src/lib/tmux-lite/crypto/secretbox.test.ts +0 -169
  185. package/src/lib/tmux-lite/crypto/secretbox.ts +0 -124
  186. package/src/lib/tmux-lite/handshake-handler.ts +0 -451
  187. package/src/lib/tmux-lite/protocol.test.ts +0 -307
  188. package/src/lib/tmux-lite/protocol.ts +0 -266
  189. package/src/lib/tmux-lite/relay-client.ts +0 -506
  190. package/src/lib/tmux-lite/server.ts +0 -1250
  191. package/src/lib/tmux-lite/shell-integration.sh +0 -37
  192. package/src/lib/tmux-lite/terminal-queries.test.ts +0 -54
  193. package/src/lib/tmux-lite/terminal-queries.ts +0 -49
  194. package/src/relay/__tests__/e2e-flow.test.ts +0 -1284
  195. package/src/relay/__tests__/helpers/auth.ts +0 -354
  196. package/src/relay/__tests__/helpers/ports.ts +0 -51
  197. package/src/relay/__tests__/protocol-validation.test.ts +0 -265
  198. package/src/relay/authorization.ts +0 -303
  199. package/src/relay/embedded-assets.generated.d.ts +0 -15
  200. package/src/relay/identity.ts +0 -352
  201. package/src/relay/index.ts +0 -57
  202. package/src/relay/pipes.test.ts +0 -427
  203. package/src/relay/pipes.ts +0 -195
  204. package/src/relay/protocol.ts +0 -804
  205. package/src/relay/registries.test.ts +0 -437
  206. package/src/relay/registries.ts +0 -593
  207. package/src/relay/server.test.ts +0 -1323
  208. package/src/relay/server.ts +0 -1092
  209. package/src/relay/signing.ts +0 -238
  210. package/src/relay/types.ts +0 -69
  211. package/src/serve/client-session-manager.ts +0 -622
  212. package/src/serve/daemon.ts +0 -497
  213. package/src/serve/pty-session.ts +0 -236
  214. package/src/serve/types.ts +0 -169
  215. package/src/shared/components/Flow.tsx +0 -453
  216. package/src/shared/components/Flow.tui.tsx +0 -343
  217. package/src/shared/components/Flow.web.tsx +0 -442
  218. package/src/shared/components/Inbox.tsx +0 -446
  219. package/src/shared/components/Inbox.tui.tsx +0 -262
  220. package/src/shared/components/Inbox.web.tsx +0 -329
  221. package/src/shared/components/MachineList.tsx +0 -187
  222. package/src/shared/components/MachineList.tui.tsx +0 -161
  223. package/src/shared/components/MachineList.web.tsx +0 -210
  224. package/src/shared/components/ProjectList.tsx +0 -176
  225. package/src/shared/components/ProjectList.tui.tsx +0 -109
  226. package/src/shared/components/ProjectList.web.tsx +0 -143
  227. package/src/shared/components/SpacesBrowser.tsx +0 -332
  228. package/src/shared/components/SpacesBrowser.tui.tsx +0 -163
  229. package/src/shared/components/SpacesBrowser.web.tsx +0 -221
  230. package/src/shared/components/index.ts +0 -103
  231. package/src/shared/hooks/index.ts +0 -16
  232. package/src/shared/hooks/useNavigation.ts +0 -226
  233. package/src/shared/index.ts +0 -122
  234. package/src/shared/providers/LocalMachineProvider.ts +0 -425
  235. package/src/shared/providers/MachineProvider.ts +0 -165
  236. package/src/shared/providers/RemoteMachineProvider.ts +0 -444
  237. package/src/shared/providers/index.ts +0 -26
  238. package/src/shared/types.ts +0 -145
  239. package/src/tui/adapters.ts +0 -120
  240. package/src/tui/app.tsx +0 -1816
  241. package/src/tui/components/Terminal.tsx +0 -580
  242. package/src/tui/hooks/index.ts +0 -35
  243. package/src/tui/hooks/useAppState.ts +0 -314
  244. package/src/tui/hooks/useDaemonStatus.ts +0 -174
  245. package/src/tui/hooks/useInboxTUI.ts +0 -113
  246. package/src/tui/hooks/useRemoteMachines.ts +0 -209
  247. package/src/tui/index.ts +0 -24
  248. package/src/tui/state.ts +0 -299
  249. package/src/tui/terminal-bracketed-paste.test.ts +0 -45
  250. package/src/tui/terminal-bracketed-paste.ts +0 -47
  251. package/src/types/bundle.ts +0 -112
  252. package/src/types/config.ts +0 -89
  253. package/src/types/errors.ts +0 -206
  254. package/src/types/identity.ts +0 -284
  255. package/src/types/workspace-fuzzy.ts +0 -49
  256. package/src/types/workspace.ts +0 -151
  257. package/src/utils/bun-socket-writer.ts +0 -80
  258. package/src/utils/deps.ts +0 -127
  259. package/src/utils/fuzzy-match.ts +0 -125
  260. package/src/utils/logger.ts +0 -127
  261. package/src/utils/markdown.ts +0 -254
  262. package/src/utils/onboarding.ts +0 -229
  263. package/src/utils/prompts.ts +0 -114
  264. package/src/utils/run-commands.ts +0 -112
  265. package/src/utils/run-scripts.ts +0 -142
  266. package/src/utils/sanitize.ts +0 -98
  267. package/src/utils/secrets.ts +0 -122
  268. package/src/utils/shell-escape.ts +0 -40
  269. package/src/utils/utf8.ts +0 -79
  270. package/src/utils/workspace-state.ts +0 -47
  271. package/src/web/README.md +0 -73
  272. package/src/web/bun.lock +0 -575
  273. package/src/web/eslint.config.js +0 -23
  274. package/src/web/index.html +0 -16
  275. package/src/web/package.json +0 -37
  276. package/src/web/public/vite.svg +0 -1
  277. package/src/web/src/App.tsx +0 -604
  278. package/src/web/src/assets/react.svg +0 -1
  279. package/src/web/src/components/Terminal.tsx +0 -207
  280. package/src/web/src/hooks/useRelayConnection.ts +0 -224
  281. package/src/web/src/hooks/useTerminal.ts +0 -699
  282. package/src/web/src/index.css +0 -55
  283. package/src/web/src/lib/crypto/__tests__/web-terminal.test.ts +0 -1158
  284. package/src/web/src/lib/crypto/frames.ts +0 -205
  285. package/src/web/src/lib/crypto/handshake.ts +0 -396
  286. package/src/web/src/lib/crypto/identity.ts +0 -128
  287. package/src/web/src/lib/crypto/keyexchange.ts +0 -246
  288. package/src/web/src/lib/crypto/relay-signing.ts +0 -53
  289. package/src/web/src/lib/invite.ts +0 -58
  290. package/src/web/src/lib/storage/identity-store.ts +0 -94
  291. package/src/web/src/main.tsx +0 -10
  292. package/src/web/src/types/identity.ts +0 -45
  293. package/src/web/tsconfig.app.json +0 -28
  294. package/src/web/tsconfig.json +0 -7
  295. package/src/web/tsconfig.node.json +0 -26
  296. package/src/web/vite.config.ts +0 -31
  297. package/todo-security.md +0 -92
  298. package/tsconfig.json +0 -23
  299. package/worker/.wrangler/state/v3/d1/miniflare-D1DatabaseObject/12b7107e435bf1b9a8713a7f320472a63e543104d633d89a26f8d21f4e4ef182.sqlite +0 -0
  300. package/worker/.wrangler/state/v3/d1/miniflare-D1DatabaseObject/12b7107e435bf1b9a8713a7f320472a63e543104d633d89a26f8d21f4e4ef182.sqlite-shm +0 -0
  301. package/worker/.wrangler/state/v3/d1/miniflare-D1DatabaseObject/12b7107e435bf1b9a8713a7f320472a63e543104d633d89a26f8d21f4e4ef182.sqlite-wal +0 -0
  302. package/worker/.wrangler/state/v3/d1/miniflare-D1DatabaseObject/1a1ac3db1ab86ecf712f90322868a9aabc2c7dc9fe2dfbe94f9b075096276b0f.sqlite +0 -0
  303. package/worker/.wrangler/state/v3/d1/miniflare-D1DatabaseObject/1a1ac3db1ab86ecf712f90322868a9aabc2c7dc9fe2dfbe94f9b075096276b0f.sqlite-shm +0 -0
  304. package/worker/.wrangler/state/v3/d1/miniflare-D1DatabaseObject/1a1ac3db1ab86ecf712f90322868a9aabc2c7dc9fe2dfbe94f9b075096276b0f.sqlite-wal +0 -0
  305. package/worker/bun.lock +0 -237
  306. package/worker/package.json +0 -22
  307. package/worker/schema.sql +0 -96
  308. package/worker/src/handlers/auth.ts +0 -451
  309. package/worker/src/handlers/subdomains.ts +0 -376
  310. package/worker/src/handlers/user.ts +0 -98
  311. package/worker/src/index.ts +0 -70
  312. package/worker/src/middleware/auth.ts +0 -152
  313. package/worker/src/services/cloudflare.ts +0 -609
  314. package/worker/src/types.ts +0 -96
  315. package/worker/tsconfig.json +0 -15
  316. package/worker/wrangler.toml +0 -26
@@ -1,1493 +0,0 @@
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
- }