opentunnel-cli 1.0.5 → 1.0.7

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/cli/index.js CHANGED
@@ -44,34 +44,104 @@ const TunnelClient_1 = require("../client/TunnelClient");
44
44
  const NgrokClient_1 = require("../client/NgrokClient");
45
45
  const utils_1 = require("../shared/utils");
46
46
  const yaml_1 = require("yaml");
47
+ const fs = __importStar(require("fs"));
48
+ const path = __importStar(require("path"));
47
49
  const CONFIG_FILE = "opentunnel.yml";
50
+ // Load .env file if exists
51
+ function loadEnvFile() {
52
+ const envPath = path.join(process.cwd(), ".env");
53
+ if (fs.existsSync(envPath)) {
54
+ const envContent = fs.readFileSync(envPath, "utf-8");
55
+ for (const line of envContent.split("\n")) {
56
+ const trimmed = line.trim();
57
+ // Skip comments and empty lines
58
+ if (!trimmed || trimmed.startsWith("#"))
59
+ continue;
60
+ const match = trimmed.match(/^([^=]+)=(.*)$/);
61
+ if (match) {
62
+ const key = match[1].trim();
63
+ let value = match[2].trim();
64
+ // Remove surrounding quotes if present
65
+ if ((value.startsWith('"') && value.endsWith('"')) ||
66
+ (value.startsWith("'") && value.endsWith("'"))) {
67
+ value = value.slice(1, -1);
68
+ }
69
+ // Only set if not already defined in environment
70
+ if (process.env[key] === undefined) {
71
+ process.env[key] = value;
72
+ }
73
+ }
74
+ }
75
+ }
76
+ }
77
+ // Docker-style environment variable substitution
78
+ // Supports: ${VAR}, ${VAR:-default}, ${VAR:=default}
79
+ function substituteEnvVars(content) {
80
+ // Pattern matches ${VAR}, ${VAR:-default}, ${VAR:=default}
81
+ const pattern = /\$\{([^}:]+)(?:(:[-=])([^}]*))?\}/g;
82
+ return content.replace(pattern, (match, varName, operator, defaultValue) => {
83
+ const envValue = process.env[varName];
84
+ if (operator === ":-" || operator === ":=") {
85
+ // Use default if variable is unset or empty
86
+ return (envValue !== undefined && envValue !== "") ? envValue : (defaultValue || "");
87
+ }
88
+ // Just ${VAR} - return value or empty string
89
+ return envValue || "";
90
+ });
91
+ }
92
+ // Load and parse config file with environment variable substitution
93
+ function loadConfig(configPath) {
94
+ if (!fs.existsSync(configPath)) {
95
+ return null;
96
+ }
97
+ // Load .env file first
98
+ loadEnvFile();
99
+ // Read and substitute environment variables
100
+ let content = fs.readFileSync(configPath, "utf-8");
101
+ content = substituteEnvVars(content);
102
+ return (0, yaml_1.parse)(content);
103
+ }
48
104
  const program = new commander_1.Command();
49
105
  program
50
106
  .name("opentunnel")
51
107
  .alias("ot")
52
108
  .description("Expose local ports to the internet via custom domains or ngrok")
53
109
  .version("1.0.0");
110
+ // Helper function to build WebSocket URL from domain
111
+ // User only provides base domain (e.g., fjrg2007.com), system handles the rest
112
+ function buildServerUrl(server, insecure, basePath) {
113
+ let hostname = server;
114
+ // Remove protocol if provided
115
+ hostname = hostname.replace(/^(wss?|https?):\/\//, "");
116
+ // Remove trailing path
117
+ hostname = hostname.replace(/\/_tunnel.*$/, "");
118
+ // Remove trailing slash
119
+ hostname = hostname.replace(/\/$/, "");
120
+ // Build the full hostname with basePath if provided and not empty
121
+ // If basePath is "op" (default), connect to op.domain.com
122
+ // If basePath is empty or not provided, connect directly to domain.com
123
+ const effectiveBasePath = basePath || "op";
124
+ const fullHostname = effectiveBasePath ? `${effectiveBasePath}.${hostname}` : hostname;
125
+ const protocol = insecure ? "ws" : "wss";
126
+ return {
127
+ url: `${protocol}://${fullHostname}/_tunnel`,
128
+ displayName: hostname,
129
+ };
130
+ }
54
131
  // Quick command - quick tunnel to any server
55
132
  program
56
133
  .command("quick <port>")
57
134
  .description("Instantly expose a local port to the internet")
58
- .requiredOption("-s, --server <url>", "Server URL (e.g., wss://op.example.com/_tunnel)")
135
+ .requiredOption("-s, --server <domain>", "Server domain (e.g., example.com)")
136
+ .option("-b, --base-path <path>", "Server base path (default: op)")
59
137
  .option("-n, --subdomain <name>", "Request a specific subdomain (e.g., 'myapp')")
60
138
  .option("-p, --protocol <proto>", "Protocol (http, https, tcp)", "http")
61
139
  .option("-h, --host <host>", "Local host to forward to", "localhost")
62
140
  .option("-t, --token <token>", "Authentication token (if server requires it)")
63
141
  .option("--insecure", "Skip SSL certificate verification (for self-signed certs)")
64
142
  .action(async (port, options) => {
65
- // Determine server display name
66
- const serverUrl = options.server;
67
- let serverDisplayName = serverUrl;
68
- try {
69
- const url = new URL(serverUrl.replace("wss://", "https://").replace("ws://", "http://"));
70
- serverDisplayName = url.hostname;
71
- }
72
- catch {
73
- // Keep original if parsing fails
74
- }
143
+ // Build server URL from domain (user provides domain, system adds basePath)
144
+ const { url: serverUrl, displayName: serverDisplayName } = buildServerUrl(options.server, options.insecure, options.basePath);
75
145
  console.log(chalk_1.default.cyan(`
76
146
  ██████╗ ██████╗ ███████╗███╗ ██╗████████╗██╗ ██╗███╗ ██╗███╗ ██╗███████╗██╗
77
147
  ██╔═══██╗██╔══██╗██╔════╝████╗ ██║╚══██╔══╝██║ ██║████╗ ██║████╗ ██║██╔════╝██║
@@ -151,15 +221,16 @@ program
151
221
  program
152
222
  .command("http <port>")
153
223
  .description("Expose a local HTTP server")
154
- .option("-s, --server <url>", "Remote server URL (if not provided, starts local server)")
224
+ .option("-s, --server <domain>", "Remote server domain (if not provided, starts local server)")
225
+ .option("-b, --base-path <path>", "Server base path (default: op)")
155
226
  .option("-t, --token <token>", "Authentication token")
156
227
  .option("-n, --subdomain <name>", "Custom subdomain (e.g., 'myapp' for myapp.op.domain.com)")
157
228
  .option("-d, --detach", "Run tunnel in background")
158
229
  .option("-h, --host <host>", "Local host", "localhost")
159
230
  .option("--domain <domain>", "Domain for the tunnel", "localhost")
160
231
  .option("--port <port>", "Server port", "443")
161
- .option("--base-path <path>", "Subdomain base path", "op")
162
232
  .option("--https", "Use HTTPS for local connection")
233
+ .option("--insecure", "Skip SSL verification (for self-signed certs)")
163
234
  .option("--ngrok", "Use ngrok instead of OpenTunnel server")
164
235
  .option("--region <region>", "Ngrok region (us, eu, ap, au, sa, jp, in)", "us")
165
236
  .action(async (port, options) => {
@@ -174,16 +245,17 @@ program
174
245
  });
175
246
  return;
176
247
  }
177
- // If remote server URL provided, just connect to it
248
+ // If remote server domain provided, just connect to it
178
249
  if (options.server) {
250
+ const { url: serverUrl } = buildServerUrl(options.server, options.insecure, options.basePath);
179
251
  await createTunnel({
180
252
  protocol: options.https ? "https" : "http",
181
253
  localHost: options.host,
182
254
  localPort: parseInt(port),
183
255
  subdomain: options.subdomain,
184
- serverUrl: options.server,
256
+ serverUrl,
185
257
  token: options.token,
186
- insecure: true,
258
+ insecure: options.insecure,
187
259
  });
188
260
  return;
189
261
  }
@@ -236,12 +308,14 @@ program
236
308
  program
237
309
  .command("tcp <port>")
238
310
  .description("Expose a local TCP server")
239
- .option("-s, --server <url>", "Remote server URL (if not provided, starts local server)")
311
+ .option("-s, --server <domain>", "Remote server domain (if not provided, starts local server)")
312
+ .option("-b, --base-path <path>", "Server base path (default: op)")
240
313
  .option("-t, --token <token>", "Authentication token")
241
314
  .option("-r, --remote-port <port>", "Remote port to use")
242
315
  .option("-h, --host <host>", "Local host", "localhost")
243
316
  .option("--domain <domain>", "Domain for the tunnel", "localhost")
244
317
  .option("--port <port>", "Server port", "443")
318
+ .option("--insecure", "Skip SSL verification (for self-signed certs)")
245
319
  .option("--ngrok", "Use ngrok instead of OpenTunnel server")
246
320
  .option("--region <region>", "Ngrok region (us, eu, ap, au, sa, jp, in)", "us")
247
321
  .action(async (port, options) => {
@@ -256,16 +330,17 @@ program
256
330
  });
257
331
  return;
258
332
  }
259
- // If remote server URL provided, just connect to it
333
+ // If remote server domain provided, just connect to it
260
334
  if (options.server) {
335
+ const { url: serverUrl } = buildServerUrl(options.server, options.insecure, options.basePath);
261
336
  await createTunnel({
262
337
  protocol: "tcp",
263
338
  localHost: options.host,
264
339
  localPort: parseInt(port),
265
340
  remotePort: options.remotePort ? parseInt(options.remotePort) : undefined,
266
- serverUrl: options.server,
341
+ serverUrl,
267
342
  token: options.token,
268
- insecure: true,
343
+ insecure: options.insecure,
269
344
  });
270
345
  return;
271
346
  }
@@ -356,13 +431,13 @@ program
356
431
  program
357
432
  .command("server")
358
433
  .description("Start the OpenTunnel server (standalone mode)")
359
- .option("-p, --port <port>", "Server port", "443")
434
+ .option("-p, --port <port>", "Server port")
360
435
  .option("--public-port <port>", "Public port shown in URLs (default: same as port)")
361
- .option("--domain <domain>", "Base domain", "localhost")
362
- .option("-b, --base-path <path>", "Subdomain base path (e.g., 'op' for *.op.domain.com)", "op")
363
- .option("--host <host>", "Bind host", "0.0.0.0")
364
- .option("--tcp-min <port>", "Minimum TCP port", "10000")
365
- .option("--tcp-max <port>", "Maximum TCP port", "20000")
436
+ .option("--domain <domain>", "Base domain")
437
+ .option("-b, --base-path <path>", "Subdomain base path (e.g., 'op' for *.op.domain.com)")
438
+ .option("--host <host>", "Bind host")
439
+ .option("--tcp-min <port>", "Minimum TCP port")
440
+ .option("--tcp-max <port>", "Maximum TCP port")
366
441
  .option("--auth-tokens <tokens>", "Comma-separated auth tokens")
367
442
  .option("--no-https", "Disable HTTPS (use plain HTTP)")
368
443
  .option("--https-cert <path>", "Path to SSL certificate (for custom certs)")
@@ -374,16 +449,48 @@ program
374
449
  .option("--duckdns-token <token>", "DuckDNS token for dynamic DNS updates")
375
450
  .option("-d, --detach", "Run server in background (detached mode)")
376
451
  .action(async (options) => {
452
+ // Load config from opentunnel.yml if exists (with env variable substitution)
453
+ const configPath = path.join(process.cwd(), CONFIG_FILE);
454
+ let fileConfig = {};
455
+ try {
456
+ const parsed = loadConfig(configPath);
457
+ if (parsed?.server && parsed.server.domain) {
458
+ fileConfig = parsed.server;
459
+ }
460
+ }
461
+ catch (err) {
462
+ // Ignore parse errors, use CLI options
463
+ }
464
+ // Merge config: CLI options override file config, then defaults
465
+ const mergedOptions = {
466
+ port: options.port || fileConfig.port?.toString() || "443",
467
+ publicPort: options.publicPort || fileConfig.publicPort?.toString(),
468
+ domain: options.domain || fileConfig.domain || "localhost",
469
+ basePath: options.basePath || fileConfig.basePath || "op",
470
+ host: options.host || fileConfig.host || "0.0.0.0",
471
+ tcpMin: options.tcpMin || fileConfig.tcpPortMin?.toString() || "10000",
472
+ tcpMax: options.tcpMax || fileConfig.tcpPortMax?.toString() || "20000",
473
+ authTokens: options.authTokens || fileConfig.token,
474
+ https: options.https !== false && fileConfig.https !== false,
475
+ httpsCert: options.httpsCert,
476
+ httpsKey: options.httpsKey,
477
+ letsencrypt: options.letsencrypt,
478
+ email: options.email,
479
+ production: options.production,
480
+ cloudflareToken: options.cloudflareToken,
481
+ duckdnsToken: options.duckdnsToken,
482
+ detach: options.detach,
483
+ };
377
484
  // Detached mode - run in background
378
- if (options.detach) {
485
+ if (mergedOptions.detach) {
379
486
  const { spawn } = await Promise.resolve().then(() => __importStar(require("child_process")));
380
- const fs = await Promise.resolve().then(() => __importStar(require("fs")));
381
- const path = await Promise.resolve().then(() => __importStar(require("path")));
382
- const pidFile = path.join(process.cwd(), ".opentunnel.pid");
383
- const logFile = path.join(process.cwd(), "opentunnel.log");
487
+ const fsAsync = await Promise.resolve().then(() => __importStar(require("fs")));
488
+ const pathAsync = await Promise.resolve().then(() => __importStar(require("path")));
489
+ const pidFile = pathAsync.join(process.cwd(), ".opentunnel.pid");
490
+ const logFile = pathAsync.join(process.cwd(), "opentunnel.log");
384
491
  // Check if already running
385
- if (fs.existsSync(pidFile)) {
386
- const oldPid = fs.readFileSync(pidFile, "utf-8").trim();
492
+ if (fsAsync.existsSync(pidFile)) {
493
+ const oldPid = fsAsync.readFileSync(pidFile, "utf-8").trim();
387
494
  try {
388
495
  process.kill(parseInt(oldPid), 0);
389
496
  console.log(chalk_1.default.yellow(`Server already running (PID: ${oldPid})`));
@@ -391,60 +498,48 @@ program
391
498
  return;
392
499
  }
393
500
  catch {
394
- fs.unlinkSync(pidFile);
501
+ fsAsync.unlinkSync(pidFile);
395
502
  }
396
503
  }
397
- // Build args without -d flag
504
+ // Build args without -d flag, using merged options
398
505
  const args = ["server"];
399
- if (options.port)
400
- args.push("-p", options.port);
401
- if (options.publicPort)
402
- args.push("--public-port", options.publicPort);
403
- if (options.domain)
404
- args.push("--domain", options.domain);
405
- if (options.basePath)
406
- args.push("-b", options.basePath);
407
- if (options.host)
408
- args.push("--host", options.host);
409
- if (options.tcpMin)
410
- args.push("--tcp-min", options.tcpMin);
411
- if (options.tcpMax)
412
- args.push("--tcp-max", options.tcpMax);
413
- if (options.authTokens)
414
- args.push("--auth-tokens", options.authTokens);
415
- if (options.https)
506
+ args.push("-p", mergedOptions.port);
507
+ args.push("--domain", mergedOptions.domain);
508
+ args.push("-b", mergedOptions.basePath);
509
+ args.push("--host", mergedOptions.host);
510
+ args.push("--tcp-min", mergedOptions.tcpMin);
511
+ args.push("--tcp-max", mergedOptions.tcpMax);
512
+ if (mergedOptions.publicPort)
513
+ args.push("--public-port", mergedOptions.publicPort);
514
+ if (mergedOptions.authTokens)
515
+ args.push("--auth-tokens", mergedOptions.authTokens);
516
+ if (mergedOptions.https)
416
517
  args.push("--https");
417
- if (options.email)
418
- args.push("--email", options.email);
419
- if (options.production)
518
+ if (mergedOptions.email)
519
+ args.push("--email", mergedOptions.email);
520
+ if (mergedOptions.production)
420
521
  args.push("--production");
421
- if (options.cloudflareToken)
422
- args.push("--cloudflare-token", options.cloudflareToken);
423
- if (options.duckdnsToken)
424
- args.push("--duckdns-token", options.duckdnsToken);
425
- if (options.autoDns)
426
- args.push("--auto-dns");
427
- if (options.dnsCreateRecords)
428
- args.push("--dns-create-records");
429
- if (options.dnsDeleteOnClose)
430
- args.push("--dns-delete-on-close");
431
- const out = fs.openSync(logFile, "a");
432
- const err = fs.openSync(logFile, "a");
522
+ if (mergedOptions.cloudflareToken)
523
+ args.push("--cloudflare-token", mergedOptions.cloudflareToken);
524
+ if (mergedOptions.duckdnsToken)
525
+ args.push("--duckdns-token", mergedOptions.duckdnsToken);
526
+ const out = fsAsync.openSync(logFile, "a");
527
+ const err = fsAsync.openSync(logFile, "a");
433
528
  const child = spawn(process.execPath, [process.argv[1], ...args], {
434
529
  detached: true,
435
530
  stdio: ["ignore", out, err],
436
531
  cwd: process.cwd(),
437
532
  });
438
533
  child.unref();
439
- fs.writeFileSync(pidFile, String(child.pid));
534
+ fsAsync.writeFileSync(pidFile, String(child.pid));
440
535
  console.log(chalk_1.default.green(`OpenTunnel server started in background`));
441
536
  console.log(chalk_1.default.gray(` PID: ${child.pid}`));
442
- console.log(chalk_1.default.gray(` Port: ${options.port}`));
443
- console.log(chalk_1.default.gray(` Domain: ${options.domain}`));
537
+ console.log(chalk_1.default.gray(` Port: ${mergedOptions.port}`));
538
+ console.log(chalk_1.default.gray(` Domain: ${mergedOptions.domain}`));
444
539
  console.log(chalk_1.default.gray(` Log: ${logFile}`));
445
540
  console.log(chalk_1.default.gray(` PID file: ${pidFile}`));
446
541
  console.log("");
447
- console.log(chalk_1.default.gray(`Stop with: node dist/cli/index.js stop`));
542
+ console.log(chalk_1.default.gray(`Stop with: opentunnel stop`));
448
543
  console.log(chalk_1.default.gray(`Logs: tail -f ${logFile}`));
449
544
  return;
450
545
  }
@@ -454,46 +549,46 @@ program
454
549
  let httpsConfig = undefined;
455
550
  let selfSignedHttpsConfig = undefined;
456
551
  let autoHttpsConfig = undefined;
457
- if (options.httpsCert && options.httpsKey) {
552
+ if (mergedOptions.httpsCert && mergedOptions.httpsKey) {
458
553
  // Custom certificates provided
459
- const fs = await Promise.resolve().then(() => __importStar(require("fs")));
554
+ const fsRead = await Promise.resolve().then(() => __importStar(require("fs")));
460
555
  httpsConfig = {
461
- cert: fs.readFileSync(options.httpsCert, "utf-8"),
462
- key: fs.readFileSync(options.httpsKey, "utf-8"),
556
+ cert: fsRead.readFileSync(mergedOptions.httpsCert, "utf-8"),
557
+ key: fsRead.readFileSync(mergedOptions.httpsKey, "utf-8"),
463
558
  };
464
559
  }
465
- else if (options.letsencrypt) {
560
+ else if (mergedOptions.letsencrypt) {
466
561
  // Let's Encrypt
467
562
  autoHttpsConfig = {
468
563
  enabled: true,
469
- email: options.email || `admin@${options.domain}`,
470
- production: options.production || false,
471
- cloudflareToken: options.cloudflareToken,
564
+ email: mergedOptions.email || `admin@${mergedOptions.domain}`,
565
+ production: mergedOptions.production || false,
566
+ cloudflareToken: mergedOptions.cloudflareToken,
472
567
  };
473
568
  }
474
569
  else {
475
570
  // Self-signed by default (use --no-https to disable)
476
571
  selfSignedHttpsConfig = {
477
- enabled: options.https !== false,
572
+ enabled: mergedOptions.https !== false,
478
573
  };
479
574
  }
480
575
  const server = new TunnelServer({
481
- port: parseInt(options.port),
482
- publicPort: options.publicPort ? parseInt(options.publicPort) : undefined,
483
- host: options.host,
484
- domain: options.domain,
485
- basePath: options.basePath,
576
+ port: parseInt(mergedOptions.port),
577
+ publicPort: mergedOptions.publicPort ? parseInt(mergedOptions.publicPort) : undefined,
578
+ host: mergedOptions.host,
579
+ domain: mergedOptions.domain,
580
+ basePath: mergedOptions.basePath,
486
581
  tunnelPortRange: {
487
- min: parseInt(options.tcpMin),
488
- max: parseInt(options.tcpMax),
582
+ min: parseInt(mergedOptions.tcpMin),
583
+ max: parseInt(mergedOptions.tcpMax),
489
584
  },
490
- auth: options.authTokens
491
- ? { required: true, tokens: options.authTokens.split(",") }
585
+ auth: mergedOptions.authTokens
586
+ ? { required: true, tokens: mergedOptions.authTokens.split(",") }
492
587
  : undefined,
493
588
  https: httpsConfig,
494
589
  selfSignedHttps: selfSignedHttpsConfig,
495
590
  autoHttps: autoHttpsConfig,
496
- autoDns: detectDnsConfig(options),
591
+ autoDns: detectDnsConfig(mergedOptions),
497
592
  });
498
593
  // Helper function to auto-detect DNS provider
499
594
  function detectDnsConfig(opts) {
@@ -506,8 +601,8 @@ program
506
601
  enabled: true,
507
602
  provider: "cloudflare",
508
603
  cloudflareToken: opts.cloudflareToken,
509
- createRecords: opts.dnsCreateRecords !== false,
510
- deleteOnClose: opts.dnsDeleteOnClose || false,
604
+ createRecords: false,
605
+ deleteOnClose: false,
511
606
  setupWildcard: true,
512
607
  };
513
608
  }
@@ -516,15 +611,11 @@ program
516
611
  enabled: true,
517
612
  provider: "duckdns",
518
613
  duckdnsToken: opts.duckdnsToken,
519
- createRecords: false, // DuckDNS doesn't support subdomains
614
+ createRecords: false,
520
615
  deleteOnClose: false,
521
616
  setupWildcard: false,
522
617
  };
523
618
  }
524
- // No auto DNS if no tokens provided
525
- if (opts.autoDns) {
526
- console.log(chalk_1.default.yellow("Warning: --auto-dns requires --cloudflare-token or --duckdns-token"));
527
- }
528
619
  return undefined;
529
620
  }
530
621
  console.log(chalk_1.default.cyan(`
@@ -542,10 +633,10 @@ program
542
633
  console.log(chalk_1.default.yellow(`[-] Tunnel closed: ${tunnelId}`));
543
634
  });
544
635
  await server.start();
545
- console.log(chalk_1.default.green(`\nServer running on ${options.host}:${options.port}`));
546
- console.log(chalk_1.default.gray(`Domain: ${options.domain}`));
547
- console.log(chalk_1.default.gray(`Subdomain pattern: *.${options.basePath}.${options.domain}`));
548
- console.log(chalk_1.default.gray(`TCP port range: ${options.tcpMin}-${options.tcpMax}\n`));
636
+ console.log(chalk_1.default.green(`\nServer running on ${mergedOptions.host}:${mergedOptions.port}`));
637
+ console.log(chalk_1.default.gray(`Domain: ${mergedOptions.domain}`));
638
+ console.log(chalk_1.default.gray(`Subdomain pattern: *.${mergedOptions.basePath}.${mergedOptions.domain}`));
639
+ console.log(chalk_1.default.gray(`TCP port range: ${mergedOptions.tcpMin}-${mergedOptions.tcpMax}\n`));
549
640
  });
550
641
  // Stop command
551
642
  program
@@ -868,48 +959,82 @@ program
868
959
  .command("init")
869
960
  .description("Create an example opentunnel.yml configuration file")
870
961
  .option("-f, --force", "Overwrite existing config file")
962
+ .option("--server", "Create server mode config")
963
+ .option("--client", "Create client mode config (default)")
871
964
  .action(async (options) => {
872
- const fs = await Promise.resolve().then(() => __importStar(require("fs")));
873
- const path = await Promise.resolve().then(() => __importStar(require("path")));
874
- const configPath = path.join(process.cwd(), CONFIG_FILE);
875
- if (fs.existsSync(configPath) && !options.force) {
965
+ const fsInit = await Promise.resolve().then(() => __importStar(require("fs")));
966
+ const pathInit = await Promise.resolve().then(() => __importStar(require("path")));
967
+ const configPath = pathInit.join(process.cwd(), CONFIG_FILE);
968
+ const envPath = pathInit.join(process.cwd(), ".env");
969
+ if (fsInit.existsSync(configPath) && !options.force) {
876
970
  console.log(chalk_1.default.yellow(`Config file already exists: ${configPath}`));
877
971
  console.log(chalk_1.default.gray("Use --force to overwrite"));
878
972
  return;
879
973
  }
880
- const exampleConfig = {
881
- version: "1.0",
882
- server: {
883
- domain: "localhost",
884
- // remote: "op.fjrg2007.com", // Use this to connect to a remote server
885
- // token: "your-auth-token",
886
- },
887
- tunnels: [
888
- {
889
- name: "web",
890
- protocol: "http",
891
- port: 3000,
892
- subdomain: "web",
893
- autostart: true,
894
- },
895
- {
896
- name: "api",
897
- protocol: "http",
898
- port: 4000,
899
- subdomain: "api",
900
- autostart: true,
901
- },
902
- {
903
- name: "database",
904
- protocol: "tcp",
905
- port: 5432,
906
- autostart: false,
907
- },
908
- ],
909
- };
910
- fs.writeFileSync(configPath, (0, yaml_1.stringify)(exampleConfig, { indent: 2 }));
974
+ // Example config with environment variable syntax
975
+ const clientConfig = `# OpenTunnel Client Configuration
976
+ # Supports environment variables: \${VAR} or \${VAR:-default}
977
+ # Create a .env file for secrets (automatically loaded)
978
+
979
+ version: "1.0"
980
+
981
+ server:
982
+ remote: \${SERVER_DOMAIN:-example.com} # Server domain (system adds basePath)
983
+ token: \${AUTH_TOKEN} # From .env (required for private servers)
984
+
985
+ tunnels:
986
+ - name: web
987
+ protocol: http
988
+ port: 3000
989
+ subdomain: web # → web.op.example.com
990
+ autostart: true
991
+
992
+ - name: api
993
+ protocol: http
994
+ port: 4000
995
+ subdomain: api # → api.op.example.com
996
+
997
+ - name: database
998
+ protocol: tcp
999
+ port: 5432
1000
+ remotePort: 15432 # → example.com:15432
1001
+ autostart: false
1002
+ `;
1003
+ const serverConfig = `# OpenTunnel Server Configuration
1004
+ # Supports environment variables: \${VAR} or \${VAR:-default}
1005
+ # Create a .env file for secrets (automatically loaded)
1006
+
1007
+ version: "1.0"
1008
+
1009
+ server:
1010
+ domain: \${DOMAIN:-example.com} # Your base domain
1011
+ token: \${AUTH_TOKEN} # From .env (optional for public server)
1012
+
1013
+ tunnels: []
1014
+ `;
1015
+ const envExample = `# OpenTunnel Environment Variables
1016
+ # Copy to .env and fill in your values
1017
+
1018
+ # Server/Remote domain
1019
+ DOMAIN=example.com
1020
+ SERVER_DOMAIN=example.com
1021
+
1022
+ # Authentication token (leave empty for public server)
1023
+ AUTH_TOKEN=
1024
+ `;
1025
+ const configContent = options.server ? serverConfig : clientConfig;
1026
+ fsInit.writeFileSync(configPath, configContent);
1027
+ // Create .env.example if it doesn't exist
1028
+ const envExamplePath = pathInit.join(process.cwd(), ".env.example");
1029
+ if (!fsInit.existsSync(envExamplePath) && !fsInit.existsSync(envPath)) {
1030
+ fsInit.writeFileSync(envExamplePath, envExample);
1031
+ console.log(chalk_1.default.green(`Created .env.example`));
1032
+ }
911
1033
  console.log(chalk_1.default.green(`Created ${CONFIG_FILE}`));
912
- console.log(chalk_1.default.gray(`\nEdit the file to configure your tunnels, then run:`));
1034
+ console.log(chalk_1.default.gray(`\nEnvironment variables supported:`));
1035
+ console.log(chalk_1.default.cyan(` \${VAR} → Use value of VAR`));
1036
+ console.log(chalk_1.default.cyan(` \${VAR:-default} → Use VAR or "default" if not set`));
1037
+ console.log(chalk_1.default.gray(`\nCreate a .env file for secrets, then run:`));
913
1038
  console.log(chalk_1.default.cyan(` opentunnel up # Start all tunnels`));
914
1039
  console.log(chalk_1.default.cyan(` opentunnel up -d # Start in background`));
915
1040
  });
@@ -921,16 +1046,12 @@ program
921
1046
  .option("-f, --file <path>", "Config file path", CONFIG_FILE)
922
1047
  .option("--no-autostart", "Ignore autostart setting, start all tunnels")
923
1048
  .action(async (options) => {
924
- const fs = await Promise.resolve().then(() => __importStar(require("fs")));
925
- const path = await Promise.resolve().then(() => __importStar(require("path")));
926
- // Load config file
927
- const configPath = path.join(process.cwd(), options.file);
928
- let config = { version: "1.0", tunnels: [] };
929
- if (fs.existsSync(configPath)) {
930
- const configContent = fs.readFileSync(configPath, "utf-8");
931
- config = (0, yaml_1.parse)(configContent);
932
- }
933
- else {
1049
+ const fsModule = await Promise.resolve().then(() => __importStar(require("fs")));
1050
+ const pathModule = await Promise.resolve().then(() => __importStar(require("path")));
1051
+ // Load config file (with env variable substitution)
1052
+ const configPath = pathModule.join(process.cwd(), options.file);
1053
+ const config = loadConfig(configPath);
1054
+ if (!config) {
934
1055
  console.log(chalk_1.default.red(`Config file not found: ${configPath}`));
935
1056
  console.log(chalk_1.default.gray(`Run 'opentunnel init' to create one`));
936
1057
  return;
@@ -964,16 +1085,16 @@ program
964
1085
  console.log(chalk_1.default.gray("\nAdd to your config:"));
965
1086
  console.log(chalk_1.default.cyan("\n # Run your own server:"));
966
1087
  console.log(chalk_1.default.white(" server:"));
967
- console.log(chalk_1.default.white(" domain: localhost"));
1088
+ console.log(chalk_1.default.white(" domain: example.com"));
968
1089
  console.log(chalk_1.default.cyan("\n # Or connect to a remote server:"));
969
1090
  console.log(chalk_1.default.white(" server:"));
970
- console.log(chalk_1.default.white(" remote: op.fjrg2007.com"));
1091
+ console.log(chalk_1.default.white(" remote: example.com"));
971
1092
  process.exit(1);
972
1093
  }
973
1094
  if (isClientMode) {
974
1095
  // CLIENT MODE: Connect to remote server
975
- const protocol = useHttps ? "wss" : "ws";
976
- const serverUrl = `${protocol}://${remote}/_tunnel`;
1096
+ // Build URL using basePath (default: op) -> wss://op.example.com/_tunnel
1097
+ const { url: serverUrl } = buildServerUrl(remote, !useHttps, basePath);
977
1098
  console.log(chalk_1.default.cyan(`Connecting to ${remote}...\n`));
978
1099
  console.log(chalk_1.default.cyan(`Starting ${tunnelsToStart.length} tunnel(s)...\n`));
979
1100
  try {