gitspace 0.2.0-rc.2 → 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.
- package/.claude/settings.local.json +5 -1
- package/AGENTS.md +28 -16
- package/README.md +11 -2
- package/docs/QUICKSTART.md +11 -2
- package/docs/SITE_DOCS_FIGMA_MAKE.md +9 -7
- package/landing-page/src/components/docs/DocsContent.tsx +16 -10
- package/landing-page/src/components/landing/CTA.tsx +1 -1
- package/landing-page/src/components/landing/Features.tsx +2 -2
- package/landing-page/src/components/landing/UseCases.tsx +1 -1
- package/package.json +7 -6
- package/src/commands/auth.ts +5 -0
- package/src/commands/host.ts +77 -5
- package/src/commands/serve.ts +60 -17
- package/src/commands/share.ts +0 -5
- package/src/core/bundle.ts +2 -2
- package/src/index.ts +29 -1
- package/src/lib/tmux-lite/cli.ts +37 -9
- package/src/lib/tmux-lite/server-lifecycle.test.ts +208 -0
- package/src/lib/tmux-lite/server.ts +5 -1
- package/src/relay/server.ts +6 -4
- package/src/shared/providers/RemoteMachineProvider.ts +1 -6
- package/src/tui/hooks/useInboxTUI.ts +6 -5
- package/src/tui/hooks/useRemoteMachines.ts +2 -3
- package/npm/darwin-arm64/bin/gssh +0 -0
- package/npm/darwin-arm64/package.json +0 -20
- 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
|
@@ -15,7 +15,11 @@
|
|
|
15
15
|
"Bash(xxd:*)",
|
|
16
16
|
"WebFetch(domain:ast-grep.github.io)",
|
|
17
17
|
"WebFetch(domain:developers.cloudflare.com)",
|
|
18
|
-
"WebFetch(domain:gitspace.sh)"
|
|
18
|
+
"WebFetch(domain:gitspace.sh)",
|
|
19
|
+
"Bash(/Users/bradleat/spaces/spaces/workspaces/remote-space/dist/gssh-darwin-arm64:*)",
|
|
20
|
+
"Bash(npx wrangler pages deploy:*)",
|
|
21
|
+
"Bash(git add:*)",
|
|
22
|
+
"Bash(git commit:*)"
|
|
19
23
|
]
|
|
20
24
|
}
|
|
21
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
|
|
219
|
-
| `gssh 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.
|
|
269
|
-
2.
|
|
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
|
-
|
|
|
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
|
-
|
|
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:
|
|
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
|
-
| `
|
|
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**:
|
|
439
|
-
**Runtime**: Bun
|
|
450
|
+
**Last Updated**: 2026-01
|
|
451
|
+
**Runtime**: Bun 1.3+
|
package/README.md
CHANGED
|
@@ -20,7 +20,6 @@ The following tools must be installed and available in your PATH:
|
|
|
20
20
|
- [GitHub CLI (`gh`)](https://cli.github.com/) - for listing repositories
|
|
21
21
|
- [Git](https://git-scm.com/) - for worktree management
|
|
22
22
|
- [jq](https://stedolan.github.io/jq/) - for JSON processing
|
|
23
|
-
- [Bun](https://bun.sh) - runtime for the CLI
|
|
24
23
|
|
|
25
24
|
**GitHub Authentication**: You must authenticate the GitHub CLI before using GitSpace:
|
|
26
25
|
|
|
@@ -31,7 +30,17 @@ gh auth login
|
|
|
31
30
|
## Installation
|
|
32
31
|
|
|
33
32
|
```bash
|
|
34
|
-
|
|
33
|
+
# npm
|
|
34
|
+
npm install -g gitspace
|
|
35
|
+
|
|
36
|
+
# bun
|
|
37
|
+
bun install -g gitspace
|
|
38
|
+
|
|
39
|
+
# pnpm
|
|
40
|
+
pnpm install -g gitspace
|
|
41
|
+
|
|
42
|
+
# yarn
|
|
43
|
+
yarn global add gitspace
|
|
35
44
|
|
|
36
45
|
# Verify installation
|
|
37
46
|
gssh --version
|
package/docs/QUICKSTART.md
CHANGED
|
@@ -8,7 +8,6 @@ Get up and running with GitSpace in 5 minutes.
|
|
|
8
8
|
|
|
9
9
|
Install these tools first:
|
|
10
10
|
|
|
11
|
-
- [Bun](https://bun.sh) - JavaScript runtime
|
|
12
11
|
- [Git](https://git-scm.com/) - Version control
|
|
13
12
|
- [GitHub CLI](https://cli.github.com/) - `gh auth login` before using GitSpace
|
|
14
13
|
|
|
@@ -17,7 +16,17 @@ Install these tools first:
|
|
|
17
16
|
## Installation
|
|
18
17
|
|
|
19
18
|
```bash
|
|
20
|
-
|
|
19
|
+
# npm
|
|
20
|
+
npm install -g gitspace
|
|
21
|
+
|
|
22
|
+
# bun
|
|
23
|
+
bun install -g gitspace
|
|
24
|
+
|
|
25
|
+
# pnpm
|
|
26
|
+
pnpm install -g gitspace
|
|
27
|
+
|
|
28
|
+
# yarn
|
|
29
|
+
yarn global add gitspace
|
|
21
30
|
|
|
22
31
|
# Verify
|
|
23
32
|
gssh --version
|
|
@@ -53,14 +53,16 @@ Prereqs (list these on docs site):
|
|
|
53
53
|
- gh (GitHub CLI)
|
|
54
54
|
- git
|
|
55
55
|
- jq
|
|
56
|
-
- bun
|
|
57
56
|
|
|
58
57
|
Optional prereqs:
|
|
59
58
|
- cloudflared (for `gssh host` commands)
|
|
60
59
|
- Linear API key (for Linear integration)
|
|
61
60
|
|
|
62
|
-
Install:
|
|
63
|
-
- `
|
|
61
|
+
Install (any package manager):
|
|
62
|
+
- `npm install -g gitspace`
|
|
63
|
+
- `bun install -g gitspace`
|
|
64
|
+
- `pnpm install -g gitspace`
|
|
65
|
+
- `yarn global add gitspace`
|
|
64
66
|
- Verify: `gssh --version`
|
|
65
67
|
|
|
66
68
|
---
|
|
@@ -700,7 +702,7 @@ Feature bullets:
|
|
|
700
702
|
Short code example:
|
|
701
703
|
```bash
|
|
702
704
|
# Install
|
|
703
|
-
|
|
705
|
+
npm install -g gitspace
|
|
704
706
|
|
|
705
707
|
# Launch the TUI
|
|
706
708
|
spaces
|
|
@@ -767,7 +769,7 @@ SECTION: Quick Start
|
|
|
767
769
|
|
|
768
770
|
```bash
|
|
769
771
|
# 1. Install
|
|
770
|
-
|
|
772
|
+
npm install -g gitspace
|
|
771
773
|
|
|
772
774
|
# 2. Create identity
|
|
773
775
|
gssh identity init
|
|
@@ -788,7 +790,7 @@ gssh serve
|
|
|
788
790
|
|
|
789
791
|
```bash
|
|
790
792
|
# Install
|
|
791
|
-
|
|
793
|
+
npm install -g gitspace
|
|
792
794
|
|
|
793
795
|
# Authenticate GitHub
|
|
794
796
|
gh auth login
|
|
@@ -819,7 +821,7 @@ Optional:
|
|
|
819
821
|
### Install GitSpace
|
|
820
822
|
|
|
821
823
|
```bash
|
|
822
|
-
|
|
824
|
+
npm install -g gitspace
|
|
823
825
|
gssh --version
|
|
824
826
|
```
|
|
825
827
|
|
|
@@ -105,8 +105,10 @@ export function DocsContent({ section }: { section: string }) {
|
|
|
105
105
|
<h1 className="text-4xl font-bold mb-6">Quick Start</h1>
|
|
106
106
|
|
|
107
107
|
<h3 className="text-xl font-semibold text-white mb-4">5-Minute Setup with gitspace.sh</h3>
|
|
108
|
-
<CodeBlock code={`# 1. Install
|
|
109
|
-
|
|
108
|
+
<CodeBlock code={`# 1. Install (pick your package manager)
|
|
109
|
+
npm install -g gitspace
|
|
110
|
+
# or: bun install -g gitspace
|
|
111
|
+
# or: pnpm install -g gitspace
|
|
110
112
|
|
|
111
113
|
# 2. Create identity
|
|
112
114
|
gssh identity init
|
|
@@ -124,7 +126,7 @@ gssh serve
|
|
|
124
126
|
|
|
125
127
|
<h3 className="text-xl font-semibold text-white mb-4 mt-12">Local-Only Quick Start</h3>
|
|
126
128
|
<CodeBlock code={`# Install
|
|
127
|
-
|
|
129
|
+
npm install -g gitspace
|
|
128
130
|
|
|
129
131
|
# Authenticate GitHub
|
|
130
132
|
gh auth login
|
|
@@ -146,19 +148,23 @@ gssh add my-feature # Create a workspace`} multiLine />
|
|
|
146
148
|
<h3 className="text-xl font-semibold text-white mb-4">Prerequisites</h3>
|
|
147
149
|
<p className="text-zinc-400 mb-4">Required:</p>
|
|
148
150
|
<ul className="list-disc list-inside space-y-2 text-zinc-400 mb-8 ml-2">
|
|
149
|
-
<li><a href="https://bun.sh" className="text-green-400 hover:underline">Bun</a> - JavaScript runtime</li>
|
|
150
151
|
<li><a href="https://git-scm.com/" className="text-green-400 hover:underline">Git</a> - Version control</li>
|
|
151
152
|
<li><a href="https://cli.github.com/" className="text-green-400 hover:underline">GitHub CLI</a> - <code className="text-zinc-300">gh auth login</code> before using GitSpace</li>
|
|
152
153
|
<li><a href="https://stedolan.github.io/jq/" className="text-green-400 hover:underline">jq</a> - JSON processing</li>
|
|
153
154
|
</ul>
|
|
154
155
|
|
|
155
|
-
<p className="text-zinc-400 mb-4">Optional:</p>
|
|
156
|
-
<ul className="list-disc list-inside space-y-2 text-zinc-400 mb-8 ml-2">
|
|
157
|
-
<li><a href="https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/" className="text-green-400 hover:underline">cloudflared</a> - For <code className="text-zinc-300">gssh host</code> commands</li>
|
|
158
|
-
</ul>
|
|
159
|
-
|
|
160
156
|
<h3 className="text-xl font-semibold text-white mb-4">Install GitSpace</h3>
|
|
161
|
-
<CodeBlock code=
|
|
157
|
+
<CodeBlock code={`# npm
|
|
158
|
+
npm install -g gitspace
|
|
159
|
+
|
|
160
|
+
# bun
|
|
161
|
+
bun install -g gitspace
|
|
162
|
+
|
|
163
|
+
# pnpm
|
|
164
|
+
pnpm install -g gitspace
|
|
165
|
+
|
|
166
|
+
# yarn
|
|
167
|
+
yarn global add gitspace`} multiLine />
|
|
162
168
|
<CodeBlock code="gssh --version" />
|
|
163
169
|
|
|
164
170
|
<h3 className="text-xl font-semibold text-white mb-4">Authenticate GitHub CLI</h3>
|
|
@@ -36,7 +36,7 @@ export function CTA() {
|
|
|
36
36
|
<div className="space-y-2 font-mono text-sm">
|
|
37
37
|
<div className="flex">
|
|
38
38
|
<span className="text-green-500 mr-2">$</span>
|
|
39
|
-
<span className="text-white">
|
|
39
|
+
<span className="text-white">npm install -g gitspace</span>
|
|
40
40
|
</div>
|
|
41
41
|
<div className="text-zinc-500 text-xs py-1">...</div>
|
|
42
42
|
<div className="flex">
|
|
@@ -47,7 +47,7 @@ export function Features() {
|
|
|
47
47
|
<TerminalWindow title="bash">
|
|
48
48
|
<div className="space-y-2 font-mono text-sm">
|
|
49
49
|
<div className="text-zinc-500"># Install</div>
|
|
50
|
-
<div className="text-zinc-400">$
|
|
50
|
+
<div className="text-zinc-400">$ npm install -g gitspace</div>
|
|
51
51
|
<br />
|
|
52
52
|
<div className="text-zinc-500"># Launch the TUI</div>
|
|
53
53
|
<div className="text-zinc-400">$ gssh</div>
|
|
@@ -166,7 +166,7 @@ export function Features() {
|
|
|
166
166
|
<div>
|
|
167
167
|
<TerminalWindow title="bash">
|
|
168
168
|
<div className="space-y-2 font-mono text-sm">
|
|
169
|
-
<div className="text-zinc-400">$
|
|
169
|
+
<div className="text-zinc-400">$ gssh stack</div>
|
|
170
170
|
<div className="text-blue-400 animate-pulse">Analyzing 47 commits across 30 files...</div>
|
|
171
171
|
<br />
|
|
172
172
|
<div className="text-zinc-300">Suggested stack:</div>
|
|
@@ -4,7 +4,7 @@ import { Card, CardContent } from "../../app/components/ui/card";
|
|
|
4
4
|
export function UseCases() {
|
|
5
5
|
const testimonials = [
|
|
6
6
|
{
|
|
7
|
-
quote: "I run 3-4 Claude agents in parallel now. Each in its own space. I check progress from my phone while making dinner.
|
|
7
|
+
quote: "I run 3-4 Claude agents in parallel now. Each in its own space. I check progress from my phone while making dinner. Context switching between tasks is instant.",
|
|
8
8
|
author: "Solo founder",
|
|
9
9
|
role: "Shipping 3x faster",
|
|
10
10
|
stars: 5
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gitspace",
|
|
3
|
-
"version": "0.2.0-rc.
|
|
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.
|
|
21
|
-
"@gitspace/darwin-x64": "0.2.0-rc.
|
|
22
|
-
"@gitspace/linux-x64": "0.2.0-rc.
|
|
23
|
-
"@gitspace/linux-arm64": "0.2.0-rc.
|
|
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",
|
|
@@ -70,5 +70,6 @@
|
|
|
70
70
|
},
|
|
71
71
|
"engines": {
|
|
72
72
|
"bun": ">=1.3.5"
|
|
73
|
-
}
|
|
73
|
+
},
|
|
74
|
+
"packageManager": "pnpm@10.12.1+sha512.f0dda8580f0ee9481c5c79a1d927b9164f2c478e90992ad268bbb2465a736984391d6333d2c327913578b2804af33474ca554ba29c04a8b13060a717675ae3ac"
|
|
74
75
|
}
|
package/src/commands/auth.ts
CHANGED
|
@@ -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
|
// ============================================================================
|
package/src/commands/host.ts
CHANGED
|
@@ -85,9 +85,11 @@ function writeHostConfig(config: HostConfig): void {
|
|
|
85
85
|
}
|
|
86
86
|
|
|
87
87
|
/**
|
|
88
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
457
|
+
logger.warning('Could not fetch tunnel credentials');
|
|
386
458
|
}
|
|
387
459
|
} catch {
|
|
388
460
|
logger.log('Could not verify status (API unreachable)');
|
package/src/commands/serve.ts
CHANGED
|
@@ -58,6 +58,7 @@ import {
|
|
|
58
58
|
sendShutdownCommand,
|
|
59
59
|
getServeLogFile,
|
|
60
60
|
setAccessCommandHandler,
|
|
61
|
+
ensureServeDaemonDir,
|
|
61
62
|
type StatusResponse,
|
|
62
63
|
} from '../serve/daemon.js';
|
|
63
64
|
|
|
@@ -65,7 +66,7 @@ import {
|
|
|
65
66
|
const PACKAGE_VERSION = '1.0.0';
|
|
66
67
|
|
|
67
68
|
/** Default relay URL */
|
|
68
|
-
|
|
69
|
+
// No default relay - must use hosting or explicit --relay
|
|
69
70
|
|
|
70
71
|
/** Local relay port for gitspace.sh hosting */
|
|
71
72
|
const LOCAL_RELAY_PORT = 4480;
|
|
@@ -502,21 +503,36 @@ export async function serve(options: {
|
|
|
502
503
|
const entries = readAccessList();
|
|
503
504
|
accessList.import(entries);
|
|
504
505
|
|
|
505
|
-
// Step 3:
|
|
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
|
|
506
524
|
const machineIdentity = readMachineIdentity();
|
|
507
525
|
const machineId = machineIdentity?.machineId ?? identity.id;
|
|
508
|
-
const relayUrl = options.relay ?? DEFAULT_RELAY_URL;
|
|
509
526
|
|
|
510
527
|
logger.log('');
|
|
511
528
|
logger.bold('Machine Identity:');
|
|
512
529
|
logger.log(` ID: ${machineId}`);
|
|
513
|
-
|
|
530
|
+
if (relayUrl) {
|
|
531
|
+
logger.log(` Relay: ${relayUrl}`);
|
|
532
|
+
}
|
|
514
533
|
logger.log('');
|
|
515
534
|
logger.dim(`Access list: ${entries.length} authorized ${entries.length === 1 ? 'client' : 'clients'}`);
|
|
516
535
|
logger.log('');
|
|
517
|
-
|
|
518
|
-
// Step 3b: Check for gitspace.sh hosting
|
|
519
|
-
const hostConfig = readHostConfig();
|
|
520
536
|
let localRelayServer: ReturnType<typeof createRelayServer> | null = null;
|
|
521
537
|
let localRelayIdentity: ReturnType<typeof generateRelayIdentity> | null = null;
|
|
522
538
|
|
|
@@ -607,6 +623,11 @@ export async function serve(options: {
|
|
|
607
623
|
return;
|
|
608
624
|
}
|
|
609
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
|
+
|
|
610
631
|
// Step 4: Create session manager
|
|
611
632
|
const sessionManager = new ClientSessionManager({
|
|
612
633
|
relay: relayUrl,
|
|
@@ -1207,21 +1228,28 @@ export async function serveStart(options: {
|
|
|
1207
1228
|
logger.log('Starting serve daemon...');
|
|
1208
1229
|
|
|
1209
1230
|
// Build args for background process
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
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];
|
|
1214
1243
|
|
|
1215
1244
|
// Write output to log file for debugging
|
|
1216
1245
|
const logFile = getServeLogFile();
|
|
1217
|
-
const { ensureServeDaemonDir } = await import('../serve/daemon.js');
|
|
1218
1246
|
ensureServeDaemonDir();
|
|
1219
1247
|
|
|
1220
1248
|
// Truncate log file at start
|
|
1221
1249
|
await Bun.write(logFile, `[${new Date().toISOString()}] Starting serve daemon...\n`);
|
|
1222
1250
|
|
|
1223
1251
|
const child = spawn({
|
|
1224
|
-
cmd
|
|
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:
|
|
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
|
});
|
package/src/commands/share.ts
CHANGED
|
@@ -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/core/bundle.ts
CHANGED
|
@@ -15,6 +15,8 @@ import {
|
|
|
15
15
|
} from 'fs';
|
|
16
16
|
import { join, basename, resolve, sep } from 'path';
|
|
17
17
|
import { tmpdir } from 'os';
|
|
18
|
+
import { exec } from 'child_process';
|
|
19
|
+
import { promisify } from 'util';
|
|
18
20
|
import { SpacesError } from '../types/errors.js';
|
|
19
21
|
import { logger } from '../utils/logger.js';
|
|
20
22
|
import type { SpacesBundle, LoadedBundle } from '../types/bundle.js';
|
|
@@ -140,8 +142,6 @@ export async function loadBundleFromUrl(url: string): Promise<LoadedBundle> {
|
|
|
140
142
|
writeFileSync(zipPath, Buffer.from(arrayBuffer));
|
|
141
143
|
|
|
142
144
|
// Extract using unzip command
|
|
143
|
-
const { exec } = await import('child_process');
|
|
144
|
-
const { promisify } = await import('util');
|
|
145
145
|
const execAsync = promisify(exec);
|
|
146
146
|
|
|
147
147
|
await execAsync(`unzip -q "${zipPath}" -d "${tempDir}"`);
|
package/src/index.ts
CHANGED
|
@@ -5,9 +5,37 @@
|
|
|
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'
|
|
25
|
+
import { readFileSync } from 'fs'
|
|
26
|
+
import { join } from 'path'
|
|
9
27
|
import { isFirstTimeSetup, initializeSpaces } from './core/config.js'
|
|
10
|
-
import { VERSION } from './version.generated.js'
|
|
28
|
+
import { VERSION as GENERATED_VERSION } from './version.generated.js'
|
|
29
|
+
|
|
30
|
+
// Read version from package.json in dev, fall back to generated for compiled binary
|
|
31
|
+
let VERSION = GENERATED_VERSION
|
|
32
|
+
try {
|
|
33
|
+
const pkgPath = join(import.meta.dir, '../package.json')
|
|
34
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'))
|
|
35
|
+
VERSION = pkg.version
|
|
36
|
+
} catch {
|
|
37
|
+
// Compiled binary - use generated version
|
|
38
|
+
}
|
|
11
39
|
import { logger } from './utils/logger.js'
|
|
12
40
|
import { SpacesError } from './types/errors.js'
|
|
13
41
|
import { addProject, addWorkspace } from './commands/add.js'
|
package/src/lib/tmux-lite/cli.ts
CHANGED
|
@@ -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
|
-
|
|
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 {
|
|
@@ -605,8 +618,10 @@ async function main() {
|
|
|
605
618
|
console.log("Starting server...");
|
|
606
619
|
spawn({
|
|
607
620
|
cmd: getServerCommand(),
|
|
608
|
-
|
|
609
|
-
|
|
621
|
+
// Use "ignore" so server doesn't inherit CLI's stdout/stderr
|
|
622
|
+
// This allows CLI to exit cleanly when piped
|
|
623
|
+
stdout: "ignore",
|
|
624
|
+
stderr: "ignore",
|
|
610
625
|
});
|
|
611
626
|
await Bun.sleep(300);
|
|
612
627
|
if (!(await isServerRunning())) {
|
|
@@ -787,10 +802,23 @@ In session:
|
|
|
787
802
|
}
|
|
788
803
|
}
|
|
789
804
|
|
|
805
|
+
// Commands that are non-interactive and should exit immediately after completion
|
|
806
|
+
const NON_INTERACTIVE_COMMANDS = new Set([
|
|
807
|
+
"list", "ls", "kill", "kill-server", "inbox", "inbox-clear", "status", "version"
|
|
808
|
+
]);
|
|
809
|
+
|
|
790
810
|
// Only run CLI when executed directly, not when imported as a module
|
|
791
811
|
if (import.meta.main) {
|
|
792
|
-
main()
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
812
|
+
main()
|
|
813
|
+
.then(() => {
|
|
814
|
+
// Force exit after non-interactive commands complete
|
|
815
|
+
// Some socket references may keep the event loop alive otherwise
|
|
816
|
+
if (NON_INTERACTIVE_COMMANDS.has(cmd)) {
|
|
817
|
+
process.exit(0);
|
|
818
|
+
}
|
|
819
|
+
})
|
|
820
|
+
.catch(e => {
|
|
821
|
+
console.error(e.message);
|
|
822
|
+
process.exit(1);
|
|
823
|
+
});
|
|
796
824
|
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* E2E tests for tmux-lite server lifecycle
|
|
3
|
+
*
|
|
4
|
+
* Tests server start, stop, and restart scenarios including
|
|
5
|
+
* proper cleanup of socket files.
|
|
6
|
+
*/
|
|
7
|
+
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
|
|
8
|
+
import { spawn } from "bun";
|
|
9
|
+
import { existsSync, unlinkSync, mkdirSync, rmSync } from "fs";
|
|
10
|
+
import { join } from "path";
|
|
11
|
+
|
|
12
|
+
// Use the same paths as --test mode in cli.ts and server.ts
|
|
13
|
+
const TEST_SOCKET = "/tmp/tmux-lite-test.sock";
|
|
14
|
+
const TEST_SESSION_DIR = "/tmp/tmux-lite-test";
|
|
15
|
+
const CLI_SCRIPT = join(import.meta.dir, "cli.ts");
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Helper to run CLI commands in test mode
|
|
19
|
+
*/
|
|
20
|
+
async function runCli(command: string): Promise<{ stdout: string; stderr: string; exitCode: number }> {
|
|
21
|
+
const proc = spawn({
|
|
22
|
+
cmd: ["bun", "run", CLI_SCRIPT, command, "--test"],
|
|
23
|
+
stdout: "pipe",
|
|
24
|
+
stderr: "pipe",
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const stdout = await new Response(proc.stdout).text();
|
|
28
|
+
const stderr = await new Response(proc.stderr).text();
|
|
29
|
+
const exitCode = await proc.exited;
|
|
30
|
+
|
|
31
|
+
// Give server time to initialize after CLI returns
|
|
32
|
+
await Bun.sleep(500);
|
|
33
|
+
|
|
34
|
+
return { stdout, stderr, exitCode };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Helper to check if server is running by checking socket exists and responds
|
|
39
|
+
*/
|
|
40
|
+
async function isServerRunning(): Promise<boolean> {
|
|
41
|
+
if (!existsSync(TEST_SOCKET)) return false;
|
|
42
|
+
|
|
43
|
+
try {
|
|
44
|
+
const result = await runCli("list");
|
|
45
|
+
// If we can list sessions, server is running
|
|
46
|
+
return result.exitCode === 0;
|
|
47
|
+
} catch {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Helper to wait for a condition with timeout
|
|
54
|
+
*/
|
|
55
|
+
async function waitFor(
|
|
56
|
+
condition: () => Promise<boolean>,
|
|
57
|
+
timeoutMs: number = 5000,
|
|
58
|
+
intervalMs: number = 100
|
|
59
|
+
): Promise<boolean> {
|
|
60
|
+
const start = Date.now();
|
|
61
|
+
while (Date.now() - start < timeoutMs) {
|
|
62
|
+
if (await condition()) return true;
|
|
63
|
+
await Bun.sleep(intervalMs);
|
|
64
|
+
}
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Force cleanup any leftover test artifacts
|
|
70
|
+
*/
|
|
71
|
+
function forceCleanup(): void {
|
|
72
|
+
try { unlinkSync(TEST_SOCKET); } catch {}
|
|
73
|
+
try { rmSync(TEST_SESSION_DIR, { recursive: true, force: true }); } catch {}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
describe("tmux-lite server lifecycle", () => {
|
|
77
|
+
beforeEach(() => {
|
|
78
|
+
// Ensure clean state before each test
|
|
79
|
+
forceCleanup();
|
|
80
|
+
mkdirSync(TEST_SESSION_DIR, { recursive: true });
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
afterEach(async () => {
|
|
84
|
+
// Kill server if still running and cleanup
|
|
85
|
+
try {
|
|
86
|
+
await runCli("kill-server");
|
|
87
|
+
await Bun.sleep(200); // Wait for cleanup
|
|
88
|
+
} catch {}
|
|
89
|
+
forceCleanup();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe("server start", () => {
|
|
93
|
+
it("should start server and create socket file", async () => {
|
|
94
|
+
// Socket should not exist initially
|
|
95
|
+
expect(existsSync(TEST_SOCKET)).toBe(false);
|
|
96
|
+
|
|
97
|
+
// Start server by running any command (list auto-starts)
|
|
98
|
+
const result = await runCli("list");
|
|
99
|
+
|
|
100
|
+
// Wait for server to be ready
|
|
101
|
+
const started = await waitFor(async () => existsSync(TEST_SOCKET));
|
|
102
|
+
expect(started).toBe(true);
|
|
103
|
+
|
|
104
|
+
// Server should be running
|
|
105
|
+
const running = await isServerRunning();
|
|
106
|
+
expect(running).toBe(true);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("should not fail if server is already running", async () => {
|
|
110
|
+
// Start server first time
|
|
111
|
+
await runCli("list");
|
|
112
|
+
await waitFor(async () => existsSync(TEST_SOCKET));
|
|
113
|
+
|
|
114
|
+
// Run another command - should not fail
|
|
115
|
+
const result = await runCli("list");
|
|
116
|
+
expect(result.exitCode).toBe(0);
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
describe("server stop", () => {
|
|
121
|
+
it("should stop server and remove socket file", async () => {
|
|
122
|
+
// Start server
|
|
123
|
+
await runCli("list");
|
|
124
|
+
await waitFor(async () => existsSync(TEST_SOCKET));
|
|
125
|
+
expect(existsSync(TEST_SOCKET)).toBe(true);
|
|
126
|
+
|
|
127
|
+
// Stop server
|
|
128
|
+
await runCli("kill-server");
|
|
129
|
+
|
|
130
|
+
// Wait for socket to be cleaned up
|
|
131
|
+
const cleaned = await waitFor(async () => !existsSync(TEST_SOCKET), 2000);
|
|
132
|
+
expect(cleaned).toBe(true);
|
|
133
|
+
|
|
134
|
+
// Socket file should be removed
|
|
135
|
+
expect(existsSync(TEST_SOCKET)).toBe(false);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("should not fail if server is not running", async () => {
|
|
139
|
+
// Ensure server is not running
|
|
140
|
+
expect(existsSync(TEST_SOCKET)).toBe(false);
|
|
141
|
+
|
|
142
|
+
// Stop should handle gracefully
|
|
143
|
+
const result = await runCli("kill-server");
|
|
144
|
+
// CLI shows "Server not running" but exits cleanly
|
|
145
|
+
expect(result.exitCode).toBe(0);
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
describe("server restart cycle", () => {
|
|
150
|
+
it("should successfully restart after stop (socket cleanup regression test)", async () => {
|
|
151
|
+
// This is the main regression test for the socket cleanup fix
|
|
152
|
+
|
|
153
|
+
// Start server
|
|
154
|
+
await runCli("list");
|
|
155
|
+
const started1 = await waitFor(async () => existsSync(TEST_SOCKET));
|
|
156
|
+
expect(started1).toBe(true);
|
|
157
|
+
|
|
158
|
+
// Stop server
|
|
159
|
+
await runCli("kill-server");
|
|
160
|
+
const stopped = await waitFor(async () => !existsSync(TEST_SOCKET), 2000);
|
|
161
|
+
expect(stopped).toBe(true);
|
|
162
|
+
|
|
163
|
+
// Start server again - THIS WAS FAILING before the fix
|
|
164
|
+
// because the socket file wasn't being cleaned up
|
|
165
|
+
await runCli("list");
|
|
166
|
+
const started2 = await waitFor(async () => existsSync(TEST_SOCKET));
|
|
167
|
+
expect(started2).toBe(true);
|
|
168
|
+
|
|
169
|
+
// Verify server is actually running
|
|
170
|
+
const running = await isServerRunning();
|
|
171
|
+
expect(running).toBe(true);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("should handle multiple restart cycles", async () => {
|
|
175
|
+
for (let i = 0; i < 3; i++) {
|
|
176
|
+
// Start
|
|
177
|
+
await runCli("list");
|
|
178
|
+
const started = await waitFor(async () => existsSync(TEST_SOCKET));
|
|
179
|
+
expect(started).toBe(true);
|
|
180
|
+
|
|
181
|
+
// Verify running
|
|
182
|
+
const running = await isServerRunning();
|
|
183
|
+
expect(running).toBe(true);
|
|
184
|
+
|
|
185
|
+
// Stop
|
|
186
|
+
await runCli("kill-server");
|
|
187
|
+
const stopped = await waitFor(async () => !existsSync(TEST_SOCKET), 2000);
|
|
188
|
+
expect(stopped).toBe(true);
|
|
189
|
+
}
|
|
190
|
+
}, 15000); // Increase timeout for 3 cycles
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
describe("stale socket handling", () => {
|
|
194
|
+
it("should clean up stale socket on server start", async () => {
|
|
195
|
+
// Create a stale socket file (simulating crashed server)
|
|
196
|
+
Bun.write(TEST_SOCKET, "stale");
|
|
197
|
+
expect(existsSync(TEST_SOCKET)).toBe(true);
|
|
198
|
+
|
|
199
|
+
// Start server - should clean up stale socket and start fresh
|
|
200
|
+
await runCli("list");
|
|
201
|
+
const started = await waitFor(async () => {
|
|
202
|
+
// Socket exists AND server responds
|
|
203
|
+
return await isServerRunning();
|
|
204
|
+
});
|
|
205
|
+
expect(started).toBe(true);
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
});
|
|
@@ -1161,7 +1161,11 @@ Bun.listen({
|
|
|
1161
1161
|
res = { type: "ok" };
|
|
1162
1162
|
if (socketState.writer) socketState.writer.write(encodeRouterMessage(res));
|
|
1163
1163
|
else socket.write(encodeRouterMessage(res));
|
|
1164
|
-
|
|
1164
|
+
// Clean up socket file after sending response, before exit
|
|
1165
|
+
setTimeout(() => {
|
|
1166
|
+
try { unlinkSync(ROUTER_SOCKET); } catch {}
|
|
1167
|
+
process.exit(0);
|
|
1168
|
+
}, 100);
|
|
1165
1169
|
return;
|
|
1166
1170
|
|
|
1167
1171
|
case "inbox":
|
package/src/relay/server.ts
CHANGED
|
@@ -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(
|
|
79
|
+
headers: { "Content-Type": getContentType(normalizedPath) },
|
|
77
80
|
});
|
|
78
81
|
}
|
|
79
82
|
}
|
|
80
83
|
|
|
81
84
|
// Fall back to filesystem (development mode)
|
|
82
|
-
const
|
|
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(
|
|
91
|
+
headers: { "Content-Type": getContentType(normalizedPath) },
|
|
90
92
|
});
|
|
91
93
|
}
|
|
92
94
|
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* through the relay server with encrypted communication.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import WebSocket from 'ws';
|
|
8
9
|
import type {
|
|
9
10
|
MachineProvider,
|
|
10
11
|
CreateSessionOptions,
|
|
@@ -18,9 +19,6 @@ import type {
|
|
|
18
19
|
SessionStream,
|
|
19
20
|
} from '../types.js';
|
|
20
21
|
|
|
21
|
-
// Use dynamic import for WebSocket to support both Node and browser
|
|
22
|
-
// In Node, we use the 'ws' package; in browser, we use native WebSocket
|
|
23
|
-
|
|
24
22
|
/**
|
|
25
23
|
* Common WebSocket interface for both Node.js (ws package) and browser environments
|
|
26
24
|
*/
|
|
@@ -86,9 +84,6 @@ export class RemoteMachineProvider implements MachineProvider {
|
|
|
86
84
|
* Connect to relay server
|
|
87
85
|
*/
|
|
88
86
|
async connect(): Promise<void> {
|
|
89
|
-
// Dynamic import for ws module (Node.js)
|
|
90
|
-
const { default: WebSocket } = await import('ws');
|
|
91
|
-
|
|
92
87
|
return new Promise((resolve, reject) => {
|
|
93
88
|
const url = new URL(this.config.relayUrl);
|
|
94
89
|
url.searchParams.set('role', 'client');
|
|
@@ -7,7 +7,12 @@
|
|
|
7
7
|
import { useCallback } from 'react';
|
|
8
8
|
import { spawn } from 'child_process';
|
|
9
9
|
import { useInbox, type UseInboxProps, type UseInboxReturn } from '../../shared/components/Inbox.js';
|
|
10
|
-
import
|
|
10
|
+
import {
|
|
11
|
+
clearInbox,
|
|
12
|
+
markInboxRead,
|
|
13
|
+
listSessions,
|
|
14
|
+
type InboxItem,
|
|
15
|
+
} from '../../lib/tmux-lite/cli.js';
|
|
11
16
|
|
|
12
17
|
interface UseTUIInboxOptions {
|
|
13
18
|
items: InboxItem[];
|
|
@@ -38,25 +43,21 @@ export function useTUIInbox(options: UseTUIInboxOptions): UseTUIInboxReturn {
|
|
|
38
43
|
|
|
39
44
|
// Clear a single inbox item
|
|
40
45
|
const onClearItem = useCallback(async (itemId: string) => {
|
|
41
|
-
const { clearInbox } = await import('../../lib/tmux-lite/cli.js');
|
|
42
46
|
await clearInbox(itemId);
|
|
43
47
|
}, []);
|
|
44
48
|
|
|
45
49
|
// Clear all inbox items
|
|
46
50
|
const onClearAll = useCallback(async () => {
|
|
47
|
-
const { clearInbox } = await import('../../lib/tmux-lite/cli.js');
|
|
48
51
|
await clearInbox();
|
|
49
52
|
}, []);
|
|
50
53
|
|
|
51
54
|
// Mark an item as read
|
|
52
55
|
const onMarkRead = useCallback(async (itemId: string) => {
|
|
53
|
-
const { markInboxRead } = await import('../../lib/tmux-lite/cli.js');
|
|
54
56
|
await markInboxRead(itemId);
|
|
55
57
|
}, []);
|
|
56
58
|
|
|
57
59
|
// Attach to a session
|
|
58
60
|
const onAttachSession = useCallback(async (sessionId: string) => {
|
|
59
|
-
const { listSessions } = await import('../../lib/tmux-lite/cli.js');
|
|
60
61
|
const sessions = await listSessions();
|
|
61
62
|
const session = sessions.find(s => s.id === sessionId);
|
|
62
63
|
|
|
@@ -6,8 +6,10 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { useState, useCallback, useEffect, useRef } from 'react';
|
|
9
|
+
import WebSocket from 'ws';
|
|
9
10
|
import type { MachineInfo } from '../../shared/components/index.js';
|
|
10
11
|
import type { MachineProvider } from '../../shared/providers/index.js';
|
|
12
|
+
import { getLocalMachineProvider, createRemoteMachineProvider } from '../../shared/providers/index.js';
|
|
11
13
|
import type WS from 'ws';
|
|
12
14
|
|
|
13
15
|
export interface RelayConfig {
|
|
@@ -78,7 +80,6 @@ export function useRemoteMachines(options: UseRemoteMachinesOptions = {}): UseRe
|
|
|
78
80
|
setError(null);
|
|
79
81
|
|
|
80
82
|
try {
|
|
81
|
-
const { default: WebSocket } = await import('ws');
|
|
82
83
|
const socket = new WebSocket(relayConfig.url);
|
|
83
84
|
|
|
84
85
|
socket.on('open', () => {
|
|
@@ -147,7 +148,6 @@ export function useRemoteMachines(options: UseRemoteMachinesOptions = {}): UseRe
|
|
|
147
148
|
|
|
148
149
|
if (isLocal(machine)) {
|
|
149
150
|
// Return local provider
|
|
150
|
-
const { getLocalMachineProvider } = await import('../../shared/providers/index.js');
|
|
151
151
|
return getLocalMachineProvider();
|
|
152
152
|
}
|
|
153
153
|
|
|
@@ -157,7 +157,6 @@ export function useRemoteMachines(options: UseRemoteMachinesOptions = {}): UseRe
|
|
|
157
157
|
return null;
|
|
158
158
|
}
|
|
159
159
|
|
|
160
|
-
const { createRemoteMachineProvider } = await import('../../shared/providers/index.js');
|
|
161
160
|
return createRemoteMachineProvider({
|
|
162
161
|
relayUrl: relayConfig.url,
|
|
163
162
|
machineId: machine.machineId,
|
|
Binary file
|
|
@@ -1,20 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@gitspace/darwin-arm64",
|
|
3
|
-
"version": "0.2.0-rc.2",
|
|
4
|
-
"description": "GitSpace CLI binary for darwin-arm64",
|
|
5
|
-
"os": [
|
|
6
|
-
"darwin"
|
|
7
|
-
],
|
|
8
|
-
"cpu": [
|
|
9
|
-
"arm64"
|
|
10
|
-
],
|
|
11
|
-
"bin": {
|
|
12
|
-
"gssh-darwin-arm64": "bin/gssh"
|
|
13
|
-
},
|
|
14
|
-
"repository": {
|
|
15
|
-
"type": "git",
|
|
16
|
-
"url": "https://github.com/inkibra/gitspace.sh.git"
|
|
17
|
-
},
|
|
18
|
-
"homepage": "https://gitspace.sh",
|
|
19
|
-
"license": "SEE LICENSE IN LICENSE"
|
|
20
|
-
}
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|