voidct 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/README.md ADDED
@@ -0,0 +1,99 @@
1
+ # VoidConnect CLI
2
+
3
+ CLI tool for managing VoidConnect mobile app servers. Built with [OpenTUI](https://github.com/nicholasgriffintn/opentui) for beautiful terminal interfaces.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ bunx voidct
9
+ ```
10
+
11
+ ## Quick Start
12
+
13
+ ```bash
14
+ # Serve the current directory
15
+ bunx voidct run
16
+
17
+ # Add a project to your saved list
18
+ bunx voidct add /path/to/project --name "My Project"
19
+
20
+ # Start serving all saved projects
21
+ bunx voidct start
22
+
23
+ # Check status
24
+ bunx voidct status
25
+ ```
26
+
27
+ ## Commands
28
+
29
+ | Command | Arguments | Description |
30
+ |---------|-----------|-------------|
31
+ | `run` | `--dir <path>`, `--cf`, `--port <port>` | Run a local project (foreground) |
32
+ | `start` | `--cf`, `--bg`, `--port <port>`, `--log` | Start server with saved projects |
33
+ | `stop` | | Stop the background server |
34
+ | `restart` | | Restart the server |
35
+ | `status` | | Show status dashboard |
36
+ | `add` | `<path>` `[--name <name>]` | Add a project path |
37
+ | `remove` | `<name>` | Remove a project |
38
+ | `config` | `--device-name`, `--password`, `--port` | Configure device settings |
39
+ | `update` | | Check for updates |
40
+
41
+ ## Flags
42
+
43
+ ### Global Flags
44
+ - `--cf` - Enable Cloudflare tunnel for external access
45
+ - `--port <port>` - Specify a custom port (default: 3838)
46
+
47
+ ### Start Command Flags
48
+ - `--bg` - Run server in background mode
49
+ - `--log` - Enable traffic logging to `void_traffic.log` in the current directory
50
+
51
+ ### Config Command Flags
52
+ - `--device-name <name>` - Set the device name shown to mobile clients
53
+ - `--password <pwd>` - Set a password for authentication
54
+ - `--no-password` - Remove password protection
55
+
56
+ ## Configuration
57
+
58
+ Configuration is stored in `~/.voidconnect/hub_config.json` and includes:
59
+ - Device name
60
+ - Password (optional)
61
+ - Default port
62
+ - Saved projects list
63
+
64
+ ## Architecture
65
+
66
+ ```
67
+ src/
68
+ ├── index.tsx # Main CLI entry (OpenTUI/React UI)
69
+ ├── config.ts # Configuration management
70
+ ├── handlers.ts # Command handlers
71
+ ├── utils.ts # Utility functions
72
+ └── hub/ # Hub server
73
+ ├── index.ts # Hono web server
74
+ ├── auth.ts # Authentication middleware
75
+ ├── config.ts # Hub config loader
76
+ ├── logger.ts # Traffic logging
77
+ ├── process_manager.ts # OpenCode process management
78
+ └── tunnel.ts # Cloudflare tunnel integration
79
+ ```
80
+
81
+ ## Development
82
+
83
+ ```bash
84
+ # Install dependencies
85
+ bun install
86
+
87
+ # Run in development mode
88
+ bun run dev
89
+
90
+ # Run the CLI directly
91
+ bun run src/index.tsx [command]
92
+
93
+ # Run the hub server directly
94
+ bun run src/hub/index.ts
95
+ ```
96
+
97
+ ## License
98
+
99
+ MIT
package/bun.lock ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "lockfileVersion": 1,
3
+ "configVersion": 0,
4
+ "workspaces": {
5
+ "": {
6
+ "name": "react",
7
+ "dependencies": {
8
+ "hono": "^4.0.0",
9
+ "qrcode-terminal": "^0.12.0",
10
+ },
11
+ "devDependencies": {
12
+ "@types/bun": "latest",
13
+ "@types/qrcode-terminal": "^0.12.2",
14
+ },
15
+ "peerDependencies": {
16
+ "typescript": "^5",
17
+ },
18
+ },
19
+ },
20
+ "packages": {
21
+ "@types/bun": ["@types/bun@1.2.20", "", { "dependencies": { "bun-types": "1.2.20" } }, "sha512-dX3RGzQ8+KgmMw7CsW4xT5ITBSCrSbfHc36SNT31EOUg/LA9JWq0VDdEXDRSe1InVWpd2yLUM1FUF/kEOyTzYA=="],
22
+
23
+ "@types/node": ["@types/node@24.3.0", "", { "dependencies": { "undici-types": "~7.10.0" } }, "sha512-aPTXCrfwnDLj4VvXrm+UUCQjNEvJgNA8s5F1cvwQU+3KNltTOkBm1j30uNLyqqPNe7gE3KFzImYoZEfLhp4Yow=="],
24
+
25
+ "@types/qrcode-terminal": ["@types/qrcode-terminal@0.12.2", "", {}, "sha512-v+RcIEJ+Uhd6ygSQ0u5YYY7ZM+la7GgPbs0V/7l/kFs2uO4S8BcIUEMoP7za4DNIqNnUD5npf0A/7kBhrCKG5Q=="],
26
+
27
+ "@types/react": ["@types/react@19.1.11", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-lr3jdBw/BGj49Eps7EvqlUaoeA0xpj3pc0RoJkHpYaCHkVK7i28dKyImLQb3JVlqs3aYSXf7qYuWOW/fgZnTXQ=="],
28
+
29
+ "bun-types": ["bun-types@1.2.20", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-pxTnQYOrKvdOwyiyd/7sMt9yFOenN004Y6O4lCcCUoKVej48FS5cvTw9geRaEcB9TsDZaJKAxPTVvi8tFsVuXA=="],
30
+
31
+ "csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
32
+
33
+ "hono": ["hono@4.11.4", "", {}, "sha512-U7tt8JsyrxSRKspfhtLET79pU8K+tInj5QZXs1jSugO1Vq5dFj3kmZsRldo29mTBfcjDRVRXrEZ6LS63Cog9ZA=="],
34
+
35
+ "qrcode-terminal": ["qrcode-terminal@0.12.0", "", { "bin": { "qrcode-terminal": "./bin/qrcode-terminal.js" } }, "sha512-EXtzRZmC+YGmGlDFbXKxQiMZNwCLEO6BANKXG4iCtSIM0yqc/pappSx3RIKr4r0uh5JsBckOXeKrB3Iz7mdQpQ=="],
36
+
37
+ "typescript": ["typescript@5.9.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A=="],
38
+
39
+ "undici-types": ["undici-types@7.10.0", "", {}, "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag=="],
40
+ }
41
+ }
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "voidct",
3
+ "version": "1.0.0",
4
+ "description": "CLI tool for managing VoidConnect mobile app servers",
5
+ "module": "src/index.ts",
6
+ "type": "module",
7
+ "bin": {
8
+ "voidct": "./src/index.ts"
9
+ },
10
+ "scripts": {
11
+ "dev": "bun run --watch src/index.ts",
12
+ "start": "bun run src/index.ts",
13
+ "hub": "bun run src/hub/index.ts"
14
+ },
15
+ "devDependencies": {
16
+ "@types/bun": "latest",
17
+ "@types/qrcode-terminal": "^0.12.2"
18
+ },
19
+ "peerDependencies": {
20
+ "typescript": "^5"
21
+ },
22
+ "dependencies": {
23
+ "hono": "^4.0.0",
24
+ "qrcode-terminal": "^0.12.0"
25
+ },
26
+ "repository": {
27
+ "type": "git",
28
+ "url": "https://github.com/xptea/voidconnect-cli"
29
+ },
30
+ "keywords": [
31
+ "cli",
32
+ "voidconnect",
33
+ "mobile",
34
+ "server",
35
+ "tunnel"
36
+ ],
37
+ "author": "xptea",
38
+ "license": "MIT"
39
+ }
package/src/config.ts ADDED
@@ -0,0 +1,131 @@
1
+ import { join } from 'path';
2
+ import { homedir } from 'os';
3
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
4
+
5
+ export interface ProjectConfig {
6
+ name: string;
7
+ path: string;
8
+ }
9
+
10
+ export interface HubConfig {
11
+ device_name: string;
12
+ password_hash: string | null;
13
+ port: number | null;
14
+ projects: ProjectConfig[];
15
+ }
16
+
17
+ export interface ServerState {
18
+ cf: boolean;
19
+ bg: boolean;
20
+ }
21
+
22
+ export function getConfigDir(): string {
23
+ const dir = join(homedir(), '.voidconnect');
24
+ if (!existsSync(dir)) {
25
+ mkdirSync(dir, { recursive: true });
26
+ }
27
+ return dir;
28
+ }
29
+
30
+ export function getConfigPath(): string {
31
+ return join(getConfigDir(), 'hub_config.json');
32
+ }
33
+
34
+ export function getPidPath(): string {
35
+ return join(getConfigDir(), 'server.pid');
36
+ }
37
+
38
+ export function getStatePath(): string {
39
+ return join(getConfigDir(), 'server.state');
40
+ }
41
+
42
+ export function loadConfig(): HubConfig {
43
+ const path = getConfigPath();
44
+ if (!existsSync(path)) {
45
+ return {
46
+ device_name: 'VoidConnect Hub',
47
+ password_hash: null,
48
+ port: null,
49
+ projects: []
50
+ };
51
+ }
52
+ try {
53
+ const content = readFileSync(path, 'utf-8');
54
+ return JSON.parse(content) as HubConfig;
55
+ } catch {
56
+ return {
57
+ device_name: 'VoidConnect Hub',
58
+ password_hash: null,
59
+ port: null,
60
+ projects: []
61
+ };
62
+ }
63
+ }
64
+
65
+ export function saveConfig(config: HubConfig): void {
66
+ const path = getConfigPath();
67
+ writeFileSync(path, JSON.stringify(config, null, 2));
68
+ }
69
+
70
+ export function saveState(state: ServerState): void {
71
+ const path = getStatePath();
72
+ writeFileSync(path, JSON.stringify(state));
73
+ }
74
+
75
+ export function loadState(): ServerState | null {
76
+ const path = getStatePath();
77
+ if (!existsSync(path)) {
78
+ return null;
79
+ }
80
+ try {
81
+ const content = readFileSync(path, 'utf-8');
82
+ return JSON.parse(content) as ServerState;
83
+ } catch {
84
+ return null;
85
+ }
86
+ }
87
+
88
+ export function addProject(pathStr: string, name?: string): string {
89
+ const config = loadConfig();
90
+ const fs = require('fs');
91
+ const path = require('path');
92
+
93
+ const absolutePath = path.resolve(pathStr);
94
+ if (!fs.existsSync(absolutePath)) {
95
+ throw new Error(`Invalid path '${pathStr}': Directory does not exist`);
96
+ }
97
+
98
+ // Clean path (remove \\?\ prefix on Windows)
99
+ let cleanPath = absolutePath;
100
+ if (cleanPath.startsWith('\\\\?\\')) {
101
+ cleanPath = cleanPath.slice(4);
102
+ }
103
+
104
+ const projectName = name || path.basename(cleanPath);
105
+
106
+ // Check if project with same path exists
107
+ const existing = config.projects.find(p => p.path === cleanPath);
108
+ if (existing) {
109
+ existing.name = projectName;
110
+ } else {
111
+ config.projects.push({
112
+ name: projectName,
113
+ path: cleanPath
114
+ });
115
+ }
116
+
117
+ saveConfig(config);
118
+ return projectName;
119
+ }
120
+
121
+ export function removeProject(name: string): boolean {
122
+ const config = loadConfig();
123
+ const originalLen = config.projects.length;
124
+ config.projects = config.projects.filter(p => p.name !== name);
125
+
126
+ if (config.projects.length < originalLen) {
127
+ saveConfig(config);
128
+ return true;
129
+ }
130
+ return false;
131
+ }
@@ -0,0 +1,357 @@
1
+ import { spawn, spawnSync } from 'bun';
2
+ import { existsSync, readFileSync, writeFileSync, unlinkSync } from 'fs';
3
+ import { resolve, basename, dirname, join } from 'path';
4
+ import {
5
+ loadConfig,
6
+ saveConfig,
7
+ saveState,
8
+ loadState,
9
+ addProject,
10
+ removeProject,
11
+ getPidPath,
12
+ getStatePath,
13
+ type HubConfig
14
+ } from './config';
15
+ import { isPortAvailable, findAvailablePort, isServerRunning, killServer, cleanupServerFiles } from './utils';
16
+
17
+ export interface RunOptions {
18
+ dir?: string;
19
+ cf?: boolean;
20
+ port?: number;
21
+ }
22
+
23
+ export interface StartOptions {
24
+ cf?: boolean;
25
+ bg?: boolean;
26
+ port?: number;
27
+ log?: boolean;
28
+ }
29
+
30
+ export async function handleRun(options: RunOptions): Promise<{ success: boolean; message: string }> {
31
+ const { running, pid } = isServerRunning();
32
+ if (running && pid) {
33
+ return {
34
+ success: false,
35
+ message: `A server is already running (PID: ${pid}). Stop it first with 'voidct stop'.`
36
+ };
37
+ }
38
+
39
+ // Determine project path
40
+ const projectPath = options.dir ? resolve(options.dir) : process.cwd();
41
+
42
+ if (!existsSync(projectPath)) {
43
+ return {
44
+ success: false,
45
+ message: `Invalid directory '${projectPath}': Directory does not exist`
46
+ };
47
+ }
48
+
49
+ const projectName = basename(projectPath);
50
+ const config = loadConfig();
51
+ const requestedPort = options.port ?? config.port ?? 3838;
52
+
53
+ let port = requestedPort;
54
+ if (!(await isPortAvailable(port))) {
55
+ port = await findAvailablePort(port + 1);
56
+ }
57
+
58
+ const hubPath = getHubPath();
59
+ if (!hubPath) {
60
+ return {
61
+ success: false,
62
+ message: 'Hub directory not found'
63
+ };
64
+ }
65
+
66
+ await ensureHubDependencies(hubPath);
67
+
68
+ const args = ['run', join(hubPath, 'index.ts')];
69
+ if (options.cf) {
70
+ args.push('--cf');
71
+ }
72
+
73
+ const env: Record<string, string> = {
74
+ ...process.env as Record<string, string>,
75
+ HUB_PROJECT_PATH: projectPath,
76
+ HUB_PROJECT_NAME: projectName,
77
+ HUB_DEVICE_NAME: config.device_name,
78
+ PORT: port.toString()
79
+ };
80
+
81
+ if (config.password_hash) {
82
+ env.HUB_PASSWORD = config.password_hash;
83
+ }
84
+
85
+ const child = spawn(['bun', ...args], {
86
+ cwd: hubPath,
87
+ env,
88
+ stdout: 'inherit',
89
+ stderr: 'inherit'
90
+ });
91
+
92
+ writeFileSync(getPidPath(), child.pid.toString());
93
+
94
+ // Wait for process to exit
95
+ await child.exited;
96
+ cleanupServerFiles();
97
+
98
+ return {
99
+ success: true,
100
+ message: `Running project "${projectName}" on port ${port}`
101
+ };
102
+ }
103
+
104
+ export async function handleStart(options: StartOptions): Promise<{ success: boolean; message: string }> {
105
+ // Save state for restart
106
+ saveState({ cf: options.cf ?? false, bg: options.bg ?? false });
107
+
108
+ const { running, pid } = isServerRunning();
109
+ if (running && pid) {
110
+ return {
111
+ success: false,
112
+ message: `A server is already running (PID: ${pid}). Stop it first with 'voidct stop'.`
113
+ };
114
+ }
115
+
116
+ const config = loadConfig();
117
+ if (config.projects.length === 0) {
118
+ return {
119
+ success: false,
120
+ message: "No projects configured. Add a project first with 'voidct add <path>'."
121
+ };
122
+ }
123
+
124
+ const requestedPort = options.port ?? config.port ?? 3838;
125
+ let port = requestedPort;
126
+ if (!(await isPortAvailable(port))) {
127
+ port = await findAvailablePort(port + 1);
128
+ }
129
+
130
+ const hubPath = getHubPath();
131
+ if (!hubPath) {
132
+ return {
133
+ success: false,
134
+ message: 'Hub directory not found'
135
+ };
136
+ }
137
+
138
+ await ensureHubDependencies(hubPath);
139
+
140
+ const args = ['run', join(hubPath, 'index.ts')];
141
+ if (options.cf) {
142
+ args.push('--cf');
143
+ }
144
+ if (options.log) {
145
+ args.push('--log');
146
+ }
147
+
148
+ const env: Record<string, string> = {
149
+ ...process.env as Record<string, string>,
150
+ HUB_DEVICE_NAME: config.device_name,
151
+ HUB_START_DIR: process.cwd(),
152
+ PORT: port.toString()
153
+ };
154
+
155
+ if (config.password_hash) {
156
+ env.HUB_PASSWORD = config.password_hash;
157
+ }
158
+
159
+ if (options.bg) {
160
+ // Background mode
161
+ const child = spawn(['bun', ...args], {
162
+ cwd: hubPath,
163
+ env,
164
+ stdout: 'pipe',
165
+ stderr: 'pipe'
166
+ });
167
+
168
+ writeFileSync(getPidPath(), child.pid.toString());
169
+
170
+ // Wait for server to output URL
171
+ const reader = child.stdout.getReader();
172
+ const decoder = new TextDecoder();
173
+
174
+ let output = '';
175
+ while (true) {
176
+ const { done, value } = await reader.read();
177
+ if (done) break;
178
+
179
+ output += decoder.decode(value);
180
+ console.log(decoder.decode(value));
181
+
182
+ if (output.includes('URL: http')) {
183
+ break;
184
+ }
185
+ }
186
+
187
+ return {
188
+ success: true,
189
+ message: `Server started in background (PID: ${child.pid}) on port ${port}`
190
+ };
191
+ }
192
+
193
+ // Foreground mode
194
+ const child = spawn(['bun', ...args], {
195
+ cwd: hubPath,
196
+ env,
197
+ stdout: 'inherit',
198
+ stderr: 'inherit'
199
+ });
200
+
201
+ writeFileSync(getPidPath(), child.pid.toString());
202
+ await child.exited;
203
+ cleanupServerFiles();
204
+
205
+ return {
206
+ success: true,
207
+ message: `Server stopped`
208
+ };
209
+ }
210
+
211
+ export async function handleStop(): Promise<{ success: boolean; message: string }> {
212
+ const { running, pid } = isServerRunning();
213
+
214
+ if (!running || !pid) {
215
+ return {
216
+ success: false,
217
+ message: 'Server is not running'
218
+ };
219
+ }
220
+
221
+ if (killServer(pid)) {
222
+ cleanupServerFiles();
223
+ return {
224
+ success: true,
225
+ message: `Server (PID: ${pid}) stopped successfully`
226
+ };
227
+ }
228
+
229
+ return {
230
+ success: false,
231
+ message: `Failed to stop server (PID: ${pid})`
232
+ };
233
+ }
234
+
235
+ export async function handleRestart(): Promise<{ success: boolean; message: string }> {
236
+ const state = loadState();
237
+ const cf = state?.cf ?? false;
238
+ const bg = state?.bg ?? false;
239
+
240
+ await handleStop();
241
+ await new Promise(r => setTimeout(r, 500));
242
+ return handleStart({ cf, bg });
243
+ }
244
+
245
+ export async function handleAdd(path: string, name?: string): Promise<{ success: boolean; message: string }> {
246
+ try {
247
+ const savedName = addProject(path, name);
248
+ return {
249
+ success: true,
250
+ message: `Project '${savedName}' has been saved!`
251
+ };
252
+ } catch (e) {
253
+ return {
254
+ success: false,
255
+ message: `Failed to add project: ${e}`
256
+ };
257
+ }
258
+ }
259
+
260
+ export async function handleRemove(name: string): Promise<{ success: boolean; message: string }> {
261
+ if (removeProject(name)) {
262
+ return {
263
+ success: true,
264
+ message: `Project '${name}' removed successfully`
265
+ };
266
+ }
267
+ return {
268
+ success: false,
269
+ message: `Project '${name}' not found`
270
+ };
271
+ }
272
+
273
+ export interface StatusInfo {
274
+ deviceName: string;
275
+ running: boolean;
276
+ pid: number | null;
277
+ projects: Array<{ name: string; path: string }>;
278
+ port: number | null;
279
+ }
280
+
281
+ export async function handleStatus(): Promise<StatusInfo> {
282
+ const config = loadConfig();
283
+ const { running, pid } = isServerRunning();
284
+
285
+ return {
286
+ deviceName: config.device_name,
287
+ running,
288
+ pid,
289
+ projects: config.projects,
290
+ port: config.port
291
+ };
292
+ }
293
+
294
+ export interface ConfigOptions {
295
+ deviceName?: string;
296
+ password?: string | null;
297
+ port?: number | null;
298
+ }
299
+
300
+ export async function handleConfig(options: ConfigOptions): Promise<{ success: boolean; message: string }> {
301
+ const config = loadConfig();
302
+
303
+ if (options.deviceName !== undefined) {
304
+ config.device_name = options.deviceName;
305
+ }
306
+ if (options.password !== undefined) {
307
+ config.password_hash = options.password;
308
+ }
309
+ if (options.port !== undefined) {
310
+ config.port = options.port;
311
+ }
312
+
313
+ saveConfig(config);
314
+ return {
315
+ success: true,
316
+ message: 'Configuration updated successfully'
317
+ };
318
+ }
319
+
320
+ export function getConfigInfo(): HubConfig {
321
+ return loadConfig();
322
+ }
323
+
324
+ function getHubPath(): string | null {
325
+ // Check various locations for the hub
326
+ const execPath = process.argv[1] || '';
327
+ const possiblePaths = [
328
+ join(dirname(execPath), 'hub'),
329
+ join(dirname(execPath), '..', 'hub'),
330
+ join(process.cwd(), 'src', 'hub'),
331
+ join(process.cwd(), 'hub'),
332
+ ];
333
+
334
+ for (const p of possiblePaths) {
335
+ if (existsSync(join(p, 'index.ts'))) {
336
+ return p;
337
+ }
338
+ }
339
+
340
+ return null;
341
+ }
342
+
343
+ async function ensureHubDependencies(hubPath: string): Promise<void> {
344
+ const nodeModules = join(hubPath, '..', 'node_modules');
345
+ if (!existsSync(nodeModules)) {
346
+ console.log('First run detected. Installing dependencies...');
347
+ const result = spawnSync(['bun', 'install'], {
348
+ cwd: join(hubPath, '..'),
349
+ stdout: 'inherit',
350
+ stderr: 'inherit'
351
+ });
352
+ if (result.exitCode !== 0) {
353
+ throw new Error('Failed to install dependencies');
354
+ }
355
+ console.log('Dependencies installed.\n');
356
+ }
357
+ }
@@ -0,0 +1,18 @@
1
+ import { type Context, type Next } from 'hono';
2
+
3
+ export async function authMiddleware(c: Context, next: Next) {
4
+ const hubPassword = process.env.HUB_PASSWORD;
5
+
6
+ if (!hubPassword) {
7
+ await next();
8
+ return;
9
+ }
10
+
11
+ const authHeader = c.req.header('Authorization');
12
+
13
+ if (!authHeader || authHeader !== `Bearer ${hubPassword}`) {
14
+ return c.json({ error: 'Unauthorized' }, 401);
15
+ }
16
+
17
+ await next();
18
+ }