episoda 0.2.15 → 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;
@@ -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.15",
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,10 +3154,643 @@ 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
3795
  var Daemon = class {
3161
3796
  constructor() {
@@ -3235,9 +3870,9 @@ var Daemon = class {
3235
3870
  machineId: this.machineId,
3236
3871
  deviceId: this.deviceId,
3237
3872
  // EP726: UUID for unified device identification
3238
- hostname: os.hostname(),
3239
- platform: os.platform(),
3240
- arch: os.arch(),
3873
+ hostname: os2.hostname(),
3874
+ platform: os2.platform(),
3875
+ arch: os2.arch(),
3241
3876
  projects
3242
3877
  };
3243
3878
  });
@@ -3462,6 +4097,150 @@ var Daemon = class {
3462
4097
  }
3463
4098
  }
3464
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
+ });
3465
4244
  client.on("shutdown", async (message) => {
3466
4245
  const shutdownMessage = message;
3467
4246
  const reason = shutdownMessage.reason || "unknown";
@@ -3514,8 +4293,8 @@ var Daemon = class {
3514
4293
  let daemonPid;
3515
4294
  try {
3516
4295
  const pidPath = getPidFilePath();
3517
- if (fs5.existsSync(pidPath)) {
3518
- const pidStr = fs5.readFileSync(pidPath, "utf-8").trim();
4296
+ if (fs7.existsSync(pidPath)) {
4297
+ const pidStr = fs7.readFileSync(pidPath, "utf-8").trim();
3519
4298
  daemonPid = parseInt(pidStr, 10);
3520
4299
  }
3521
4300
  } catch (pidError) {
@@ -3539,9 +4318,9 @@ var Daemon = class {
3539
4318
  client.once("auth_error", errorHandler);
3540
4319
  });
3541
4320
  await client.connect(wsUrl, config.access_token, this.machineId, {
3542
- hostname: os.hostname(),
3543
- osPlatform: os.platform(),
3544
- osArch: os.arch(),
4321
+ hostname: os2.hostname(),
4322
+ osPlatform: os2.platform(),
4323
+ osArch: os2.arch(),
3545
4324
  daemonPid
3546
4325
  });
3547
4326
  console.log(`[Daemon] Successfully connected to project ${projectId}`);
@@ -3594,29 +4373,29 @@ var Daemon = class {
3594
4373
  */
3595
4374
  async configureGitUser(projectPath, userId, workspaceId, machineId, projectId, deviceId) {
3596
4375
  try {
3597
- const { execSync: execSync2 } = await import("child_process");
3598
- execSync2(`git config episoda.userId ${userId}`, {
4376
+ const { execSync: execSync3 } = await import("child_process");
4377
+ execSync3(`git config episoda.userId ${userId}`, {
3599
4378
  cwd: projectPath,
3600
4379
  encoding: "utf8",
3601
4380
  stdio: "pipe"
3602
4381
  });
3603
- execSync2(`git config episoda.workspaceId ${workspaceId}`, {
4382
+ execSync3(`git config episoda.workspaceId ${workspaceId}`, {
3604
4383
  cwd: projectPath,
3605
4384
  encoding: "utf8",
3606
4385
  stdio: "pipe"
3607
4386
  });
3608
- execSync2(`git config episoda.machineId ${machineId}`, {
4387
+ execSync3(`git config episoda.machineId ${machineId}`, {
3609
4388
  cwd: projectPath,
3610
4389
  encoding: "utf8",
3611
4390
  stdio: "pipe"
3612
4391
  });
3613
- execSync2(`git config episoda.projectId ${projectId}`, {
4392
+ execSync3(`git config episoda.projectId ${projectId}`, {
3614
4393
  cwd: projectPath,
3615
4394
  encoding: "utf8",
3616
4395
  stdio: "pipe"
3617
4396
  });
3618
4397
  if (deviceId) {
3619
- execSync2(`git config episoda.deviceId ${deviceId}`, {
4398
+ execSync3(`git config episoda.deviceId ${deviceId}`, {
3620
4399
  cwd: projectPath,
3621
4400
  encoding: "utf8",
3622
4401
  stdio: "pipe"
@@ -3636,27 +4415,27 @@ var Daemon = class {
3636
4415
  */
3637
4416
  async installGitHooks(projectPath) {
3638
4417
  const hooks = ["post-checkout", "pre-commit"];
3639
- const hooksDir = path6.join(projectPath, ".git", "hooks");
3640
- if (!fs5.existsSync(hooksDir)) {
4418
+ const hooksDir = path8.join(projectPath, ".git", "hooks");
4419
+ if (!fs7.existsSync(hooksDir)) {
3641
4420
  console.warn(`[Daemon] Hooks directory not found: ${hooksDir}`);
3642
4421
  return;
3643
4422
  }
3644
4423
  for (const hookName of hooks) {
3645
4424
  try {
3646
- const hookPath = path6.join(hooksDir, hookName);
3647
- const bundledHookPath = path6.join(__dirname, "..", "hooks", hookName);
3648
- if (!fs5.existsSync(bundledHookPath)) {
4425
+ const hookPath = path8.join(hooksDir, hookName);
4426
+ const bundledHookPath = path8.join(__dirname, "..", "hooks", hookName);
4427
+ if (!fs7.existsSync(bundledHookPath)) {
3649
4428
  console.warn(`[Daemon] Bundled hook not found: ${bundledHookPath}`);
3650
4429
  continue;
3651
4430
  }
3652
- const hookContent = fs5.readFileSync(bundledHookPath, "utf-8");
3653
- if (fs5.existsSync(hookPath)) {
3654
- 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");
3655
4434
  if (existingContent === hookContent) {
3656
4435
  continue;
3657
4436
  }
3658
4437
  }
3659
- fs5.writeFileSync(hookPath, hookContent, { mode: 493 });
4438
+ fs7.writeFileSync(hookPath, hookContent, { mode: 493 });
3660
4439
  console.log(`[Daemon] Installed git hook: ${hookName}`);
3661
4440
  } catch (error) {
3662
4441
  console.warn(`[Daemon] Failed to install ${hookName} hook:`, error instanceof Error ? error.message : error);
@@ -3704,6 +4483,13 @@ var Daemon = class {
3704
4483
  await connection.client.disconnect();
3705
4484
  }
3706
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
+ }
3707
4493
  await this.ipcServer.stop();
3708
4494
  console.log("[Daemon] Shutdown complete");
3709
4495
  }
@@ -3715,8 +4501,8 @@ var Daemon = class {
3715
4501
  await this.shutdown();
3716
4502
  try {
3717
4503
  const pidPath = getPidFilePath();
3718
- if (fs5.existsSync(pidPath)) {
3719
- fs5.unlinkSync(pidPath);
4504
+ if (fs7.existsSync(pidPath)) {
4505
+ fs7.unlinkSync(pidPath);
3720
4506
  console.log("[Daemon] PID file cleaned up");
3721
4507
  }
3722
4508
  } catch (error) {