@seflless/ghosttown 1.5.0 → 1.6.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.
@@ -0,0 +1,605 @@
1
+ #!/bin/bash
2
+
3
+ # =============================================================================
4
+ # Ghosttown CLI Test Suite
5
+ # =============================================================================
6
+ #
7
+ # This test file validates the session management functionality of the
8
+ # ghosttown CLI. It covers session creation, naming, renaming, and the
9
+ # stable ID system that enables rename-resilient URLs.
10
+ #
11
+ # IMPORTANT FOR AI AGENTS:
12
+ # ------------------------
13
+ # When making changes to session management code, run this test suite to
14
+ # verify your changes don't break existing functionality. If you add new
15
+ # features, add corresponding tests here.
16
+ #
17
+ # Run with: bash src/cli.test.sh
18
+ #
19
+ # The tests use tmux directly to verify behavior, so tmux must be installed.
20
+ # Tests clean up after themselves by killing any gt-test-* sessions created.
21
+ #
22
+ # =============================================================================
23
+
24
+ set -e
25
+
26
+ # Colors for output
27
+ RED='\033[0;31m'
28
+ GREEN='\033[0;32m'
29
+ YELLOW='\033[1;33m'
30
+ CYAN='\033[0;36m'
31
+ NC='\033[0m' # No Color
32
+
33
+ # Test counters
34
+ TESTS_RUN=0
35
+ TESTS_PASSED=0
36
+ TESTS_FAILED=0
37
+
38
+ # Prefix for test sessions (to avoid conflicts with real sessions)
39
+ TEST_PREFIX="gt-test-"
40
+
41
+ # =============================================================================
42
+ # Test Utilities
43
+ # =============================================================================
44
+
45
+ # Print a test description
46
+ test_start() {
47
+ TESTS_RUN=$((TESTS_RUN + 1))
48
+ echo -e "${CYAN}TEST $TESTS_RUN:${NC} $1"
49
+ }
50
+
51
+ # Mark test as passed
52
+ test_pass() {
53
+ TESTS_PASSED=$((TESTS_PASSED + 1))
54
+ echo -e " ${GREEN}✓ PASSED${NC}"
55
+ }
56
+
57
+ # Mark test as failed
58
+ test_fail() {
59
+ TESTS_FAILED=$((TESTS_FAILED + 1))
60
+ echo -e " ${RED}✗ FAILED:${NC} $1"
61
+ }
62
+
63
+ # Assert that a condition is true
64
+ assert() {
65
+ if eval "$1"; then
66
+ return 0
67
+ else
68
+ return 1
69
+ fi
70
+ }
71
+
72
+ # Assert string contains substring
73
+ assert_contains() {
74
+ if [[ "$1" == *"$2"* ]]; then
75
+ return 0
76
+ else
77
+ return 1
78
+ fi
79
+ }
80
+
81
+ # Assert string matches regex
82
+ assert_matches() {
83
+ if [[ "$1" =~ $2 ]]; then
84
+ return 0
85
+ else
86
+ return 1
87
+ fi
88
+ }
89
+
90
+ # Clean up any test sessions
91
+ cleanup_test_sessions() {
92
+ # Kill all sessions starting with gt-test-
93
+ tmux list-sessions -F "#{session_name}" 2>/dev/null | grep "^gt-test-" | while read -r session; do
94
+ tmux kill-session -t "$session" 2>/dev/null || true
95
+ done
96
+ # Also clean up temp sessions
97
+ tmux list-sessions -F "#{session_name}" 2>/dev/null | grep "^gt-temp-" | while read -r session; do
98
+ tmux kill-session -t "$session" 2>/dev/null || true
99
+ done
100
+ }
101
+
102
+ # Create a test session directly via tmux (bypassing CLI for setup)
103
+ create_test_session() {
104
+ local name="$1"
105
+ tmux new-session -d -s "$name" -x 80 -y 24
106
+ }
107
+
108
+ # Get session info in format: session_name|session_id
109
+ get_session_info() {
110
+ local name="$1"
111
+ tmux list-sessions -F "#{session_name}|#{session_id}" 2>/dev/null | grep "^$name|" || echo ""
112
+ }
113
+
114
+ # =============================================================================
115
+ # Test: Session Name Validation
116
+ # =============================================================================
117
+
118
+ test_session_name_validation() {
119
+ test_start "Session name validation - valid names"
120
+
121
+ # Valid names should pass (we test the validation logic indirectly)
122
+ # Valid: alphanumeric, hyphens, underscores
123
+ local valid_names=("my-session" "test_session" "Session123" "a" "a-b_c")
124
+ local all_valid=true
125
+
126
+ for name in "${valid_names[@]}"; do
127
+ # Names that are purely numeric should fail, others should pass
128
+ if [[ "$name" =~ ^[0-9]+$ ]]; then
129
+ all_valid=false
130
+ break
131
+ fi
132
+ # Names with invalid chars should fail
133
+ if [[ ! "$name" =~ ^[a-zA-Z0-9_-]+$ ]]; then
134
+ all_valid=false
135
+ break
136
+ fi
137
+ done
138
+
139
+ if $all_valid; then
140
+ test_pass
141
+ else
142
+ test_fail "Valid names were rejected"
143
+ fi
144
+ }
145
+
146
+ test_session_name_validation_invalid() {
147
+ test_start "Session name validation - invalid names rejected"
148
+
149
+ # These should all be invalid:
150
+ # - Purely numeric (conflicts with auto-generated IDs)
151
+ # - Contains spaces or special chars
152
+ # - Empty
153
+ local invalid_names=("123" "my session" "test@session" "")
154
+ local all_rejected=true
155
+
156
+ for name in "${invalid_names[@]}"; do
157
+ # Purely numeric should be rejected
158
+ if [[ "$name" =~ ^[0-9]+$ ]]; then
159
+ continue # This is correctly invalid
160
+ fi
161
+ # Empty should be rejected
162
+ if [[ -z "$name" ]]; then
163
+ continue # This is correctly invalid
164
+ fi
165
+ # Invalid chars should be rejected
166
+ if [[ ! "$name" =~ ^[a-zA-Z0-9_-]+$ ]]; then
167
+ continue # This is correctly invalid
168
+ fi
169
+ # If we get here, the name passed validation but shouldn't have
170
+ all_rejected=false
171
+ done
172
+
173
+ if $all_rejected; then
174
+ test_pass
175
+ else
176
+ test_fail "Invalid names were accepted"
177
+ fi
178
+ }
179
+
180
+ # =============================================================================
181
+ # Test: Session Naming Format
182
+ # =============================================================================
183
+ #
184
+ # BEHAVIOR: Sessions use format gt-<stableId>-<displayName>
185
+ # - stableId: Numeric part of tmux's internal $N session ID
186
+ # - displayName: User-facing name (custom or auto-generated number)
187
+ #
188
+ # This enables rename-resilient URLs: even if displayName changes,
189
+ # the stableId remains constant.
190
+ #
191
+ # =============================================================================
192
+
193
+ test_session_naming_format() {
194
+ test_start "Session naming format - gt-<stableId>-<displayName>"
195
+
196
+ cleanup_test_sessions
197
+
198
+ # Create a session via the API simulation
199
+ # We'll create it manually with the expected format
200
+ local temp_name="gt-temp-$$"
201
+ tmux new-session -d -s "$temp_name" -x 80 -y 24
202
+
203
+ # Get the session ID
204
+ local session_id
205
+ session_id=$(tmux list-sessions -F "#{session_name}|#{session_id}" | grep "^$temp_name|" | cut -d'|' -f2 | tr -d '$')
206
+
207
+ # Rename to final format
208
+ local final_name="gt-${session_id}-testname"
209
+ tmux rename-session -t "$temp_name" "$final_name"
210
+
211
+ # Verify the session exists with correct format
212
+ if tmux has-session -t "$final_name" 2>/dev/null; then
213
+ test_pass
214
+ tmux kill-session -t "$final_name" 2>/dev/null || true
215
+ else
216
+ test_fail "Session not created with expected format"
217
+ fi
218
+ }
219
+
220
+ test_parse_session_name() {
221
+ test_start "Parse session name - extracts stableId and displayName"
222
+
223
+ # Test parsing logic
224
+ # Format: gt-<stableId>-<displayName>
225
+ local test_name="gt-42-my-cool-session"
226
+
227
+ # Extract stableId (first number after gt-)
228
+ local stable_id
229
+ if [[ "$test_name" =~ ^gt-([0-9]+)-(.+)$ ]]; then
230
+ stable_id="${BASH_REMATCH[1]}"
231
+ local display_name="${BASH_REMATCH[2]}"
232
+
233
+ if [[ "$stable_id" == "42" ]] && [[ "$display_name" == "my-cool-session" ]]; then
234
+ test_pass
235
+ else
236
+ test_fail "Parsed values incorrect: stableId=$stable_id, displayName=$display_name"
237
+ fi
238
+ else
239
+ test_fail "Regex did not match"
240
+ fi
241
+ }
242
+
243
+ test_legacy_session_name_parsing() {
244
+ test_start "Legacy session name parsing - handles old gt-<name> format"
245
+
246
+ # BEHAVIOR: Old sessions without stableId should still work
247
+ # They get stableId=null and displayName=<everything after gt->
248
+
249
+ local legacy_name="gt-old-session"
250
+
251
+ # This should NOT match the new format
252
+ if [[ "$legacy_name" =~ ^gt-([0-9]+)-(.+)$ ]]; then
253
+ test_fail "Legacy name incorrectly matched new format"
254
+ else
255
+ # Extract display name for legacy format
256
+ local display_name="${legacy_name#gt-}"
257
+ if [[ "$display_name" == "old-session" ]]; then
258
+ test_pass
259
+ else
260
+ test_fail "Legacy display name extraction failed"
261
+ fi
262
+ fi
263
+ }
264
+
265
+ # =============================================================================
266
+ # Test: Session Rename Preserves Stable ID
267
+ # =============================================================================
268
+ #
269
+ # BEHAVIOR: When renaming a session, the stableId prefix is preserved.
270
+ # This allows URLs using the stableId to continue working after rename.
271
+ #
272
+ # =============================================================================
273
+
274
+ test_rename_preserves_stable_id() {
275
+ test_start "Rename preserves stable ID"
276
+
277
+ cleanup_test_sessions
278
+
279
+ # Create a session with stable ID format
280
+ local temp_name="gt-temp-$$"
281
+ tmux new-session -d -s "$temp_name" -x 80 -y 24
282
+
283
+ local session_id
284
+ session_id=$(tmux list-sessions -F "#{session_name}|#{session_id}" | grep "^$temp_name|" | cut -d'|' -f2 | tr -d '$')
285
+
286
+ local original_name="gt-${session_id}-original"
287
+ tmux rename-session -t "$temp_name" "$original_name"
288
+
289
+ # Now rename to a new display name, preserving stable ID
290
+ local new_name="gt-${session_id}-renamed"
291
+ tmux rename-session -t "$original_name" "$new_name"
292
+
293
+ # Verify the session exists with new name but same stable ID
294
+ if tmux has-session -t "$new_name" 2>/dev/null; then
295
+ # Extract stable ID from new name
296
+ if [[ "$new_name" =~ ^gt-([0-9]+)- ]]; then
297
+ local extracted_id="${BASH_REMATCH[1]}"
298
+ if [[ "$extracted_id" == "$session_id" ]]; then
299
+ test_pass
300
+ else
301
+ test_fail "Stable ID changed after rename: expected $session_id, got $extracted_id"
302
+ fi
303
+ else
304
+ test_fail "New name doesn't match expected format"
305
+ fi
306
+ tmux kill-session -t "$new_name" 2>/dev/null || true
307
+ else
308
+ test_fail "Session not found after rename"
309
+ fi
310
+ }
311
+
312
+ # =============================================================================
313
+ # Test: Find Session by Stable ID
314
+ # =============================================================================
315
+ #
316
+ # BEHAVIOR: Sessions can be looked up by their stable ID regardless of
317
+ # their current display name. This is how the web UI connects after rename.
318
+ #
319
+ # =============================================================================
320
+
321
+ test_find_session_by_stable_id() {
322
+ test_start "Find session by stable ID"
323
+
324
+ cleanup_test_sessions
325
+
326
+ # Create a session
327
+ local temp_name="gt-temp-$$"
328
+ tmux new-session -d -s "$temp_name" -x 80 -y 24
329
+
330
+ local session_id
331
+ session_id=$(tmux list-sessions -F "#{session_name}|#{session_id}" | grep "^$temp_name|" | cut -d'|' -f2 | tr -d '$')
332
+
333
+ local session_name="gt-${session_id}-findme"
334
+ tmux rename-session -t "$temp_name" "$session_name"
335
+
336
+ # Search for session by stable ID
337
+ local found=""
338
+ while IFS= read -r line; do
339
+ if [[ "$line" =~ ^gt-${session_id}- ]]; then
340
+ found="$line"
341
+ break
342
+ fi
343
+ done < <(tmux list-sessions -F "#{session_name}" 2>/dev/null)
344
+
345
+ if [[ -n "$found" ]]; then
346
+ test_pass
347
+ tmux kill-session -t "$session_name" 2>/dev/null || true
348
+ else
349
+ test_fail "Could not find session by stable ID $session_id"
350
+ fi
351
+ }
352
+
353
+ test_find_session_by_display_name() {
354
+ test_start "Find session by display name (legacy support)"
355
+
356
+ cleanup_test_sessions
357
+
358
+ # Create a session
359
+ local temp_name="gt-temp-$$"
360
+ tmux new-session -d -s "$temp_name" -x 80 -y 24
361
+
362
+ local session_id
363
+ session_id=$(tmux list-sessions -F "#{session_name}|#{session_id}" | grep "^$temp_name|" | cut -d'|' -f2 | tr -d '$')
364
+
365
+ local display_name="unique-test-name-$$"
366
+ local session_name="gt-${session_id}-${display_name}"
367
+ tmux rename-session -t "$temp_name" "$session_name"
368
+
369
+ # Search for session by display name
370
+ local found=""
371
+ while IFS= read -r line; do
372
+ # Parse the session name
373
+ if [[ "$line" =~ ^gt-[0-9]+-(.+)$ ]]; then
374
+ local parsed_display="${BASH_REMATCH[1]}"
375
+ if [[ "$parsed_display" == "$display_name" ]]; then
376
+ found="$line"
377
+ break
378
+ fi
379
+ fi
380
+ done < <(tmux list-sessions -F "#{session_name}" 2>/dev/null)
381
+
382
+ if [[ -n "$found" ]]; then
383
+ test_pass
384
+ tmux kill-session -t "$session_name" 2>/dev/null || true
385
+ else
386
+ test_fail "Could not find session by display name $display_name"
387
+ fi
388
+ }
389
+
390
+ # =============================================================================
391
+ # Test: Auto-generated Display Names
392
+ # =============================================================================
393
+ #
394
+ # BEHAVIOR: When no custom name is provided, sessions get auto-generated
395
+ # numeric display names (1, 2, 3, etc.) based on the highest existing number.
396
+ #
397
+ # =============================================================================
398
+
399
+ test_auto_generated_display_names() {
400
+ test_start "Auto-generated display names increment correctly"
401
+
402
+ cleanup_test_sessions
403
+
404
+ # Create sessions with numeric display names
405
+ # Simulate gt-<id>-1, gt-<id>-2 format
406
+
407
+ # Session 1
408
+ local temp1="gt-temp-1-$$"
409
+ tmux new-session -d -s "$temp1" -x 80 -y 24
410
+ local id1
411
+ id1=$(tmux list-sessions -F "#{session_name}|#{session_id}" | grep "^$temp1|" | cut -d'|' -f2 | tr -d '$')
412
+ tmux rename-session -t "$temp1" "gt-${id1}-1"
413
+
414
+ # Session 2
415
+ local temp2="gt-temp-2-$$"
416
+ tmux new-session -d -s "$temp2" -x 80 -y 24
417
+ local id2
418
+ id2=$(tmux list-sessions -F "#{session_name}|#{session_id}" | grep "^$temp2|" | cut -d'|' -f2 | tr -d '$')
419
+ tmux rename-session -t "$temp2" "gt-${id2}-2"
420
+
421
+ # Now find the next available number
422
+ local max_num=0
423
+ while IFS= read -r line; do
424
+ if [[ "$line" =~ ^gt-[0-9]+-([0-9]+)$ ]]; then
425
+ local num="${BASH_REMATCH[1]}"
426
+ if (( num > max_num )); then
427
+ max_num=$num
428
+ fi
429
+ fi
430
+ done < <(tmux list-sessions -F "#{session_name}" 2>/dev/null)
431
+
432
+ local next_num=$((max_num + 1))
433
+
434
+ if [[ "$next_num" == "3" ]]; then
435
+ test_pass
436
+ else
437
+ test_fail "Expected next number to be 3, got $next_num"
438
+ fi
439
+
440
+ # Cleanup
441
+ tmux kill-session -t "gt-${id1}-1" 2>/dev/null || true
442
+ tmux kill-session -t "gt-${id2}-2" 2>/dev/null || true
443
+ }
444
+
445
+ # =============================================================================
446
+ # Test: Display Name Uniqueness
447
+ # =============================================================================
448
+ #
449
+ # BEHAVIOR: Two sessions cannot have the same display name, even if they
450
+ # have different stable IDs.
451
+ #
452
+ # =============================================================================
453
+
454
+ test_display_name_uniqueness() {
455
+ test_start "Display names must be unique"
456
+
457
+ cleanup_test_sessions
458
+
459
+ # Create a session with a specific display name
460
+ local temp1="gt-temp-1-$$"
461
+ tmux new-session -d -s "$temp1" -x 80 -y 24
462
+ local id1
463
+ id1=$(tmux list-sessions -F "#{session_name}|#{session_id}" | grep "^$temp1|" | cut -d'|' -f2 | tr -d '$')
464
+ local display_name="unique-name-$$"
465
+ tmux rename-session -t "$temp1" "gt-${id1}-${display_name}"
466
+
467
+ # Check if display name already exists (simulating validation)
468
+ local exists=false
469
+ while IFS= read -r line; do
470
+ if [[ "$line" =~ ^gt-[0-9]+-(.+)$ ]]; then
471
+ if [[ "${BASH_REMATCH[1]}" == "$display_name" ]]; then
472
+ exists=true
473
+ break
474
+ fi
475
+ fi
476
+ done < <(tmux list-sessions -F "#{session_name}" 2>/dev/null)
477
+
478
+ if $exists; then
479
+ test_pass
480
+ else
481
+ test_fail "Display name uniqueness check failed"
482
+ fi
483
+
484
+ # Cleanup
485
+ tmux kill-session -t "gt-${id1}-${display_name}" 2>/dev/null || true
486
+ }
487
+
488
+ # =============================================================================
489
+ # Test: URL Format
490
+ # =============================================================================
491
+ #
492
+ # BEHAVIOR: URLs use format /?session=<displayName>&id=<stableId>
493
+ # - session: Human-readable display name
494
+ # - id: Stable ID for rename resilience
495
+ #
496
+ # The system tries to look up by stable ID first, then by display name.
497
+ #
498
+ # =============================================================================
499
+
500
+ test_url_format() {
501
+ test_start "URL format includes both display name and stable ID"
502
+
503
+ # Simulate URL construction
504
+ local display_name="my-session"
505
+ local stable_id="42"
506
+
507
+ local url="/?session=${display_name}&id=${stable_id}"
508
+
509
+ # Parse it back
510
+ # Extract session param
511
+ if [[ "$url" =~ session=([^&]+) ]]; then
512
+ local parsed_session="${BASH_REMATCH[1]}"
513
+ fi
514
+
515
+ # Extract id param
516
+ if [[ "$url" =~ id=([^&]+) ]]; then
517
+ local parsed_id="${BASH_REMATCH[1]}"
518
+ fi
519
+
520
+ if [[ "$parsed_session" == "$display_name" ]] && [[ "$parsed_id" == "$stable_id" ]]; then
521
+ test_pass
522
+ else
523
+ test_fail "URL parsing failed: session=$parsed_session, id=$parsed_id"
524
+ fi
525
+ }
526
+
527
+ # =============================================================================
528
+ # Run All Tests
529
+ # =============================================================================
530
+
531
+ main() {
532
+ echo ""
533
+ echo -e "${YELLOW}==============================================================================${NC}"
534
+ echo -e "${YELLOW} Ghosttown CLI Test Suite${NC}"
535
+ echo -e "${YELLOW}==============================================================================${NC}"
536
+ echo ""
537
+
538
+ # Ensure tmux is installed
539
+ if ! command -v tmux &> /dev/null; then
540
+ echo -e "${RED}ERROR: tmux is not installed. Tests require tmux.${NC}"
541
+ exit 1
542
+ fi
543
+
544
+ # Clean up any leftover test sessions
545
+ cleanup_test_sessions
546
+
547
+ echo -e "${CYAN}Running tests...${NC}"
548
+ echo ""
549
+
550
+ # Session Name Validation
551
+ test_session_name_validation
552
+ test_session_name_validation_invalid
553
+
554
+ # Session Naming Format
555
+ test_session_naming_format
556
+ test_parse_session_name
557
+ test_legacy_session_name_parsing
558
+
559
+ # Rename Behavior
560
+ test_rename_preserves_stable_id
561
+
562
+ # Session Lookup
563
+ test_find_session_by_stable_id
564
+ test_find_session_by_display_name
565
+
566
+ # Auto-generated Names
567
+ test_auto_generated_display_names
568
+
569
+ # Display Name Uniqueness
570
+ test_display_name_uniqueness
571
+
572
+ # URL Format
573
+ test_url_format
574
+
575
+ # Final cleanup
576
+ cleanup_test_sessions
577
+
578
+ # Print summary
579
+ echo ""
580
+ echo -e "${YELLOW}==============================================================================${NC}"
581
+ echo -e "${YELLOW} Test Summary${NC}"
582
+ echo -e "${YELLOW}==============================================================================${NC}"
583
+ echo ""
584
+ echo -e " Tests run: ${TESTS_RUN}"
585
+ echo -e " ${GREEN}Passed: ${TESTS_PASSED}${NC}"
586
+ if [[ $TESTS_FAILED -gt 0 ]]; then
587
+ echo -e " ${RED}Failed: ${TESTS_FAILED}${NC}"
588
+ else
589
+ echo -e " Failed: ${TESTS_FAILED}"
590
+ fi
591
+ echo ""
592
+
593
+ if [[ $TESTS_FAILED -gt 0 ]]; then
594
+ echo -e "${RED}SOME TESTS FAILED${NC}"
595
+ exit 1
596
+ else
597
+ echo -e "${GREEN}ALL TESTS PASSED${NC}"
598
+ exit 0
599
+ fi
600
+ }
601
+
602
+ # Run main if script is executed directly
603
+ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
604
+ main "$@"
605
+ fi
@@ -0,0 +1,75 @@
1
+ /**
2
+ * CLI tests
3
+ *
4
+ * Tests for the ghosttown CLI command-line interface.
5
+ */
6
+
7
+ import { describe, expect, test } from 'bun:test';
8
+ import { spawn } from 'child_process';
9
+ import { join } from 'path';
10
+ import { fileURLToPath } from 'url';
11
+
12
+ const __dirname = join(fileURLToPath(import.meta.url), '..');
13
+ const CLI_PATH = join(__dirname, '..', 'bin', 'gt.js');
14
+
15
+ /**
16
+ * Run the CLI with given arguments and return stdout/stderr/exit code
17
+ */
18
+ function runCli(args: string[]): Promise<{ stdout: string; stderr: string; code: number }> {
19
+ return new Promise((resolve) => {
20
+ const proc = spawn('node', [CLI_PATH, ...args], {
21
+ stdio: ['pipe', 'pipe', 'pipe'],
22
+ });
23
+
24
+ let stdout = '';
25
+ let stderr = '';
26
+
27
+ proc.stdout.on('data', (data) => {
28
+ stdout += data.toString();
29
+ });
30
+
31
+ proc.stderr.on('data', (data) => {
32
+ stderr += data.toString();
33
+ });
34
+
35
+ proc.on('close', (code) => {
36
+ resolve({ stdout, stderr, code: code ?? 1 });
37
+ });
38
+ });
39
+ }
40
+
41
+ describe('CLI', () => {
42
+ describe('version flag', () => {
43
+ test('-v should print version and exit', async () => {
44
+ const result = await runCli(['-v']);
45
+ expect(result.code).toBe(0);
46
+ expect(result.stdout.trim()).toMatch(/^\d+\.\d+\.\d+$/);
47
+ expect(result.stderr).toBe('');
48
+ });
49
+
50
+ test('--version should print version and exit', async () => {
51
+ const result = await runCli(['--version']);
52
+ expect(result.code).toBe(0);
53
+ expect(result.stdout.trim()).toMatch(/^\d+\.\d+\.\d+$/);
54
+ expect(result.stderr).toBe('');
55
+ });
56
+ });
57
+
58
+ describe('help flag', () => {
59
+ test('-h should print help and exit', async () => {
60
+ const result = await runCli(['-h']);
61
+ expect(result.code).toBe(0);
62
+ expect(result.stdout).toContain('Usage: ghosttown');
63
+ expect(result.stdout).toContain('--version');
64
+ expect(result.stdout).toContain('--help');
65
+ expect(result.stderr).toBe('');
66
+ });
67
+
68
+ test('--help should print help and exit', async () => {
69
+ const result = await runCli(['--help']);
70
+ expect(result.code).toBe(0);
71
+ expect(result.stdout).toContain('Usage: ghosttown');
72
+ expect(result.stderr).toBe('');
73
+ });
74
+ });
75
+ });