@tongil_kim/clautunnel 1.1.0 → 1.3.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 CHANGED
@@ -26,15 +26,21 @@ clautunnel setup
26
26
  ```
27
27
 
28
28
  You'll need:
29
- - **Supabase Project URL**: Dashboard → Settings → API (e.g., `https://xxxx.supabase.co`)
30
- - **Supabase Anon Key**: Dashboard → Settings → API → `anon` `public` key
29
+ - **Supabase Project ID**: Dashboard → Settings → General Project ID
30
+ - **Supabase Anon Key**: Dashboard → Settings → API Keys Legacy anon Tab → Copy anon key
31
31
 
32
32
  ## Usage
33
33
 
34
34
  ```bash
35
- # Authenticate
35
+ # Create account (first time)
36
+ clautunnel signup
37
+
38
+ # Login (returning user)
36
39
  clautunnel login
37
40
 
41
+ # Logout
42
+ clautunnel logout
43
+
38
44
  # Start a session
39
45
  clautunnel start
40
46
 
package/dist/index.d.ts CHANGED
@@ -11,6 +11,7 @@ interface ConfigData {
11
11
  };
12
12
  supabaseUrl?: string;
13
13
  supabaseAnonKey?: string;
14
+ mobileProjectPath?: string;
14
15
  }
15
16
  declare class Config {
16
17
  private configDir;
@@ -37,6 +38,8 @@ declare class Config {
37
38
  refreshToken: string;
38
39
  }): void;
39
40
  clearSessionTokens(): void;
41
+ getMobileProjectPath(): string | undefined;
42
+ setMobileProjectPath(path: string): void;
40
43
  }
41
44
  declare function getConfig(): Config;
42
45
 
package/dist/index.js CHANGED
@@ -236,7 +236,7 @@ var Config = class {
236
236
  requireConfiguration() {
237
237
  if (!this.isConfigured()) {
238
238
  throw new ConfigurationError(
239
- 'ClauTunnel is not configured.\n\nPlease run "clautunnel setup" to configure your Supabase credentials,\nor set the following environment variables:\n - SUPABASE_URL\n - SUPABASE_ANON_KEY'
239
+ 'ClauTunnel is not configured.\n\nRun "clautunnel setup" to configure your Supabase credentials.\n\nOr set environment variables in your shell profile (~/.zshrc or ~/.bashrc):\n export SUPABASE_URL=https://<project-id>.supabase.co\n export SUPABASE_ANON_KEY=<your-anon-key>'
240
240
  );
241
241
  }
242
242
  }
@@ -262,6 +262,13 @@ var Config = class {
262
262
  delete this.data.sessionTokens;
263
263
  this.saveConfig();
264
264
  }
265
+ getMobileProjectPath() {
266
+ return this.data.mobileProjectPath;
267
+ }
268
+ setMobileProjectPath(path4) {
269
+ this.data.mobileProjectPath = path4;
270
+ this.saveConfig();
271
+ }
265
272
  };
266
273
  var defaultConfig = null;
267
274
  function getConfig() {
@@ -325,20 +332,22 @@ import { EventEmitter } from "events";
325
332
  var DEFAULT_TIMEOUT = 1e4;
326
333
  function subscribeWithTimeout(channel, channelName, timeout = DEFAULT_TIMEOUT) {
327
334
  return new Promise((resolve3) => {
335
+ let lastStatus = "unknown";
328
336
  const timer = setTimeout(() => {
329
337
  console.warn(
330
- `[WARN] Realtime subscription timeout for ${channelName}. Mobile sync disabled.`
338
+ `[WARN] Realtime subscription timeout for ${channelName} (last status: ${lastStatus}).`
331
339
  );
332
340
  resolve3(false);
333
341
  }, timeout);
334
342
  channel.subscribe((status, err) => {
343
+ lastStatus = status;
335
344
  if (status === "SUBSCRIBED") {
336
345
  clearTimeout(timer);
337
346
  resolve3(true);
338
347
  } else if (status === "CHANNEL_ERROR" || status === "CLOSED" || status === "TIMED_OUT") {
339
348
  clearTimeout(timer);
340
349
  console.warn(
341
- `[WARN] Channel ${channelName} ${status.toLowerCase()}. Mobile sync disabled.`
350
+ `[WARN] Channel ${channelName} ${status.toLowerCase()}.`
342
351
  );
343
352
  if (err) {
344
353
  console.warn(`[WARN] Error details: ${err.message || err}`);
@@ -2301,7 +2310,7 @@ ${confirmationMsg}
2301
2310
  };
2302
2311
 
2303
2312
  // src/index.ts
2304
- import { Command as Command8 } from "commander";
2313
+ import { Command as Command9 } from "commander";
2305
2314
 
2306
2315
  // src/commands/start.ts
2307
2316
  import { Command } from "commander";
@@ -2604,13 +2613,344 @@ function getFullDiskAccessStatus(enabled, terminalApp) {
2604
2613
  };
2605
2614
  }
2606
2615
 
2616
+ // src/mobile/mobile-server.ts
2617
+ import { spawn as spawn2, execSync as execSync2 } from "child_process";
2618
+ import {
2619
+ existsSync as existsSync4,
2620
+ mkdirSync as mkdirSync3,
2621
+ writeFileSync as writeFileSync3,
2622
+ createWriteStream
2623
+ } from "fs";
2624
+ import { join as join5 } from "path";
2625
+ import { homedir as homedir5 } from "os";
2626
+ import { get } from "http";
2627
+ var REPO_URL = "https://github.com/TongilKim/ClauTunnel.git";
2628
+ var DEFAULT_MOBILE_DIR = join5(homedir5(), ".clautunnel", "mobile");
2629
+ var MobileServerManager = class {
2630
+ options;
2631
+ mobileProjectPath;
2632
+ logDir;
2633
+ expoPort;
2634
+ ngrokProcess = null;
2635
+ expoProcess = null;
2636
+ ngrokLogStream = null;
2637
+ expoLogStream = null;
2638
+ tunnelUrl = null;
2639
+ onProgress;
2640
+ constructor(options) {
2641
+ this.options = options;
2642
+ this.mobileProjectPath = options.mobileProjectPath ?? DEFAULT_MOBILE_DIR;
2643
+ this.expoPort = options.expoPort ?? 8081;
2644
+ this.logDir = options.logDir ?? join5(homedir5(), ".clautunnel", "logs");
2645
+ this.onProgress = options.onProgress ?? (() => {
2646
+ });
2647
+ }
2648
+ getMobileProjectPath() {
2649
+ return this.mobileProjectPath;
2650
+ }
2651
+ checkPrerequisites() {
2652
+ const issues = [];
2653
+ let needsInstall = false;
2654
+ try {
2655
+ execSync2("which ngrok", { stdio: "pipe" });
2656
+ } catch {
2657
+ issues.push(
2658
+ "ngrok is not installed.\n Install: brew install ngrok\n Sign up: https://ngrok.com\n Auth: ngrok config add-authtoken <your-token>"
2659
+ );
2660
+ }
2661
+ const nodeModulesPath = join5(this.mobileProjectPath, "node_modules");
2662
+ if (!existsSync4(nodeModulesPath)) {
2663
+ needsInstall = true;
2664
+ }
2665
+ return { ready: issues.length === 0, issues, needsInstall };
2666
+ }
2667
+ ensureMobileProject() {
2668
+ const packageJson2 = join5(this.mobileProjectPath, "package.json");
2669
+ if (existsSync4(packageJson2)) {
2670
+ return { ready: true, cloned: false };
2671
+ }
2672
+ if (this.mobileProjectPath && !existsSync4(this.mobileProjectPath)) {
2673
+ return {
2674
+ ready: false,
2675
+ cloned: false,
2676
+ error: `Mobile project path not found: ${this.mobileProjectPath}`
2677
+ };
2678
+ }
2679
+ try {
2680
+ execSync2("which git", { stdio: "pipe" });
2681
+ } catch {
2682
+ return {
2683
+ ready: false,
2684
+ cloned: false,
2685
+ error: "git is required to download the mobile app.\n Install: https://git-scm.com/downloads\n macOS: xcode-select --install"
2686
+ };
2687
+ }
2688
+ try {
2689
+ const clautunnelDir = join5(homedir5(), ".clautunnel");
2690
+ if (!existsSync4(clautunnelDir)) {
2691
+ mkdirSync3(clautunnelDir, { recursive: true });
2692
+ }
2693
+ const repoDir = join5(clautunnelDir, "repo");
2694
+ if (existsSync4(repoDir)) {
2695
+ execSync2("git pull --ff-only", {
2696
+ cwd: repoDir,
2697
+ stdio: "pipe",
2698
+ timeout: 6e4
2699
+ });
2700
+ } else {
2701
+ execSync2(
2702
+ `git clone --depth 1 --filter=blob:none --sparse "${REPO_URL}" repo`,
2703
+ {
2704
+ cwd: clautunnelDir,
2705
+ stdio: "pipe",
2706
+ timeout: 6e4
2707
+ }
2708
+ );
2709
+ execSync2("git sparse-checkout set apps/mobile", {
2710
+ cwd: repoDir,
2711
+ stdio: "pipe",
2712
+ timeout: 3e4
2713
+ });
2714
+ }
2715
+ this.mobileProjectPath = join5(repoDir, "apps", "mobile");
2716
+ if (!existsSync4(join5(this.mobileProjectPath, "package.json"))) {
2717
+ return { ready: false, cloned: false, error: "Clone succeeded but apps/mobile not found" };
2718
+ }
2719
+ return { ready: true, cloned: true };
2720
+ } catch (error) {
2721
+ return {
2722
+ ready: false,
2723
+ cloned: false,
2724
+ error: `Failed to clone mobile project: ${error instanceof Error ? error.message : "Unknown error"}`
2725
+ };
2726
+ }
2727
+ }
2728
+ ensureEnvFile() {
2729
+ const envPath = join5(this.mobileProjectPath, ".env");
2730
+ const envContent = [
2731
+ `EXPO_PUBLIC_SUPABASE_URL=${this.options.supabaseUrl}`,
2732
+ `EXPO_PUBLIC_SUPABASE_ANON_KEY=${this.options.supabaseAnonKey}`,
2733
+ ""
2734
+ ].join("\n");
2735
+ writeFileSync3(envPath, envContent);
2736
+ }
2737
+ installDependencies() {
2738
+ const nodeModulesPath = join5(this.mobileProjectPath, "node_modules");
2739
+ if (existsSync4(nodeModulesPath)) return true;
2740
+ try {
2741
+ execSync2("pnpm install", {
2742
+ cwd: this.mobileProjectPath,
2743
+ stdio: "pipe",
2744
+ timeout: 12e4
2745
+ // 2 minute timeout
2746
+ });
2747
+ return true;
2748
+ } catch {
2749
+ return false;
2750
+ }
2751
+ }
2752
+ async startNgrok() {
2753
+ this.ensureLogDir();
2754
+ this.ngrokLogStream = createWriteStream(join5(this.logDir, "ngrok.log"));
2755
+ this.ngrokProcess = spawn2("ngrok", ["http", String(this.expoPort)], {
2756
+ stdio: ["ignore", "pipe", "pipe"],
2757
+ detached: false
2758
+ });
2759
+ this.ngrokProcess.stdout?.pipe(this.ngrokLogStream);
2760
+ this.ngrokProcess.stderr?.pipe(this.ngrokLogStream);
2761
+ this.ngrokProcess.on("error", () => {
2762
+ });
2763
+ for (let i = 0; i < 10; i++) {
2764
+ await this.sleep(1e3);
2765
+ const url = await this.getNgrokTunnelUrl();
2766
+ if (url) {
2767
+ this.tunnelUrl = url;
2768
+ return url;
2769
+ }
2770
+ }
2771
+ this.killProcess(this.ngrokProcess);
2772
+ this.ngrokProcess = null;
2773
+ return null;
2774
+ }
2775
+ async startExpo(tunnelUrl) {
2776
+ this.ensureLogDir();
2777
+ this.expoLogStream = createWriteStream(join5(this.logDir, "expo.log"));
2778
+ this.expoProcess = spawn2("npx", ["expo", "start", "--port", String(this.expoPort)], {
2779
+ cwd: this.mobileProjectPath,
2780
+ env: {
2781
+ ...process.env,
2782
+ EXPO_PACKAGER_PROXY_URL: tunnelUrl
2783
+ },
2784
+ stdio: ["ignore", "pipe", "pipe"],
2785
+ detached: false
2786
+ });
2787
+ this.expoProcess.on("error", () => {
2788
+ });
2789
+ return new Promise((resolve3) => {
2790
+ let qrDetected = false;
2791
+ let qrFinished = false;
2792
+ let resolved = false;
2793
+ const timeout = setTimeout(() => {
2794
+ if (!resolved) {
2795
+ resolved = true;
2796
+ this.expoProcess?.stdout?.pipe(this.expoLogStream);
2797
+ this.expoProcess?.stderr?.pipe(this.expoLogStream);
2798
+ resolve3(qrDetected);
2799
+ }
2800
+ }, 3e4);
2801
+ this.expoProcess?.stdout?.on("data", (data) => {
2802
+ const text = data.toString();
2803
+ const lines = text.split("\n");
2804
+ for (const line of lines) {
2805
+ if (qrFinished) {
2806
+ this.expoLogStream?.write(line + "\n");
2807
+ continue;
2808
+ }
2809
+ if (this.isQrCodeLine(line)) {
2810
+ qrDetected = true;
2811
+ process.stdout.write(line + "\n");
2812
+ } else if (qrDetected && this.isExpoReadyLine(line)) {
2813
+ process.stdout.write(line + "\n");
2814
+ qrFinished = true;
2815
+ if (!resolved) {
2816
+ resolved = true;
2817
+ clearTimeout(timeout);
2818
+ resolve3(true);
2819
+ }
2820
+ } else if (qrDetected && !qrFinished) {
2821
+ process.stdout.write(line + "\n");
2822
+ } else {
2823
+ this.expoLogStream?.write(line + "\n");
2824
+ }
2825
+ }
2826
+ });
2827
+ this.expoProcess?.stderr?.pipe(this.expoLogStream);
2828
+ this.expoProcess?.on("exit", () => {
2829
+ if (!resolved) {
2830
+ resolved = true;
2831
+ clearTimeout(timeout);
2832
+ resolve3(false);
2833
+ }
2834
+ });
2835
+ });
2836
+ }
2837
+ async start() {
2838
+ this.onProgress("Checking mobile project...");
2839
+ const projectResult = this.ensureMobileProject();
2840
+ if (!projectResult.ready) {
2841
+ return { started: false, error: projectResult.error };
2842
+ }
2843
+ if (projectResult.cloned) {
2844
+ this.onProgress("Mobile project cloned to ~/.clautunnel/repo/apps/mobile");
2845
+ }
2846
+ this.onProgress("Checking prerequisites...");
2847
+ const prereqs = this.checkPrerequisites();
2848
+ if (!prereqs.ready) {
2849
+ return { started: false, error: prereqs.issues.join("; ") };
2850
+ }
2851
+ if (prereqs.needsInstall) {
2852
+ this.onProgress("Installing dependencies (this may take a minute)...");
2853
+ const installed = this.installDependencies();
2854
+ if (!installed) {
2855
+ return { started: false, error: "Failed to install mobile dependencies" };
2856
+ }
2857
+ }
2858
+ this.onProgress("Syncing credentials...");
2859
+ this.ensureEnvFile();
2860
+ this.onProgress("Starting ngrok tunnel...");
2861
+ const tunnelUrl = await this.startNgrok();
2862
+ if (!tunnelUrl) {
2863
+ return { started: false, error: "Failed to start ngrok tunnel" };
2864
+ }
2865
+ this.onProgress("Starting Expo server...");
2866
+ const expoStarted = await this.startExpo(tunnelUrl);
2867
+ if (!expoStarted) {
2868
+ await this.stop();
2869
+ return { started: false, error: "Failed to start Expo server" };
2870
+ }
2871
+ return { started: true, tunnelUrl };
2872
+ }
2873
+ async stop() {
2874
+ if (this.expoProcess) {
2875
+ this.killProcess(this.expoProcess);
2876
+ this.expoProcess = null;
2877
+ }
2878
+ if (this.ngrokProcess) {
2879
+ this.killProcess(this.ngrokProcess);
2880
+ this.ngrokProcess = null;
2881
+ }
2882
+ if (this.expoLogStream) {
2883
+ this.expoLogStream.end();
2884
+ this.expoLogStream = null;
2885
+ }
2886
+ if (this.ngrokLogStream) {
2887
+ this.ngrokLogStream.end();
2888
+ this.ngrokLogStream = null;
2889
+ }
2890
+ this.tunnelUrl = null;
2891
+ }
2892
+ getTunnelUrl() {
2893
+ return this.tunnelUrl;
2894
+ }
2895
+ isQrCodeLine(line) {
2896
+ return line.includes("\u2588") || line.includes("\u2584") || line.includes("\u2580");
2897
+ }
2898
+ isExpoReadyLine(line) {
2899
+ return line.includes("Metro waiting on") || line.includes("Logs for your project");
2900
+ }
2901
+ getNgrokTunnelUrl() {
2902
+ return new Promise((resolve3) => {
2903
+ const req = get("http://localhost:4040/api/tunnels", (res) => {
2904
+ let data = "";
2905
+ res.on("data", (chunk) => data += chunk);
2906
+ res.on("end", () => {
2907
+ try {
2908
+ const tunnels = JSON.parse(data).tunnels;
2909
+ const httpsTunnel = tunnels?.find(
2910
+ (t) => t.proto === "https"
2911
+ );
2912
+ resolve3(httpsTunnel?.public_url ?? null);
2913
+ } catch {
2914
+ resolve3(null);
2915
+ }
2916
+ });
2917
+ });
2918
+ req.on("error", () => resolve3(null));
2919
+ req.setTimeout(2e3, () => {
2920
+ req.destroy();
2921
+ resolve3(null);
2922
+ });
2923
+ });
2924
+ }
2925
+ killProcess(proc) {
2926
+ try {
2927
+ proc.kill("SIGTERM");
2928
+ setTimeout(() => {
2929
+ try {
2930
+ proc.kill("SIGKILL");
2931
+ } catch {
2932
+ }
2933
+ }, 3e3);
2934
+ } catch {
2935
+ }
2936
+ }
2937
+ ensureLogDir() {
2938
+ if (!existsSync4(this.logDir)) {
2939
+ mkdirSync3(this.logDir, { recursive: true });
2940
+ }
2941
+ }
2942
+ sleep(ms) {
2943
+ return new Promise((resolve3) => setTimeout(resolve3, ms));
2944
+ }
2945
+ };
2946
+
2607
2947
  // src/commands/start.ts
2608
2948
  if (typeof globalThis.WebSocket === "undefined") {
2609
2949
  globalThis.WebSocket = WebSocket;
2610
2950
  }
2611
2951
  function createStartCommand() {
2612
2952
  const command = new Command("start");
2613
- command.description("Start ClauTunnel and listen for session requests from mobile app").option("-n, --name <name>", "Machine name").option("--prevent-sleep", "Auto-enable sleep prevention (skip prompt)").action(async (options) => {
2953
+ command.description("Start ClauTunnel and listen for session requests from mobile app").option("-n, --name <name>", "Machine name").option("--prevent-sleep", "Auto-enable sleep prevention (skip prompt)").option("--no-mobile", "Skip mobile server startup").action(async (options) => {
2614
2954
  const config2 = new Config();
2615
2955
  const logger = new Logger();
2616
2956
  const spinner = new Spinner("Starting ClauTunnel...");
@@ -2628,10 +2968,13 @@ function createStartCommand() {
2628
2968
  const session = await restoreSession(supabase, config2);
2629
2969
  if (!session) {
2630
2970
  spinner.fail("Not authenticated");
2631
- logger.error('Run "clautunnel login" first.');
2971
+ logger.error(
2972
+ 'Run "clautunnel login" or "clautunnel signup" first.'
2973
+ );
2632
2974
  process.exit(1);
2633
2975
  }
2634
2976
  const { user } = session;
2977
+ spinner.update(`Authenticated as ${user.email}...`);
2635
2978
  let fdaStatus = null;
2636
2979
  if (isMacOS()) {
2637
2980
  spinner.stop();
@@ -2698,7 +3041,34 @@ function createStartCommand() {
2698
3041
  }
2699
3042
  spinner.start();
2700
3043
  }
2701
- const cleanup2 = () => {
3044
+ let mobileServer = null;
3045
+ if (options.mobile !== false) {
3046
+ const mobileProjectPath = config2.getMobileProjectPath();
3047
+ mobileServer = new MobileServerManager({
3048
+ mobileProjectPath,
3049
+ supabaseUrl: config2.getSupabaseUrl(),
3050
+ supabaseAnonKey: config2.getSupabaseAnonKey(),
3051
+ onProgress: (msg) => spinner.update(msg)
3052
+ });
3053
+ const result = await mobileServer.start();
3054
+ spinner.stop();
3055
+ if (result.started) {
3056
+ logger.info("");
3057
+ logger.info(` Tunnel: ${result.tunnelUrl}`);
3058
+ logger.info("");
3059
+ } else {
3060
+ logger.error(`Mobile server failed: ${result.error}`);
3061
+ process.exit(1);
3062
+ }
3063
+ spinner.start();
3064
+ }
3065
+ const cleanup2 = async () => {
3066
+ if (mobileServer) {
3067
+ try {
3068
+ await mobileServer.stop();
3069
+ } catch {
3070
+ }
3071
+ }
2702
3072
  if (sleepState.pmsetEnabled) {
2703
3073
  console.log("Restoring sleep settings...");
2704
3074
  }
@@ -2754,9 +3124,12 @@ function createStartCommand() {
2754
3124
  const connected = await machineClient.connect();
2755
3125
  spinner.stop();
2756
3126
  if (!connected) {
2757
- logger.error(
2758
- "Failed to connect to realtime. Check your network connection."
2759
- );
3127
+ logger.error("Failed to connect to Supabase Realtime.");
3128
+ logger.error("");
3129
+ logger.error("This may be a temporary issue. Try the following:");
3130
+ logger.error(' 1. Open a new terminal and run "clautunnel start" again');
3131
+ logger.error(" 2. Check your network connection");
3132
+ logger.error(' 3. Try "clautunnel login" to refresh your session');
2760
3133
  process.exit(1);
2761
3134
  }
2762
3135
  logger.info("");
@@ -2774,6 +3147,9 @@ function createStartCommand() {
2774
3147
  ` Sleep prevention: ${sleepState.pmsetEnabled ? "Lid-closed mode" : "Basic mode"}`
2775
3148
  );
2776
3149
  }
3150
+ if (mobileServer) {
3151
+ logger.info(" Mobile server: Running");
3152
+ }
2777
3153
  logger.info("");
2778
3154
  logger.info("Open the mobile app to start a session.");
2779
3155
  logger.info("Press Ctrl+C to stop.");
@@ -3083,10 +3459,28 @@ function createLoginCommand() {
3083
3459
  return command;
3084
3460
  }
3085
3461
 
3086
- // src/commands/signup.ts
3462
+ // src/commands/logout.ts
3087
3463
  import { Command as Command5 } from "commander";
3464
+ function createLogoutCommand() {
3465
+ const command = new Command5("logout");
3466
+ command.description("Log out of ClauTunnel").action(async () => {
3467
+ const config2 = new Config();
3468
+ const logger = new Logger();
3469
+ const session = config2.getSessionTokens();
3470
+ if (!session) {
3471
+ logger.info("Not currently logged in.");
3472
+ return;
3473
+ }
3474
+ config2.clearSessionTokens();
3475
+ logger.info("Logged out successfully.");
3476
+ });
3477
+ return command;
3478
+ }
3479
+
3480
+ // src/commands/signup.ts
3481
+ import { Command as Command6 } from "commander";
3088
3482
  function createSignupCommand() {
3089
- const command = new Command5("signup");
3483
+ const command = new Command6("signup");
3090
3484
  command.description("Create a new ClauTunnel account").action(async () => {
3091
3485
  const config2 = new Config();
3092
3486
  const logger = new Logger();
@@ -3156,9 +3550,9 @@ function createSignupCommand() {
3156
3550
  }
3157
3551
 
3158
3552
  // src/commands/setup.ts
3159
- import { Command as Command6 } from "commander";
3553
+ import { Command as Command7 } from "commander";
3160
3554
  function createSetupCommand() {
3161
- const command = new Command6("setup");
3555
+ const command = new Command7("setup");
3162
3556
  command.description("Configure ClauTunnel with Supabase credentials").action(async () => {
3163
3557
  const config2 = new Config();
3164
3558
  const logger = new Logger();
@@ -3223,11 +3617,11 @@ function createSetupCommand() {
3223
3617
  }
3224
3618
 
3225
3619
  // src/commands/mobile-setup.ts
3226
- import { Command as Command7 } from "commander";
3227
- import { existsSync as existsSync5, writeFileSync as writeFileSync3 } from "fs";
3228
- import { join as join6, resolve } from "path";
3620
+ import { Command as Command8 } from "commander";
3621
+ import { existsSync as existsSync6, writeFileSync as writeFileSync4 } from "fs";
3622
+ import { join as join7, resolve } from "path";
3229
3623
  function createMobileSetupCommand() {
3230
- const command = new Command7("mobile-setup");
3624
+ const command = new Command8("mobile-setup");
3231
3625
  command.description("Generate mobile app .env file from CLI credentials").action(async () => {
3232
3626
  const config2 = new Config();
3233
3627
  const logger = new Logger();
@@ -3236,7 +3630,7 @@ function createMobileSetupCommand() {
3236
3630
  const supabaseUrl = config2.getSupabaseUrl();
3237
3631
  const supabaseAnonKey = config2.getSupabaseAnonKey();
3238
3632
  const mobileDir = resolve(process.cwd(), "apps", "mobile");
3239
- if (!existsSync5(mobileDir)) {
3633
+ if (!existsSync6(mobileDir)) {
3240
3634
  logger.error("Could not find apps/mobile directory.");
3241
3635
  logger.error("");
3242
3636
  logger.error("Make sure you run this command from the ClauTunnel project root:");
@@ -3244,8 +3638,8 @@ function createMobileSetupCommand() {
3244
3638
  logger.error(" clautunnel mobile-setup");
3245
3639
  process.exit(1);
3246
3640
  }
3247
- const envPath = join6(mobileDir, ".env");
3248
- if (existsSync5(envPath)) {
3641
+ const envPath = join7(mobileDir, ".env");
3642
+ if (existsSync6(envPath)) {
3249
3643
  logger.warn("apps/mobile/.env already exists and will be overwritten.");
3250
3644
  }
3251
3645
  const envContent = [
@@ -3253,15 +3647,15 @@ function createMobileSetupCommand() {
3253
3647
  `EXPO_PUBLIC_SUPABASE_ANON_KEY=${supabaseAnonKey}`,
3254
3648
  ""
3255
3649
  ].join("\n");
3256
- writeFileSync3(envPath, envContent);
3650
+ writeFileSync4(envPath, envContent);
3651
+ config2.setMobileProjectPath(mobileDir);
3257
3652
  logger.info("");
3258
- logger.info("Mobile app .env file created successfully!");
3259
- logger.info(` ${envPath}`);
3653
+ logger.info("Mobile app configured successfully!");
3654
+ logger.info(` Project: ${mobileDir}`);
3655
+ logger.info(` .env: ${envPath}`);
3260
3656
  logger.info("");
3261
- logger.info("Next steps:");
3262
- logger.info(" 1. cd apps/mobile");
3263
- logger.info(" 2. pnpm start");
3264
- logger.info(" 3. Scan the QR code with Expo Go");
3657
+ logger.info('The mobile server will start automatically with "clautunnel start".');
3658
+ logger.info('Use "clautunnel start --no-mobile" to skip mobile server.');
3265
3659
  } catch (error) {
3266
3660
  if (error instanceof ConfigurationError) {
3267
3661
  logger.error(error.message);
@@ -3284,13 +3678,14 @@ var packageJson = JSON.parse(
3284
3678
  readFileSync5(resolve2(__dirname, "../package.json"), "utf-8")
3285
3679
  );
3286
3680
  var version = packageJson.version || "0.0.0";
3287
- var program = new Command8();
3681
+ var program = new Command9();
3288
3682
  program.name("clautunnel").description("Remote control for Claude Code CLI").version(version);
3289
3683
  program.addCommand(createSetupCommand());
3290
3684
  program.addCommand(createStartCommand());
3291
3685
  program.addCommand(createStopCommand());
3292
3686
  program.addCommand(createStatusCommand());
3293
3687
  program.addCommand(createLoginCommand());
3688
+ program.addCommand(createLogoutCommand());
3294
3689
  program.addCommand(createSignupCommand());
3295
3690
  program.addCommand(createMobileSetupCommand());
3296
3691
  if (process.argv[1]?.includes("clautunnel") || process.argv[1]?.endsWith("/index.js") || process.argv[1]?.endsWith("/index.ts")) {