episoda 0.2.14 → 0.2.16

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.
@@ -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 fs6 = await Promise.resolve().then(() => __importStar(require("fs"))).then((m) => m.promises);
1492
+ const fs8 = 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 fs6.access(rebaseMergePath);
1496
+ await fs8.access(rebaseMergePath);
1497
1497
  inRebase = true;
1498
1498
  } catch {
1499
1499
  try {
1500
- await fs6.access(rebaseApplyPath);
1500
+ await fs8.access(rebaseApplyPath);
1501
1501
  inRebase = true;
1502
1502
  } catch {
1503
1503
  inRebase = false;
@@ -1718,6 +1718,18 @@ var require_websocket_client = __commonJS({
1718
1718
  this.isIntentionalDisconnect = false;
1719
1719
  this.lastConnectAttemptTime = Date.now();
1720
1720
  this.lastErrorCode = void 0;
1721
+ if (this.ws) {
1722
+ try {
1723
+ this.ws.removeAllListeners();
1724
+ this.ws.terminate();
1725
+ } catch {
1726
+ }
1727
+ this.ws = void 0;
1728
+ }
1729
+ if (this.reconnectTimeout) {
1730
+ clearTimeout(this.reconnectTimeout);
1731
+ this.reconnectTimeout = void 0;
1732
+ }
1721
1733
  return new Promise((resolve2, reject) => {
1722
1734
  const connectionTimeout = setTimeout(() => {
1723
1735
  if (this.ws) {
@@ -1946,6 +1958,10 @@ var require_websocket_client = __commonJS({
1946
1958
  console.log("[EpisodaClient] Intentional disconnect - not reconnecting");
1947
1959
  return;
1948
1960
  }
1961
+ if (this.reconnectTimeout) {
1962
+ console.log("[EpisodaClient] Reconnection already scheduled, skipping duplicate");
1963
+ return;
1964
+ }
1949
1965
  if (this.heartbeatTimer) {
1950
1966
  clearInterval(this.heartbeatTimer);
1951
1967
  this.heartbeatTimer = void 0;
@@ -2102,31 +2118,31 @@ var require_auth = __commonJS({
2102
2118
  exports2.loadConfig = loadConfig2;
2103
2119
  exports2.saveConfig = saveConfig2;
2104
2120
  exports2.validateToken = validateToken;
2105
- var fs6 = __importStar(require("fs"));
2106
- var path7 = __importStar(require("path"));
2107
- var os2 = __importStar(require("os"));
2121
+ var fs8 = __importStar(require("fs"));
2122
+ var path9 = __importStar(require("path"));
2123
+ var os3 = __importStar(require("os"));
2108
2124
  var child_process_1 = require("child_process");
2109
2125
  var DEFAULT_CONFIG_FILE = "config.json";
2110
2126
  function getConfigDir5() {
2111
- return process.env.EPISODA_CONFIG_DIR || path7.join(os2.homedir(), ".episoda");
2127
+ return process.env.EPISODA_CONFIG_DIR || path9.join(os3.homedir(), ".episoda");
2112
2128
  }
2113
2129
  function getConfigPath(configPath) {
2114
2130
  if (configPath) {
2115
2131
  return configPath;
2116
2132
  }
2117
- return path7.join(getConfigDir5(), DEFAULT_CONFIG_FILE);
2133
+ return path9.join(getConfigDir5(), DEFAULT_CONFIG_FILE);
2118
2134
  }
2119
2135
  function ensureConfigDir(configPath) {
2120
- const dir = path7.dirname(configPath);
2121
- const isNew = !fs6.existsSync(dir);
2136
+ const dir = path9.dirname(configPath);
2137
+ const isNew = !fs8.existsSync(dir);
2122
2138
  if (isNew) {
2123
- fs6.mkdirSync(dir, { recursive: true, mode: 448 });
2139
+ fs8.mkdirSync(dir, { recursive: true, mode: 448 });
2124
2140
  }
2125
2141
  if (process.platform === "darwin") {
2126
- const nosyncPath = path7.join(dir, ".nosync");
2127
- if (isNew || !fs6.existsSync(nosyncPath)) {
2142
+ const nosyncPath = path9.join(dir, ".nosync");
2143
+ if (isNew || !fs8.existsSync(nosyncPath)) {
2128
2144
  try {
2129
- fs6.writeFileSync(nosyncPath, "", { mode: 384 });
2145
+ fs8.writeFileSync(nosyncPath, "", { mode: 384 });
2130
2146
  (0, child_process_1.execSync)(`xattr -w com.apple.fileprovider.ignore 1 "${dir}"`, {
2131
2147
  stdio: "ignore",
2132
2148
  timeout: 5e3
@@ -2138,11 +2154,11 @@ var require_auth = __commonJS({
2138
2154
  }
2139
2155
  async function loadConfig2(configPath) {
2140
2156
  const fullPath = getConfigPath(configPath);
2141
- if (!fs6.existsSync(fullPath)) {
2157
+ if (!fs8.existsSync(fullPath)) {
2142
2158
  return null;
2143
2159
  }
2144
2160
  try {
2145
- const content = fs6.readFileSync(fullPath, "utf8");
2161
+ const content = fs8.readFileSync(fullPath, "utf8");
2146
2162
  const config = JSON.parse(content);
2147
2163
  return config;
2148
2164
  } catch (error) {
@@ -2155,7 +2171,7 @@ var require_auth = __commonJS({
2155
2171
  ensureConfigDir(fullPath);
2156
2172
  try {
2157
2173
  const content = JSON.stringify(config, null, 2);
2158
- fs6.writeFileSync(fullPath, content, { mode: 384 });
2174
+ fs8.writeFileSync(fullPath, content, { mode: 384 });
2159
2175
  } catch (error) {
2160
2176
  throw new Error(`Failed to save config: ${error instanceof Error ? error.message : String(error)}`);
2161
2177
  }
@@ -2264,7 +2280,7 @@ var require_package = __commonJS({
2264
2280
  "package.json"(exports2, module2) {
2265
2281
  module2.exports = {
2266
2282
  name: "episoda",
2267
- version: "0.2.13",
2283
+ version: "0.2.15",
2268
2284
  description: "CLI tool for Episoda local development workflow orchestration",
2269
2285
  main: "dist/index.js",
2270
2286
  types: "dist/index.d.ts",
@@ -2291,6 +2307,7 @@ var require_package = __commonJS({
2291
2307
  commander: "^11.1.0",
2292
2308
  ora: "^5.4.1",
2293
2309
  semver: "7.7.3",
2310
+ tar: "7.5.2",
2294
2311
  ws: "^8.18.0",
2295
2312
  zod: "^4.0.10"
2296
2313
  },
@@ -2298,6 +2315,7 @@ var require_package = __commonJS({
2298
2315
  "@episoda/core": "*",
2299
2316
  "@types/node": "^20.11.24",
2300
2317
  "@types/semver": "7.7.1",
2318
+ "@types/tar": "6.1.13",
2301
2319
  "@types/ws": "^8.5.10",
2302
2320
  tsup: "8.5.1",
2303
2321
  typescript: "^5.3.3"
@@ -3136,10 +3154,643 @@ async function handleExec(command, projectPath) {
3136
3154
  });
3137
3155
  }
3138
3156
 
3139
- // src/daemon/daemon-process.ts
3157
+ // src/tunnel/cloudflared-manager.ts
3158
+ var import_child_process4 = require("child_process");
3140
3159
  var fs5 = __toESM(require("fs"));
3141
- var os = __toESM(require("os"));
3142
3160
  var path6 = __toESM(require("path"));
3161
+ var os = __toESM(require("os"));
3162
+ var https = __toESM(require("https"));
3163
+ var tar = __toESM(require("tar"));
3164
+ var DOWNLOAD_URLS = {
3165
+ darwin: {
3166
+ arm64: "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-darwin-arm64.tgz",
3167
+ x64: "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-darwin-amd64.tgz"
3168
+ },
3169
+ linux: {
3170
+ arm64: "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-arm64",
3171
+ x64: "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64"
3172
+ },
3173
+ win32: {
3174
+ x64: "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-windows-amd64.exe",
3175
+ ia32: "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-windows-386.exe"
3176
+ }
3177
+ };
3178
+ function getEpisodaBinDir() {
3179
+ return path6.join(os.homedir(), ".episoda", "bin");
3180
+ }
3181
+ function getCloudflaredPath() {
3182
+ const binaryName = os.platform() === "win32" ? "cloudflared.exe" : "cloudflared";
3183
+ return path6.join(getEpisodaBinDir(), binaryName);
3184
+ }
3185
+ function isCloudflaredInPath() {
3186
+ try {
3187
+ const command = os.platform() === "win32" ? "where" : "which";
3188
+ const binaryName = os.platform() === "win32" ? "cloudflared.exe" : "cloudflared";
3189
+ const result = (0, import_child_process4.spawnSync)(command, [binaryName], { encoding: "utf-8" });
3190
+ if (result.status === 0 && result.stdout.trim()) {
3191
+ return result.stdout.trim().split("\n")[0].trim();
3192
+ }
3193
+ } catch {
3194
+ }
3195
+ return null;
3196
+ }
3197
+ function isCloudflaredInstalled() {
3198
+ const cloudflaredPath = getCloudflaredPath();
3199
+ try {
3200
+ fs5.accessSync(cloudflaredPath, fs5.constants.X_OK);
3201
+ return true;
3202
+ } catch {
3203
+ return false;
3204
+ }
3205
+ }
3206
+ function verifyCloudflared(binaryPath) {
3207
+ try {
3208
+ const result = (0, import_child_process4.spawnSync)(binaryPath, ["version"], { encoding: "utf-8", timeout: 5e3 });
3209
+ return result.status === 0 && result.stdout.includes("cloudflared");
3210
+ } catch {
3211
+ return false;
3212
+ }
3213
+ }
3214
+ function getDownloadUrl() {
3215
+ const platform3 = os.platform();
3216
+ const arch3 = os.arch();
3217
+ const platformUrls = DOWNLOAD_URLS[platform3];
3218
+ if (!platformUrls) {
3219
+ return null;
3220
+ }
3221
+ return platformUrls[arch3] || null;
3222
+ }
3223
+ async function downloadFile(url, destPath) {
3224
+ return new Promise((resolve2, reject) => {
3225
+ const followRedirect = (currentUrl, redirectCount = 0) => {
3226
+ if (redirectCount > 5) {
3227
+ reject(new Error("Too many redirects"));
3228
+ return;
3229
+ }
3230
+ const urlObj = new URL(currentUrl);
3231
+ const options = {
3232
+ hostname: urlObj.hostname,
3233
+ path: urlObj.pathname + urlObj.search,
3234
+ headers: {
3235
+ "User-Agent": "episoda-cli"
3236
+ }
3237
+ };
3238
+ https.get(options, (response) => {
3239
+ if (response.statusCode === 301 || response.statusCode === 302) {
3240
+ const redirectUrl = response.headers.location;
3241
+ if (redirectUrl) {
3242
+ followRedirect(redirectUrl, redirectCount + 1);
3243
+ return;
3244
+ }
3245
+ }
3246
+ if (response.statusCode !== 200) {
3247
+ reject(new Error(`Failed to download: HTTP ${response.statusCode}`));
3248
+ return;
3249
+ }
3250
+ const file = fs5.createWriteStream(destPath);
3251
+ response.pipe(file);
3252
+ file.on("finish", () => {
3253
+ file.close();
3254
+ resolve2();
3255
+ });
3256
+ file.on("error", (err) => {
3257
+ fs5.unlinkSync(destPath);
3258
+ reject(err);
3259
+ });
3260
+ }).on("error", reject);
3261
+ };
3262
+ followRedirect(url);
3263
+ });
3264
+ }
3265
+ async function extractTgz(archivePath, destDir) {
3266
+ return tar.x({
3267
+ file: archivePath,
3268
+ cwd: destDir
3269
+ });
3270
+ }
3271
+ async function downloadCloudflared() {
3272
+ const url = getDownloadUrl();
3273
+ if (!url) {
3274
+ throw new Error(`Unsupported platform: ${os.platform()} ${os.arch()}`);
3275
+ }
3276
+ const binDir = getEpisodaBinDir();
3277
+ const cloudflaredPath = getCloudflaredPath();
3278
+ fs5.mkdirSync(binDir, { recursive: true });
3279
+ const isTgz = url.endsWith(".tgz");
3280
+ if (isTgz) {
3281
+ const tempFile = path6.join(binDir, "cloudflared.tgz");
3282
+ console.log(`[Tunnel] Downloading cloudflared from ${url}...`);
3283
+ await downloadFile(url, tempFile);
3284
+ console.log("[Tunnel] Extracting cloudflared...");
3285
+ await extractTgz(tempFile, binDir);
3286
+ fs5.unlinkSync(tempFile);
3287
+ } else {
3288
+ console.log(`[Tunnel] Downloading cloudflared from ${url}...`);
3289
+ await downloadFile(url, cloudflaredPath);
3290
+ }
3291
+ if (os.platform() !== "win32") {
3292
+ fs5.chmodSync(cloudflaredPath, 493);
3293
+ }
3294
+ if (!verifyCloudflared(cloudflaredPath)) {
3295
+ throw new Error("Downloaded cloudflared binary failed verification");
3296
+ }
3297
+ console.log("[Tunnel] cloudflared installed successfully");
3298
+ return cloudflaredPath;
3299
+ }
3300
+ async function ensureCloudflared() {
3301
+ const pathBinary = isCloudflaredInPath();
3302
+ if (pathBinary && verifyCloudflared(pathBinary)) {
3303
+ return pathBinary;
3304
+ }
3305
+ const episodaBinary = getCloudflaredPath();
3306
+ if (isCloudflaredInstalled() && verifyCloudflared(episodaBinary)) {
3307
+ return episodaBinary;
3308
+ }
3309
+ return downloadCloudflared();
3310
+ }
3311
+
3312
+ // src/tunnel/tunnel-manager.ts
3313
+ var import_child_process5 = require("child_process");
3314
+ var import_events = require("events");
3315
+ var TUNNEL_URL_REGEX = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/i;
3316
+ var DEFAULT_RECONNECT_CONFIG = {
3317
+ maxRetries: 5,
3318
+ initialDelayMs: 1e3,
3319
+ maxDelayMs: 3e4,
3320
+ backoffMultiplier: 2
3321
+ };
3322
+ var TunnelManager = class extends import_events.EventEmitter {
3323
+ constructor(config) {
3324
+ super();
3325
+ this.tunnelStates = /* @__PURE__ */ new Map();
3326
+ this.cloudflaredPath = null;
3327
+ this.reconnectConfig = { ...DEFAULT_RECONNECT_CONFIG, ...config };
3328
+ }
3329
+ /**
3330
+ * Emit typed tunnel events
3331
+ */
3332
+ emitEvent(event) {
3333
+ this.emit("tunnel", event);
3334
+ }
3335
+ /**
3336
+ * Initialize the tunnel manager
3337
+ * Ensures cloudflared is available
3338
+ */
3339
+ async initialize() {
3340
+ this.cloudflaredPath = await ensureCloudflared();
3341
+ }
3342
+ /**
3343
+ * EP672-9: Calculate delay for exponential backoff
3344
+ */
3345
+ calculateBackoffDelay(retryCount) {
3346
+ const delay = this.reconnectConfig.initialDelayMs * Math.pow(this.reconnectConfig.backoffMultiplier, retryCount);
3347
+ return Math.min(delay, this.reconnectConfig.maxDelayMs);
3348
+ }
3349
+ /**
3350
+ * EP672-9: Attempt to reconnect a crashed tunnel
3351
+ */
3352
+ async attemptReconnect(moduleUid) {
3353
+ const state = this.tunnelStates.get(moduleUid);
3354
+ if (!state || state.intentionallyStopped) {
3355
+ return;
3356
+ }
3357
+ if (state.retryCount >= this.reconnectConfig.maxRetries) {
3358
+ console.log(`[Tunnel] Max retries (${this.reconnectConfig.maxRetries}) reached for ${moduleUid}, giving up`);
3359
+ this.emitEvent({
3360
+ type: "error",
3361
+ moduleUid,
3362
+ error: `Tunnel failed after ${this.reconnectConfig.maxRetries} reconnection attempts`
3363
+ });
3364
+ state.options.onStatusChange?.("error", "Max reconnection attempts reached");
3365
+ this.tunnelStates.delete(moduleUid);
3366
+ return;
3367
+ }
3368
+ const delay = this.calculateBackoffDelay(state.retryCount);
3369
+ console.log(`[Tunnel] Reconnecting ${moduleUid} in ${delay}ms (attempt ${state.retryCount + 1}/${this.reconnectConfig.maxRetries})`);
3370
+ this.emitEvent({ type: "reconnecting", moduleUid });
3371
+ state.options.onStatusChange?.("reconnecting");
3372
+ state.retryTimeoutId = setTimeout(async () => {
3373
+ state.retryCount++;
3374
+ const result = await this.startTunnelProcess(state.options, state);
3375
+ if (result.success) {
3376
+ console.log(`[Tunnel] Reconnected ${moduleUid} successfully with new URL: ${result.url}`);
3377
+ state.retryCount = 0;
3378
+ }
3379
+ }, delay);
3380
+ }
3381
+ /**
3382
+ * EP672-9: Internal method to start the tunnel process
3383
+ * Separated from startTunnel to support reconnection
3384
+ */
3385
+ async startTunnelProcess(options, existingState) {
3386
+ const { moduleUid, port = 3e3, onUrl, onStatusChange } = options;
3387
+ if (!this.cloudflaredPath) {
3388
+ try {
3389
+ this.cloudflaredPath = await ensureCloudflared();
3390
+ } catch (error) {
3391
+ const errorMessage = error instanceof Error ? error.message : String(error);
3392
+ return { success: false, error: `Failed to get cloudflared: ${errorMessage}` };
3393
+ }
3394
+ }
3395
+ return new Promise((resolve2) => {
3396
+ const tunnelInfo = {
3397
+ moduleUid,
3398
+ url: "",
3399
+ port,
3400
+ status: "starting",
3401
+ startedAt: /* @__PURE__ */ new Date(),
3402
+ process: null
3403
+ // Will be set below
3404
+ };
3405
+ const process2 = (0, import_child_process5.spawn)(this.cloudflaredPath, [
3406
+ "tunnel",
3407
+ "--url",
3408
+ `http://localhost:${port}`
3409
+ ], {
3410
+ stdio: ["ignore", "pipe", "pipe"]
3411
+ });
3412
+ tunnelInfo.process = process2;
3413
+ tunnelInfo.pid = process2.pid;
3414
+ const state = existingState || {
3415
+ info: tunnelInfo,
3416
+ options,
3417
+ intentionallyStopped: false,
3418
+ retryCount: 0,
3419
+ retryTimeoutId: null
3420
+ };
3421
+ state.info = tunnelInfo;
3422
+ this.tunnelStates.set(moduleUid, state);
3423
+ let urlFound = false;
3424
+ let stdoutBuffer = "";
3425
+ let stderrBuffer = "";
3426
+ const parseOutput = (data) => {
3427
+ if (urlFound) return;
3428
+ const match = data.match(TUNNEL_URL_REGEX);
3429
+ if (match) {
3430
+ urlFound = true;
3431
+ tunnelInfo.url = match[0];
3432
+ tunnelInfo.status = "connected";
3433
+ onStatusChange?.("connected");
3434
+ onUrl?.(tunnelInfo.url);
3435
+ this.emitEvent({
3436
+ type: "started",
3437
+ moduleUid,
3438
+ url: tunnelInfo.url
3439
+ });
3440
+ resolve2({ success: true, url: tunnelInfo.url });
3441
+ }
3442
+ };
3443
+ process2.stdout?.on("data", (data) => {
3444
+ stdoutBuffer += data.toString();
3445
+ parseOutput(stdoutBuffer);
3446
+ });
3447
+ process2.stderr?.on("data", (data) => {
3448
+ stderrBuffer += data.toString();
3449
+ parseOutput(stderrBuffer);
3450
+ });
3451
+ process2.on("exit", (code, signal) => {
3452
+ const wasConnected = tunnelInfo.status === "connected";
3453
+ tunnelInfo.status = "disconnected";
3454
+ const currentState = this.tunnelStates.get(moduleUid);
3455
+ if (!urlFound) {
3456
+ const errorMsg = `Tunnel process exited with code ${code}`;
3457
+ tunnelInfo.status = "error";
3458
+ tunnelInfo.error = errorMsg;
3459
+ if (currentState && !currentState.intentionallyStopped) {
3460
+ this.attemptReconnect(moduleUid);
3461
+ } else {
3462
+ this.tunnelStates.delete(moduleUid);
3463
+ onStatusChange?.("error", errorMsg);
3464
+ this.emitEvent({ type: "error", moduleUid, error: errorMsg });
3465
+ }
3466
+ resolve2({ success: false, error: errorMsg });
3467
+ } else if (wasConnected) {
3468
+ if (currentState && !currentState.intentionallyStopped) {
3469
+ console.log(`[Tunnel] ${moduleUid} crashed unexpectedly, attempting reconnect...`);
3470
+ onStatusChange?.("reconnecting");
3471
+ this.attemptReconnect(moduleUid);
3472
+ } else {
3473
+ this.tunnelStates.delete(moduleUid);
3474
+ onStatusChange?.("disconnected");
3475
+ this.emitEvent({ type: "stopped", moduleUid });
3476
+ }
3477
+ }
3478
+ });
3479
+ process2.on("error", (error) => {
3480
+ tunnelInfo.status = "error";
3481
+ tunnelInfo.error = error.message;
3482
+ const currentState = this.tunnelStates.get(moduleUid);
3483
+ if (currentState && !currentState.intentionallyStopped) {
3484
+ this.attemptReconnect(moduleUid);
3485
+ } else {
3486
+ this.tunnelStates.delete(moduleUid);
3487
+ onStatusChange?.("error", error.message);
3488
+ this.emitEvent({ type: "error", moduleUid, error: error.message });
3489
+ }
3490
+ if (!urlFound) {
3491
+ resolve2({ success: false, error: error.message });
3492
+ }
3493
+ });
3494
+ setTimeout(() => {
3495
+ if (!urlFound) {
3496
+ process2.kill();
3497
+ const errorMsg = "Tunnel startup timed out after 30 seconds";
3498
+ tunnelInfo.status = "error";
3499
+ tunnelInfo.error = errorMsg;
3500
+ const currentState = this.tunnelStates.get(moduleUid);
3501
+ if (currentState && !currentState.intentionallyStopped) {
3502
+ this.attemptReconnect(moduleUid);
3503
+ } else {
3504
+ this.tunnelStates.delete(moduleUid);
3505
+ onStatusChange?.("error", errorMsg);
3506
+ this.emitEvent({ type: "error", moduleUid, error: errorMsg });
3507
+ }
3508
+ resolve2({ success: false, error: errorMsg });
3509
+ }
3510
+ }, 3e4);
3511
+ });
3512
+ }
3513
+ /**
3514
+ * Start a tunnel for a module
3515
+ */
3516
+ async startTunnel(options) {
3517
+ const { moduleUid } = options;
3518
+ const existingState = this.tunnelStates.get(moduleUid);
3519
+ if (existingState) {
3520
+ if (existingState.info.status === "connected") {
3521
+ return { success: true, url: existingState.info.url };
3522
+ }
3523
+ await this.stopTunnel(moduleUid);
3524
+ }
3525
+ return this.startTunnelProcess(options);
3526
+ }
3527
+ /**
3528
+ * Stop a tunnel for a module
3529
+ */
3530
+ async stopTunnel(moduleUid) {
3531
+ const state = this.tunnelStates.get(moduleUid);
3532
+ if (!state) {
3533
+ return;
3534
+ }
3535
+ state.intentionallyStopped = true;
3536
+ if (state.retryTimeoutId) {
3537
+ clearTimeout(state.retryTimeoutId);
3538
+ state.retryTimeoutId = null;
3539
+ }
3540
+ const tunnel = state.info;
3541
+ if (tunnel.process && !tunnel.process.killed) {
3542
+ tunnel.process.kill("SIGTERM");
3543
+ await new Promise((resolve2) => {
3544
+ const timeout = setTimeout(() => {
3545
+ if (tunnel.process && !tunnel.process.killed) {
3546
+ tunnel.process.kill("SIGKILL");
3547
+ }
3548
+ resolve2();
3549
+ }, 3e3);
3550
+ tunnel.process.once("exit", () => {
3551
+ clearTimeout(timeout);
3552
+ resolve2();
3553
+ });
3554
+ });
3555
+ }
3556
+ this.tunnelStates.delete(moduleUid);
3557
+ this.emitEvent({ type: "stopped", moduleUid });
3558
+ }
3559
+ /**
3560
+ * Stop all active tunnels
3561
+ */
3562
+ async stopAllTunnels() {
3563
+ const moduleUids = Array.from(this.tunnelStates.keys());
3564
+ await Promise.all(moduleUids.map((uid) => this.stopTunnel(uid)));
3565
+ }
3566
+ /**
3567
+ * Get information about an active tunnel
3568
+ */
3569
+ getTunnel(moduleUid) {
3570
+ const state = this.tunnelStates.get(moduleUid);
3571
+ if (!state) return null;
3572
+ const { process: process2, ...info } = state.info;
3573
+ return info;
3574
+ }
3575
+ /**
3576
+ * Get all active tunnels
3577
+ */
3578
+ getAllTunnels() {
3579
+ return Array.from(this.tunnelStates.values()).map((state) => {
3580
+ const { process: process2, ...info } = state.info;
3581
+ return info;
3582
+ });
3583
+ }
3584
+ /**
3585
+ * Check if a tunnel is active for a module
3586
+ */
3587
+ hasTunnel(moduleUid) {
3588
+ return this.tunnelStates.has(moduleUid);
3589
+ }
3590
+ /**
3591
+ * Get the URL for an active tunnel
3592
+ */
3593
+ getTunnelUrl(moduleUid) {
3594
+ return this.tunnelStates.get(moduleUid)?.info.url || null;
3595
+ }
3596
+ };
3597
+ var tunnelManagerInstance = null;
3598
+ function getTunnelManager() {
3599
+ if (!tunnelManagerInstance) {
3600
+ tunnelManagerInstance = new TunnelManager();
3601
+ }
3602
+ return tunnelManagerInstance;
3603
+ }
3604
+
3605
+ // src/utils/dev-server.ts
3606
+ var import_child_process6 = require("child_process");
3607
+
3608
+ // src/utils/port-check.ts
3609
+ var net2 = __toESM(require("net"));
3610
+ async function isPortInUse(port) {
3611
+ return new Promise((resolve2) => {
3612
+ const server = net2.createServer();
3613
+ server.once("error", (err) => {
3614
+ if (err.code === "EADDRINUSE") {
3615
+ resolve2(true);
3616
+ } else {
3617
+ resolve2(false);
3618
+ }
3619
+ });
3620
+ server.once("listening", () => {
3621
+ server.close();
3622
+ resolve2(false);
3623
+ });
3624
+ server.listen(port);
3625
+ });
3626
+ }
3627
+
3628
+ // src/utils/dev-server.ts
3629
+ var activeServers = /* @__PURE__ */ new Map();
3630
+ async function waitForPort(port, timeoutMs = 3e4) {
3631
+ const startTime = Date.now();
3632
+ const checkInterval = 500;
3633
+ while (Date.now() - startTime < timeoutMs) {
3634
+ if (await isPortInUse(port)) {
3635
+ return true;
3636
+ }
3637
+ await new Promise((resolve2) => setTimeout(resolve2, checkInterval));
3638
+ }
3639
+ return false;
3640
+ }
3641
+ async function startDevServer(projectPath, port = 3e3, moduleUid = "default") {
3642
+ if (await isPortInUse(port)) {
3643
+ console.log(`[DevServer] Server already running on port ${port}`);
3644
+ return { success: true, alreadyRunning: true };
3645
+ }
3646
+ if (activeServers.has(moduleUid)) {
3647
+ const existing = activeServers.get(moduleUid);
3648
+ if (existing && !existing.killed) {
3649
+ console.log(`[DevServer] Process already exists for ${moduleUid}`);
3650
+ return { success: true, alreadyRunning: true };
3651
+ }
3652
+ }
3653
+ console.log(`[DevServer] Starting dev server for ${moduleUid} on port ${port}...`);
3654
+ try {
3655
+ const devProcess = (0, import_child_process6.spawn)("npm", ["run", "dev"], {
3656
+ cwd: projectPath,
3657
+ env: {
3658
+ ...process.env,
3659
+ PORT: String(port)
3660
+ },
3661
+ stdio: ["ignore", "pipe", "pipe"],
3662
+ detached: false
3663
+ });
3664
+ activeServers.set(moduleUid, devProcess);
3665
+ devProcess.stdout?.on("data", (data) => {
3666
+ const line = data.toString().trim();
3667
+ if (line) {
3668
+ console.log(`[DevServer:${moduleUid}] ${line}`);
3669
+ }
3670
+ });
3671
+ devProcess.stderr?.on("data", (data) => {
3672
+ const line = data.toString().trim();
3673
+ if (line) {
3674
+ console.error(`[DevServer:${moduleUid}] ${line}`);
3675
+ }
3676
+ });
3677
+ devProcess.on("exit", (code, signal) => {
3678
+ console.log(`[DevServer] Process for ${moduleUid} exited with code ${code}, signal ${signal}`);
3679
+ activeServers.delete(moduleUid);
3680
+ });
3681
+ devProcess.on("error", (error) => {
3682
+ console.error(`[DevServer] Process error for ${moduleUid}:`, error);
3683
+ activeServers.delete(moduleUid);
3684
+ });
3685
+ console.log(`[DevServer] Waiting for server to start on port ${port}...`);
3686
+ const serverReady = await waitForPort(port, 6e4);
3687
+ if (!serverReady) {
3688
+ devProcess.kill();
3689
+ activeServers.delete(moduleUid);
3690
+ return { success: false, error: "Dev server failed to start within timeout" };
3691
+ }
3692
+ console.log(`[DevServer] Server started successfully on port ${port}`);
3693
+ return { success: true };
3694
+ } catch (error) {
3695
+ const errorMsg = error instanceof Error ? error.message : String(error);
3696
+ console.error(`[DevServer] Failed to start:`, error);
3697
+ return { success: false, error: errorMsg };
3698
+ }
3699
+ }
3700
+ async function stopDevServer(moduleUid) {
3701
+ const process2 = activeServers.get(moduleUid);
3702
+ if (process2 && !process2.killed) {
3703
+ console.log(`[DevServer] Stopping server for ${moduleUid}`);
3704
+ process2.kill("SIGTERM");
3705
+ await new Promise((resolve2) => setTimeout(resolve2, 2e3));
3706
+ if (!process2.killed) {
3707
+ process2.kill("SIGKILL");
3708
+ }
3709
+ activeServers.delete(moduleUid);
3710
+ }
3711
+ }
3712
+ async function ensureDevServer(projectPath, port = 3e3, moduleUid = "default") {
3713
+ if (await isPortInUse(port)) {
3714
+ return { success: true };
3715
+ }
3716
+ return startDevServer(projectPath, port, moduleUid);
3717
+ }
3718
+
3719
+ // src/utils/port-detect.ts
3720
+ var fs6 = __toESM(require("fs"));
3721
+ var path7 = __toESM(require("path"));
3722
+ var DEFAULT_PORT = 3e3;
3723
+ function detectDevPort(projectPath) {
3724
+ const envPort = getPortFromEnv(projectPath);
3725
+ if (envPort) {
3726
+ console.log(`[PortDetect] Found PORT=${envPort} in .env`);
3727
+ return envPort;
3728
+ }
3729
+ const scriptPort = getPortFromPackageJson(projectPath);
3730
+ if (scriptPort) {
3731
+ console.log(`[PortDetect] Found port ${scriptPort} in package.json dev script`);
3732
+ return scriptPort;
3733
+ }
3734
+ console.log(`[PortDetect] Using default port ${DEFAULT_PORT}`);
3735
+ return DEFAULT_PORT;
3736
+ }
3737
+ function getPortFromEnv(projectPath) {
3738
+ const envPaths = [
3739
+ path7.join(projectPath, ".env"),
3740
+ path7.join(projectPath, ".env.local"),
3741
+ path7.join(projectPath, ".env.development"),
3742
+ path7.join(projectPath, ".env.development.local")
3743
+ ];
3744
+ for (const envPath of envPaths) {
3745
+ try {
3746
+ if (!fs6.existsSync(envPath)) continue;
3747
+ const content = fs6.readFileSync(envPath, "utf-8");
3748
+ const lines = content.split("\n");
3749
+ for (const line of lines) {
3750
+ const match = line.match(/^\s*PORT\s*=\s*["']?(\d+)["']?\s*(?:#.*)?$/);
3751
+ if (match) {
3752
+ const port = parseInt(match[1], 10);
3753
+ if (port > 0 && port < 65536) {
3754
+ return port;
3755
+ }
3756
+ }
3757
+ }
3758
+ } catch {
3759
+ }
3760
+ }
3761
+ return null;
3762
+ }
3763
+ function getPortFromPackageJson(projectPath) {
3764
+ const packageJsonPath = path7.join(projectPath, "package.json");
3765
+ try {
3766
+ if (!fs6.existsSync(packageJsonPath)) return null;
3767
+ const content = fs6.readFileSync(packageJsonPath, "utf-8");
3768
+ const pkg = JSON.parse(content);
3769
+ const devScript = pkg.scripts?.dev;
3770
+ if (!devScript) return null;
3771
+ const portMatch = devScript.match(/(?:--port[=\s]|-p[=\s])(\d+)/);
3772
+ if (portMatch) {
3773
+ const port = parseInt(portMatch[1], 10);
3774
+ if (port > 0 && port < 65536) {
3775
+ return port;
3776
+ }
3777
+ }
3778
+ const envMatch = devScript.match(/PORT[=\s](\d+)/);
3779
+ if (envMatch) {
3780
+ const port = parseInt(envMatch[1], 10);
3781
+ if (port > 0 && port < 65536) {
3782
+ return port;
3783
+ }
3784
+ }
3785
+ } catch {
3786
+ }
3787
+ return null;
3788
+ }
3789
+
3790
+ // src/daemon/daemon-process.ts
3791
+ var fs7 = __toESM(require("fs"));
3792
+ var os2 = __toESM(require("os"));
3793
+ var path8 = __toESM(require("path"));
3143
3794
  var packageJson = require_package();
3144
3795
  var Daemon = class {
3145
3796
  constructor() {
@@ -3156,6 +3807,10 @@ var Daemon = class {
3156
3807
  // Updated by 'auth_success' (add) and 'disconnected' (remove) events
3157
3808
  this.liveConnections = /* @__PURE__ */ new Set();
3158
3809
  // projectPath
3810
+ // EP813: Track connections that are still authenticating (in progress)
3811
+ // Prevents race condition between restoreConnections() and add-project IPC
3812
+ this.pendingConnections = /* @__PURE__ */ new Set();
3813
+ // projectPath
3159
3814
  this.shuttingDown = false;
3160
3815
  this.ipcServer = new IPCServer();
3161
3816
  }
@@ -3215,27 +3870,40 @@ var Daemon = class {
3215
3870
  machineId: this.machineId,
3216
3871
  deviceId: this.deviceId,
3217
3872
  // EP726: UUID for unified device identification
3218
- hostname: os.hostname(),
3219
- platform: os.platform(),
3220
- arch: os.arch(),
3873
+ hostname: os2.hostname(),
3874
+ platform: os2.platform(),
3875
+ arch: os2.arch(),
3221
3876
  projects
3222
3877
  };
3223
3878
  });
3224
3879
  this.ipcServer.on("add-project", async (params) => {
3225
3880
  const { projectId, projectPath } = params;
3226
3881
  addProject(projectId, projectPath);
3227
- try {
3228
- await this.connectProject(projectId, projectPath);
3229
- const isHealthy = this.isConnectionHealthy(projectPath);
3230
- if (!isHealthy) {
3231
- console.warn(`[Daemon] Connection completed but not healthy for ${projectPath}`);
3232
- return { success: false, connected: false, error: "Connection established but not healthy" };
3882
+ const MAX_RETRIES = 3;
3883
+ const INITIAL_DELAY = 1e3;
3884
+ let lastError = "";
3885
+ for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
3886
+ try {
3887
+ await this.connectProject(projectId, projectPath);
3888
+ const isHealthy = this.isConnectionHealthy(projectPath);
3889
+ if (!isHealthy) {
3890
+ console.warn(`[Daemon] Connection completed but not healthy for ${projectPath}`);
3891
+ lastError = "Connection established but not healthy";
3892
+ return { success: false, connected: false, error: lastError };
3893
+ }
3894
+ return { success: true, connected: true };
3895
+ } catch (error) {
3896
+ lastError = error instanceof Error ? error.message : String(error);
3897
+ console.error(`[Daemon] Connection attempt ${attempt}/${MAX_RETRIES} failed:`, lastError);
3898
+ if (attempt < MAX_RETRIES) {
3899
+ const delay = INITIAL_DELAY * Math.pow(2, attempt - 1);
3900
+ console.log(`[Daemon] Retrying in ${delay / 1e3}s...`);
3901
+ await new Promise((resolve2) => setTimeout(resolve2, delay));
3902
+ await this.disconnectProject(projectPath);
3903
+ }
3233
3904
  }
3234
- return { success: true, connected: true };
3235
- } catch (error) {
3236
- const errorMessage = error instanceof Error ? error.message : String(error);
3237
- return { success: false, connected: false, error: errorMessage };
3238
3905
  }
3906
+ return { success: false, connected: false, error: `Failed after ${MAX_RETRIES} attempts: ${lastError}` };
3239
3907
  });
3240
3908
  this.ipcServer.on("remove-project", async (params) => {
3241
3909
  const { projectPath } = params;
@@ -3310,6 +3978,19 @@ var Daemon = class {
3310
3978
  console.log(`[Daemon] Already connected to ${projectPath}`);
3311
3979
  return;
3312
3980
  }
3981
+ if (this.pendingConnections.has(projectPath)) {
3982
+ console.log(`[Daemon] Connection in progress for ${projectPath}, waiting...`);
3983
+ const maxWait = 35e3;
3984
+ const startTime = Date.now();
3985
+ while (this.pendingConnections.has(projectPath) && Date.now() - startTime < maxWait) {
3986
+ await new Promise((resolve2) => setTimeout(resolve2, 500));
3987
+ }
3988
+ if (this.liveConnections.has(projectPath)) {
3989
+ console.log(`[Daemon] Pending connection succeeded for ${projectPath}`);
3990
+ return;
3991
+ }
3992
+ console.warn(`[Daemon] Pending connection timed out for ${projectPath}`);
3993
+ }
3313
3994
  console.warn(`[Daemon] Stale connection detected for ${projectPath}, forcing reconnection`);
3314
3995
  await this.disconnectProject(projectPath);
3315
3996
  }
@@ -3336,6 +4017,7 @@ var Daemon = class {
3336
4017
  gitExecutor
3337
4018
  };
3338
4019
  this.connections.set(projectPath, connection);
4020
+ this.pendingConnections.add(projectPath);
3339
4021
  client.on("command", async (message) => {
3340
4022
  if (message.type === "command" && message.command) {
3341
4023
  console.log(`[Daemon] Received command for ${projectId}:`, message.command);
@@ -3415,6 +4097,150 @@ var Daemon = class {
3415
4097
  }
3416
4098
  }
3417
4099
  });
4100
+ client.on("tunnel_command", async (message) => {
4101
+ if (message.type === "tunnel_command" && message.command) {
4102
+ const cmd = message.command;
4103
+ console.log(`[Daemon] Received tunnel command for ${projectId}:`, cmd.action);
4104
+ client.updateActivity();
4105
+ try {
4106
+ const tunnelManager = getTunnelManager();
4107
+ let result;
4108
+ if (cmd.action === "start") {
4109
+ const port = cmd.port || detectDevPort(projectPath);
4110
+ const previewUrl = `https://${cmd.moduleUid.toLowerCase()}-${cmd.projectUid.toLowerCase()}.episoda.site`;
4111
+ const reportTunnelStatus = async (data) => {
4112
+ const config2 = await (0, import_core5.loadConfig)();
4113
+ if (config2?.access_token) {
4114
+ try {
4115
+ const apiUrl = config2.api_url || "https://episoda.dev";
4116
+ const response = await fetch(`${apiUrl}/api/modules/${cmd.moduleUid}/tunnel`, {
4117
+ method: "POST",
4118
+ headers: {
4119
+ "Authorization": `Bearer ${config2.access_token}`,
4120
+ "Content-Type": "application/json"
4121
+ },
4122
+ body: JSON.stringify(data)
4123
+ });
4124
+ if (response.ok) {
4125
+ console.log(`[Daemon] Tunnel status reported for ${cmd.moduleUid}`);
4126
+ } else {
4127
+ console.warn(`[Daemon] Failed to report tunnel status: ${response.statusText}`);
4128
+ }
4129
+ } catch (reportError) {
4130
+ console.warn(`[Daemon] Error reporting tunnel status:`, reportError);
4131
+ }
4132
+ }
4133
+ };
4134
+ (async () => {
4135
+ const MAX_RETRIES = 3;
4136
+ const RETRY_DELAY_MS = 2e3;
4137
+ await reportTunnelStatus({
4138
+ tunnel_started_at: (/* @__PURE__ */ new Date()).toISOString(),
4139
+ tunnel_error: null
4140
+ // Clear any previous error
4141
+ });
4142
+ try {
4143
+ await tunnelManager.initialize();
4144
+ console.log(`[Daemon] Ensuring dev server is running on port ${port}...`);
4145
+ const devServerResult = await ensureDevServer(projectPath, port, cmd.moduleUid);
4146
+ if (!devServerResult.success) {
4147
+ const errorMsg2 = `Dev server failed to start: ${devServerResult.error}`;
4148
+ console.error(`[Daemon] ${errorMsg2}`);
4149
+ await reportTunnelStatus({ tunnel_error: errorMsg2 });
4150
+ return;
4151
+ }
4152
+ console.log(`[Daemon] Dev server ready on port ${port}`);
4153
+ let lastError;
4154
+ for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
4155
+ console.log(`[Daemon] Starting tunnel (attempt ${attempt}/${MAX_RETRIES})...`);
4156
+ const startResult = await tunnelManager.startTunnel({
4157
+ moduleUid: cmd.moduleUid,
4158
+ port,
4159
+ onUrl: async (url) => {
4160
+ console.log(`[Daemon] Tunnel URL for ${cmd.moduleUid}: ${url}`);
4161
+ await reportTunnelStatus({
4162
+ tunnel_url: url,
4163
+ tunnel_error: null
4164
+ // Clear error on success
4165
+ });
4166
+ },
4167
+ onStatusChange: (status, error) => {
4168
+ if (status === "error") {
4169
+ console.error(`[Daemon] Tunnel error for ${cmd.moduleUid}: ${error}`);
4170
+ reportTunnelStatus({ tunnel_error: error || "Tunnel connection error" });
4171
+ } else if (status === "reconnecting") {
4172
+ console.log(`[Daemon] Tunnel reconnecting for ${cmd.moduleUid}...`);
4173
+ }
4174
+ }
4175
+ });
4176
+ if (startResult.success) {
4177
+ console.log(`[Daemon] Tunnel started successfully for ${cmd.moduleUid}`);
4178
+ return;
4179
+ }
4180
+ lastError = startResult.error;
4181
+ console.warn(`[Daemon] Tunnel start attempt ${attempt} failed: ${lastError}`);
4182
+ if (attempt < MAX_RETRIES) {
4183
+ console.log(`[Daemon] Retrying in ${RETRY_DELAY_MS}ms...`);
4184
+ await new Promise((resolve2) => setTimeout(resolve2, RETRY_DELAY_MS));
4185
+ }
4186
+ }
4187
+ const errorMsg = `Tunnel failed after ${MAX_RETRIES} attempts: ${lastError}`;
4188
+ console.error(`[Daemon] ${errorMsg}`);
4189
+ await reportTunnelStatus({ tunnel_error: errorMsg });
4190
+ } catch (error) {
4191
+ const errorMsg = error instanceof Error ? error.message : String(error);
4192
+ console.error(`[Daemon] Async tunnel startup error:`, error);
4193
+ await reportTunnelStatus({ tunnel_error: `Unexpected error: ${errorMsg}` });
4194
+ }
4195
+ })();
4196
+ result = {
4197
+ success: true,
4198
+ previewUrl
4199
+ // Note: actual tunnel URL will be reported via API when ready
4200
+ };
4201
+ } else if (cmd.action === "stop") {
4202
+ await tunnelManager.stopTunnel(cmd.moduleUid);
4203
+ await stopDevServer(cmd.moduleUid);
4204
+ const config2 = await (0, import_core5.loadConfig)();
4205
+ if (config2?.access_token) {
4206
+ try {
4207
+ const apiUrl = config2.api_url || "https://episoda.dev";
4208
+ await fetch(`${apiUrl}/api/modules/${cmd.moduleUid}/tunnel`, {
4209
+ method: "DELETE",
4210
+ headers: {
4211
+ "Authorization": `Bearer ${config2.access_token}`
4212
+ }
4213
+ });
4214
+ console.log(`[Daemon] Tunnel URL cleared for ${cmd.moduleUid}`);
4215
+ } catch {
4216
+ }
4217
+ }
4218
+ result = { success: true };
4219
+ } else {
4220
+ result = {
4221
+ success: false,
4222
+ error: `Unknown tunnel action: ${cmd.action}`
4223
+ };
4224
+ }
4225
+ await client.send({
4226
+ type: "tunnel_result",
4227
+ commandId: message.id,
4228
+ result
4229
+ });
4230
+ console.log(`[Daemon] Tunnel command ${cmd.action} completed for ${cmd.moduleUid}:`, result.success ? "success" : "failed");
4231
+ } catch (error) {
4232
+ await client.send({
4233
+ type: "tunnel_result",
4234
+ commandId: message.id,
4235
+ result: {
4236
+ success: false,
4237
+ error: error instanceof Error ? error.message : String(error)
4238
+ }
4239
+ });
4240
+ console.error(`[Daemon] Tunnel command execution error:`, error);
4241
+ }
4242
+ }
4243
+ });
3418
4244
  client.on("shutdown", async (message) => {
3419
4245
  const shutdownMessage = message;
3420
4246
  const reason = shutdownMessage.reason || "unknown";
@@ -3431,6 +4257,7 @@ var Daemon = class {
3431
4257
  console.log(`[Daemon] Authenticated for project ${projectId}`);
3432
4258
  touchProject(projectPath);
3433
4259
  this.liveConnections.add(projectPath);
4260
+ this.pendingConnections.delete(projectPath);
3434
4261
  const authMessage = message;
3435
4262
  if (authMessage.userId && authMessage.workspaceId) {
3436
4263
  await this.configureGitUser(projectPath, authMessage.userId, authMessage.workspaceId, this.machineId, projectId, authMessage.deviceId);
@@ -3466,17 +4293,17 @@ var Daemon = class {
3466
4293
  let daemonPid;
3467
4294
  try {
3468
4295
  const pidPath = getPidFilePath();
3469
- if (fs5.existsSync(pidPath)) {
3470
- const pidStr = fs5.readFileSync(pidPath, "utf-8").trim();
4296
+ if (fs7.existsSync(pidPath)) {
4297
+ const pidStr = fs7.readFileSync(pidPath, "utf-8").trim();
3471
4298
  daemonPid = parseInt(pidStr, 10);
3472
4299
  }
3473
4300
  } catch (pidError) {
3474
4301
  console.warn(`[Daemon] Could not read daemon PID:`, pidError instanceof Error ? pidError.message : pidError);
3475
4302
  }
3476
4303
  const authSuccessPromise = new Promise((resolve2, reject) => {
3477
- const AUTH_TIMEOUT = 1e4;
4304
+ const AUTH_TIMEOUT = 3e4;
3478
4305
  const timeout = setTimeout(() => {
3479
- reject(new Error("Authentication timeout - server did not respond with auth_success"));
4306
+ reject(new Error("Authentication timeout after 30s - server may be under heavy load. Try again in a few seconds."));
3480
4307
  }, AUTH_TIMEOUT);
3481
4308
  const authHandler = () => {
3482
4309
  clearTimeout(timeout);
@@ -3491,9 +4318,9 @@ var Daemon = class {
3491
4318
  client.once("auth_error", errorHandler);
3492
4319
  });
3493
4320
  await client.connect(wsUrl, config.access_token, this.machineId, {
3494
- hostname: os.hostname(),
3495
- osPlatform: os.platform(),
3496
- osArch: os.arch(),
4321
+ hostname: os2.hostname(),
4322
+ osPlatform: os2.platform(),
4323
+ osArch: os2.arch(),
3497
4324
  daemonPid
3498
4325
  });
3499
4326
  console.log(`[Daemon] Successfully connected to project ${projectId}`);
@@ -3502,6 +4329,7 @@ var Daemon = class {
3502
4329
  } catch (error) {
3503
4330
  console.error(`[Daemon] Failed to connect to ${projectId}:`, error);
3504
4331
  this.connections.delete(projectPath);
4332
+ this.pendingConnections.delete(projectPath);
3505
4333
  throw error;
3506
4334
  }
3507
4335
  }
@@ -3519,6 +4347,7 @@ var Daemon = class {
3519
4347
  await connection.client.disconnect();
3520
4348
  this.connections.delete(projectPath);
3521
4349
  this.liveConnections.delete(projectPath);
4350
+ this.pendingConnections.delete(projectPath);
3522
4351
  console.log(`[Daemon] Disconnected from ${projectPath}`);
3523
4352
  }
3524
4353
  /**
@@ -3544,29 +4373,29 @@ var Daemon = class {
3544
4373
  */
3545
4374
  async configureGitUser(projectPath, userId, workspaceId, machineId, projectId, deviceId) {
3546
4375
  try {
3547
- const { execSync: execSync2 } = await import("child_process");
3548
- execSync2(`git config episoda.userId ${userId}`, {
4376
+ const { execSync: execSync3 } = await import("child_process");
4377
+ execSync3(`git config episoda.userId ${userId}`, {
3549
4378
  cwd: projectPath,
3550
4379
  encoding: "utf8",
3551
4380
  stdio: "pipe"
3552
4381
  });
3553
- execSync2(`git config episoda.workspaceId ${workspaceId}`, {
4382
+ execSync3(`git config episoda.workspaceId ${workspaceId}`, {
3554
4383
  cwd: projectPath,
3555
4384
  encoding: "utf8",
3556
4385
  stdio: "pipe"
3557
4386
  });
3558
- execSync2(`git config episoda.machineId ${machineId}`, {
4387
+ execSync3(`git config episoda.machineId ${machineId}`, {
3559
4388
  cwd: projectPath,
3560
4389
  encoding: "utf8",
3561
4390
  stdio: "pipe"
3562
4391
  });
3563
- execSync2(`git config episoda.projectId ${projectId}`, {
4392
+ execSync3(`git config episoda.projectId ${projectId}`, {
3564
4393
  cwd: projectPath,
3565
4394
  encoding: "utf8",
3566
4395
  stdio: "pipe"
3567
4396
  });
3568
4397
  if (deviceId) {
3569
- execSync2(`git config episoda.deviceId ${deviceId}`, {
4398
+ execSync3(`git config episoda.deviceId ${deviceId}`, {
3570
4399
  cwd: projectPath,
3571
4400
  encoding: "utf8",
3572
4401
  stdio: "pipe"
@@ -3586,27 +4415,27 @@ var Daemon = class {
3586
4415
  */
3587
4416
  async installGitHooks(projectPath) {
3588
4417
  const hooks = ["post-checkout", "pre-commit"];
3589
- const hooksDir = path6.join(projectPath, ".git", "hooks");
3590
- if (!fs5.existsSync(hooksDir)) {
4418
+ const hooksDir = path8.join(projectPath, ".git", "hooks");
4419
+ if (!fs7.existsSync(hooksDir)) {
3591
4420
  console.warn(`[Daemon] Hooks directory not found: ${hooksDir}`);
3592
4421
  return;
3593
4422
  }
3594
4423
  for (const hookName of hooks) {
3595
4424
  try {
3596
- const hookPath = path6.join(hooksDir, hookName);
3597
- const bundledHookPath = path6.join(__dirname, "..", "hooks", hookName);
3598
- if (!fs5.existsSync(bundledHookPath)) {
4425
+ const hookPath = path8.join(hooksDir, hookName);
4426
+ const bundledHookPath = path8.join(__dirname, "..", "hooks", hookName);
4427
+ if (!fs7.existsSync(bundledHookPath)) {
3599
4428
  console.warn(`[Daemon] Bundled hook not found: ${bundledHookPath}`);
3600
4429
  continue;
3601
4430
  }
3602
- const hookContent = fs5.readFileSync(bundledHookPath, "utf-8");
3603
- if (fs5.existsSync(hookPath)) {
3604
- const existingContent = fs5.readFileSync(hookPath, "utf-8");
4431
+ const hookContent = fs7.readFileSync(bundledHookPath, "utf-8");
4432
+ if (fs7.existsSync(hookPath)) {
4433
+ const existingContent = fs7.readFileSync(hookPath, "utf-8");
3605
4434
  if (existingContent === hookContent) {
3606
4435
  continue;
3607
4436
  }
3608
4437
  }
3609
- fs5.writeFileSync(hookPath, hookContent, { mode: 493 });
4438
+ fs7.writeFileSync(hookPath, hookContent, { mode: 493 });
3610
4439
  console.log(`[Daemon] Installed git hook: ${hookName}`);
3611
4440
  } catch (error) {
3612
4441
  console.warn(`[Daemon] Failed to install ${hookName} hook:`, error instanceof Error ? error.message : error);
@@ -3654,6 +4483,13 @@ var Daemon = class {
3654
4483
  await connection.client.disconnect();
3655
4484
  }
3656
4485
  this.connections.clear();
4486
+ try {
4487
+ const tunnelManager = getTunnelManager();
4488
+ await tunnelManager.stopAllTunnels();
4489
+ console.log("[Daemon] All tunnels stopped");
4490
+ } catch (error) {
4491
+ console.error("[Daemon] Failed to stop tunnels:", error);
4492
+ }
3657
4493
  await this.ipcServer.stop();
3658
4494
  console.log("[Daemon] Shutdown complete");
3659
4495
  }
@@ -3665,8 +4501,8 @@ var Daemon = class {
3665
4501
  await this.shutdown();
3666
4502
  try {
3667
4503
  const pidPath = getPidFilePath();
3668
- if (fs5.existsSync(pidPath)) {
3669
- fs5.unlinkSync(pidPath);
4504
+ if (fs7.existsSync(pidPath)) {
4505
+ fs7.unlinkSync(pidPath);
3670
4506
  console.log("[Daemon] PID file cleaned up");
3671
4507
  }
3672
4508
  } catch (error) {