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