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/dist/chunk-MCKGQKYU.js +15 -0
- package/dist/chunk-TJTKYXIZ.js +2782 -0
- package/dist/index.cjs +6804 -0
- package/dist/index.d.cts +372 -0
- package/dist/index.d.ts +372 -0
- package/dist/index.js +155 -0
- package/dist/localtunnel-XT32JGNN.js +3805 -0
- package/dist/server-XW6FLG7E.js +7 -0
- package/package.json +58 -0
- package/src/index.ts +82 -0
- package/src/qrcode.ts +164 -0
- package/src/server.ts +562 -0
- package/src/terminal.ts +163 -0
- package/src/tunnel.ts +258 -0
- package/src/types.ts +169 -0
- package/src/web-client.ts +657 -0
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]
|