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