gitspace 0.2.0-rc.3 → 0.2.0-rc.4

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.
@@ -17,7 +17,9 @@
17
17
  "WebFetch(domain:developers.cloudflare.com)",
18
18
  "WebFetch(domain:gitspace.sh)",
19
19
  "Bash(/Users/bradleat/spaces/spaces/workspaces/remote-space/dist/gssh-darwin-arm64:*)",
20
- "Bash(npx wrangler pages deploy:*)"
20
+ "Bash(npx wrangler pages deploy:*)",
21
+ "Bash(git add:*)",
22
+ "Bash(git commit:*)"
21
23
  ]
22
24
  }
23
25
  }
package/AGENTS.md CHANGED
@@ -215,10 +215,11 @@ src/
215
215
  | Command | Description |
216
216
  |---------|-------------|
217
217
  | `gssh relay start` | Start relay server |
218
- | `gssh relay token` | Create account JWT (HMAC) |
219
- | `gssh relay authorize <pubkey>` | Authorize a client on relay |
220
- | `gssh relay revoke <id>` | Revoke client authorization |
218
+ | `gssh relay authorize <pubkey>` | Authorize a machine on relay |
219
+ | `gssh relay revoke <id>` | Revoke machine authorization |
221
220
  | `gssh relay machines` | List authorized machines |
221
+ | `gssh relay trusted` | List trusted relays |
222
+ | `gssh relay untrust <url>` | Remove relay from trusted list |
222
223
 
223
224
  ### tmux-lite Daemon
224
225
  | Command | Description |
@@ -263,10 +264,19 @@ src/
263
264
  └─────────────────┘ └─────────────────┘ └─────────────────┘
264
265
  ```
265
266
 
266
- ### Connection Flow
267
+ ### Connection Flow (gitspace.sh Hosting)
267
268
 
268
- 1. Machine runs `gssh serve --relay <url> --token <jwt>`
269
- 2. Machine registers with relay (sends public keys)
269
+ 1. User logs in: `gssh auth login` (GitHub OAuth)
270
+ 2. User reserves subdomain: `gssh host reserve <name>`
271
+ 3. Machine runs `gssh serve start` (auto-starts local relay + cloudflared tunnel)
272
+ 4. Client connects via web: `https://<name>.gitspace.sh`
273
+ 5. X3DH handshake establishes session keys
274
+ 6. All terminal I/O is E2E encrypted
275
+
276
+ ### Connection Flow (Self-Hosted Relay)
277
+
278
+ 1. Machine runs `gssh serve start --relay ws://relay:4480/ws`
279
+ 2. Machine registers with relay (Ed25519 challenge-response auth)
270
280
  3. Machine creates invite: `gssh share create`
271
281
  4. Client connects with invite: `gssh connect <token>`
272
282
  5. X3DH handshake establishes session keys
@@ -280,7 +290,7 @@ src/
280
290
  | Key exchange | X25519 |
281
291
  | Symmetric encryption | AES-256-GCM |
282
292
  | Key derivation | HKDF-SHA256 |
283
- | JWT signing | HMAC-SHA256 |
293
+ | Relay authentication | Ed25519 challenge-response |
284
294
 
285
295
  ## TUI/Web Shared Component Pattern
286
296
 
@@ -322,11 +332,14 @@ bun run build
322
332
  # Run TUI
323
333
  bun src/index.ts
324
334
 
325
- # Run relay
326
- RELAY_SIGNING_SECRET=secret bun src/index.ts relay start
335
+ # Run relay (uses Ed25519 identity from keychain)
336
+ bun src/index.ts relay start
337
+
338
+ # Run serve with gitspace.sh hosting (requires auth login + host reserve)
339
+ bun src/index.ts serve start
327
340
 
328
- # Run serve
329
- bun src/index.ts serve --relay ws://localhost:8080/ws --token <jwt>
341
+ # Run serve with self-hosted relay
342
+ bun src/index.ts serve start --relay ws://localhost:4480/ws
330
343
  ```
331
344
 
332
345
  ### Code Style
@@ -409,6 +422,7 @@ bun src/index.ts serve --relay ws://localhost:8080/ws --token <jwt>
409
422
  | Access list | `~/gitspace/.access.json` | Authorized client public keys |
410
423
  | Machine info | `~/gitspace/.machine.json` | Machine registration |
411
424
  | Relay config | `~/gitspace/.relay.json` | Relay configuration cache |
425
+ | Host config | `~/gitspace/host.json` | gitspace.sh subdomain config |
412
426
  | Daemon state | `~/gitspace/.serve/` | PID files, status sockets |
413
427
  | tmux-lite state | `/tmp/` | Session data (configurable via `TMUX_LITE_SESSION_DIR`) |
414
428
 
@@ -418,9 +432,8 @@ bun src/index.ts serve --relay ws://localhost:8080/ws --token <jwt>
418
432
  |----------|-------------|---------|
419
433
  | `RELAY_PORT` | Relay server port | `4480` |
420
434
  | `RELAY_BIND` | Relay bind address | `0.0.0.0` |
421
- | `RELAY_SIGNING_SECRET` | Secret for HMAC JWT signing | Required |
422
435
  | `RELAY_PRIVATE_KEY` | Base64 Ed25519 private key | Uses keychain |
423
- | `SPACES_CURRENT_PROJECT` | Override current project | From config |
436
+ | `RELAY_LABEL` | Label for relay identity | None |
424
437
  | `GITSPACE_API_URL` | gitspace.sh API URL | `https://api.gitspace.sh` |
425
438
 
426
439
  ### Default Values
@@ -431,9 +444,8 @@ bun src/index.ts serve --relay ws://localhost:8080/ws --token <jwt>
431
444
  | Base branch | `main` | Global/project config |
432
445
  | Stale workspace days | `30` | Global config |
433
446
  | Relay port | `4480` | CLI/env |
434
- | Default relay URL | `wss://relay.gitspace.sh` | CLI |
435
447
 
436
448
  ---
437
449
 
438
- **Last Updated**: 2025-01
439
- **Runtime**: Bun / Node.js 18+
450
+ **Last Updated**: 2026-01
451
+ **Runtime**: Bun 1.3+
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gitspace",
3
- "version": "0.2.0-rc.3",
3
+ "version": "0.2.0-rc.4",
4
4
  "description": "CLI for managing GitHub workspaces with git worktrees and secure remote terminal access",
5
5
  "bin": {
6
6
  "gssh": "./bin/gssh"
@@ -17,10 +17,10 @@
17
17
  "relay": "bun src/relay/index.ts"
18
18
  },
19
19
  "optionalDependencies": {
20
- "@gitspace/darwin-arm64": "0.2.0-rc.3",
21
- "@gitspace/darwin-x64": "0.2.0-rc.3",
22
- "@gitspace/linux-x64": "0.2.0-rc.3",
23
- "@gitspace/linux-arm64": "0.2.0-rc.3"
20
+ "@gitspace/darwin-arm64": "0.2.0-rc.4",
21
+ "@gitspace/darwin-x64": "0.2.0-rc.4",
22
+ "@gitspace/linux-x64": "0.2.0-rc.4",
23
+ "@gitspace/linux-arm64": "0.2.0-rc.4"
24
24
  },
25
25
  "keywords": [
26
26
  "cli",
@@ -12,6 +12,7 @@ import { sign, serializeIdentity } from '../lib/tmux-lite/crypto/identity.js';
12
12
  import { promptPassword } from '../utils/prompts.js';
13
13
  import { logger } from '../utils/logger.js';
14
14
  import { NoIdentityError, SpacesError } from '../types/errors.js';
15
+ import { syncHostConfig } from './host.js';
15
16
 
16
17
  // API Configuration
17
18
  const API_BASE = process.env.GITSPACE_API_URL || 'https://api.gitspace.sh';
@@ -194,6 +195,10 @@ export async function authLogin(): Promise<void> {
194
195
  logger.success('Authentication complete');
195
196
  logger.success(`Logged in as ${user.github_username}`);
196
197
  logger.success('Token saved to keychain');
198
+
199
+ // Step 6: Sync host config (fetches existing subdomains from API)
200
+ // Interactive mode will prompt user to select primary or reserve a subdomain
201
+ await syncHostConfig(true);
197
202
  }
198
203
 
199
204
  // ============================================================================
@@ -85,9 +85,11 @@ function writeHostConfig(config: HostConfig): void {
85
85
  }
86
86
 
87
87
  /**
88
- * Update host config after subdomain changes
88
+ * Sync host config from gitspace.sh API
89
+ * Called after login and subdomain changes to keep local config in sync
90
+ * @param interactive - If true, prompt user to select primary if needed
89
91
  */
90
- async function syncHostConfig(): Promise<void> {
92
+ export async function syncHostConfig(interactive: boolean = false): Promise<void> {
91
93
  const token = await getSecret('GITSPACE_TOKEN');
92
94
  if (!token) return;
93
95
 
@@ -99,7 +101,40 @@ async function syncHostConfig(): Promise<void> {
99
101
 
100
102
  const subdomains: SubdomainInfo[] = await res.json();
101
103
  const activeSubdomains = subdomains.filter((s) => s.status === 'active');
102
- const primary = activeSubdomains.find((s) => s.is_primary);
104
+
105
+ // No subdomains - tell user to reserve one
106
+ if (activeSubdomains.length === 0) {
107
+ if (interactive) {
108
+ logger.log('');
109
+ logger.dim('No subdomains reserved yet.');
110
+ logger.dim('To enable remote access, reserve a subdomain:');
111
+ logger.command(' gssh host reserve <name>');
112
+ }
113
+ return;
114
+ }
115
+
116
+ // Check for primary
117
+ let primary = activeSubdomains.find((s) => s.is_primary);
118
+
119
+ // If no primary set and interactive, ask user to pick one
120
+ if (!primary && interactive && activeSubdomains.length > 0) {
121
+ logger.log('');
122
+ logger.log('Your subdomains:');
123
+ activeSubdomains.forEach((s, i) => {
124
+ logger.log(` ${i + 1}. ${s.subdomain}.gitspace.sh`);
125
+ });
126
+
127
+ if (activeSubdomains.length === 1) {
128
+ // Auto-set the only one as primary
129
+ primary = activeSubdomains[0];
130
+ logger.dim(`Setting ${primary.subdomain}.gitspace.sh as primary...`);
131
+ await hostSetPrimary(primary.subdomain);
132
+ } else {
133
+ logger.log('');
134
+ logger.dim('Select a primary subdomain for this machine:');
135
+ logger.command(' gssh host set-primary <name>');
136
+ }
137
+ }
103
138
 
104
139
  if (primary) {
105
140
  writeHostConfig({
@@ -107,6 +142,26 @@ async function syncHostConfig(): Promise<void> {
107
142
  subdomains: activeSubdomains.map((s) => s.subdomain),
108
143
  createdAt: primary.created_at,
109
144
  });
145
+
146
+ // Sync tunnel token if not present (e.g., new machine with existing account)
147
+ const existingToken = await getSecret(`TUNNEL_TOKEN_${primary.subdomain}`);
148
+ if (!existingToken) {
149
+ if (interactive) {
150
+ logger.dim(`Fetching tunnel credentials for ${primary.subdomain}.gitspace.sh...`);
151
+ }
152
+ try {
153
+ const tokenRes = await fetch(`${API_BASE}/subdomains/${primary.subdomain}/token`, { headers });
154
+ if (tokenRes.ok) {
155
+ const { tunnelToken } = await tokenRes.json();
156
+ await setSecret(`TUNNEL_TOKEN_${primary.subdomain}`, tunnelToken);
157
+ if (interactive) {
158
+ logger.success('Tunnel credentials saved');
159
+ }
160
+ }
161
+ } catch {
162
+ // Ignore token fetch errors
163
+ }
164
+ }
110
165
  }
111
166
  } catch {
112
167
  // Ignore sync errors
@@ -378,11 +433,28 @@ export async function hostStatus(): Promise<void> {
378
433
  logger.log(`Status: ${primary.status}`);
379
434
 
380
435
  // Check if tunnel token exists locally
381
- const tunnelToken = await getSecret(`TUNNEL_TOKEN_${primary.subdomain}`);
436
+ let tunnelToken = await getSecret(`TUNNEL_TOKEN_${primary.subdomain}`);
437
+
438
+ // Auto-fetch token if missing
439
+ if (!tunnelToken) {
440
+ logger.dim('Tunnel credentials missing, fetching...');
441
+ try {
442
+ const tokenRes = await fetch(`${API_BASE}/subdomains/${primary.subdomain}/token`, { headers });
443
+ if (tokenRes.ok) {
444
+ const { tunnelToken: newToken } = await tokenRes.json();
445
+ await setSecret(`TUNNEL_TOKEN_${primary.subdomain}`, newToken);
446
+ tunnelToken = newToken;
447
+ logger.success('Tunnel credentials synced');
448
+ }
449
+ } catch {
450
+ // Ignore
451
+ }
452
+ }
453
+
382
454
  logger.log(`Tunnel token: ${tunnelToken ? 'configured' : 'missing'}`);
383
455
 
384
456
  if (!tunnelToken) {
385
- logger.dim('Run: gssh host reserve ' + primary.subdomain + ' (to refresh token)');
457
+ logger.warning('Could not fetch tunnel credentials');
386
458
  }
387
459
  } catch {
388
460
  logger.log('Could not verify status (API unreachable)');
@@ -66,7 +66,7 @@ import {
66
66
  const PACKAGE_VERSION = '1.0.0';
67
67
 
68
68
  /** Default relay URL */
69
- const DEFAULT_RELAY_URL = 'wss://relay.gitspace.sh';
69
+ // No default relay - must use hosting or explicit --relay
70
70
 
71
71
  /** Local relay port for gitspace.sh hosting */
72
72
  const LOCAL_RELAY_PORT = 4480;
@@ -503,21 +503,36 @@ export async function serve(options: {
503
503
  const entries = readAccessList();
504
504
  accessList.import(entries);
505
505
 
506
- // Step 3: Display info
506
+ // Step 3: Check for gitspace.sh hosting or explicit relay
507
+ const hostConfig = readHostConfig();
508
+ const relayUrl = options.relay; // No default - must use hosting or explicit --relay
509
+
510
+ // If no hosting config and no explicit relay, error out
511
+ if (!hostConfig?.subdomain && !relayUrl) {
512
+ throw new SpacesError(
513
+ 'No relay configured.\n\n' +
514
+ 'Either set up gitspace.sh hosting:\n' +
515
+ ' gssh auth login\n' +
516
+ ' gssh host reserve <subdomain>\n\n' +
517
+ 'Or specify a relay explicitly:\n' +
518
+ ' gssh serve start --relay ws://localhost:4480/ws',
519
+ 'USER_ERROR'
520
+ );
521
+ }
522
+
523
+ // Display info
507
524
  const machineIdentity = readMachineIdentity();
508
525
  const machineId = machineIdentity?.machineId ?? identity.id;
509
- const relayUrl = options.relay ?? DEFAULT_RELAY_URL;
510
526
 
511
527
  logger.log('');
512
528
  logger.bold('Machine Identity:');
513
529
  logger.log(` ID: ${machineId}`);
514
- logger.log(` Relay: ${relayUrl}`);
530
+ if (relayUrl) {
531
+ logger.log(` Relay: ${relayUrl}`);
532
+ }
515
533
  logger.log('');
516
534
  logger.dim(`Access list: ${entries.length} authorized ${entries.length === 1 ? 'client' : 'clients'}`);
517
535
  logger.log('');
518
-
519
- // Step 3b: Check for gitspace.sh hosting
520
- const hostConfig = readHostConfig();
521
536
  let localRelayServer: ReturnType<typeof createRelayServer> | null = null;
522
537
  let localRelayIdentity: ReturnType<typeof generateRelayIdentity> | null = null;
523
538
 
@@ -608,6 +623,11 @@ export async function serve(options: {
608
623
  return;
609
624
  }
610
625
 
626
+ // If we get here, relayUrl must be defined (checked earlier)
627
+ if (!relayUrl) {
628
+ throw new SpacesError('No relay URL configured', 'USER_ERROR');
629
+ }
630
+
611
631
  // Step 4: Create session manager
612
632
  const sessionManager = new ClientSessionManager({
613
633
  relay: relayUrl,
@@ -1208,10 +1228,18 @@ export async function serveStart(options: {
1208
1228
  logger.log('Starting serve daemon...');
1209
1229
 
1210
1230
  // Build args for background process
1211
- const args = [process.argv[1], 'serve', 'start', '--foreground'];
1212
- if (options.relay) args.push('--relay', options.relay);
1213
- if (options.relayPubkey) args.push('--relay-pubkey', options.relayPubkey);
1214
- args.push('--password-stdin');
1231
+ // Detect if we're running as a compiled binary vs dev mode
1232
+ const isCompiled = !process.execPath.endsWith('bun');
1233
+
1234
+ const serveArgs = ['serve', 'start', '--foreground'];
1235
+ if (options.relay) serveArgs.push('--relay', options.relay);
1236
+ if (options.relayPubkey) serveArgs.push('--relay-pubkey', options.relayPubkey);
1237
+ serveArgs.push('--password-stdin');
1238
+
1239
+ // Build command: compiled binary runs directly, dev mode uses bun
1240
+ const cmd = isCompiled
1241
+ ? [process.execPath, ...serveArgs]
1242
+ : ['bun', process.argv[1], ...serveArgs];
1215
1243
 
1216
1244
  // Write output to log file for debugging
1217
1245
  const logFile = getServeLogFile();
@@ -1221,7 +1249,7 @@ export async function serveStart(options: {
1221
1249
  await Bun.write(logFile, `[${new Date().toISOString()}] Starting serve daemon...\n`);
1222
1250
 
1223
1251
  const child = spawn({
1224
- cmd: ['bun', ...args],
1252
+ cmd,
1225
1253
  stdin: 'pipe',
1226
1254
  stdout: Bun.file(logFile),
1227
1255
  stderr: Bun.file(logFile),
@@ -1269,20 +1297,35 @@ export async function serveStart(options: {
1269
1297
  // Get config
1270
1298
  const machineIdentity = readMachineIdentity();
1271
1299
  const machineId = machineIdentity?.machineId ?? identity.id;
1272
- const relayUrl = options.relay ?? DEFAULT_RELAY_URL;
1273
1300
 
1274
1301
  // Check for gitspace.sh hosting
1275
1302
  const hostConfig = readHostConfig();
1303
+ const relayUrl = options.relay; // No default - must use hosting or explicit --relay
1304
+
1305
+ // If no hosting config and no explicit relay, error out
1306
+ if (!hostConfig?.subdomain && !relayUrl) {
1307
+ cleanupServeFiles();
1308
+ throw new SpacesError(
1309
+ 'No relay configured.\n\n' +
1310
+ 'Either set up gitspace.sh hosting:\n' +
1311
+ ' gssh auth login\n' +
1312
+ ' gssh host reserve <subdomain>\n\n' +
1313
+ 'Or specify a relay explicitly:\n' +
1314
+ ' gssh serve start --relay ws://localhost:4480/ws',
1315
+ 'USER_ERROR'
1316
+ );
1317
+ }
1318
+
1276
1319
  let localRelayServer: ReturnType<typeof createRelayServer> | null = null;
1277
1320
  let localRelayIdentity: ReturnType<typeof generateRelayIdentity> | null = null;
1278
- let effectiveRelayUrl = relayUrl;
1321
+ let effectiveRelayUrl = relayUrl || '';
1279
1322
 
1280
1323
  // Initialize daemon state
1281
1324
  setDaemonState({
1282
1325
  version: PACKAGE_VERSION,
1283
1326
  startTime: Date.now(),
1284
1327
  relay: {
1285
- url: relayUrl,
1328
+ url: effectiveRelayUrl,
1286
1329
  status: 'connecting',
1287
1330
  },
1288
1331
  clients: 0,
@@ -1373,7 +1416,7 @@ export async function serveStart(options: {
1373
1416
 
1374
1417
  // Save relay config for share/access commands
1375
1418
  writeRelayConfig({
1376
- relayUrl,
1419
+ relayUrl: effectiveRelayUrl,
1377
1420
  machineId,
1378
1421
  savedAt: Date.now(),
1379
1422
  });
@@ -27,11 +27,6 @@ import { SpacesError, NoIdentityError, InvalidPasswordError } from '../types/err
27
27
  import chalk from 'chalk';
28
28
  import { createHash } from 'crypto';
29
29
 
30
- /**
31
- * Default relay URL
32
- */
33
- const DEFAULT_RELAY_URL = 'wss://relay.gitspace.sh';
34
-
35
30
  /**
36
31
  * Duration string formats supported:
37
32
  * - "1h" -> 1 hour
package/src/index.ts CHANGED
@@ -5,6 +5,22 @@
5
5
  * Manages GitHub workspaces with git worktrees and secure remote terminal access
6
6
  */
7
7
 
8
+ // Internal command: run tmux-lite server directly (for compiled binary)
9
+ // This must be checked before any other imports to avoid loading unnecessary modules
10
+ if (process.argv.includes('--internal-tmux-server')) {
11
+ // Pass through --test flag if present
12
+ if (process.argv.includes('--test')) {
13
+ process.env.TMUX_LITE_SOCKET = '/tmp/tmux-lite-test.sock';
14
+ process.env.TMUX_LITE_SESSION_DIR = '/tmp/tmux-lite-test';
15
+ process.env.TMUX_LITE_PID_FILE = '/tmp/tmux-lite-test.pid';
16
+ }
17
+ // Import and run server (module auto-starts on import)
18
+ await import('./lib/tmux-lite/server.js');
19
+ // Keep process alive - server runs via Bun.listen() which is async
20
+ // We need to prevent the rest of this file from executing
21
+ await new Promise(() => {}); // Block forever
22
+ }
23
+
8
24
  import { Command } from 'commander'
9
25
  import { readFileSync } from 'fs'
10
26
  import { join } from 'path'
@@ -67,9 +67,22 @@ if (isTestMode) {
67
67
  process.env.TMUX_LITE_SESSION_DIR = "/tmp/tmux-lite-test";
68
68
  }
69
69
 
70
- const getServerCommand = (): string[] => (
71
- isTestMode ? ["bun", "run", SERVER_SCRIPT, "--test"] : ["bun", "run", SERVER_SCRIPT]
72
- );
70
+ const getServerCommand = (): string[] => {
71
+ // Detect if we're running as a compiled binary (not bun)
72
+ const isCompiled = !process.execPath.endsWith('bun');
73
+
74
+ if (isCompiled) {
75
+ // Use the binary with internal flag
76
+ return isTestMode
77
+ ? [process.execPath, '--internal-tmux-server', '--test']
78
+ : [process.execPath, '--internal-tmux-server'];
79
+ }
80
+
81
+ // Dev mode: use bun run
82
+ return isTestMode
83
+ ? ['bun', 'run', SERVER_SCRIPT, '--test']
84
+ : ['bun', 'run', SERVER_SCRIPT];
85
+ };
73
86
 
74
87
  // Check if we're already inside a tmux-lite session
75
88
  export function isNested(): boolean {
@@ -68,25 +68,27 @@ function getContentType(pathname: string): string {
68
68
  * Serve a static file - tries embedded assets first, falls back to filesystem
69
69
  */
70
70
  async function serveStaticFile(pathname: string): Promise<Response | null> {
71
+ // Normalize path for content type (/ -> /index.html)
72
+ const normalizedPath = pathname === "/" ? "/index.html" : pathname;
73
+
71
74
  // Try embedded assets first (compiled binary)
72
75
  if (hasEmbeddedAssets() && embeddedAssets) {
73
76
  const blob = embeddedAssets.getEmbeddedFile(pathname);
74
77
  if (blob) {
75
78
  return new Response(blob, {
76
- headers: { "Content-Type": getContentType(pathname) },
79
+ headers: { "Content-Type": getContentType(normalizedPath) },
77
80
  });
78
81
  }
79
82
  }
80
83
 
81
84
  // Fall back to filesystem (development mode)
82
- const filePath = pathname === "/" ? "/index.html" : pathname;
83
- const resolvedPath = resolveAssetPath(filePath);
85
+ const resolvedPath = resolveAssetPath(normalizedPath);
84
86
  if (!resolvedPath) return null;
85
87
 
86
88
  const file = Bun.file(resolvedPath);
87
89
  if (await file.exists()) {
88
90
  return new Response(file, {
89
- headers: { "Content-Type": getContentType(pathname) },
91
+ headers: { "Content-Type": getContentType(normalizedPath) },
90
92
  });
91
93
  }
92
94