episoda 0.2.39 → 0.2.41

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.
@@ -2726,7 +2726,7 @@ var require_package = __commonJS({
2726
2726
  "package.json"(exports2, module2) {
2727
2727
  module2.exports = {
2728
2728
  name: "episoda",
2729
- version: "0.2.39",
2729
+ version: "0.2.41",
2730
2730
  description: "CLI tool for Episoda local development workflow orchestration",
2731
2731
  main: "dist/index.js",
2732
2732
  types: "dist/index.d.ts",
@@ -4018,7 +4018,134 @@ var import_events = require("events");
4018
4018
  var fs6 = __toESM(require("fs"));
4019
4019
  var path7 = __toESM(require("path"));
4020
4020
  var os2 = __toESM(require("os"));
4021
+
4022
+ // src/tunnel/tunnel-api.ts
4023
+ var import_core6 = __toESM(require_dist());
4024
+ async function provisionNamedTunnel(moduleId) {
4025
+ const config = await (0, import_core6.loadConfig)();
4026
+ if (!config?.access_token) {
4027
+ return { success: false, error: "Not authenticated" };
4028
+ }
4029
+ try {
4030
+ const apiUrl = config.api_url || "https://episoda.dev";
4031
+ const response = await fetch(`${apiUrl}/api/tunnels`, {
4032
+ method: "POST",
4033
+ headers: {
4034
+ "Authorization": `Bearer ${config.access_token}`,
4035
+ "Content-Type": "application/json"
4036
+ },
4037
+ body: JSON.stringify({ module_id: moduleId })
4038
+ });
4039
+ const data = await response.json();
4040
+ if (!response.ok) {
4041
+ return {
4042
+ success: false,
4043
+ error: data.error?.message || data.message || `HTTP ${response.status}`
4044
+ };
4045
+ }
4046
+ return {
4047
+ success: true,
4048
+ tunnel: data.data?.tunnel,
4049
+ message: data.data?.message
4050
+ };
4051
+ } catch (error) {
4052
+ return {
4053
+ success: false,
4054
+ error: error instanceof Error ? error.message : "Failed to provision tunnel"
4055
+ };
4056
+ }
4057
+ }
4058
+ async function provisionNamedTunnelByUid(moduleUid) {
4059
+ const config = await (0, import_core6.loadConfig)();
4060
+ if (!config?.access_token) {
4061
+ return { success: false, error: "Not authenticated" };
4062
+ }
4063
+ try {
4064
+ const apiUrl = config.api_url || "https://episoda.dev";
4065
+ const moduleResponse = await fetch(`${apiUrl}/api/modules/${moduleUid}`, {
4066
+ headers: {
4067
+ "Authorization": `Bearer ${config.access_token}`
4068
+ }
4069
+ });
4070
+ if (!moduleResponse.ok) {
4071
+ return {
4072
+ success: false,
4073
+ error: `Failed to find module ${moduleUid}`
4074
+ };
4075
+ }
4076
+ const moduleData = await moduleResponse.json();
4077
+ const moduleId = moduleData.data?.id;
4078
+ if (!moduleId) {
4079
+ return {
4080
+ success: false,
4081
+ error: `Module ${moduleUid} has no ID`
4082
+ };
4083
+ }
4084
+ return provisionNamedTunnel(moduleId);
4085
+ } catch (error) {
4086
+ return {
4087
+ success: false,
4088
+ error: error instanceof Error ? error.message : "Failed to provision tunnel"
4089
+ };
4090
+ }
4091
+ }
4092
+ async function updateTunnelStatus(moduleUid, status, error) {
4093
+ if (!moduleUid || moduleUid === "LOCAL") {
4094
+ return;
4095
+ }
4096
+ const config = await (0, import_core6.loadConfig)();
4097
+ if (!config?.access_token) {
4098
+ return;
4099
+ }
4100
+ try {
4101
+ const apiUrl = config.api_url || "https://episoda.dev";
4102
+ await fetch(`${apiUrl}/api/modules/${moduleUid}/tunnel`, {
4103
+ method: "PATCH",
4104
+ headers: {
4105
+ "Authorization": `Bearer ${config.access_token}`,
4106
+ "Content-Type": "application/json"
4107
+ },
4108
+ body: JSON.stringify({
4109
+ tunnel_health_status: status,
4110
+ tunnel_last_health_check: (/* @__PURE__ */ new Date()).toISOString(),
4111
+ ...error && { tunnel_error: error }
4112
+ })
4113
+ });
4114
+ } catch {
4115
+ }
4116
+ }
4117
+ async function clearTunnelUrl(moduleUid) {
4118
+ if (!moduleUid || moduleUid === "LOCAL") {
4119
+ return;
4120
+ }
4121
+ const config = await (0, import_core6.loadConfig)();
4122
+ if (!config?.access_token) {
4123
+ return;
4124
+ }
4125
+ try {
4126
+ const apiUrl = config.api_url || "https://episoda.dev";
4127
+ await fetch(`${apiUrl}/api/modules/${moduleUid}/tunnel`, {
4128
+ method: "DELETE",
4129
+ headers: {
4130
+ "Authorization": `Bearer ${config.access_token}`
4131
+ }
4132
+ });
4133
+ } catch {
4134
+ }
4135
+ }
4136
+
4137
+ // src/tunnel/tunnel-manager.ts
4021
4138
  var TUNNEL_PID_DIR = path7.join(os2.homedir(), ".episoda", "tunnels");
4139
+ var TUNNEL_TIMEOUTS = {
4140
+ /** Time to wait for Named Tunnel connection (includes API token fetch + connect) */
4141
+ NAMED_TUNNEL_CONNECT: 6e4,
4142
+ /** Time to wait for Quick Tunnel connection (simpler, faster connection) */
4143
+ QUICK_TUNNEL_CONNECT: 3e4,
4144
+ /** Time to wait for cloudflared process to start before giving up */
4145
+ PROCESS_START: 1e4,
4146
+ /** Grace period after starting cloudflared before checking status */
4147
+ STARTUP_GRACE: 2e3
4148
+ };
4022
4149
  var TUNNEL_URL_REGEX = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/i;
4023
4150
  var DEFAULT_RECONNECT_CONFIG = {
4024
4151
  maxRetries: 5,
@@ -4075,6 +4202,7 @@ var TunnelManager = class extends import_events.EventEmitter {
4075
4202
  }
4076
4203
  /**
4077
4204
  * EP877: Read PID from file
4205
+ * EP948: Enhanced with validation and stale file cleanup
4078
4206
  */
4079
4207
  readPidFile(moduleUid) {
4080
4208
  try {
@@ -4082,9 +4210,25 @@ var TunnelManager = class extends import_events.EventEmitter {
4082
4210
  if (!fs6.existsSync(pidPath)) {
4083
4211
  return null;
4084
4212
  }
4085
- const pid = parseInt(fs6.readFileSync(pidPath, "utf8").trim(), 10);
4086
- return isNaN(pid) ? null : pid;
4213
+ const content = fs6.readFileSync(pidPath, "utf8").trim();
4214
+ const pid = parseInt(content, 10);
4215
+ if (isNaN(pid) || pid <= 0) {
4216
+ console.warn(`[Tunnel] EP948: Invalid PID file content for ${moduleUid}: "${content}", removing stale file`);
4217
+ this.removePidFile(moduleUid);
4218
+ return null;
4219
+ }
4220
+ if (!this.isProcessRunning(pid)) {
4221
+ console.log(`[Tunnel] EP948: PID ${pid} for ${moduleUid} is not running, removing stale file`);
4222
+ this.removePidFile(moduleUid);
4223
+ return null;
4224
+ }
4225
+ return pid;
4087
4226
  } catch (error) {
4227
+ console.warn(`[Tunnel] EP948: Failed to read PID file for ${moduleUid}: ${error.message}`);
4228
+ try {
4229
+ this.removePidFile(moduleUid);
4230
+ } catch {
4231
+ }
4088
4232
  return null;
4089
4233
  }
4090
4234
  }
@@ -4288,10 +4432,190 @@ var TunnelManager = class extends import_events.EventEmitter {
4288
4432
  }, delay);
4289
4433
  }
4290
4434
  /**
4291
- * EP672-9: Internal method to start the tunnel process
4292
- * Separated from startTunnel to support reconnection
4435
+ * EP948: Start a Named Tunnel using a pre-provisioned token
4436
+ * Named Tunnels connect to a persistent tunnel created via Cloudflare API
4437
+ */
4438
+ async startNamedTunnelProcess(options, existingState) {
4439
+ const { moduleUid, port = 3e3, onUrl, onStatusChange, tunnelToken, previewUrl } = options;
4440
+ if (!tunnelToken) {
4441
+ return { success: false, error: "Named tunnel requires a token" };
4442
+ }
4443
+ if (!this.cloudflaredPath) {
4444
+ try {
4445
+ this.cloudflaredPath = await ensureCloudflared();
4446
+ } catch (error) {
4447
+ const errorMessage = error instanceof Error ? error.message : String(error);
4448
+ return { success: false, error: `Failed to get cloudflared: ${errorMessage}` };
4449
+ }
4450
+ }
4451
+ return new Promise((resolve3) => {
4452
+ const tunnelInfo = {
4453
+ moduleUid,
4454
+ url: previewUrl || "",
4455
+ // Named tunnels have a known URL
4456
+ port,
4457
+ status: "starting",
4458
+ startedAt: /* @__PURE__ */ new Date(),
4459
+ process: null
4460
+ };
4461
+ console.log(`[Tunnel] EP948: Starting Named Tunnel for ${moduleUid} with preview URL ${previewUrl}`);
4462
+ const process2 = (0, import_child_process6.spawn)(this.cloudflaredPath, [
4463
+ "tunnel",
4464
+ "run",
4465
+ "--token",
4466
+ tunnelToken
4467
+ ], {
4468
+ stdio: ["ignore", "pipe", "pipe"]
4469
+ });
4470
+ tunnelInfo.process = process2;
4471
+ tunnelInfo.pid = process2.pid;
4472
+ if (process2.pid) {
4473
+ this.writePidFile(moduleUid, process2.pid);
4474
+ }
4475
+ const state = existingState || {
4476
+ info: tunnelInfo,
4477
+ options,
4478
+ intentionallyStopped: false,
4479
+ retryCount: 0,
4480
+ retryTimeoutId: null
4481
+ };
4482
+ state.info = tunnelInfo;
4483
+ this.tunnelStates.set(moduleUid, state);
4484
+ let connected = false;
4485
+ let stderrBuffer = "";
4486
+ const connectionPatterns = [
4487
+ /Connection.*registered/i,
4488
+ // "Connection [id] registered"
4489
+ /Registered tunnel connection/i,
4490
+ // Alternative format
4491
+ /connected to.*connector/i,
4492
+ // "connected to [colo] connector ID"
4493
+ /Tunnel is ready/i,
4494
+ // Some versions use this
4495
+ /ingress.*rules.*applied/i,
4496
+ // Indicates ingress rules are active
4497
+ /Initial.*connection.*established/i
4498
+ // Initial connection message
4499
+ ];
4500
+ const checkConnection = (data) => {
4501
+ if (connected) return;
4502
+ const isConnected = connectionPatterns.some((pattern) => pattern.test(data));
4503
+ if (isConnected) {
4504
+ connected = true;
4505
+ tunnelInfo.status = "connected";
4506
+ tunnelInfo.url = previewUrl || "";
4507
+ console.log(`[Tunnel] EP948: Named Tunnel connected for ${moduleUid}: ${previewUrl}`);
4508
+ updateTunnelStatus(moduleUid, "healthy").catch(() => {
4509
+ });
4510
+ onStatusChange?.("connected");
4511
+ onUrl?.(tunnelInfo.url);
4512
+ this.emitEvent({
4513
+ type: "started",
4514
+ moduleUid,
4515
+ url: tunnelInfo.url
4516
+ });
4517
+ resolve3({ success: true, url: tunnelInfo.url });
4518
+ }
4519
+ };
4520
+ process2.stderr?.on("data", (data) => {
4521
+ stderrBuffer += data.toString();
4522
+ checkConnection(stderrBuffer);
4523
+ });
4524
+ process2.stdout?.on("data", (data) => {
4525
+ checkConnection(data.toString());
4526
+ });
4527
+ process2.on("exit", (code, signal) => {
4528
+ const wasConnected = tunnelInfo.status === "connected";
4529
+ tunnelInfo.status = "disconnected";
4530
+ const currentState = this.tunnelStates.get(moduleUid);
4531
+ if (!connected) {
4532
+ const errorMsg = `Named tunnel process exited with code ${code}`;
4533
+ tunnelInfo.status = "error";
4534
+ tunnelInfo.error = errorMsg;
4535
+ updateTunnelStatus(moduleUid, "error", errorMsg).catch(() => {
4536
+ });
4537
+ if (currentState && !currentState.intentionallyStopped) {
4538
+ this.attemptReconnect(moduleUid);
4539
+ } else {
4540
+ this.tunnelStates.delete(moduleUid);
4541
+ onStatusChange?.("error", errorMsg);
4542
+ this.emitEvent({ type: "error", moduleUid, error: errorMsg });
4543
+ }
4544
+ resolve3({ success: false, error: errorMsg });
4545
+ } else if (wasConnected) {
4546
+ if (currentState && !currentState.intentionallyStopped) {
4547
+ console.log(`[Tunnel] EP948: Named tunnel ${moduleUid} crashed unexpectedly, attempting reconnect...`);
4548
+ updateTunnelStatus(moduleUid, "disconnected").catch(() => {
4549
+ });
4550
+ onStatusChange?.("reconnecting");
4551
+ this.attemptReconnect(moduleUid);
4552
+ } else {
4553
+ updateTunnelStatus(moduleUid, "disconnected").catch(() => {
4554
+ });
4555
+ this.tunnelStates.delete(moduleUid);
4556
+ onStatusChange?.("disconnected");
4557
+ this.emitEvent({ type: "stopped", moduleUid });
4558
+ }
4559
+ }
4560
+ });
4561
+ process2.on("error", (error) => {
4562
+ tunnelInfo.status = "error";
4563
+ tunnelInfo.error = error.message;
4564
+ updateTunnelStatus(moduleUid, "error", error.message).catch(() => {
4565
+ });
4566
+ const currentState = this.tunnelStates.get(moduleUid);
4567
+ if (currentState && !currentState.intentionallyStopped) {
4568
+ this.attemptReconnect(moduleUid);
4569
+ } else {
4570
+ this.tunnelStates.delete(moduleUid);
4571
+ onStatusChange?.("error", error.message);
4572
+ this.emitEvent({ type: "error", moduleUid, error: error.message });
4573
+ }
4574
+ if (!connected) {
4575
+ resolve3({ success: false, error: error.message });
4576
+ }
4577
+ });
4578
+ setTimeout(() => {
4579
+ if (!connected) {
4580
+ process2.kill();
4581
+ if (stderrBuffer) {
4582
+ console.error(`[Tunnel] EP948: Named tunnel ${moduleUid} stderr before timeout:`);
4583
+ console.error(stderrBuffer.slice(-2e3));
4584
+ }
4585
+ const errorMsg = "Named tunnel connection timed out after 60 seconds. Check logs for cloudflared output.";
4586
+ tunnelInfo.status = "error";
4587
+ tunnelInfo.error = errorMsg;
4588
+ updateTunnelStatus(moduleUid, "error", errorMsg).catch(() => {
4589
+ });
4590
+ const currentState = this.tunnelStates.get(moduleUid);
4591
+ if (currentState && !currentState.intentionallyStopped) {
4592
+ this.attemptReconnect(moduleUid);
4593
+ } else {
4594
+ this.tunnelStates.delete(moduleUid);
4595
+ onStatusChange?.("error", errorMsg);
4596
+ this.emitEvent({ type: "error", moduleUid, error: errorMsg });
4597
+ }
4598
+ resolve3({ success: false, error: errorMsg });
4599
+ }
4600
+ }, TUNNEL_TIMEOUTS.NAMED_TUNNEL_CONNECT);
4601
+ });
4602
+ }
4603
+ /**
4604
+ * EP948: Route to the appropriate tunnel process method based on mode
4293
4605
  */
4294
4606
  async startTunnelProcess(options, existingState) {
4607
+ const mode = options.mode || "named";
4608
+ if (mode === "named" && options.tunnelToken) {
4609
+ return this.startNamedTunnelProcess(options, existingState);
4610
+ }
4611
+ console.log(`[Tunnel] EP948: Using Quick Tunnel mode for ${options.moduleUid}`);
4612
+ return this.startQuickTunnelProcess(options, existingState);
4613
+ }
4614
+ /**
4615
+ * EP672-9: Internal method to start the tunnel process (Quick Tunnel mode)
4616
+ * Separated from startTunnel to support reconnection
4617
+ */
4618
+ async startQuickTunnelProcess(options, existingState) {
4295
4619
  const { moduleUid, port = 3e3, onUrl, onStatusChange } = options;
4296
4620
  if (!this.cloudflaredPath) {
4297
4621
  try {
@@ -4419,7 +4743,7 @@ var TunnelManager = class extends import_events.EventEmitter {
4419
4743
  }
4420
4744
  resolve3({ success: false, error: errorMsg });
4421
4745
  }
4422
- }, 3e4);
4746
+ }, TUNNEL_TIMEOUTS.QUICK_TUNNEL_CONNECT);
4423
4747
  });
4424
4748
  }
4425
4749
  /**
@@ -4446,9 +4770,11 @@ var TunnelManager = class extends import_events.EventEmitter {
4446
4770
  * EP877: Internal start implementation with lock already held
4447
4771
  * EP901: Enhanced to clean up ALL orphaned cloudflared processes before starting
4448
4772
  * EP904: Added port-based deduplication to prevent multiple tunnels on same port
4773
+ * EP948: Added Named Tunnel provisioning via platform API
4449
4774
  */
4450
4775
  async startTunnelWithLock(options) {
4451
4776
  const { moduleUid, port = 3e3 } = options;
4777
+ let resolvedOptions = { ...options };
4452
4778
  const existingState = this.tunnelStates.get(moduleUid);
4453
4779
  if (existingState) {
4454
4780
  if (existingState.info.status === "connected") {
@@ -4475,7 +4801,23 @@ var TunnelManager = class extends import_events.EventEmitter {
4475
4801
  if (cleanup.cleaned > 0) {
4476
4802
  console.log(`[Tunnel] EP901: Pre-start cleanup removed ${cleanup.cleaned} orphaned processes`);
4477
4803
  }
4478
- return this.startTunnelProcess(options);
4804
+ const mode = resolvedOptions.mode || "named";
4805
+ if (mode === "named" && !resolvedOptions.tunnelToken && moduleUid !== "LOCAL") {
4806
+ console.log(`[Tunnel] EP948: Provisioning Named Tunnel for ${moduleUid}...`);
4807
+ const provisionResult = await provisionNamedTunnelByUid(moduleUid);
4808
+ if (provisionResult.success && provisionResult.tunnel) {
4809
+ console.log(`[Tunnel] EP948: Named Tunnel provisioned: ${provisionResult.tunnel.preview_url}`);
4810
+ resolvedOptions = {
4811
+ ...resolvedOptions,
4812
+ tunnelToken: provisionResult.tunnel.token,
4813
+ previewUrl: provisionResult.tunnel.preview_url
4814
+ };
4815
+ } else {
4816
+ console.warn(`[Tunnel] EP948: Named Tunnel provisioning failed: ${provisionResult.error}. Falling back to Quick Tunnel.`);
4817
+ resolvedOptions = { ...resolvedOptions, mode: "quick" };
4818
+ }
4819
+ }
4820
+ return this.startTunnelProcess(resolvedOptions);
4479
4821
  }
4480
4822
  /**
4481
4823
  * Stop a tunnel for a module
@@ -4574,28 +4916,6 @@ function getTunnelManager() {
4574
4916
  return tunnelManagerInstance;
4575
4917
  }
4576
4918
 
4577
- // src/tunnel/tunnel-api.ts
4578
- var import_core6 = __toESM(require_dist());
4579
- async function clearTunnelUrl(moduleUid) {
4580
- if (!moduleUid || moduleUid === "LOCAL") {
4581
- return;
4582
- }
4583
- const config = await (0, import_core6.loadConfig)();
4584
- if (!config?.access_token) {
4585
- return;
4586
- }
4587
- try {
4588
- const apiUrl = config.api_url || "https://episoda.dev";
4589
- await fetch(`${apiUrl}/api/modules/${moduleUid}/tunnel`, {
4590
- method: "DELETE",
4591
- headers: {
4592
- "Authorization": `Bearer ${config.access_token}`
4593
- }
4594
- });
4595
- } catch {
4596
- }
4597
- }
4598
-
4599
4919
  // src/agent/claude-binary.ts
4600
4920
  var import_child_process7 = require("child_process");
4601
4921
  var path8 = __toESM(require("path"));
@@ -6892,10 +7212,17 @@ var Daemon = class _Daemon {
6892
7212
  serverUrl = config.project_settings.local_server_url;
6893
7213
  console.log(`[Daemon] Using cached server URL: ${serverUrl}`);
6894
7214
  }
6895
- const serverUrlObj = new URL(serverUrl);
6896
- const wsProtocol = serverUrlObj.protocol === "https:" ? "wss:" : "ws:";
6897
- const wsPort = process.env.EPISODA_WS_PORT || "3001";
6898
- const wsUrl = `${wsProtocol}//${serverUrlObj.hostname}:${wsPort}`;
7215
+ let wsUrl;
7216
+ if (config.ws_url) {
7217
+ wsUrl = config.ws_url;
7218
+ console.log(`[Daemon] Using configured ws_url: ${wsUrl}`);
7219
+ } else {
7220
+ const serverUrlObj = new URL(serverUrl);
7221
+ const wsProtocol = serverUrlObj.protocol === "https:" ? "wss:" : "ws:";
7222
+ const wsPort = process.env.EPISODA_WS_PORT || "3001";
7223
+ const wsHostname = serverUrlObj.hostname === "episoda.dev" ? "ws.episoda.dev" : serverUrlObj.hostname;
7224
+ wsUrl = `${wsProtocol}//${wsHostname}:${wsPort}`;
7225
+ }
6899
7226
  console.log(`[Daemon] Connecting to ${wsUrl} for project ${projectId}...`);
6900
7227
  const client = new import_core10.EpisodaClient();
6901
7228
  const gitExecutor = new import_core10.GitExecutor();