episoda 0.2.15 → 0.2.17

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/index.js CHANGED
@@ -1489,15 +1489,15 @@ var require_git_executor = __commonJS({
1489
1489
  try {
1490
1490
  const { stdout: gitDir } = await execAsync("git rev-parse --git-dir", { cwd, timeout: 5e3 });
1491
1491
  const gitDirPath = gitDir.trim();
1492
- const fs5 = await Promise.resolve().then(() => __importStar(require("fs"))).then((m) => m.promises);
1492
+ const fs6 = await Promise.resolve().then(() => __importStar(require("fs"))).then((m) => m.promises);
1493
1493
  const rebaseMergePath = `${gitDirPath}/rebase-merge`;
1494
1494
  const rebaseApplyPath = `${gitDirPath}/rebase-apply`;
1495
1495
  try {
1496
- await fs5.access(rebaseMergePath);
1496
+ await fs6.access(rebaseMergePath);
1497
1497
  inRebase = true;
1498
1498
  } catch {
1499
1499
  try {
1500
- await fs5.access(rebaseApplyPath);
1500
+ await fs6.access(rebaseApplyPath);
1501
1501
  inRebase = true;
1502
1502
  } catch {
1503
1503
  inRebase = false;
@@ -2115,34 +2115,34 @@ var require_auth = __commonJS({
2115
2115
  Object.defineProperty(exports2, "__esModule", { value: true });
2116
2116
  exports2.getConfigDir = getConfigDir4;
2117
2117
  exports2.getConfigPath = getConfigPath3;
2118
- exports2.loadConfig = loadConfig3;
2118
+ exports2.loadConfig = loadConfig4;
2119
2119
  exports2.saveConfig = saveConfig3;
2120
2120
  exports2.validateToken = validateToken;
2121
- var fs5 = __importStar(require("fs"));
2122
- var path7 = __importStar(require("path"));
2123
- var os2 = __importStar(require("os"));
2121
+ var fs6 = __importStar(require("fs"));
2122
+ var path8 = __importStar(require("path"));
2123
+ var os3 = __importStar(require("os"));
2124
2124
  var child_process_1 = require("child_process");
2125
2125
  var DEFAULT_CONFIG_FILE = "config.json";
2126
2126
  function getConfigDir4() {
2127
- return process.env.EPISODA_CONFIG_DIR || path7.join(os2.homedir(), ".episoda");
2127
+ return process.env.EPISODA_CONFIG_DIR || path8.join(os3.homedir(), ".episoda");
2128
2128
  }
2129
2129
  function getConfigPath3(configPath) {
2130
2130
  if (configPath) {
2131
2131
  return configPath;
2132
2132
  }
2133
- return path7.join(getConfigDir4(), DEFAULT_CONFIG_FILE);
2133
+ return path8.join(getConfigDir4(), DEFAULT_CONFIG_FILE);
2134
2134
  }
2135
2135
  function ensureConfigDir(configPath) {
2136
- const dir = path7.dirname(configPath);
2137
- const isNew = !fs5.existsSync(dir);
2136
+ const dir = path8.dirname(configPath);
2137
+ const isNew = !fs6.existsSync(dir);
2138
2138
  if (isNew) {
2139
- fs5.mkdirSync(dir, { recursive: true, mode: 448 });
2139
+ fs6.mkdirSync(dir, { recursive: true, mode: 448 });
2140
2140
  }
2141
2141
  if (process.platform === "darwin") {
2142
- const nosyncPath = path7.join(dir, ".nosync");
2143
- if (isNew || !fs5.existsSync(nosyncPath)) {
2142
+ const nosyncPath = path8.join(dir, ".nosync");
2143
+ if (isNew || !fs6.existsSync(nosyncPath)) {
2144
2144
  try {
2145
- fs5.writeFileSync(nosyncPath, "", { mode: 384 });
2145
+ fs6.writeFileSync(nosyncPath, "", { mode: 384 });
2146
2146
  (0, child_process_1.execSync)(`xattr -w com.apple.fileprovider.ignore 1 "${dir}"`, {
2147
2147
  stdio: "ignore",
2148
2148
  timeout: 5e3
@@ -2152,13 +2152,13 @@ var require_auth = __commonJS({
2152
2152
  }
2153
2153
  }
2154
2154
  }
2155
- async function loadConfig3(configPath) {
2155
+ async function loadConfig4(configPath) {
2156
2156
  const fullPath = getConfigPath3(configPath);
2157
- if (!fs5.existsSync(fullPath)) {
2157
+ if (!fs6.existsSync(fullPath)) {
2158
2158
  return null;
2159
2159
  }
2160
2160
  try {
2161
- const content = fs5.readFileSync(fullPath, "utf8");
2161
+ const content = fs6.readFileSync(fullPath, "utf8");
2162
2162
  const config = JSON.parse(content);
2163
2163
  return config;
2164
2164
  } catch (error) {
@@ -2171,7 +2171,7 @@ var require_auth = __commonJS({
2171
2171
  ensureConfigDir(fullPath);
2172
2172
  try {
2173
2173
  const content = JSON.stringify(config, null, 2);
2174
- fs5.writeFileSync(fullPath, content, { mode: 384 });
2174
+ fs6.writeFileSync(fullPath, content, { mode: 384 });
2175
2175
  } catch (error) {
2176
2176
  throw new Error(`Failed to save config: ${error instanceof Error ? error.message : String(error)}`);
2177
2177
  }
@@ -2277,7 +2277,7 @@ var require_dist = __commonJS({
2277
2277
 
2278
2278
  // src/index.ts
2279
2279
  var import_commander = require("commander");
2280
- var import_core7 = __toESM(require_dist());
2280
+ var import_core8 = __toESM(require_dist());
2281
2281
 
2282
2282
  // src/commands/dev.ts
2283
2283
  var import_core3 = __toESM(require_dist());
@@ -2676,12 +2676,12 @@ async function sendCommand(command, params, timeout = DEFAULT_TIMEOUT) {
2676
2676
  reject(new Error(`Command timed out after ${timeout}ms`));
2677
2677
  }, timeout);
2678
2678
  socket.on("connect", () => {
2679
- const request = {
2679
+ const request2 = {
2680
2680
  id: requestId,
2681
2681
  command,
2682
2682
  params
2683
2683
  };
2684
- socket.write(JSON.stringify(request) + "\n");
2684
+ socket.write(JSON.stringify(request2) + "\n");
2685
2685
  });
2686
2686
  socket.on("data", (chunk) => {
2687
2687
  buffer += chunk.toString();
@@ -3601,10 +3601,10 @@ async function exchangeDeviceCode(apiUrl, deviceCode, machineId) {
3601
3601
  return tokenResponse;
3602
3602
  }
3603
3603
  function openBrowser(url) {
3604
- const platform2 = os.platform();
3604
+ const platform3 = os.platform();
3605
3605
  let command;
3606
3606
  let args;
3607
- switch (platform2) {
3607
+ switch (platform3) {
3608
3608
  case "darwin":
3609
3609
  command = "open";
3610
3610
  args = [url];
@@ -3661,8 +3661,8 @@ async function installGitCredentialHelper(apiUrl) {
3661
3661
  }
3662
3662
  }
3663
3663
  function updateShellProfile(binDir) {
3664
- const platform2 = os.platform();
3665
- if (platform2 === "win32") {
3664
+ const platform3 = os.platform();
3665
+ if (platform3 === "win32") {
3666
3666
  return;
3667
3667
  }
3668
3668
  const homeDir = os.homedir();
@@ -3817,8 +3817,634 @@ async function stopCommand(options = {}) {
3817
3817
  }
3818
3818
  }
3819
3819
 
3820
+ // src/commands/tunnel.ts
3821
+ var import_core7 = __toESM(require_dist());
3822
+
3823
+ // src/tunnel/cloudflared-manager.ts
3824
+ var import_child_process5 = require("child_process");
3825
+ var fs5 = __toESM(require("fs"));
3826
+ var path7 = __toESM(require("path"));
3827
+ var os2 = __toESM(require("os"));
3828
+ var https = __toESM(require("https"));
3829
+ var tar = __toESM(require("tar"));
3830
+ var DOWNLOAD_URLS = {
3831
+ darwin: {
3832
+ arm64: "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-darwin-arm64.tgz",
3833
+ x64: "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-darwin-amd64.tgz"
3834
+ },
3835
+ linux: {
3836
+ arm64: "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-arm64",
3837
+ x64: "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64"
3838
+ },
3839
+ win32: {
3840
+ x64: "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-windows-amd64.exe",
3841
+ ia32: "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-windows-386.exe"
3842
+ }
3843
+ };
3844
+ function getEpisodaBinDir() {
3845
+ return path7.join(os2.homedir(), ".episoda", "bin");
3846
+ }
3847
+ function getCloudflaredPath() {
3848
+ const binaryName = os2.platform() === "win32" ? "cloudflared.exe" : "cloudflared";
3849
+ return path7.join(getEpisodaBinDir(), binaryName);
3850
+ }
3851
+ function isCloudflaredInPath() {
3852
+ try {
3853
+ const command = os2.platform() === "win32" ? "where" : "which";
3854
+ const binaryName = os2.platform() === "win32" ? "cloudflared.exe" : "cloudflared";
3855
+ const result = (0, import_child_process5.spawnSync)(command, [binaryName], { encoding: "utf-8" });
3856
+ if (result.status === 0 && result.stdout.trim()) {
3857
+ return result.stdout.trim().split("\n")[0].trim();
3858
+ }
3859
+ } catch {
3860
+ }
3861
+ return null;
3862
+ }
3863
+ function isCloudflaredInstalled() {
3864
+ const cloudflaredPath = getCloudflaredPath();
3865
+ try {
3866
+ fs5.accessSync(cloudflaredPath, fs5.constants.X_OK);
3867
+ return true;
3868
+ } catch {
3869
+ return false;
3870
+ }
3871
+ }
3872
+ function verifyCloudflared(binaryPath) {
3873
+ try {
3874
+ const result = (0, import_child_process5.spawnSync)(binaryPath, ["version"], { encoding: "utf-8", timeout: 5e3 });
3875
+ return result.status === 0 && result.stdout.includes("cloudflared");
3876
+ } catch {
3877
+ return false;
3878
+ }
3879
+ }
3880
+ function getDownloadUrl() {
3881
+ const platform3 = os2.platform();
3882
+ const arch2 = os2.arch();
3883
+ const platformUrls = DOWNLOAD_URLS[platform3];
3884
+ if (!platformUrls) {
3885
+ return null;
3886
+ }
3887
+ return platformUrls[arch2] || null;
3888
+ }
3889
+ async function downloadFile(url, destPath) {
3890
+ return new Promise((resolve2, reject) => {
3891
+ const followRedirect = (currentUrl, redirectCount = 0) => {
3892
+ if (redirectCount > 5) {
3893
+ reject(new Error("Too many redirects"));
3894
+ return;
3895
+ }
3896
+ const urlObj = new URL(currentUrl);
3897
+ const options = {
3898
+ hostname: urlObj.hostname,
3899
+ path: urlObj.pathname + urlObj.search,
3900
+ headers: {
3901
+ "User-Agent": "episoda-cli"
3902
+ }
3903
+ };
3904
+ https.get(options, (response) => {
3905
+ if (response.statusCode === 301 || response.statusCode === 302) {
3906
+ const redirectUrl = response.headers.location;
3907
+ if (redirectUrl) {
3908
+ followRedirect(redirectUrl, redirectCount + 1);
3909
+ return;
3910
+ }
3911
+ }
3912
+ if (response.statusCode !== 200) {
3913
+ reject(new Error(`Failed to download: HTTP ${response.statusCode}`));
3914
+ return;
3915
+ }
3916
+ const file = fs5.createWriteStream(destPath);
3917
+ response.pipe(file);
3918
+ file.on("finish", () => {
3919
+ file.close();
3920
+ resolve2();
3921
+ });
3922
+ file.on("error", (err) => {
3923
+ fs5.unlinkSync(destPath);
3924
+ reject(err);
3925
+ });
3926
+ }).on("error", reject);
3927
+ };
3928
+ followRedirect(url);
3929
+ });
3930
+ }
3931
+ async function extractTgz(archivePath, destDir) {
3932
+ return tar.x({
3933
+ file: archivePath,
3934
+ cwd: destDir
3935
+ });
3936
+ }
3937
+ async function downloadCloudflared() {
3938
+ const url = getDownloadUrl();
3939
+ if (!url) {
3940
+ throw new Error(`Unsupported platform: ${os2.platform()} ${os2.arch()}`);
3941
+ }
3942
+ const binDir = getEpisodaBinDir();
3943
+ const cloudflaredPath = getCloudflaredPath();
3944
+ fs5.mkdirSync(binDir, { recursive: true });
3945
+ const isTgz = url.endsWith(".tgz");
3946
+ if (isTgz) {
3947
+ const tempFile = path7.join(binDir, "cloudflared.tgz");
3948
+ console.log(`[Tunnel] Downloading cloudflared from ${url}...`);
3949
+ await downloadFile(url, tempFile);
3950
+ console.log("[Tunnel] Extracting cloudflared...");
3951
+ await extractTgz(tempFile, binDir);
3952
+ fs5.unlinkSync(tempFile);
3953
+ } else {
3954
+ console.log(`[Tunnel] Downloading cloudflared from ${url}...`);
3955
+ await downloadFile(url, cloudflaredPath);
3956
+ }
3957
+ if (os2.platform() !== "win32") {
3958
+ fs5.chmodSync(cloudflaredPath, 493);
3959
+ }
3960
+ if (!verifyCloudflared(cloudflaredPath)) {
3961
+ throw new Error("Downloaded cloudflared binary failed verification");
3962
+ }
3963
+ console.log("[Tunnel] cloudflared installed successfully");
3964
+ return cloudflaredPath;
3965
+ }
3966
+ async function ensureCloudflared() {
3967
+ const pathBinary = isCloudflaredInPath();
3968
+ if (pathBinary && verifyCloudflared(pathBinary)) {
3969
+ return pathBinary;
3970
+ }
3971
+ const episodaBinary = getCloudflaredPath();
3972
+ if (isCloudflaredInstalled() && verifyCloudflared(episodaBinary)) {
3973
+ return episodaBinary;
3974
+ }
3975
+ return downloadCloudflared();
3976
+ }
3977
+
3978
+ // src/tunnel/tunnel-manager.ts
3979
+ var import_child_process6 = require("child_process");
3980
+ var import_events = require("events");
3981
+ var TUNNEL_URL_REGEX = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/i;
3982
+ var DEFAULT_RECONNECT_CONFIG = {
3983
+ maxRetries: 5,
3984
+ initialDelayMs: 1e3,
3985
+ maxDelayMs: 3e4,
3986
+ backoffMultiplier: 2
3987
+ };
3988
+ var TunnelManager = class extends import_events.EventEmitter {
3989
+ constructor(config) {
3990
+ super();
3991
+ this.tunnelStates = /* @__PURE__ */ new Map();
3992
+ this.cloudflaredPath = null;
3993
+ this.reconnectConfig = { ...DEFAULT_RECONNECT_CONFIG, ...config };
3994
+ }
3995
+ /**
3996
+ * Emit typed tunnel events
3997
+ */
3998
+ emitEvent(event) {
3999
+ this.emit("tunnel", event);
4000
+ }
4001
+ /**
4002
+ * Initialize the tunnel manager
4003
+ * Ensures cloudflared is available
4004
+ */
4005
+ async initialize() {
4006
+ this.cloudflaredPath = await ensureCloudflared();
4007
+ }
4008
+ /**
4009
+ * EP672-9: Calculate delay for exponential backoff
4010
+ */
4011
+ calculateBackoffDelay(retryCount) {
4012
+ const delay = this.reconnectConfig.initialDelayMs * Math.pow(this.reconnectConfig.backoffMultiplier, retryCount);
4013
+ return Math.min(delay, this.reconnectConfig.maxDelayMs);
4014
+ }
4015
+ /**
4016
+ * EP672-9: Attempt to reconnect a crashed tunnel
4017
+ */
4018
+ async attemptReconnect(moduleUid) {
4019
+ const state = this.tunnelStates.get(moduleUid);
4020
+ if (!state || state.intentionallyStopped) {
4021
+ return;
4022
+ }
4023
+ if (state.retryCount >= this.reconnectConfig.maxRetries) {
4024
+ console.log(`[Tunnel] Max retries (${this.reconnectConfig.maxRetries}) reached for ${moduleUid}, giving up`);
4025
+ this.emitEvent({
4026
+ type: "error",
4027
+ moduleUid,
4028
+ error: `Tunnel failed after ${this.reconnectConfig.maxRetries} reconnection attempts`
4029
+ });
4030
+ state.options.onStatusChange?.("error", "Max reconnection attempts reached");
4031
+ this.tunnelStates.delete(moduleUid);
4032
+ return;
4033
+ }
4034
+ const delay = this.calculateBackoffDelay(state.retryCount);
4035
+ console.log(`[Tunnel] Reconnecting ${moduleUid} in ${delay}ms (attempt ${state.retryCount + 1}/${this.reconnectConfig.maxRetries})`);
4036
+ this.emitEvent({ type: "reconnecting", moduleUid });
4037
+ state.options.onStatusChange?.("reconnecting");
4038
+ state.retryTimeoutId = setTimeout(async () => {
4039
+ state.retryCount++;
4040
+ const result = await this.startTunnelProcess(state.options, state);
4041
+ if (result.success) {
4042
+ console.log(`[Tunnel] Reconnected ${moduleUid} successfully with new URL: ${result.url}`);
4043
+ state.retryCount = 0;
4044
+ }
4045
+ }, delay);
4046
+ }
4047
+ /**
4048
+ * EP672-9: Internal method to start the tunnel process
4049
+ * Separated from startTunnel to support reconnection
4050
+ */
4051
+ async startTunnelProcess(options, existingState) {
4052
+ const { moduleUid, port = 3e3, onUrl, onStatusChange } = options;
4053
+ if (!this.cloudflaredPath) {
4054
+ try {
4055
+ this.cloudflaredPath = await ensureCloudflared();
4056
+ } catch (error) {
4057
+ const errorMessage = error instanceof Error ? error.message : String(error);
4058
+ return { success: false, error: `Failed to get cloudflared: ${errorMessage}` };
4059
+ }
4060
+ }
4061
+ return new Promise((resolve2) => {
4062
+ const tunnelInfo = {
4063
+ moduleUid,
4064
+ url: "",
4065
+ port,
4066
+ status: "starting",
4067
+ startedAt: /* @__PURE__ */ new Date(),
4068
+ process: null
4069
+ // Will be set below
4070
+ };
4071
+ const process2 = (0, import_child_process6.spawn)(this.cloudflaredPath, [
4072
+ "tunnel",
4073
+ "--url",
4074
+ `http://localhost:${port}`
4075
+ ], {
4076
+ stdio: ["ignore", "pipe", "pipe"]
4077
+ });
4078
+ tunnelInfo.process = process2;
4079
+ tunnelInfo.pid = process2.pid;
4080
+ const state = existingState || {
4081
+ info: tunnelInfo,
4082
+ options,
4083
+ intentionallyStopped: false,
4084
+ retryCount: 0,
4085
+ retryTimeoutId: null
4086
+ };
4087
+ state.info = tunnelInfo;
4088
+ this.tunnelStates.set(moduleUid, state);
4089
+ let urlFound = false;
4090
+ let stdoutBuffer = "";
4091
+ let stderrBuffer = "";
4092
+ const parseOutput = (data) => {
4093
+ if (urlFound) return;
4094
+ const match = data.match(TUNNEL_URL_REGEX);
4095
+ if (match) {
4096
+ urlFound = true;
4097
+ tunnelInfo.url = match[0];
4098
+ tunnelInfo.status = "connected";
4099
+ onStatusChange?.("connected");
4100
+ onUrl?.(tunnelInfo.url);
4101
+ this.emitEvent({
4102
+ type: "started",
4103
+ moduleUid,
4104
+ url: tunnelInfo.url
4105
+ });
4106
+ resolve2({ success: true, url: tunnelInfo.url });
4107
+ }
4108
+ };
4109
+ process2.stdout?.on("data", (data) => {
4110
+ stdoutBuffer += data.toString();
4111
+ parseOutput(stdoutBuffer);
4112
+ });
4113
+ process2.stderr?.on("data", (data) => {
4114
+ stderrBuffer += data.toString();
4115
+ parseOutput(stderrBuffer);
4116
+ });
4117
+ process2.on("exit", (code, signal) => {
4118
+ const wasConnected = tunnelInfo.status === "connected";
4119
+ tunnelInfo.status = "disconnected";
4120
+ const currentState = this.tunnelStates.get(moduleUid);
4121
+ if (!urlFound) {
4122
+ const errorMsg = `Tunnel process exited with code ${code}`;
4123
+ tunnelInfo.status = "error";
4124
+ tunnelInfo.error = errorMsg;
4125
+ if (currentState && !currentState.intentionallyStopped) {
4126
+ this.attemptReconnect(moduleUid);
4127
+ } else {
4128
+ this.tunnelStates.delete(moduleUid);
4129
+ onStatusChange?.("error", errorMsg);
4130
+ this.emitEvent({ type: "error", moduleUid, error: errorMsg });
4131
+ }
4132
+ resolve2({ success: false, error: errorMsg });
4133
+ } else if (wasConnected) {
4134
+ if (currentState && !currentState.intentionallyStopped) {
4135
+ console.log(`[Tunnel] ${moduleUid} crashed unexpectedly, attempting reconnect...`);
4136
+ onStatusChange?.("reconnecting");
4137
+ this.attemptReconnect(moduleUid);
4138
+ } else {
4139
+ this.tunnelStates.delete(moduleUid);
4140
+ onStatusChange?.("disconnected");
4141
+ this.emitEvent({ type: "stopped", moduleUid });
4142
+ }
4143
+ }
4144
+ });
4145
+ process2.on("error", (error) => {
4146
+ tunnelInfo.status = "error";
4147
+ tunnelInfo.error = error.message;
4148
+ const currentState = this.tunnelStates.get(moduleUid);
4149
+ if (currentState && !currentState.intentionallyStopped) {
4150
+ this.attemptReconnect(moduleUid);
4151
+ } else {
4152
+ this.tunnelStates.delete(moduleUid);
4153
+ onStatusChange?.("error", error.message);
4154
+ this.emitEvent({ type: "error", moduleUid, error: error.message });
4155
+ }
4156
+ if (!urlFound) {
4157
+ resolve2({ success: false, error: error.message });
4158
+ }
4159
+ });
4160
+ setTimeout(() => {
4161
+ if (!urlFound) {
4162
+ process2.kill();
4163
+ const errorMsg = "Tunnel startup timed out after 30 seconds";
4164
+ tunnelInfo.status = "error";
4165
+ tunnelInfo.error = errorMsg;
4166
+ const currentState = this.tunnelStates.get(moduleUid);
4167
+ if (currentState && !currentState.intentionallyStopped) {
4168
+ this.attemptReconnect(moduleUid);
4169
+ } else {
4170
+ this.tunnelStates.delete(moduleUid);
4171
+ onStatusChange?.("error", errorMsg);
4172
+ this.emitEvent({ type: "error", moduleUid, error: errorMsg });
4173
+ }
4174
+ resolve2({ success: false, error: errorMsg });
4175
+ }
4176
+ }, 3e4);
4177
+ });
4178
+ }
4179
+ /**
4180
+ * Start a tunnel for a module
4181
+ */
4182
+ async startTunnel(options) {
4183
+ const { moduleUid } = options;
4184
+ const existingState = this.tunnelStates.get(moduleUid);
4185
+ if (existingState) {
4186
+ if (existingState.info.status === "connected") {
4187
+ return { success: true, url: existingState.info.url };
4188
+ }
4189
+ await this.stopTunnel(moduleUid);
4190
+ }
4191
+ return this.startTunnelProcess(options);
4192
+ }
4193
+ /**
4194
+ * Stop a tunnel for a module
4195
+ */
4196
+ async stopTunnel(moduleUid) {
4197
+ const state = this.tunnelStates.get(moduleUid);
4198
+ if (!state) {
4199
+ return;
4200
+ }
4201
+ state.intentionallyStopped = true;
4202
+ if (state.retryTimeoutId) {
4203
+ clearTimeout(state.retryTimeoutId);
4204
+ state.retryTimeoutId = null;
4205
+ }
4206
+ const tunnel = state.info;
4207
+ if (tunnel.process && !tunnel.process.killed) {
4208
+ tunnel.process.kill("SIGTERM");
4209
+ await new Promise((resolve2) => {
4210
+ const timeout = setTimeout(() => {
4211
+ if (tunnel.process && !tunnel.process.killed) {
4212
+ tunnel.process.kill("SIGKILL");
4213
+ }
4214
+ resolve2();
4215
+ }, 3e3);
4216
+ tunnel.process.once("exit", () => {
4217
+ clearTimeout(timeout);
4218
+ resolve2();
4219
+ });
4220
+ });
4221
+ }
4222
+ this.tunnelStates.delete(moduleUid);
4223
+ this.emitEvent({ type: "stopped", moduleUid });
4224
+ }
4225
+ /**
4226
+ * Stop all active tunnels
4227
+ */
4228
+ async stopAllTunnels() {
4229
+ const moduleUids = Array.from(this.tunnelStates.keys());
4230
+ await Promise.all(moduleUids.map((uid) => this.stopTunnel(uid)));
4231
+ }
4232
+ /**
4233
+ * Get information about an active tunnel
4234
+ */
4235
+ getTunnel(moduleUid) {
4236
+ const state = this.tunnelStates.get(moduleUid);
4237
+ if (!state) return null;
4238
+ const { process: process2, ...info } = state.info;
4239
+ return info;
4240
+ }
4241
+ /**
4242
+ * Get all active tunnels
4243
+ */
4244
+ getAllTunnels() {
4245
+ return Array.from(this.tunnelStates.values()).map((state) => {
4246
+ const { process: process2, ...info } = state.info;
4247
+ return info;
4248
+ });
4249
+ }
4250
+ /**
4251
+ * Check if a tunnel is active for a module
4252
+ */
4253
+ hasTunnel(moduleUid) {
4254
+ return this.tunnelStates.has(moduleUid);
4255
+ }
4256
+ /**
4257
+ * Get the URL for an active tunnel
4258
+ */
4259
+ getTunnelUrl(moduleUid) {
4260
+ return this.tunnelStates.get(moduleUid)?.info.url || null;
4261
+ }
4262
+ };
4263
+ var tunnelManagerInstance = null;
4264
+ function getTunnelManager() {
4265
+ if (!tunnelManagerInstance) {
4266
+ tunnelManagerInstance = new TunnelManager();
4267
+ }
4268
+ return tunnelManagerInstance;
4269
+ }
4270
+
4271
+ // src/commands/tunnel.ts
4272
+ var https2 = __toESM(require("https"));
4273
+ async function checkTunnelHealth(url, timeoutMs = 5e3) {
4274
+ return new Promise((resolve2) => {
4275
+ try {
4276
+ const urlObj = new URL(url);
4277
+ const req = https2.request({
4278
+ hostname: urlObj.hostname,
4279
+ path: urlObj.pathname || "/",
4280
+ method: "HEAD",
4281
+ timeout: timeoutMs
4282
+ }, (res) => {
4283
+ resolve2({ reachable: true, statusCode: res.statusCode });
4284
+ });
4285
+ req.on("error", (err) => {
4286
+ resolve2({ reachable: false, error: err.message });
4287
+ });
4288
+ req.on("timeout", () => {
4289
+ req.destroy();
4290
+ resolve2({ reachable: false, error: "Connection timed out" });
4291
+ });
4292
+ req.end();
4293
+ } catch (err) {
4294
+ resolve2({ reachable: false, error: err instanceof Error ? err.message : "Invalid URL" });
4295
+ }
4296
+ });
4297
+ }
4298
+ async function tunnelStartCommand(options = {}) {
4299
+ try {
4300
+ const config = await (0, import_core7.loadConfig)();
4301
+ if (!config || !config.access_token || !config.project_id) {
4302
+ status.error("Not authenticated. Please run: episoda auth");
4303
+ process.exit(1);
4304
+ }
4305
+ const port = options.port || getServerPort();
4306
+ const moduleUid = options.moduleUid || "LOCAL";
4307
+ const serverRunning = await isPortInUse(port);
4308
+ if (!serverRunning) {
4309
+ status.warning(`No server detected on port ${port}`);
4310
+ status.info("Start your dev server first, or specify a different port with --port");
4311
+ process.exit(1);
4312
+ }
4313
+ status.info(`Starting tunnel for localhost:${port}...`);
4314
+ const tunnelManager = getTunnelManager();
4315
+ await tunnelManager.initialize();
4316
+ const result = await tunnelManager.startTunnel({
4317
+ moduleUid,
4318
+ port,
4319
+ onUrl: (url) => {
4320
+ status.success(`Tunnel started: ${url}`);
4321
+ },
4322
+ onStatusChange: (newStatus, error) => {
4323
+ if (newStatus === "error") {
4324
+ status.error(`Tunnel error: ${error}`);
4325
+ }
4326
+ }
4327
+ });
4328
+ if (!result.success) {
4329
+ status.error(`Failed to start tunnel: ${result.error}`);
4330
+ process.exit(1);
4331
+ }
4332
+ if (result.url && moduleUid !== "LOCAL") {
4333
+ try {
4334
+ const apiUrl = config.api_url || "https://episoda.dev";
4335
+ const response = await fetch(`${apiUrl}/api/modules/${moduleUid}/tunnel`, {
4336
+ method: "POST",
4337
+ headers: {
4338
+ "Authorization": `Bearer ${config.access_token}`,
4339
+ "Content-Type": "application/json"
4340
+ },
4341
+ body: JSON.stringify({ tunnel_url: result.url })
4342
+ });
4343
+ if (response.ok) {
4344
+ status.success(`Tunnel URL reported to Episoda for module ${moduleUid}`);
4345
+ } else {
4346
+ const data = await response.json().catch(() => ({}));
4347
+ status.warning(`Could not report tunnel URL: ${data.message || response.statusText}`);
4348
+ }
4349
+ } catch (error) {
4350
+ status.warning(`Could not report tunnel URL to platform: ${error instanceof Error ? error.message : String(error)}`);
4351
+ }
4352
+ }
4353
+ status.info("");
4354
+ status.info("Tunnel is running. Press Ctrl+C to stop.");
4355
+ status.info(`Preview URL: ${result.url}`);
4356
+ status.info("");
4357
+ await new Promise((resolve2) => {
4358
+ const cleanup = async () => {
4359
+ status.info("\nStopping tunnel...");
4360
+ await tunnelManager.stopTunnel(moduleUid);
4361
+ if (moduleUid !== "LOCAL") {
4362
+ try {
4363
+ const apiUrl = config.api_url || "https://episoda.dev";
4364
+ await fetch(`${apiUrl}/api/modules/${moduleUid}/tunnel`, {
4365
+ method: "DELETE",
4366
+ headers: {
4367
+ "Authorization": `Bearer ${config.access_token}`
4368
+ }
4369
+ });
4370
+ } catch {
4371
+ }
4372
+ }
4373
+ status.success("Tunnel stopped");
4374
+ resolve2();
4375
+ };
4376
+ process.on("SIGTERM", cleanup);
4377
+ process.on("SIGINT", cleanup);
4378
+ });
4379
+ } catch (error) {
4380
+ status.error(`Tunnel command failed: ${error instanceof Error ? error.message : String(error)}`);
4381
+ process.exit(1);
4382
+ }
4383
+ }
4384
+ async function tunnelStopCommand(options = {}) {
4385
+ try {
4386
+ const config = await (0, import_core7.loadConfig)();
4387
+ const moduleUid = options.moduleUid || "LOCAL";
4388
+ const tunnelManager = getTunnelManager();
4389
+ const tunnel = tunnelManager.getTunnel(moduleUid);
4390
+ if (!tunnel) {
4391
+ status.info("No active tunnel found");
4392
+ return;
4393
+ }
4394
+ status.info(`Stopping tunnel for ${moduleUid}...`);
4395
+ await tunnelManager.stopTunnel(moduleUid);
4396
+ if (config?.access_token && moduleUid !== "LOCAL") {
4397
+ try {
4398
+ const apiUrl = config.api_url || "https://episoda.dev";
4399
+ await fetch(`${apiUrl}/api/modules/${moduleUid}/tunnel`, {
4400
+ method: "DELETE",
4401
+ headers: {
4402
+ "Authorization": `Bearer ${config.access_token}`
4403
+ }
4404
+ });
4405
+ } catch {
4406
+ }
4407
+ }
4408
+ status.success("Tunnel stopped");
4409
+ } catch (error) {
4410
+ status.error(`Failed to stop tunnel: ${error instanceof Error ? error.message : String(error)}`);
4411
+ process.exit(1);
4412
+ }
4413
+ }
4414
+ async function tunnelStatusCommand() {
4415
+ try {
4416
+ const tunnelManager = getTunnelManager();
4417
+ const tunnels = tunnelManager.getAllTunnels();
4418
+ if (tunnels.length === 0) {
4419
+ status.info("No active tunnels");
4420
+ return;
4421
+ }
4422
+ status.info("Active tunnels:");
4423
+ for (const tunnel of tunnels) {
4424
+ status.info("");
4425
+ status.info(` Module: ${tunnel.moduleUid}`);
4426
+ status.info(` URL: ${tunnel.url}`);
4427
+ status.info(` Port: ${tunnel.port}`);
4428
+ status.info(` Status: ${tunnel.status}`);
4429
+ status.info(` Started: ${tunnel.startedAt.toLocaleString()}`);
4430
+ if (tunnel.url && tunnel.status === "connected") {
4431
+ status.info(" Health: Checking...");
4432
+ const health = await checkTunnelHealth(tunnel.url);
4433
+ if (health.reachable) {
4434
+ status.success(` Health: \u2713 Online (HTTP ${health.statusCode})`);
4435
+ } else {
4436
+ status.warning(` Health: \u2717 Unreachable (${health.error})`);
4437
+ }
4438
+ }
4439
+ }
4440
+ } catch (error) {
4441
+ status.error(`Failed to get tunnel status: ${error instanceof Error ? error.message : String(error)}`);
4442
+ process.exit(1);
4443
+ }
4444
+ }
4445
+
3820
4446
  // src/index.ts
3821
- import_commander.program.name("episoda").description("Episoda local development workflow orchestration").version(import_core7.VERSION);
4447
+ import_commander.program.name("episoda").description("Episoda local development workflow orchestration").version(import_core8.VERSION);
3822
4448
  import_commander.program.command("auth").description("Authenticate to Episoda via OAuth and configure CLI").option("--api-url <url>", "API URL (default: https://episoda.dev)").action(async (options) => {
3823
4449
  try {
3824
4450
  await authCommand(options);
@@ -3866,5 +4492,35 @@ import_commander.program.command("disconnect").description("Disconnect from epis
3866
4492
  process.exit(1);
3867
4493
  }
3868
4494
  });
4495
+ var tunnelCmd = import_commander.program.command("tunnel").description("Manage Cloudflare tunnels for local dev preview");
4496
+ tunnelCmd.command("start").description("Start a tunnel to expose local dev server").option("-p, --port <port>", "Local port to tunnel (default: 3000)", (val) => parseInt(val, 10)).option("-m, --module <uid>", "Module UID to associate with tunnel").action(async (options) => {
4497
+ try {
4498
+ await tunnelStartCommand({
4499
+ port: options.port,
4500
+ moduleUid: options.module
4501
+ });
4502
+ } catch (error) {
4503
+ status.error(`Tunnel start failed: ${error instanceof Error ? error.message : String(error)}`);
4504
+ process.exit(1);
4505
+ }
4506
+ });
4507
+ tunnelCmd.command("stop").description("Stop a running tunnel").option("-m, --module <uid>", "Module UID of tunnel to stop").action(async (options) => {
4508
+ try {
4509
+ await tunnelStopCommand({
4510
+ moduleUid: options.module
4511
+ });
4512
+ } catch (error) {
4513
+ status.error(`Tunnel stop failed: ${error instanceof Error ? error.message : String(error)}`);
4514
+ process.exit(1);
4515
+ }
4516
+ });
4517
+ tunnelCmd.command("status").description("Show active tunnels").action(async () => {
4518
+ try {
4519
+ await tunnelStatusCommand();
4520
+ } catch (error) {
4521
+ status.error(`Tunnel status failed: ${error instanceof Error ? error.message : String(error)}`);
4522
+ process.exit(1);
4523
+ }
4524
+ });
3869
4525
  import_commander.program.parse();
3870
4526
  //# sourceMappingURL=index.js.map