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.
@@ -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;
@@ -2118,31 +2118,31 @@ var require_auth = __commonJS({
2118
2118
  exports2.loadConfig = loadConfig2;
2119
2119
  exports2.saveConfig = saveConfig2;
2120
2120
  exports2.validateToken = validateToken;
2121
- var fs6 = __importStar(require("fs"));
2122
- var path7 = __importStar(require("path"));
2123
- var os2 = __importStar(require("os"));
2121
+ var fs8 = __importStar(require("fs"));
2122
+ var path9 = __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 getConfigDir5() {
2127
- return process.env.EPISODA_CONFIG_DIR || path7.join(os2.homedir(), ".episoda");
2127
+ return process.env.EPISODA_CONFIG_DIR || path9.join(os3.homedir(), ".episoda");
2128
2128
  }
2129
2129
  function getConfigPath(configPath) {
2130
2130
  if (configPath) {
2131
2131
  return configPath;
2132
2132
  }
2133
- return path7.join(getConfigDir5(), DEFAULT_CONFIG_FILE);
2133
+ return path9.join(getConfigDir5(), DEFAULT_CONFIG_FILE);
2134
2134
  }
2135
2135
  function ensureConfigDir(configPath) {
2136
- const dir = path7.dirname(configPath);
2137
- const isNew = !fs6.existsSync(dir);
2136
+ const dir = path9.dirname(configPath);
2137
+ const isNew = !fs8.existsSync(dir);
2138
2138
  if (isNew) {
2139
- fs6.mkdirSync(dir, { recursive: true, mode: 448 });
2139
+ fs8.mkdirSync(dir, { recursive: true, mode: 448 });
2140
2140
  }
2141
2141
  if (process.platform === "darwin") {
2142
- const nosyncPath = path7.join(dir, ".nosync");
2143
- if (isNew || !fs6.existsSync(nosyncPath)) {
2142
+ const nosyncPath = path9.join(dir, ".nosync");
2143
+ if (isNew || !fs8.existsSync(nosyncPath)) {
2144
2144
  try {
2145
- fs6.writeFileSync(nosyncPath, "", { mode: 384 });
2145
+ fs8.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
@@ -2154,11 +2154,11 @@ var require_auth = __commonJS({
2154
2154
  }
2155
2155
  async function loadConfig2(configPath) {
2156
2156
  const fullPath = getConfigPath(configPath);
2157
- if (!fs6.existsSync(fullPath)) {
2157
+ if (!fs8.existsSync(fullPath)) {
2158
2158
  return null;
2159
2159
  }
2160
2160
  try {
2161
- const content = fs6.readFileSync(fullPath, "utf8");
2161
+ const content = fs8.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
- fs6.writeFileSync(fullPath, content, { mode: 384 });
2174
+ fs8.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
  }
@@ -2280,7 +2280,7 @@ var require_package = __commonJS({
2280
2280
  "package.json"(exports2, module2) {
2281
2281
  module2.exports = {
2282
2282
  name: "episoda",
2283
- version: "0.2.14",
2283
+ version: "0.2.16",
2284
2284
  description: "CLI tool for Episoda local development workflow orchestration",
2285
2285
  main: "dist/index.js",
2286
2286
  types: "dist/index.d.ts",
@@ -2307,6 +2307,7 @@ var require_package = __commonJS({
2307
2307
  commander: "^11.1.0",
2308
2308
  ora: "^5.4.1",
2309
2309
  semver: "7.7.3",
2310
+ tar: "7.5.2",
2310
2311
  ws: "^8.18.0",
2311
2312
  zod: "^4.0.10"
2312
2313
  },
@@ -2314,6 +2315,7 @@ var require_package = __commonJS({
2314
2315
  "@episoda/core": "*",
2315
2316
  "@types/node": "^20.11.24",
2316
2317
  "@types/semver": "7.7.1",
2318
+ "@types/tar": "6.1.13",
2317
2319
  "@types/ws": "^8.5.10",
2318
2320
  tsup: "8.5.1",
2319
2321
  typescript: "^5.3.3"
@@ -3152,12 +3154,645 @@ async function handleExec(command, projectPath) {
3152
3154
  });
3153
3155
  }
3154
3156
 
3155
- // src/daemon/daemon-process.ts
3157
+ // src/tunnel/cloudflared-manager.ts
3158
+ var import_child_process4 = require("child_process");
3156
3159
  var fs5 = __toESM(require("fs"));
3157
- var os = __toESM(require("os"));
3158
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"));
3159
3794
  var packageJson = require_package();
3160
- var Daemon = class {
3795
+ var Daemon = class _Daemon {
3161
3796
  constructor() {
3162
3797
  this.machineId = "";
3163
3798
  this.deviceId = null;
@@ -3177,8 +3812,16 @@ var Daemon = class {
3177
3812
  this.pendingConnections = /* @__PURE__ */ new Set();
3178
3813
  // projectPath
3179
3814
  this.shuttingDown = false;
3815
+ // EP822: Periodic tunnel polling interval
3816
+ this.tunnelPollInterval = null;
3817
+ // 15 seconds
3818
+ // EP822: Prevent concurrent tunnel syncs (backpressure guard)
3819
+ this.tunnelSyncInProgress = false;
3180
3820
  this.ipcServer = new IPCServer();
3181
3821
  }
3822
+ static {
3823
+ this.TUNNEL_POLL_INTERVAL_MS = 15e3;
3824
+ }
3182
3825
  /**
3183
3826
  * Start the daemon
3184
3827
  */
@@ -3195,6 +3838,8 @@ var Daemon = class {
3195
3838
  console.log("[Daemon] IPC server started");
3196
3839
  this.registerIPCHandlers();
3197
3840
  await this.restoreConnections();
3841
+ await this.cleanupOrphanedTunnels();
3842
+ this.startTunnelPolling();
3198
3843
  this.setupShutdownHandlers();
3199
3844
  console.log("[Daemon] Daemon started successfully");
3200
3845
  this.checkAndNotifyUpdates();
@@ -3235,9 +3880,9 @@ var Daemon = class {
3235
3880
  machineId: this.machineId,
3236
3881
  deviceId: this.deviceId,
3237
3882
  // EP726: UUID for unified device identification
3238
- hostname: os.hostname(),
3239
- platform: os.platform(),
3240
- arch: os.arch(),
3883
+ hostname: os2.hostname(),
3884
+ platform: os2.platform(),
3885
+ arch: os2.arch(),
3241
3886
  projects
3242
3887
  };
3243
3888
  });
@@ -3462,6 +4107,150 @@ var Daemon = class {
3462
4107
  }
3463
4108
  }
3464
4109
  });
4110
+ client.on("tunnel_command", async (message) => {
4111
+ if (message.type === "tunnel_command" && message.command) {
4112
+ const cmd = message.command;
4113
+ console.log(`[Daemon] Received tunnel command for ${projectId}:`, cmd.action);
4114
+ client.updateActivity();
4115
+ try {
4116
+ const tunnelManager = getTunnelManager();
4117
+ let result;
4118
+ if (cmd.action === "start") {
4119
+ const port = cmd.port || detectDevPort(projectPath);
4120
+ const previewUrl = `https://${cmd.moduleUid.toLowerCase()}-${cmd.projectUid.toLowerCase()}.episoda.site`;
4121
+ const reportTunnelStatus = async (data) => {
4122
+ const config2 = await (0, import_core5.loadConfig)();
4123
+ if (config2?.access_token) {
4124
+ try {
4125
+ const apiUrl = config2.api_url || "https://episoda.dev";
4126
+ const response = await fetch(`${apiUrl}/api/modules/${cmd.moduleUid}/tunnel`, {
4127
+ method: "POST",
4128
+ headers: {
4129
+ "Authorization": `Bearer ${config2.access_token}`,
4130
+ "Content-Type": "application/json"
4131
+ },
4132
+ body: JSON.stringify(data)
4133
+ });
4134
+ if (response.ok) {
4135
+ console.log(`[Daemon] Tunnel status reported for ${cmd.moduleUid}`);
4136
+ } else {
4137
+ console.warn(`[Daemon] Failed to report tunnel status: ${response.statusText}`);
4138
+ }
4139
+ } catch (reportError) {
4140
+ console.warn(`[Daemon] Error reporting tunnel status:`, reportError);
4141
+ }
4142
+ }
4143
+ };
4144
+ (async () => {
4145
+ const MAX_RETRIES = 3;
4146
+ const RETRY_DELAY_MS = 2e3;
4147
+ await reportTunnelStatus({
4148
+ tunnel_started_at: (/* @__PURE__ */ new Date()).toISOString(),
4149
+ tunnel_error: null
4150
+ // Clear any previous error
4151
+ });
4152
+ try {
4153
+ await tunnelManager.initialize();
4154
+ console.log(`[Daemon] Ensuring dev server is running on port ${port}...`);
4155
+ const devServerResult = await ensureDevServer(projectPath, port, cmd.moduleUid);
4156
+ if (!devServerResult.success) {
4157
+ const errorMsg2 = `Dev server failed to start: ${devServerResult.error}`;
4158
+ console.error(`[Daemon] ${errorMsg2}`);
4159
+ await reportTunnelStatus({ tunnel_error: errorMsg2 });
4160
+ return;
4161
+ }
4162
+ console.log(`[Daemon] Dev server ready on port ${port}`);
4163
+ let lastError;
4164
+ for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
4165
+ console.log(`[Daemon] Starting tunnel (attempt ${attempt}/${MAX_RETRIES})...`);
4166
+ const startResult = await tunnelManager.startTunnel({
4167
+ moduleUid: cmd.moduleUid,
4168
+ port,
4169
+ onUrl: async (url) => {
4170
+ console.log(`[Daemon] Tunnel URL for ${cmd.moduleUid}: ${url}`);
4171
+ await reportTunnelStatus({
4172
+ tunnel_url: url,
4173
+ tunnel_error: null
4174
+ // Clear error on success
4175
+ });
4176
+ },
4177
+ onStatusChange: (status, error) => {
4178
+ if (status === "error") {
4179
+ console.error(`[Daemon] Tunnel error for ${cmd.moduleUid}: ${error}`);
4180
+ reportTunnelStatus({ tunnel_error: error || "Tunnel connection error" });
4181
+ } else if (status === "reconnecting") {
4182
+ console.log(`[Daemon] Tunnel reconnecting for ${cmd.moduleUid}...`);
4183
+ }
4184
+ }
4185
+ });
4186
+ if (startResult.success) {
4187
+ console.log(`[Daemon] Tunnel started successfully for ${cmd.moduleUid}`);
4188
+ return;
4189
+ }
4190
+ lastError = startResult.error;
4191
+ console.warn(`[Daemon] Tunnel start attempt ${attempt} failed: ${lastError}`);
4192
+ if (attempt < MAX_RETRIES) {
4193
+ console.log(`[Daemon] Retrying in ${RETRY_DELAY_MS}ms...`);
4194
+ await new Promise((resolve2) => setTimeout(resolve2, RETRY_DELAY_MS));
4195
+ }
4196
+ }
4197
+ const errorMsg = `Tunnel failed after ${MAX_RETRIES} attempts: ${lastError}`;
4198
+ console.error(`[Daemon] ${errorMsg}`);
4199
+ await reportTunnelStatus({ tunnel_error: errorMsg });
4200
+ } catch (error) {
4201
+ const errorMsg = error instanceof Error ? error.message : String(error);
4202
+ console.error(`[Daemon] Async tunnel startup error:`, error);
4203
+ await reportTunnelStatus({ tunnel_error: `Unexpected error: ${errorMsg}` });
4204
+ }
4205
+ })();
4206
+ result = {
4207
+ success: true,
4208
+ previewUrl
4209
+ // Note: actual tunnel URL will be reported via API when ready
4210
+ };
4211
+ } else if (cmd.action === "stop") {
4212
+ await tunnelManager.stopTunnel(cmd.moduleUid);
4213
+ await stopDevServer(cmd.moduleUid);
4214
+ const config2 = await (0, import_core5.loadConfig)();
4215
+ if (config2?.access_token) {
4216
+ try {
4217
+ const apiUrl = config2.api_url || "https://episoda.dev";
4218
+ await fetch(`${apiUrl}/api/modules/${cmd.moduleUid}/tunnel`, {
4219
+ method: "DELETE",
4220
+ headers: {
4221
+ "Authorization": `Bearer ${config2.access_token}`
4222
+ }
4223
+ });
4224
+ console.log(`[Daemon] Tunnel URL cleared for ${cmd.moduleUid}`);
4225
+ } catch {
4226
+ }
4227
+ }
4228
+ result = { success: true };
4229
+ } else {
4230
+ result = {
4231
+ success: false,
4232
+ error: `Unknown tunnel action: ${cmd.action}`
4233
+ };
4234
+ }
4235
+ await client.send({
4236
+ type: "tunnel_result",
4237
+ commandId: message.id,
4238
+ result
4239
+ });
4240
+ console.log(`[Daemon] Tunnel command ${cmd.action} completed for ${cmd.moduleUid}:`, result.success ? "success" : "failed");
4241
+ } catch (error) {
4242
+ await client.send({
4243
+ type: "tunnel_result",
4244
+ commandId: message.id,
4245
+ result: {
4246
+ success: false,
4247
+ error: error instanceof Error ? error.message : String(error)
4248
+ }
4249
+ });
4250
+ console.error(`[Daemon] Tunnel command execution error:`, error);
4251
+ }
4252
+ }
4253
+ });
3465
4254
  client.on("shutdown", async (message) => {
3466
4255
  const shutdownMessage = message;
3467
4256
  const reason = shutdownMessage.reason || "unknown";
@@ -3497,6 +4286,9 @@ var Daemon = class {
3497
4286
  this.flyMachineId = authMessage.flyMachineId;
3498
4287
  console.log(`[Daemon] Fly Machine ID: ${this.flyMachineId}`);
3499
4288
  }
4289
+ this.autoStartTunnelsForProject(projectPath, projectId).catch((error) => {
4290
+ console.error(`[Daemon] EP819: Failed to auto-start tunnels:`, error);
4291
+ });
3500
4292
  });
3501
4293
  client.on("error", (message) => {
3502
4294
  console.error(`[Daemon] Server error for ${projectId}:`, message);
@@ -3514,8 +4306,8 @@ var Daemon = class {
3514
4306
  let daemonPid;
3515
4307
  try {
3516
4308
  const pidPath = getPidFilePath();
3517
- if (fs5.existsSync(pidPath)) {
3518
- const pidStr = fs5.readFileSync(pidPath, "utf-8").trim();
4309
+ if (fs7.existsSync(pidPath)) {
4310
+ const pidStr = fs7.readFileSync(pidPath, "utf-8").trim();
3519
4311
  daemonPid = parseInt(pidStr, 10);
3520
4312
  }
3521
4313
  } catch (pidError) {
@@ -3539,9 +4331,9 @@ var Daemon = class {
3539
4331
  client.once("auth_error", errorHandler);
3540
4332
  });
3541
4333
  await client.connect(wsUrl, config.access_token, this.machineId, {
3542
- hostname: os.hostname(),
3543
- osPlatform: os.platform(),
3544
- osArch: os.arch(),
4334
+ hostname: os2.hostname(),
4335
+ osPlatform: os2.platform(),
4336
+ osArch: os2.arch(),
3545
4337
  daemonPid
3546
4338
  });
3547
4339
  console.log(`[Daemon] Successfully connected to project ${projectId}`);
@@ -3594,29 +4386,29 @@ var Daemon = class {
3594
4386
  */
3595
4387
  async configureGitUser(projectPath, userId, workspaceId, machineId, projectId, deviceId) {
3596
4388
  try {
3597
- const { execSync: execSync2 } = await import("child_process");
3598
- execSync2(`git config episoda.userId ${userId}`, {
4389
+ const { execSync: execSync3 } = await import("child_process");
4390
+ execSync3(`git config episoda.userId ${userId}`, {
3599
4391
  cwd: projectPath,
3600
4392
  encoding: "utf8",
3601
4393
  stdio: "pipe"
3602
4394
  });
3603
- execSync2(`git config episoda.workspaceId ${workspaceId}`, {
4395
+ execSync3(`git config episoda.workspaceId ${workspaceId}`, {
3604
4396
  cwd: projectPath,
3605
4397
  encoding: "utf8",
3606
4398
  stdio: "pipe"
3607
4399
  });
3608
- execSync2(`git config episoda.machineId ${machineId}`, {
4400
+ execSync3(`git config episoda.machineId ${machineId}`, {
3609
4401
  cwd: projectPath,
3610
4402
  encoding: "utf8",
3611
4403
  stdio: "pipe"
3612
4404
  });
3613
- execSync2(`git config episoda.projectId ${projectId}`, {
4405
+ execSync3(`git config episoda.projectId ${projectId}`, {
3614
4406
  cwd: projectPath,
3615
4407
  encoding: "utf8",
3616
4408
  stdio: "pipe"
3617
4409
  });
3618
4410
  if (deviceId) {
3619
- execSync2(`git config episoda.deviceId ${deviceId}`, {
4411
+ execSync3(`git config episoda.deviceId ${deviceId}`, {
3620
4412
  cwd: projectPath,
3621
4413
  encoding: "utf8",
3622
4414
  stdio: "pipe"
@@ -3636,27 +4428,27 @@ var Daemon = class {
3636
4428
  */
3637
4429
  async installGitHooks(projectPath) {
3638
4430
  const hooks = ["post-checkout", "pre-commit"];
3639
- const hooksDir = path6.join(projectPath, ".git", "hooks");
3640
- if (!fs5.existsSync(hooksDir)) {
4431
+ const hooksDir = path8.join(projectPath, ".git", "hooks");
4432
+ if (!fs7.existsSync(hooksDir)) {
3641
4433
  console.warn(`[Daemon] Hooks directory not found: ${hooksDir}`);
3642
4434
  return;
3643
4435
  }
3644
4436
  for (const hookName of hooks) {
3645
4437
  try {
3646
- const hookPath = path6.join(hooksDir, hookName);
3647
- const bundledHookPath = path6.join(__dirname, "..", "hooks", hookName);
3648
- if (!fs5.existsSync(bundledHookPath)) {
4438
+ const hookPath = path8.join(hooksDir, hookName);
4439
+ const bundledHookPath = path8.join(__dirname, "..", "hooks", hookName);
4440
+ if (!fs7.existsSync(bundledHookPath)) {
3649
4441
  console.warn(`[Daemon] Bundled hook not found: ${bundledHookPath}`);
3650
4442
  continue;
3651
4443
  }
3652
- const hookContent = fs5.readFileSync(bundledHookPath, "utf-8");
3653
- if (fs5.existsSync(hookPath)) {
3654
- const existingContent = fs5.readFileSync(hookPath, "utf-8");
4444
+ const hookContent = fs7.readFileSync(bundledHookPath, "utf-8");
4445
+ if (fs7.existsSync(hookPath)) {
4446
+ const existingContent = fs7.readFileSync(hookPath, "utf-8");
3655
4447
  if (existingContent === hookContent) {
3656
4448
  continue;
3657
4449
  }
3658
4450
  }
3659
- fs5.writeFileSync(hookPath, hookContent, { mode: 493 });
4451
+ fs7.writeFileSync(hookPath, hookContent, { mode: 493 });
3660
4452
  console.log(`[Daemon] Installed git hook: ${hookName}`);
3661
4453
  } catch (error) {
3662
4454
  console.warn(`[Daemon] Failed to install ${hookName} hook:`, error instanceof Error ? error.message : error);
@@ -3690,6 +4482,295 @@ var Daemon = class {
3690
4482
  console.warn("[Daemon] Failed to cache device ID:", error instanceof Error ? error.message : error);
3691
4483
  }
3692
4484
  }
4485
+ /**
4486
+ * EP819: Auto-start tunnels for active local modules on daemon connect/reconnect
4487
+ *
4488
+ * Queries for modules in doing/review state with dev_mode=local that don't have
4489
+ * an active tunnel_url, and starts tunnels for each.
4490
+ */
4491
+ async autoStartTunnelsForProject(projectPath, projectUid) {
4492
+ console.log(`[Daemon] EP819: Checking for active local modules to auto-start tunnels...`);
4493
+ try {
4494
+ const config = await (0, import_core5.loadConfig)();
4495
+ if (!config?.access_token) {
4496
+ console.warn(`[Daemon] EP819: No access token, skipping tunnel auto-start`);
4497
+ return;
4498
+ }
4499
+ const apiUrl = config.api_url || "https://episoda.dev";
4500
+ const response = await fetch(
4501
+ `${apiUrl}/api/modules?state=doing,review&fields=id,uid,dev_mode,tunnel_url,checkout_machine_id`,
4502
+ {
4503
+ headers: {
4504
+ "Authorization": `Bearer ${config.access_token}`,
4505
+ "Content-Type": "application/json"
4506
+ }
4507
+ }
4508
+ );
4509
+ if (!response.ok) {
4510
+ console.warn(`[Daemon] EP819: Failed to fetch modules: ${response.status}`);
4511
+ return;
4512
+ }
4513
+ const data = await response.json();
4514
+ const modules = data.modules || [];
4515
+ const tunnelManager = getTunnelManager();
4516
+ await tunnelManager.initialize();
4517
+ const localModulesNeedingTunnel = modules.filter(
4518
+ (m) => m.dev_mode === "local" && (!m.checkout_machine_id || m.checkout_machine_id === this.deviceId) && !tunnelManager.hasTunnel(m.uid)
4519
+ );
4520
+ if (localModulesNeedingTunnel.length === 0) {
4521
+ console.log(`[Daemon] EP819: No local modules need tunnel auto-start`);
4522
+ return;
4523
+ }
4524
+ console.log(`[Daemon] EP819: Found ${localModulesNeedingTunnel.length} local modules needing tunnels`);
4525
+ for (const module2 of localModulesNeedingTunnel) {
4526
+ const moduleUid = module2.uid;
4527
+ const port = detectDevPort(projectPath);
4528
+ console.log(`[Daemon] EP819: Auto-starting tunnel for ${moduleUid} on port ${port}`);
4529
+ const reportTunnelStatus = async (statusData) => {
4530
+ try {
4531
+ const statusResponse = await fetch(`${apiUrl}/api/modules/${moduleUid}/tunnel`, {
4532
+ method: "POST",
4533
+ headers: {
4534
+ "Authorization": `Bearer ${config.access_token}`,
4535
+ "Content-Type": "application/json"
4536
+ },
4537
+ body: JSON.stringify(statusData)
4538
+ });
4539
+ if (statusResponse.ok) {
4540
+ console.log(`[Daemon] EP819: Tunnel status reported for ${moduleUid}`);
4541
+ } else {
4542
+ console.warn(`[Daemon] EP819: Failed to report tunnel status: ${statusResponse.statusText}`);
4543
+ }
4544
+ } catch (reportError) {
4545
+ console.warn(`[Daemon] EP819: Error reporting tunnel status:`, reportError);
4546
+ }
4547
+ };
4548
+ (async () => {
4549
+ const MAX_RETRIES = 3;
4550
+ const RETRY_DELAY_MS = 2e3;
4551
+ await reportTunnelStatus({
4552
+ tunnel_started_at: (/* @__PURE__ */ new Date()).toISOString(),
4553
+ tunnel_error: null
4554
+ });
4555
+ try {
4556
+ console.log(`[Daemon] EP819: Ensuring dev server is running for ${moduleUid}...`);
4557
+ const devServerResult = await ensureDevServer(projectPath, port, moduleUid);
4558
+ if (!devServerResult.success) {
4559
+ const errorMsg2 = `Dev server failed to start: ${devServerResult.error}`;
4560
+ console.error(`[Daemon] EP819: ${errorMsg2}`);
4561
+ await reportTunnelStatus({ tunnel_error: errorMsg2 });
4562
+ return;
4563
+ }
4564
+ console.log(`[Daemon] EP819: Dev server ready on port ${port}`);
4565
+ let lastError;
4566
+ for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
4567
+ console.log(`[Daemon] EP819: Starting tunnel for ${moduleUid} (attempt ${attempt}/${MAX_RETRIES})...`);
4568
+ const startResult = await tunnelManager.startTunnel({
4569
+ moduleUid,
4570
+ port,
4571
+ onUrl: async (url) => {
4572
+ console.log(`[Daemon] EP819: Tunnel URL for ${moduleUid}: ${url}`);
4573
+ await reportTunnelStatus({
4574
+ tunnel_url: url,
4575
+ tunnel_error: null
4576
+ });
4577
+ },
4578
+ onStatusChange: (status, error) => {
4579
+ if (status === "error") {
4580
+ console.error(`[Daemon] EP819: Tunnel error for ${moduleUid}: ${error}`);
4581
+ reportTunnelStatus({ tunnel_error: error || "Tunnel connection error" });
4582
+ } else if (status === "reconnecting") {
4583
+ console.log(`[Daemon] EP819: Tunnel reconnecting for ${moduleUid}...`);
4584
+ }
4585
+ }
4586
+ });
4587
+ if (startResult.success) {
4588
+ console.log(`[Daemon] EP819: Tunnel started successfully for ${moduleUid}`);
4589
+ return;
4590
+ }
4591
+ lastError = startResult.error;
4592
+ console.warn(`[Daemon] EP819: Tunnel start attempt ${attempt} failed: ${lastError}`);
4593
+ if (attempt < MAX_RETRIES) {
4594
+ console.log(`[Daemon] EP819: Retrying in ${RETRY_DELAY_MS}ms...`);
4595
+ await new Promise((resolve2) => setTimeout(resolve2, RETRY_DELAY_MS));
4596
+ }
4597
+ }
4598
+ const errorMsg = `Tunnel failed after ${MAX_RETRIES} attempts: ${lastError}`;
4599
+ console.error(`[Daemon] EP819: ${errorMsg}`);
4600
+ await reportTunnelStatus({ tunnel_error: errorMsg });
4601
+ } catch (error) {
4602
+ const errorMsg = error instanceof Error ? error.message : String(error);
4603
+ console.error(`[Daemon] EP819: Async tunnel startup error:`, error);
4604
+ await reportTunnelStatus({ tunnel_error: `Unexpected error: ${errorMsg}` });
4605
+ }
4606
+ })();
4607
+ }
4608
+ } catch (error) {
4609
+ console.error(`[Daemon] EP819: Error auto-starting tunnels:`, error);
4610
+ }
4611
+ }
4612
+ /**
4613
+ * EP822: Start periodic tunnel polling
4614
+ *
4615
+ * Polls every 30 seconds to detect module state changes and manage tunnels:
4616
+ * - Start tunnels for modules entering doing/review state
4617
+ * - Stop tunnels for modules leaving doing/review state
4618
+ */
4619
+ startTunnelPolling() {
4620
+ if (this.tunnelPollInterval) {
4621
+ console.log("[Daemon] EP822: Tunnel polling already running");
4622
+ return;
4623
+ }
4624
+ console.log(`[Daemon] EP822: Starting tunnel polling (every ${_Daemon.TUNNEL_POLL_INTERVAL_MS / 1e3}s)`);
4625
+ this.tunnelPollInterval = setInterval(() => {
4626
+ this.syncTunnelsWithActiveModules().catch((error) => {
4627
+ console.error("[Daemon] EP822: Tunnel sync error:", error);
4628
+ });
4629
+ }, _Daemon.TUNNEL_POLL_INTERVAL_MS);
4630
+ }
4631
+ /**
4632
+ * EP822: Stop periodic tunnel polling
4633
+ */
4634
+ stopTunnelPolling() {
4635
+ if (this.tunnelPollInterval) {
4636
+ clearInterval(this.tunnelPollInterval);
4637
+ this.tunnelPollInterval = null;
4638
+ console.log("[Daemon] EP822: Tunnel polling stopped");
4639
+ }
4640
+ }
4641
+ /**
4642
+ * EP822: Clean up orphaned tunnels from previous daemon runs
4643
+ *
4644
+ * When the daemon crashes or is killed, tunnels may continue running.
4645
+ * This method stops any tunnels that are running but shouldn't be,
4646
+ * ensuring a clean slate on startup.
4647
+ */
4648
+ async cleanupOrphanedTunnels() {
4649
+ try {
4650
+ const tunnelManager = getTunnelManager();
4651
+ const runningTunnels = tunnelManager.getAllTunnels();
4652
+ if (runningTunnels.length === 0) {
4653
+ return;
4654
+ }
4655
+ console.log(`[Daemon] EP822: Found ${runningTunnels.length} orphaned tunnel(s) from previous run, cleaning up...`);
4656
+ for (const tunnel of runningTunnels) {
4657
+ try {
4658
+ await tunnelManager.stopTunnel(tunnel.moduleUid);
4659
+ await stopDevServer(tunnel.moduleUid);
4660
+ console.log(`[Daemon] EP822: Cleaned up orphaned tunnel for ${tunnel.moduleUid}`);
4661
+ } catch (error) {
4662
+ console.error(`[Daemon] EP822: Failed to clean up tunnel for ${tunnel.moduleUid}:`, error);
4663
+ }
4664
+ }
4665
+ console.log("[Daemon] EP822: Orphaned tunnel cleanup complete");
4666
+ } catch (error) {
4667
+ console.error("[Daemon] EP822: Failed to clean up orphaned tunnels:", error);
4668
+ }
4669
+ }
4670
+ /**
4671
+ * EP822: Sync tunnels with active modules
4672
+ *
4673
+ * Compares running tunnels against modules in doing/review state.
4674
+ * - Starts tunnels for modules that need them
4675
+ * - Stops tunnels for modules that left active zone
4676
+ *
4677
+ * Fixes from peer review:
4678
+ * - Backpressure guard prevents concurrent syncs
4679
+ * - Uses deviceId (UUID) instead of machineId (string) for machine comparison
4680
+ * - Groups modules by project_id for correct multi-project routing
4681
+ */
4682
+ async syncTunnelsWithActiveModules() {
4683
+ if (this.tunnelSyncInProgress) {
4684
+ console.log("[Daemon] EP822: Sync already in progress, skipping");
4685
+ return;
4686
+ }
4687
+ if (this.liveConnections.size === 0) {
4688
+ return;
4689
+ }
4690
+ this.tunnelSyncInProgress = true;
4691
+ try {
4692
+ const config = await (0, import_core5.loadConfig)();
4693
+ if (!config?.access_token) {
4694
+ return;
4695
+ }
4696
+ const apiUrl = config.api_url || "https://episoda.dev";
4697
+ const tunnelManager = getTunnelManager();
4698
+ const runningTunnels = tunnelManager.getAllTunnels();
4699
+ const runningModuleUids = new Set(runningTunnels.map((t) => t.moduleUid));
4700
+ const response = await fetch(
4701
+ `${apiUrl}/api/modules?state=doing,review&fields=id,uid,dev_mode,tunnel_url,checkout_machine_id,project_id`,
4702
+ {
4703
+ headers: {
4704
+ "Authorization": `Bearer ${config.access_token}`,
4705
+ "Content-Type": "application/json"
4706
+ }
4707
+ }
4708
+ );
4709
+ if (!response.ok) {
4710
+ console.warn(`[Daemon] EP822: Failed to fetch modules: ${response.status}`);
4711
+ return;
4712
+ }
4713
+ const data = await response.json();
4714
+ const modules = data.modules || [];
4715
+ const activeLocalModules = modules.filter(
4716
+ (m) => m.dev_mode === "local" && (!m.checkout_machine_id || m.checkout_machine_id === this.deviceId)
4717
+ );
4718
+ const activeModuleUids = new Set(activeLocalModules.map((m) => m.uid));
4719
+ const modulesNeedingTunnel = activeLocalModules.filter(
4720
+ (m) => !runningModuleUids.has(m.uid)
4721
+ );
4722
+ const tunnelsToStop = runningTunnels.filter(
4723
+ (t) => !activeModuleUids.has(t.moduleUid)
4724
+ );
4725
+ if (modulesNeedingTunnel.length > 0) {
4726
+ console.log(`[Daemon] EP822: Starting tunnels for ${modulesNeedingTunnel.length} module(s)`);
4727
+ const modulesByProject = /* @__PURE__ */ new Map();
4728
+ for (const module2 of modulesNeedingTunnel) {
4729
+ const projectId = module2.project_id;
4730
+ if (!projectId) continue;
4731
+ if (!modulesByProject.has(projectId)) {
4732
+ modulesByProject.set(projectId, []);
4733
+ }
4734
+ modulesByProject.get(projectId).push(module2);
4735
+ }
4736
+ const trackedProjects = getAllProjects();
4737
+ for (const [projectId, projectModules] of modulesByProject) {
4738
+ const project = trackedProjects.find((p) => p.id === projectId);
4739
+ if (project) {
4740
+ console.log(`[Daemon] EP822: Starting ${projectModules.length} tunnel(s) for project ${projectId}`);
4741
+ await this.autoStartTunnelsForProject(project.path, project.id);
4742
+ } else {
4743
+ console.warn(`[Daemon] EP822: Project ${projectId} not tracked locally, skipping ${projectModules.length} module(s)`);
4744
+ }
4745
+ }
4746
+ }
4747
+ if (tunnelsToStop.length > 0) {
4748
+ console.log(`[Daemon] EP822: Stopping ${tunnelsToStop.length} orphaned tunnel(s)`);
4749
+ for (const tunnel of tunnelsToStop) {
4750
+ try {
4751
+ await tunnelManager.stopTunnel(tunnel.moduleUid);
4752
+ await stopDevServer(tunnel.moduleUid);
4753
+ try {
4754
+ await fetch(`${apiUrl}/api/modules/${tunnel.moduleUid}/tunnel`, {
4755
+ method: "DELETE",
4756
+ headers: {
4757
+ "Authorization": `Bearer ${config.access_token}`
4758
+ }
4759
+ });
4760
+ console.log(`[Daemon] EP822: Tunnel stopped and cleared for ${tunnel.moduleUid}`);
4761
+ } catch {
4762
+ }
4763
+ } catch (error) {
4764
+ console.error(`[Daemon] EP822: Failed to stop tunnel for ${tunnel.moduleUid}:`, error);
4765
+ }
4766
+ }
4767
+ }
4768
+ } catch (error) {
4769
+ console.error("[Daemon] EP822: Error syncing tunnels:", error);
4770
+ } finally {
4771
+ this.tunnelSyncInProgress = false;
4772
+ }
4773
+ }
3693
4774
  /**
3694
4775
  * Gracefully shutdown daemon
3695
4776
  */
@@ -3697,6 +4778,7 @@ var Daemon = class {
3697
4778
  if (this.shuttingDown) return;
3698
4779
  this.shuttingDown = true;
3699
4780
  console.log("[Daemon] Shutting down...");
4781
+ this.stopTunnelPolling();
3700
4782
  for (const [projectPath, connection] of this.connections) {
3701
4783
  if (connection.reconnectTimer) {
3702
4784
  clearTimeout(connection.reconnectTimer);
@@ -3704,6 +4786,13 @@ var Daemon = class {
3704
4786
  await connection.client.disconnect();
3705
4787
  }
3706
4788
  this.connections.clear();
4789
+ try {
4790
+ const tunnelManager = getTunnelManager();
4791
+ await tunnelManager.stopAllTunnels();
4792
+ console.log("[Daemon] All tunnels stopped");
4793
+ } catch (error) {
4794
+ console.error("[Daemon] Failed to stop tunnels:", error);
4795
+ }
3707
4796
  await this.ipcServer.stop();
3708
4797
  console.log("[Daemon] Shutdown complete");
3709
4798
  }
@@ -3715,8 +4804,8 @@ var Daemon = class {
3715
4804
  await this.shutdown();
3716
4805
  try {
3717
4806
  const pidPath = getPidFilePath();
3718
- if (fs5.existsSync(pidPath)) {
3719
- fs5.unlinkSync(pidPath);
4807
+ if (fs7.existsSync(pidPath)) {
4808
+ fs7.unlinkSync(pidPath);
3720
4809
  console.log("[Daemon] PID file cleaned up");
3721
4810
  }
3722
4811
  } catch (error) {