nikcli-remote 1.0.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.
package/src/tunnel.ts ADDED
@@ -0,0 +1,258 @@
1
+ /**
2
+ * @nikcli/remote - Tunnel Manager
3
+ * Provides public URL access via various tunnel providers
4
+ */
5
+
6
+ import { spawn, type ChildProcess } from 'node:child_process'
7
+ import type { TunnelProvider } from './types'
8
+
9
+ export class TunnelManager {
10
+ private provider: TunnelProvider
11
+ private process: ChildProcess | null = null
12
+ private url: string | null = null
13
+ private tunnelInstance: any = null
14
+
15
+ constructor(provider: TunnelProvider) {
16
+ this.provider = provider
17
+ }
18
+
19
+ /**
20
+ * Create tunnel and return public URL
21
+ */
22
+ async create(port: number): Promise<string> {
23
+ switch (this.provider) {
24
+ case 'localtunnel':
25
+ return this.createLocaltunnel(port)
26
+ case 'cloudflared':
27
+ return this.createCloudflared(port)
28
+ case 'ngrok':
29
+ return this.createNgrok(port)
30
+ default:
31
+ throw new Error(`Unknown tunnel provider: ${this.provider}`)
32
+ }
33
+ }
34
+
35
+ /**
36
+ * Close tunnel
37
+ */
38
+ async close(): Promise<void> {
39
+ if (this.tunnelInstance?.close) {
40
+ this.tunnelInstance.close()
41
+ this.tunnelInstance = null
42
+ }
43
+ if (this.process) {
44
+ this.process.kill()
45
+ this.process = null
46
+ }
47
+ this.url = null
48
+ }
49
+
50
+ /**
51
+ * Get tunnel URL
52
+ */
53
+ getUrl(): string | null {
54
+ return this.url
55
+ }
56
+
57
+ /**
58
+ * Create localtunnel
59
+ */
60
+ private async createLocaltunnel(port: number): Promise<string> {
61
+ // Try library first
62
+ try {
63
+ const localtunnel = await import('localtunnel')
64
+ const tunnel = await localtunnel.default({ port })
65
+ this.tunnelInstance = tunnel
66
+ this.url = tunnel.url
67
+
68
+ tunnel.on('close', () => {
69
+ this.url = null
70
+ })
71
+
72
+ return tunnel.url
73
+ } catch {
74
+ // Try CLI
75
+ return this.createLocaltunnelCli(port)
76
+ }
77
+ }
78
+
79
+ /**
80
+ * Create localtunnel via CLI
81
+ */
82
+ private createLocaltunnelCli(port: number): Promise<string> {
83
+ return new Promise((resolve, reject) => {
84
+ this.process = spawn('npx', ['localtunnel', '--port', port.toString()], {
85
+ stdio: ['pipe', 'pipe', 'pipe'],
86
+ shell: true,
87
+ })
88
+
89
+ let output = ''
90
+ const timeout = setTimeout(() => {
91
+ reject(new Error('Localtunnel timeout'))
92
+ }, 30000)
93
+
94
+ this.process.stdout?.on('data', (data: Buffer) => {
95
+ output += data.toString()
96
+ const match = output.match(/your url is:\s*(https?:\/\/[^\s]+)/i)
97
+ if (match) {
98
+ clearTimeout(timeout)
99
+ this.url = match[1]
100
+ resolve(match[1])
101
+ }
102
+ })
103
+
104
+ this.process.stderr?.on('data', (data: Buffer) => {
105
+ output += data.toString()
106
+ })
107
+
108
+ this.process.on('error', (error) => {
109
+ clearTimeout(timeout)
110
+ reject(error)
111
+ })
112
+
113
+ this.process.on('exit', (code) => {
114
+ if (code !== 0 && !this.url) {
115
+ clearTimeout(timeout)
116
+ reject(new Error(`Localtunnel exited with code ${code}`))
117
+ }
118
+ })
119
+ })
120
+ }
121
+
122
+ /**
123
+ * Create cloudflared tunnel
124
+ */
125
+ private createCloudflared(port: number): Promise<string> {
126
+ return new Promise((resolve, reject) => {
127
+ this.process = spawn(
128
+ 'cloudflared',
129
+ ['tunnel', '--url', `http://localhost:${port}`],
130
+ {
131
+ stdio: ['pipe', 'pipe', 'pipe'],
132
+ }
133
+ )
134
+
135
+ let output = ''
136
+ const timeout = setTimeout(() => {
137
+ reject(new Error('Cloudflared timeout'))
138
+ }, 30000)
139
+
140
+ const handleData = (data: Buffer) => {
141
+ output += data.toString()
142
+ // Cloudflared outputs URL to stderr
143
+ const match = output.match(/(https:\/\/[^\s]+\.trycloudflare\.com)/i)
144
+ if (match) {
145
+ clearTimeout(timeout)
146
+ this.url = match[1]
147
+ resolve(match[1])
148
+ }
149
+ }
150
+
151
+ this.process.stdout?.on('data', handleData)
152
+ this.process.stderr?.on('data', handleData)
153
+
154
+ this.process.on('error', (error) => {
155
+ clearTimeout(timeout)
156
+ reject(error)
157
+ })
158
+
159
+ this.process.on('exit', (code) => {
160
+ if (code !== 0 && !this.url) {
161
+ clearTimeout(timeout)
162
+ reject(new Error(`Cloudflared exited with code ${code}`))
163
+ }
164
+ })
165
+ })
166
+ }
167
+
168
+ /**
169
+ * Create ngrok tunnel
170
+ */
171
+ private createNgrok(port: number): Promise<string> {
172
+ return new Promise((resolve, reject) => {
173
+ this.process = spawn('ngrok', ['http', port.toString(), '--log=stdout'], {
174
+ stdio: ['pipe', 'pipe', 'pipe'],
175
+ })
176
+
177
+ let output = ''
178
+ const timeout = setTimeout(() => {
179
+ reject(new Error('Ngrok timeout'))
180
+ }, 30000)
181
+
182
+ this.process.stdout?.on('data', (data: Buffer) => {
183
+ output += data.toString()
184
+ // Parse ngrok log output
185
+ const match = output.match(/url=(https?:\/\/[^\s]+)/i)
186
+ if (match) {
187
+ clearTimeout(timeout)
188
+ this.url = match[1]
189
+ resolve(match[1])
190
+ }
191
+ })
192
+
193
+ this.process.stderr?.on('data', (data: Buffer) => {
194
+ output += data.toString()
195
+ })
196
+
197
+ this.process.on('error', (error) => {
198
+ clearTimeout(timeout)
199
+ reject(error)
200
+ })
201
+
202
+ this.process.on('exit', (code) => {
203
+ if (code !== 0 && !this.url) {
204
+ clearTimeout(timeout)
205
+ reject(new Error(`Ngrok exited with code ${code}`))
206
+ }
207
+ })
208
+ })
209
+ }
210
+ }
211
+
212
+ /**
213
+ * Check if a tunnel provider is available
214
+ */
215
+ export async function checkTunnelAvailability(provider: TunnelProvider): Promise<boolean> {
216
+ try {
217
+ const { execSync } = await import('node:child_process')
218
+
219
+ switch (provider) {
220
+ case 'localtunnel':
221
+ try {
222
+ await import('localtunnel')
223
+ return true
224
+ } catch {
225
+ execSync('npx localtunnel --version', { stdio: 'pipe' })
226
+ return true
227
+ }
228
+
229
+ case 'cloudflared':
230
+ execSync('cloudflared --version', { stdio: 'pipe' })
231
+ return true
232
+
233
+ case 'ngrok':
234
+ execSync('ngrok version', { stdio: 'pipe' })
235
+ return true
236
+
237
+ default:
238
+ return false
239
+ }
240
+ } catch {
241
+ return false
242
+ }
243
+ }
244
+
245
+ /**
246
+ * Find best available tunnel provider
247
+ */
248
+ export async function findAvailableTunnel(): Promise<TunnelProvider | null> {
249
+ const providers: TunnelProvider[] = ['localtunnel', 'cloudflared', 'ngrok']
250
+
251
+ for (const provider of providers) {
252
+ if (await checkTunnelAvailability(provider)) {
253
+ return provider
254
+ }
255
+ }
256
+
257
+ return null
258
+ }
package/src/types.ts ADDED
@@ -0,0 +1,169 @@
1
+ /**
2
+ * @nikcli/remote - Type definitions
3
+ */
4
+
5
+ export type SessionStatus = 'starting' | 'waiting' | 'connected' | 'stopped' | 'error'
6
+ export type TunnelProvider = 'localtunnel' | 'cloudflared' | 'ngrok' | 'none'
7
+
8
+ export interface DeviceInfo {
9
+ id: string
10
+ userAgent?: string
11
+ connectedAt: Date
12
+ lastActivity: Date
13
+ ip?: string
14
+ }
15
+
16
+ export interface RemoteSession {
17
+ id: string
18
+ name: string
19
+ qrCode: string
20
+ qrUrl: string
21
+ localUrl: string
22
+ tunnelUrl?: string
23
+ status: SessionStatus
24
+ connectedDevices: DeviceInfo[]
25
+ startedAt: Date
26
+ lastActivity: Date
27
+ error?: string
28
+ port: number
29
+ }
30
+
31
+ export interface ServerConfig {
32
+ /** Port to listen on (0 = auto-assign) */
33
+ port: number
34
+ /** Host to bind to */
35
+ host: string
36
+ /** Enable tunnel for public access */
37
+ enableTunnel: boolean
38
+ /** Tunnel provider */
39
+ tunnelProvider: TunnelProvider
40
+ /** Session secret for authentication */
41
+ sessionSecret?: string
42
+ /** Maximum concurrent connections */
43
+ maxConnections: number
44
+ /** Heartbeat interval in ms */
45
+ heartbeatInterval: number
46
+ /** Shell command to spawn */
47
+ shell: string
48
+ /** Initial terminal columns */
49
+ cols: number
50
+ /** Initial terminal rows */
51
+ rows: number
52
+ /** Working directory for shell */
53
+ cwd?: string
54
+ /** Environment variables */
55
+ env?: Record<string, string>
56
+ /** Enable terminal (PTY) */
57
+ enableTerminal: boolean
58
+ /** Session timeout in ms (0 = no timeout) */
59
+ sessionTimeout: number
60
+ }
61
+
62
+ export interface BroadcastMessage {
63
+ type: string
64
+ payload: unknown
65
+ timestamp?: number
66
+ }
67
+
68
+ export interface RemoteNotification {
69
+ type: 'success' | 'error' | 'warning' | 'info'
70
+ title: string
71
+ body: string
72
+ data?: unknown
73
+ }
74
+
75
+ export interface ClientMessage {
76
+ type: string
77
+ [key: string]: unknown
78
+ }
79
+
80
+ export interface ServerMessage {
81
+ type: string
82
+ payload?: unknown
83
+ timestamp: number
84
+ }
85
+
86
+ export interface TerminalData {
87
+ data: string
88
+ }
89
+
90
+ export interface TerminalResize {
91
+ cols: number
92
+ rows: number
93
+ }
94
+
95
+ export interface CommandMessage {
96
+ command: string
97
+ args?: string[]
98
+ }
99
+
100
+ export interface ClientConnection {
101
+ id: string
102
+ authenticated: boolean
103
+ device: DeviceInfo
104
+ lastPing: number
105
+ }
106
+
107
+ export const DEFAULT_CONFIG: ServerConfig = {
108
+ port: 0,
109
+ host: '0.0.0.0',
110
+ enableTunnel: true,
111
+ tunnelProvider: 'localtunnel',
112
+ maxConnections: 5,
113
+ heartbeatInterval: 30000,
114
+ shell: '/bin/bash',
115
+ cols: 80,
116
+ rows: 24,
117
+ enableTerminal: true,
118
+ sessionTimeout: 0,
119
+ }
120
+
121
+ /**
122
+ * Get default shell based on platform
123
+ */
124
+ export function getDefaultShell(): string {
125
+ if (typeof process !== 'undefined') {
126
+ if (process.platform === 'win32') return 'powershell.exe'
127
+ return process.env.SHELL || '/bin/bash'
128
+ }
129
+ return '/bin/bash'
130
+ }
131
+
132
+ // Message types for protocol
133
+ export const MessageTypes = {
134
+ // Auth
135
+ AUTH_REQUIRED: 'auth:required',
136
+ AUTH: 'auth',
137
+ AUTH_SUCCESS: 'auth:success',
138
+ AUTH_FAILED: 'auth:failed',
139
+
140
+ // Terminal
141
+ TERMINAL_OUTPUT: 'terminal:output',
142
+ TERMINAL_INPUT: 'terminal:input',
143
+ TERMINAL_RESIZE: 'terminal:resize',
144
+ TERMINAL_EXIT: 'terminal:exit',
145
+ TERMINAL_CLEAR: 'terminal:clear',
146
+
147
+ // Notifications
148
+ NOTIFICATION: 'notification',
149
+
150
+ // Heartbeat
151
+ PING: 'ping',
152
+ PONG: 'pong',
153
+
154
+ // Session
155
+ SESSION_INFO: 'session:info',
156
+ SESSION_END: 'session:end',
157
+
158
+ // Commands (NikCLI specific)
159
+ COMMAND: 'command',
160
+ COMMAND_RESULT: 'command:result',
161
+
162
+ // Agent events
163
+ AGENT_START: 'agent:start',
164
+ AGENT_PROGRESS: 'agent:progress',
165
+ AGENT_COMPLETE: 'agent:complete',
166
+ AGENT_ERROR: 'agent:error',
167
+ } as const
168
+
169
+ export type MessageType = (typeof MessageTypes)[keyof typeof MessageTypes]