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.
- package/.claude/settings.local.json +21 -0
- package/.gitspace/bundle.json +50 -0
- package/.gitspace/select/01-status.sh +40 -0
- package/.gitspace/setup/01-install-deps.sh +12 -0
- package/.gitspace/setup/02-typecheck.sh +16 -0
- package/AGENTS.md +439 -0
- package/CLAUDE.md +1 -0
- package/LICENSE +25 -0
- package/README.md +607 -0
- package/bin/gssh +62 -0
- package/bun.lock +647 -0
- package/docs/CONNECTION.md +623 -0
- package/docs/GATEWAY-WORKER.md +319 -0
- package/docs/GETTING-STARTED.md +448 -0
- package/docs/GITSPACE-PLATFORM.md +1819 -0
- package/docs/INFRASTRUCTURE.md +1347 -0
- package/docs/PROTOCOL.md +619 -0
- package/docs/QUICKSTART.md +174 -0
- package/docs/RELAY.md +327 -0
- package/docs/REMOTE-DESIGN.md +549 -0
- package/docs/ROADMAP.md +564 -0
- package/docs/SITE_DOCS_FIGMA_MAKE.md +1167 -0
- package/docs/STACK-DESIGN.md +588 -0
- package/docs/UNIFIED_ARCHITECTURE.md +292 -0
- package/experiments/pty-benchmark.ts +148 -0
- package/experiments/pty-latency.ts +100 -0
- package/experiments/router/client.ts +199 -0
- package/experiments/router/protocol.ts +74 -0
- package/experiments/router/router.ts +217 -0
- package/experiments/router/session.ts +180 -0
- package/experiments/router/test.ts +133 -0
- package/experiments/socket-bandwidth.ts +77 -0
- package/homebrew/gitspace.rb +45 -0
- package/landing-page/ATTRIBUTIONS.md +3 -0
- package/landing-page/README.md +11 -0
- package/landing-page/bun.lock +801 -0
- package/landing-page/guidelines/Guidelines.md +61 -0
- package/landing-page/index.html +37 -0
- package/landing-page/package.json +90 -0
- package/landing-page/postcss.config.mjs +15 -0
- package/landing-page/public/_redirects +1 -0
- package/landing-page/public/favicon.png +0 -0
- package/landing-page/src/app/App.tsx +53 -0
- package/landing-page/src/app/components/figma/ImageWithFallback.tsx +27 -0
- package/landing-page/src/app/components/ui/accordion.tsx +66 -0
- package/landing-page/src/app/components/ui/alert-dialog.tsx +157 -0
- package/landing-page/src/app/components/ui/alert.tsx +66 -0
- package/landing-page/src/app/components/ui/aspect-ratio.tsx +11 -0
- package/landing-page/src/app/components/ui/avatar.tsx +53 -0
- package/landing-page/src/app/components/ui/badge.tsx +46 -0
- package/landing-page/src/app/components/ui/breadcrumb.tsx +109 -0
- package/landing-page/src/app/components/ui/button.tsx +57 -0
- package/landing-page/src/app/components/ui/calendar.tsx +75 -0
- package/landing-page/src/app/components/ui/card.tsx +92 -0
- package/landing-page/src/app/components/ui/carousel.tsx +241 -0
- package/landing-page/src/app/components/ui/chart.tsx +353 -0
- package/landing-page/src/app/components/ui/checkbox.tsx +32 -0
- package/landing-page/src/app/components/ui/collapsible.tsx +33 -0
- package/landing-page/src/app/components/ui/command.tsx +177 -0
- package/landing-page/src/app/components/ui/context-menu.tsx +252 -0
- package/landing-page/src/app/components/ui/dialog.tsx +135 -0
- package/landing-page/src/app/components/ui/drawer.tsx +132 -0
- package/landing-page/src/app/components/ui/dropdown-menu.tsx +257 -0
- package/landing-page/src/app/components/ui/form.tsx +168 -0
- package/landing-page/src/app/components/ui/hover-card.tsx +44 -0
- package/landing-page/src/app/components/ui/input-otp.tsx +77 -0
- package/landing-page/src/app/components/ui/input.tsx +21 -0
- package/landing-page/src/app/components/ui/label.tsx +24 -0
- package/landing-page/src/app/components/ui/menubar.tsx +276 -0
- package/landing-page/src/app/components/ui/navigation-menu.tsx +168 -0
- package/landing-page/src/app/components/ui/pagination.tsx +127 -0
- package/landing-page/src/app/components/ui/popover.tsx +48 -0
- package/landing-page/src/app/components/ui/progress.tsx +31 -0
- package/landing-page/src/app/components/ui/radio-group.tsx +45 -0
- package/landing-page/src/app/components/ui/resizable.tsx +56 -0
- package/landing-page/src/app/components/ui/scroll-area.tsx +58 -0
- package/landing-page/src/app/components/ui/select.tsx +189 -0
- package/landing-page/src/app/components/ui/separator.tsx +28 -0
- package/landing-page/src/app/components/ui/sheet.tsx +139 -0
- package/landing-page/src/app/components/ui/sidebar.tsx +726 -0
- package/landing-page/src/app/components/ui/skeleton.tsx +13 -0
- package/landing-page/src/app/components/ui/slider.tsx +63 -0
- package/landing-page/src/app/components/ui/sonner.tsx +25 -0
- package/landing-page/src/app/components/ui/switch.tsx +31 -0
- package/landing-page/src/app/components/ui/table.tsx +116 -0
- package/landing-page/src/app/components/ui/tabs.tsx +66 -0
- package/landing-page/src/app/components/ui/textarea.tsx +18 -0
- package/landing-page/src/app/components/ui/toggle-group.tsx +73 -0
- package/landing-page/src/app/components/ui/toggle.tsx +47 -0
- package/landing-page/src/app/components/ui/tooltip.tsx +61 -0
- package/landing-page/src/app/components/ui/use-mobile.ts +21 -0
- package/landing-page/src/app/components/ui/utils.ts +6 -0
- package/landing-page/src/components/docs/DocsContent.tsx +718 -0
- package/landing-page/src/components/docs/DocsSidebar.tsx +84 -0
- package/landing-page/src/components/landing/CTA.tsx +59 -0
- package/landing-page/src/components/landing/Comparison.tsx +84 -0
- package/landing-page/src/components/landing/FaultyTerminal.tsx +424 -0
- package/landing-page/src/components/landing/Features.tsx +201 -0
- package/landing-page/src/components/landing/Hero.tsx +142 -0
- package/landing-page/src/components/landing/Pricing.tsx +140 -0
- package/landing-page/src/components/landing/Roadmap.tsx +86 -0
- package/landing-page/src/components/landing/Security.tsx +81 -0
- package/landing-page/src/components/landing/TerminalWindow.tsx +27 -0
- package/landing-page/src/components/landing/UseCases.tsx +55 -0
- package/landing-page/src/components/landing/Workflow.tsx +101 -0
- package/landing-page/src/components/layout/DashboardNavbar.tsx +37 -0
- package/landing-page/src/components/layout/Footer.tsx +55 -0
- package/landing-page/src/components/layout/LandingNavbar.tsx +82 -0
- package/landing-page/src/components/ui/badge.tsx +39 -0
- package/landing-page/src/components/ui/breadcrumb.tsx +115 -0
- package/landing-page/src/components/ui/button.tsx +57 -0
- package/landing-page/src/components/ui/card.tsx +79 -0
- package/landing-page/src/components/ui/mock-terminal.tsx +68 -0
- package/landing-page/src/components/ui/separator.tsx +28 -0
- package/landing-page/src/lib/utils.ts +6 -0
- package/landing-page/src/main.tsx +10 -0
- package/landing-page/src/pages/Dashboard.tsx +133 -0
- package/landing-page/src/pages/DocsPage.tsx +79 -0
- package/landing-page/src/pages/LandingPage.tsx +31 -0
- package/landing-page/src/pages/TerminalView.tsx +106 -0
- package/landing-page/src/styles/fonts.css +0 -0
- package/landing-page/src/styles/index.css +3 -0
- package/landing-page/src/styles/tailwind.css +4 -0
- package/landing-page/src/styles/theme.css +181 -0
- package/landing-page/vite.config.ts +19 -0
- package/npm/darwin-arm64/bin/gssh +0 -0
- package/npm/darwin-arm64/package.json +20 -0
- package/package.json +74 -0
- package/scripts/build.ts +284 -0
- package/scripts/release.ts +140 -0
- package/src/__tests__/test-utils.ts +298 -0
- package/src/commands/__tests__/serve-messages.test.ts +190 -0
- package/src/commands/access.ts +298 -0
- package/src/commands/add.ts +452 -0
- package/src/commands/auth.ts +364 -0
- package/src/commands/connect.ts +287 -0
- package/src/commands/directory.ts +16 -0
- package/src/commands/host.ts +396 -0
- package/src/commands/identity.ts +184 -0
- package/src/commands/list.ts +200 -0
- package/src/commands/relay.ts +315 -0
- package/src/commands/remove.ts +241 -0
- package/src/commands/serve.ts +1493 -0
- package/src/commands/share.ts +456 -0
- package/src/commands/status.ts +125 -0
- package/src/commands/switch.ts +353 -0
- package/src/commands/tmux.ts +317 -0
- package/src/core/__tests__/access.test.ts +240 -0
- package/src/core/access.ts +277 -0
- package/src/core/bundle.ts +342 -0
- package/src/core/config.ts +510 -0
- package/src/core/git.ts +317 -0
- package/src/core/github.ts +151 -0
- package/src/core/identity.ts +631 -0
- package/src/core/linear.ts +225 -0
- package/src/core/shell.ts +161 -0
- package/src/core/trusted-relays.ts +315 -0
- package/src/index.ts +821 -0
- package/src/lib/remote-session/index.ts +7 -0
- package/src/lib/remote-session/protocol.ts +267 -0
- package/src/lib/remote-session/session-handler.ts +581 -0
- package/src/lib/remote-session/workspace-scanner.ts +167 -0
- package/src/lib/tmux-lite/README.md +81 -0
- package/src/lib/tmux-lite/cli.ts +796 -0
- package/src/lib/tmux-lite/crypto/__tests__/helpers/handshake-runner.ts +349 -0
- package/src/lib/tmux-lite/crypto/__tests__/helpers/mock-relay.ts +291 -0
- package/src/lib/tmux-lite/crypto/__tests__/helpers/test-identities.ts +142 -0
- package/src/lib/tmux-lite/crypto/__tests__/integration/authorization.integration.test.ts +339 -0
- package/src/lib/tmux-lite/crypto/__tests__/integration/e2e-communication.integration.test.ts +477 -0
- package/src/lib/tmux-lite/crypto/__tests__/integration/error-handling.integration.test.ts +499 -0
- package/src/lib/tmux-lite/crypto/__tests__/integration/handshake.integration.test.ts +371 -0
- package/src/lib/tmux-lite/crypto/__tests__/integration/security.integration.test.ts +573 -0
- package/src/lib/tmux-lite/crypto/access-control.test.ts +512 -0
- package/src/lib/tmux-lite/crypto/access-control.ts +320 -0
- package/src/lib/tmux-lite/crypto/frames.test.ts +262 -0
- package/src/lib/tmux-lite/crypto/frames.ts +141 -0
- package/src/lib/tmux-lite/crypto/handshake.ts +894 -0
- package/src/lib/tmux-lite/crypto/identity.test.ts +220 -0
- package/src/lib/tmux-lite/crypto/identity.ts +286 -0
- package/src/lib/tmux-lite/crypto/index.ts +51 -0
- package/src/lib/tmux-lite/crypto/invites.test.ts +381 -0
- package/src/lib/tmux-lite/crypto/invites.ts +215 -0
- package/src/lib/tmux-lite/crypto/keyexchange.ts +435 -0
- package/src/lib/tmux-lite/crypto/keys.test.ts +58 -0
- package/src/lib/tmux-lite/crypto/keys.ts +47 -0
- package/src/lib/tmux-lite/crypto/secretbox.test.ts +169 -0
- package/src/lib/tmux-lite/crypto/secretbox.ts +124 -0
- package/src/lib/tmux-lite/handshake-handler.ts +451 -0
- package/src/lib/tmux-lite/protocol.test.ts +307 -0
- package/src/lib/tmux-lite/protocol.ts +266 -0
- package/src/lib/tmux-lite/relay-client.ts +506 -0
- package/src/lib/tmux-lite/server.ts +1250 -0
- package/src/lib/tmux-lite/shell-integration.sh +37 -0
- package/src/lib/tmux-lite/terminal-queries.test.ts +54 -0
- package/src/lib/tmux-lite/terminal-queries.ts +49 -0
- package/src/relay/__tests__/e2e-flow.test.ts +1284 -0
- package/src/relay/__tests__/helpers/auth.ts +354 -0
- package/src/relay/__tests__/helpers/ports.ts +51 -0
- package/src/relay/__tests__/protocol-validation.test.ts +265 -0
- package/src/relay/authorization.ts +303 -0
- package/src/relay/embedded-assets.generated.d.ts +15 -0
- package/src/relay/identity.ts +352 -0
- package/src/relay/index.ts +57 -0
- package/src/relay/pipes.test.ts +427 -0
- package/src/relay/pipes.ts +195 -0
- package/src/relay/protocol.ts +804 -0
- package/src/relay/registries.test.ts +437 -0
- package/src/relay/registries.ts +593 -0
- package/src/relay/server.test.ts +1323 -0
- package/src/relay/server.ts +1092 -0
- package/src/relay/signing.ts +238 -0
- package/src/relay/types.ts +69 -0
- package/src/serve/client-session-manager.ts +622 -0
- package/src/serve/daemon.ts +497 -0
- package/src/serve/pty-session.ts +236 -0
- package/src/serve/types.ts +169 -0
- package/src/shared/components/Flow.tsx +453 -0
- package/src/shared/components/Flow.tui.tsx +343 -0
- package/src/shared/components/Flow.web.tsx +442 -0
- package/src/shared/components/Inbox.tsx +446 -0
- package/src/shared/components/Inbox.tui.tsx +262 -0
- package/src/shared/components/Inbox.web.tsx +329 -0
- package/src/shared/components/MachineList.tsx +187 -0
- package/src/shared/components/MachineList.tui.tsx +161 -0
- package/src/shared/components/MachineList.web.tsx +210 -0
- package/src/shared/components/ProjectList.tsx +176 -0
- package/src/shared/components/ProjectList.tui.tsx +109 -0
- package/src/shared/components/ProjectList.web.tsx +143 -0
- package/src/shared/components/SpacesBrowser.tsx +332 -0
- package/src/shared/components/SpacesBrowser.tui.tsx +163 -0
- package/src/shared/components/SpacesBrowser.web.tsx +221 -0
- package/src/shared/components/index.ts +103 -0
- package/src/shared/hooks/index.ts +16 -0
- package/src/shared/hooks/useNavigation.ts +226 -0
- package/src/shared/index.ts +122 -0
- package/src/shared/providers/LocalMachineProvider.ts +425 -0
- package/src/shared/providers/MachineProvider.ts +165 -0
- package/src/shared/providers/RemoteMachineProvider.ts +444 -0
- package/src/shared/providers/index.ts +26 -0
- package/src/shared/types.ts +145 -0
- package/src/tui/adapters.ts +120 -0
- package/src/tui/app.tsx +1816 -0
- package/src/tui/components/Terminal.tsx +580 -0
- package/src/tui/hooks/index.ts +35 -0
- package/src/tui/hooks/useAppState.ts +314 -0
- package/src/tui/hooks/useDaemonStatus.ts +174 -0
- package/src/tui/hooks/useInboxTUI.ts +113 -0
- package/src/tui/hooks/useRemoteMachines.ts +209 -0
- package/src/tui/index.ts +24 -0
- package/src/tui/state.ts +299 -0
- package/src/tui/terminal-bracketed-paste.test.ts +45 -0
- package/src/tui/terminal-bracketed-paste.ts +47 -0
- package/src/types/bundle.ts +112 -0
- package/src/types/config.ts +89 -0
- package/src/types/errors.ts +206 -0
- package/src/types/identity.ts +284 -0
- package/src/types/workspace-fuzzy.ts +49 -0
- package/src/types/workspace.ts +151 -0
- package/src/utils/bun-socket-writer.ts +80 -0
- package/src/utils/deps.ts +127 -0
- package/src/utils/fuzzy-match.ts +125 -0
- package/src/utils/logger.ts +127 -0
- package/src/utils/markdown.ts +254 -0
- package/src/utils/onboarding.ts +229 -0
- package/src/utils/prompts.ts +114 -0
- package/src/utils/run-commands.ts +112 -0
- package/src/utils/run-scripts.ts +142 -0
- package/src/utils/sanitize.ts +98 -0
- package/src/utils/secrets.ts +122 -0
- package/src/utils/shell-escape.ts +40 -0
- package/src/utils/utf8.ts +79 -0
- package/src/utils/workspace-state.ts +47 -0
- package/src/web/README.md +73 -0
- package/src/web/bun.lock +575 -0
- package/src/web/eslint.config.js +23 -0
- package/src/web/index.html +16 -0
- package/src/web/package.json +37 -0
- package/src/web/public/vite.svg +1 -0
- package/src/web/src/App.tsx +604 -0
- package/src/web/src/assets/react.svg +1 -0
- package/src/web/src/components/Terminal.tsx +207 -0
- package/src/web/src/hooks/useRelayConnection.ts +224 -0
- package/src/web/src/hooks/useTerminal.ts +699 -0
- package/src/web/src/index.css +55 -0
- package/src/web/src/lib/crypto/__tests__/web-terminal.test.ts +1158 -0
- package/src/web/src/lib/crypto/frames.ts +205 -0
- package/src/web/src/lib/crypto/handshake.ts +396 -0
- package/src/web/src/lib/crypto/identity.ts +128 -0
- package/src/web/src/lib/crypto/keyexchange.ts +246 -0
- package/src/web/src/lib/crypto/relay-signing.ts +53 -0
- package/src/web/src/lib/invite.ts +58 -0
- package/src/web/src/lib/storage/identity-store.ts +94 -0
- package/src/web/src/main.tsx +10 -0
- package/src/web/src/types/identity.ts +45 -0
- package/src/web/tsconfig.app.json +28 -0
- package/src/web/tsconfig.json +7 -0
- package/src/web/tsconfig.node.json +26 -0
- package/src/web/vite.config.ts +31 -0
- package/todo-security.md +92 -0
- package/tsconfig.json +23 -0
- package/worker/.wrangler/state/v3/d1/miniflare-D1DatabaseObject/12b7107e435bf1b9a8713a7f320472a63e543104d633d89a26f8d21f4e4ef182.sqlite +0 -0
- package/worker/.wrangler/state/v3/d1/miniflare-D1DatabaseObject/12b7107e435bf1b9a8713a7f320472a63e543104d633d89a26f8d21f4e4ef182.sqlite-shm +0 -0
- package/worker/.wrangler/state/v3/d1/miniflare-D1DatabaseObject/12b7107e435bf1b9a8713a7f320472a63e543104d633d89a26f8d21f4e4ef182.sqlite-wal +0 -0
- package/worker/.wrangler/state/v3/d1/miniflare-D1DatabaseObject/1a1ac3db1ab86ecf712f90322868a9aabc2c7dc9fe2dfbe94f9b075096276b0f.sqlite +0 -0
- package/worker/.wrangler/state/v3/d1/miniflare-D1DatabaseObject/1a1ac3db1ab86ecf712f90322868a9aabc2c7dc9fe2dfbe94f9b075096276b0f.sqlite-shm +0 -0
- package/worker/.wrangler/state/v3/d1/miniflare-D1DatabaseObject/1a1ac3db1ab86ecf712f90322868a9aabc2c7dc9fe2dfbe94f9b075096276b0f.sqlite-wal +0 -0
- package/worker/bun.lock +237 -0
- package/worker/package.json +22 -0
- package/worker/schema.sql +96 -0
- package/worker/src/handlers/auth.ts +451 -0
- package/worker/src/handlers/subdomains.ts +376 -0
- package/worker/src/handlers/user.ts +98 -0
- package/worker/src/index.ts +70 -0
- package/worker/src/middleware/auth.ts +152 -0
- package/worker/src/services/cloudflare.ts +609 -0
- package/worker/src/types.ts +96 -0
- package/worker/tsconfig.json +15 -0
- 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
|
+
}
|