@seflless/ghosttown 1.6.1 → 1.7.0

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,236 @@
1
+ /**
2
+ * Session Management Types
3
+ *
4
+ * Core types for the custom PTY session management system that replaces tmux.
5
+ * Sessions persist across reconnections and server restarts.
6
+ */
7
+
8
+ /**
9
+ * Unique identifier for a session (UUID v4).
10
+ * This ID is stable and survives renames, reconnections, and server restarts.
11
+ */
12
+ export type SessionId = string;
13
+
14
+ /**
15
+ * Share permission level for a session connection.
16
+ */
17
+ export type SharePermission = 'read-only' | 'read-write';
18
+
19
+ /**
20
+ * Configuration for sharing a session with others.
21
+ */
22
+ export interface SharingConfig {
23
+ /** Whether sharing is currently enabled */
24
+ enabled: boolean;
25
+ /** Secure token for share access (32-byte random, base64url) */
26
+ token: string;
27
+ /** Permission level for shared access */
28
+ permissions: SharePermission;
29
+ /** Unix timestamp when share expires, null for never */
30
+ expiresAt: number | null;
31
+ /** Maximum number of concurrent viewers, null for unlimited */
32
+ maxViewers: number | null;
33
+ }
34
+
35
+ /**
36
+ * Process state for a session.
37
+ * The pid is null after server restart (process was killed).
38
+ */
39
+ export interface ProcessState {
40
+ /** Process ID, null if process is not running */
41
+ pid: number | null;
42
+ /** Shell executable path (e.g., '/bin/zsh') */
43
+ shell: string;
44
+ /** Shell arguments */
45
+ args: string[];
46
+ /** Current working directory */
47
+ cwd: string;
48
+ /** Environment variables */
49
+ env: Record<string, string>;
50
+ }
51
+
52
+ /**
53
+ * Terminal state for a session.
54
+ */
55
+ export interface TerminalState {
56
+ /** Number of columns */
57
+ cols: number;
58
+ /** Number of rows */
59
+ rows: number;
60
+ /** Cursor X position */
61
+ cursorX: number;
62
+ /** Cursor Y position */
63
+ cursorY: number;
64
+ /** Cursor style */
65
+ cursorStyle: 'block' | 'underline' | 'bar';
66
+ /** Whether cursor is visible */
67
+ cursorVisible: boolean;
68
+ }
69
+
70
+ /**
71
+ * Core session state that survives server restarts.
72
+ * Persisted to disk as JSON.
73
+ */
74
+ export interface Session {
75
+ /** Unique identifier (UUID v4) */
76
+ id: SessionId;
77
+ /** User-facing display name */
78
+ displayName: string;
79
+ /** Unix timestamp when session was created */
80
+ createdAt: number;
81
+ /** Unix timestamp of last activity */
82
+ lastActivity: number;
83
+ /** Process state */
84
+ process: ProcessState;
85
+ /** Terminal state */
86
+ terminal: TerminalState;
87
+ /** Path to scrollback file (relative to session directory) */
88
+ scrollbackFile: string;
89
+ /** Total number of lines in scrollback history */
90
+ scrollbackLength: number;
91
+ /** Sharing configuration, null if not shared */
92
+ sharing: SharingConfig | null;
93
+ }
94
+
95
+ /**
96
+ * Connection to a session (in-memory, not persisted).
97
+ */
98
+ export interface Connection {
99
+ /** Unique connection ID */
100
+ id: string;
101
+ /** Session being connected to */
102
+ sessionId: SessionId;
103
+ /** User identifier */
104
+ userId: string;
105
+ /** Connection type */
106
+ type: 'owner' | 'viewer';
107
+ /** Permission level */
108
+ permissions: SharePermission;
109
+ /** Unix timestamp when connected */
110
+ connectedAt: number;
111
+ }
112
+
113
+ /**
114
+ * Options for creating a new session.
115
+ */
116
+ export interface CreateSessionOptions {
117
+ /** Display name (auto-generated if not provided) */
118
+ name?: string;
119
+ /** Shell executable (defaults to user's shell) */
120
+ shell?: string;
121
+ /** Shell arguments */
122
+ args?: string[];
123
+ /** Working directory (defaults to home directory) */
124
+ cwd?: string;
125
+ /** Environment variables to add/override */
126
+ env?: Record<string, string>;
127
+ /** Terminal columns (default: 80) */
128
+ cols?: number;
129
+ /** Terminal rows (default: 24) */
130
+ rows?: number;
131
+ /**
132
+ * Whether to start the PTY process immediately (default: true).
133
+ * Set to false for web sessions where output should wait for client connection.
134
+ */
135
+ startProcess?: boolean;
136
+ }
137
+
138
+ /**
139
+ * Options for connecting to a session.
140
+ */
141
+ export interface ConnectOptions {
142
+ /** Session ID to connect to */
143
+ sessionId: SessionId;
144
+ /** Share token (required for viewer access) */
145
+ shareToken?: string;
146
+ /** User identifier */
147
+ userId: string;
148
+ }
149
+
150
+ /**
151
+ * Session info returned from list operations.
152
+ */
153
+ export interface SessionInfo {
154
+ /** Session ID */
155
+ id: SessionId;
156
+ /** Display name */
157
+ displayName: string;
158
+ /** Unix timestamp of last activity */
159
+ lastActivity: number;
160
+ /** Whether a process is currently running */
161
+ isRunning: boolean;
162
+ /** Number of active connections */
163
+ connectionCount: number;
164
+ /** Whether session is being shared */
165
+ isShared: boolean;
166
+ }
167
+
168
+ /**
169
+ * Result of creating a share link.
170
+ */
171
+ export interface ShareLink {
172
+ /** Local URL for LAN access */
173
+ localUrl: string;
174
+ /** Public URL for internet access (if tunneling enabled) */
175
+ publicUrl?: string;
176
+ /** Share token */
177
+ token: string;
178
+ /** Unix timestamp when share expires */
179
+ expiresAt: number | null;
180
+ }
181
+
182
+ /**
183
+ * Options for creating a share link.
184
+ */
185
+ export interface CreateShareOptions {
186
+ /** Permission level (default: 'read-only') */
187
+ permissions?: SharePermission;
188
+ /** Expiration time in milliseconds from now */
189
+ expiresIn?: number;
190
+ /** Maximum concurrent viewers */
191
+ maxViewers?: number;
192
+ }
193
+
194
+ /**
195
+ * Events emitted by SessionManager.
196
+ */
197
+ export interface SessionManagerEvents {
198
+ /** Emitted when a new session is created */
199
+ 'session:created': (session: Session) => void;
200
+ /** Emitted when a session is deleted */
201
+ 'session:deleted': (sessionId: SessionId) => void;
202
+ /** Emitted when a session's process exits */
203
+ 'session:exit': (sessionId: SessionId, exitCode: number) => void;
204
+ /** Emitted when a connection is established */
205
+ 'connection:open': (connection: Connection) => void;
206
+ /** Emitted when a connection is closed */
207
+ 'connection:close': (connectionId: string) => void;
208
+ /** Emitted when session output is received */
209
+ 'session:output': (sessionId: SessionId, data: string) => void;
210
+ }
211
+
212
+ /**
213
+ * Storage paths for session data.
214
+ */
215
+ export interface SessionPaths {
216
+ /** Base directory for all session data */
217
+ baseDir: string;
218
+ /** Directory for a specific session */
219
+ sessionDir: (sessionId: SessionId) => string;
220
+ /** Path to session metadata file */
221
+ metadataFile: (sessionId: SessionId) => string;
222
+ /** Path to scrollback file */
223
+ scrollbackFile: (sessionId: SessionId) => string;
224
+ }
225
+
226
+ /**
227
+ * Configuration for SessionManager.
228
+ */
229
+ export interface SessionManagerConfig {
230
+ /** Base directory for session storage (default: ~/.config/ghosttown/sessions) */
231
+ storageDir?: string;
232
+ /** Maximum scrollback lines per session (default: 10000) */
233
+ scrollbackLimit?: number;
234
+ /** Default shell (default: $SHELL or /bin/sh) */
235
+ defaultShell?: string;
236
+ }
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Session Utilities
3
+ *
4
+ * Helper functions for working with SessionManager sessions.
5
+ * Simplified for UUID-based session IDs (no longer depends on tmux).
6
+ */
7
+
8
+ /**
9
+ * @typedef {Object} SessionInfo
10
+ * @property {string} id - UUID
11
+ * @property {string} displayName - User-facing name
12
+ */
13
+
14
+ /**
15
+ * Validate a session display name.
16
+ *
17
+ * @param {string} name - The name to validate
18
+ * @returns {{ valid: boolean, error?: string }} Validation result
19
+ */
20
+ export function validateSessionName(name) {
21
+ if (!name || name.length === 0) {
22
+ return { valid: false, error: 'Session name cannot be empty' };
23
+ }
24
+ if (name.length > 50) {
25
+ return { valid: false, error: 'Session name too long (max 50 chars)' };
26
+ }
27
+ // Reserved name for system use
28
+ if (name === 'ghosttown-host') {
29
+ return { valid: false, error: "'ghosttown-host' is a reserved name" };
30
+ }
31
+ // Allow letters, numbers, hyphens, underscores for URL safety
32
+ // Note: Purely numeric names are now OK since we use UUIDs for session identity
33
+ if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
34
+ return {
35
+ valid: false,
36
+ error: 'Session name can only contain letters, numbers, hyphens, and underscores',
37
+ };
38
+ }
39
+ return { valid: true };
40
+ }
41
+
42
+ /**
43
+ * Find a session by display name from a list.
44
+ *
45
+ * @param {SessionInfo[]} sessions - List from SessionManager.listSessions()
46
+ * @param {string} displayName - The display name to search for
47
+ * @returns {SessionInfo|null} The matching session, or null if not found
48
+ */
49
+ export function findByDisplayName(sessions, displayName) {
50
+ return sessions.find((s) => s.displayName === displayName) || null;
51
+ }
52
+
53
+ /**
54
+ * Find a session by ID or display name.
55
+ * Useful for CLI commands that accept either.
56
+ *
57
+ * @param {SessionInfo[]} sessions - List from SessionManager.listSessions()
58
+ * @param {string} identifier - UUID, short UUID prefix, or display name
59
+ * @returns {SessionInfo|null} The matching session, or null if not found
60
+ */
61
+ export function findSession(sessions, identifier) {
62
+ if (!identifier) {
63
+ return null;
64
+ }
65
+
66
+ // Try exact ID match first
67
+ const byId = sessions.find((s) => s.id === identifier);
68
+ if (byId) {
69
+ return byId;
70
+ }
71
+
72
+ // Try short ID prefix match (for convenience)
73
+ const byPrefix = sessions.find((s) => s.id.startsWith(identifier));
74
+ if (byPrefix) {
75
+ return byPrefix;
76
+ }
77
+
78
+ // Try by display name
79
+ return findByDisplayName(sessions, identifier);
80
+ }
81
+
82
+ /**
83
+ * Check if a display name already exists in the session list.
84
+ *
85
+ * @param {SessionInfo[]} sessions - List from SessionManager.listSessions()
86
+ * @param {string} displayName - The display name to check
87
+ * @returns {boolean} True if the name already exists
88
+ */
89
+ export function displayNameExists(sessions, displayName) {
90
+ return findByDisplayName(sessions, displayName) !== null;
91
+ }
@@ -0,0 +1,229 @@
1
+ /**
2
+ * Session Utilities Tests
3
+ *
4
+ * Tests for session lookup and validation helpers.
5
+ * Simplified for UUID-based session IDs (no longer depends on tmux).
6
+ */
7
+
8
+ import { describe, expect, test } from 'bun:test';
9
+ import {
10
+ displayNameExists,
11
+ findByDisplayName,
12
+ findSession,
13
+ validateSessionName,
14
+ } from './session-utils.js';
15
+
16
+ describe('validateSessionName', () => {
17
+ describe('valid names', () => {
18
+ test('accepts simple text name', () => {
19
+ const result = validateSessionName('my-project');
20
+ expect(result).toEqual({ valid: true });
21
+ });
22
+
23
+ test('accepts name with numbers', () => {
24
+ const result = validateSessionName('project-123');
25
+ expect(result).toEqual({ valid: true });
26
+ });
27
+
28
+ test('accepts name with underscores', () => {
29
+ const result = validateSessionName('my_project_2');
30
+ expect(result).toEqual({ valid: true });
31
+ });
32
+
33
+ test('accepts single character name', () => {
34
+ const result = validateSessionName('x');
35
+ expect(result).toEqual({ valid: true });
36
+ });
37
+
38
+ test('accepts purely numeric name (now allowed with UUID-based IDs)', () => {
39
+ const result = validateSessionName('123');
40
+ expect(result).toEqual({ valid: true });
41
+ });
42
+ });
43
+
44
+ describe('invalid names', () => {
45
+ test('rejects empty name', () => {
46
+ expect(validateSessionName('')).toEqual({
47
+ valid: false,
48
+ error: 'Session name cannot be empty',
49
+ });
50
+ });
51
+
52
+ test('rejects null/undefined', () => {
53
+ expect(validateSessionName(null)).toEqual({
54
+ valid: false,
55
+ error: 'Session name cannot be empty',
56
+ });
57
+ expect(validateSessionName(undefined)).toEqual({
58
+ valid: false,
59
+ error: 'Session name cannot be empty',
60
+ });
61
+ });
62
+
63
+ test('rejects name over 50 characters', () => {
64
+ const longName = 'a'.repeat(51);
65
+ expect(validateSessionName(longName)).toEqual({
66
+ valid: false,
67
+ error: 'Session name too long (max 50 chars)',
68
+ });
69
+ });
70
+
71
+ test('rejects reserved name ghosttown-host', () => {
72
+ expect(validateSessionName('ghosttown-host')).toEqual({
73
+ valid: false,
74
+ error: "'ghosttown-host' is a reserved name",
75
+ });
76
+ });
77
+
78
+ test('rejects names with spaces', () => {
79
+ expect(validateSessionName('my project')).toEqual({
80
+ valid: false,
81
+ error: 'Session name can only contain letters, numbers, hyphens, and underscores',
82
+ });
83
+ });
84
+
85
+ test('rejects names with special characters', () => {
86
+ expect(validateSessionName('my.project')).toEqual({
87
+ valid: false,
88
+ error: 'Session name can only contain letters, numbers, hyphens, and underscores',
89
+ });
90
+ expect(validateSessionName('my:project')).toEqual({
91
+ valid: false,
92
+ error: 'Session name can only contain letters, numbers, hyphens, and underscores',
93
+ });
94
+ expect(validateSessionName('my/project')).toEqual({
95
+ valid: false,
96
+ error: 'Session name can only contain letters, numbers, hyphens, and underscores',
97
+ });
98
+ });
99
+ });
100
+ });
101
+
102
+ describe('findByDisplayName', () => {
103
+ const sessions = [
104
+ { id: 'abc-123', displayName: 'project-1' },
105
+ { id: 'def-456', displayName: 'project-2' },
106
+ { id: 'ghi-789', displayName: 'my-app' },
107
+ ];
108
+
109
+ test('finds session by exact display name', () => {
110
+ const result = findByDisplayName(sessions, 'project-1');
111
+ expect(result).toEqual({ id: 'abc-123', displayName: 'project-1' });
112
+ });
113
+
114
+ test('finds session with different name', () => {
115
+ const result = findByDisplayName(sessions, 'my-app');
116
+ expect(result).toEqual({ id: 'ghi-789', displayName: 'my-app' });
117
+ });
118
+
119
+ test('returns null for non-existent name', () => {
120
+ expect(findByDisplayName(sessions, 'nonexistent')).toBeNull();
121
+ });
122
+
123
+ test('returns null for empty name', () => {
124
+ expect(findByDisplayName(sessions, '')).toBeNull();
125
+ });
126
+
127
+ test('returns null for empty sessions list', () => {
128
+ expect(findByDisplayName([], 'anything')).toBeNull();
129
+ });
130
+
131
+ test('is case-sensitive', () => {
132
+ expect(findByDisplayName(sessions, 'Project-1')).toBeNull();
133
+ expect(findByDisplayName(sessions, 'PROJECT-1')).toBeNull();
134
+ });
135
+ });
136
+
137
+ describe('findSession', () => {
138
+ const sessions = [
139
+ { id: 'abc12345-6789-0123-4567-890abcdef012', displayName: 'project-1' },
140
+ { id: 'def45678-9012-3456-7890-123456789abc', displayName: 'project-2' },
141
+ { id: 'ghi78901-2345-6789-0123-456789abcdef', displayName: 'my-app' },
142
+ ];
143
+
144
+ describe('by exact ID', () => {
145
+ test('finds session by full UUID', () => {
146
+ const result = findSession(sessions, 'abc12345-6789-0123-4567-890abcdef012');
147
+ expect(result?.displayName).toBe('project-1');
148
+ });
149
+
150
+ test('finds different session by full UUID', () => {
151
+ const result = findSession(sessions, 'ghi78901-2345-6789-0123-456789abcdef');
152
+ expect(result?.displayName).toBe('my-app');
153
+ });
154
+ });
155
+
156
+ describe('by ID prefix', () => {
157
+ test('finds session by short prefix', () => {
158
+ const result = findSession(sessions, 'abc12345');
159
+ expect(result?.displayName).toBe('project-1');
160
+ });
161
+
162
+ test('finds session by very short prefix', () => {
163
+ const result = findSession(sessions, 'def');
164
+ expect(result?.displayName).toBe('project-2');
165
+ });
166
+ });
167
+
168
+ describe('by display name', () => {
169
+ test('finds session by display name when ID does not match', () => {
170
+ const result = findSession(sessions, 'my-app');
171
+ expect(result?.id).toBe('ghi78901-2345-6789-0123-456789abcdef');
172
+ });
173
+
174
+ test('prefers ID match over display name match', () => {
175
+ // If a session had displayName that looks like another session's ID prefix,
176
+ // ID matching should take precedence
177
+ const sessionsWithConflict = [
178
+ { id: 'abc-123', displayName: 'def' },
179
+ { id: 'def-456', displayName: 'other' },
180
+ ];
181
+ const result = findSession(sessionsWithConflict, 'def');
182
+ // Should match 'def-456' by ID prefix, not 'abc-123' by displayName
183
+ expect(result?.displayName).toBe('other');
184
+ });
185
+ });
186
+
187
+ describe('not found', () => {
188
+ test('returns null for non-existent identifier', () => {
189
+ expect(findSession(sessions, 'nonexistent')).toBeNull();
190
+ });
191
+
192
+ test('returns null for null identifier', () => {
193
+ expect(findSession(sessions, null)).toBeNull();
194
+ });
195
+
196
+ test('returns null for undefined identifier', () => {
197
+ expect(findSession(sessions, undefined)).toBeNull();
198
+ });
199
+
200
+ test('returns null for empty identifier', () => {
201
+ expect(findSession(sessions, '')).toBeNull();
202
+ });
203
+
204
+ test('returns null for empty sessions list', () => {
205
+ expect(findSession([], 'anything')).toBeNull();
206
+ });
207
+ });
208
+ });
209
+
210
+ describe('displayNameExists', () => {
211
+ const sessions = [
212
+ { id: 'abc-123', displayName: 'project-1' },
213
+ { id: 'def-456', displayName: 'project-2' },
214
+ ];
215
+
216
+ test('returns true for existing name', () => {
217
+ expect(displayNameExists(sessions, 'project-1')).toBe(true);
218
+ expect(displayNameExists(sessions, 'project-2')).toBe(true);
219
+ });
220
+
221
+ test('returns false for non-existent name', () => {
222
+ expect(displayNameExists(sessions, 'project-3')).toBe(false);
223
+ expect(displayNameExists(sessions, 'nonexistent')).toBe(false);
224
+ });
225
+
226
+ test('returns false for empty sessions list', () => {
227
+ expect(displayNameExists([], 'anything')).toBe(false);
228
+ });
229
+ });