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
|
@@ -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;
|
|
@@ -2118,31 +2118,31 @@ var require_auth = __commonJS({
|
|
|
2118
2118
|
exports2.loadConfig = loadConfig2;
|
|
2119
2119
|
exports2.saveConfig = saveConfig2;
|
|
2120
2120
|
exports2.validateToken = validateToken;
|
|
2121
|
-
var
|
|
2122
|
-
var
|
|
2123
|
-
var
|
|
2121
|
+
var fs8 = __importStar(require("fs"));
|
|
2122
|
+
var path9 = __importStar(require("path"));
|
|
2123
|
+
var os3 = __importStar(require("os"));
|
|
2124
2124
|
var child_process_1 = require("child_process");
|
|
2125
2125
|
var DEFAULT_CONFIG_FILE = "config.json";
|
|
2126
2126
|
function getConfigDir5() {
|
|
2127
|
-
return process.env.EPISODA_CONFIG_DIR ||
|
|
2127
|
+
return process.env.EPISODA_CONFIG_DIR || path9.join(os3.homedir(), ".episoda");
|
|
2128
2128
|
}
|
|
2129
2129
|
function getConfigPath(configPath) {
|
|
2130
2130
|
if (configPath) {
|
|
2131
2131
|
return configPath;
|
|
2132
2132
|
}
|
|
2133
|
-
return
|
|
2133
|
+
return path9.join(getConfigDir5(), DEFAULT_CONFIG_FILE);
|
|
2134
2134
|
}
|
|
2135
2135
|
function ensureConfigDir(configPath) {
|
|
2136
|
-
const dir =
|
|
2137
|
-
const isNew = !
|
|
2136
|
+
const dir = path9.dirname(configPath);
|
|
2137
|
+
const isNew = !fs8.existsSync(dir);
|
|
2138
2138
|
if (isNew) {
|
|
2139
|
-
|
|
2139
|
+
fs8.mkdirSync(dir, { recursive: true, mode: 448 });
|
|
2140
2140
|
}
|
|
2141
2141
|
if (process.platform === "darwin") {
|
|
2142
|
-
const nosyncPath =
|
|
2143
|
-
if (isNew || !
|
|
2142
|
+
const nosyncPath = path9.join(dir, ".nosync");
|
|
2143
|
+
if (isNew || !fs8.existsSync(nosyncPath)) {
|
|
2144
2144
|
try {
|
|
2145
|
-
|
|
2145
|
+
fs8.writeFileSync(nosyncPath, "", { mode: 384 });
|
|
2146
2146
|
(0, child_process_1.execSync)(`xattr -w com.apple.fileprovider.ignore 1 "${dir}"`, {
|
|
2147
2147
|
stdio: "ignore",
|
|
2148
2148
|
timeout: 5e3
|
|
@@ -2154,11 +2154,11 @@ var require_auth = __commonJS({
|
|
|
2154
2154
|
}
|
|
2155
2155
|
async function loadConfig2(configPath) {
|
|
2156
2156
|
const fullPath = getConfigPath(configPath);
|
|
2157
|
-
if (!
|
|
2157
|
+
if (!fs8.existsSync(fullPath)) {
|
|
2158
2158
|
return null;
|
|
2159
2159
|
}
|
|
2160
2160
|
try {
|
|
2161
|
-
const content =
|
|
2161
|
+
const content = fs8.readFileSync(fullPath, "utf8");
|
|
2162
2162
|
const config = JSON.parse(content);
|
|
2163
2163
|
return config;
|
|
2164
2164
|
} catch (error) {
|
|
@@ -2171,7 +2171,7 @@ var require_auth = __commonJS({
|
|
|
2171
2171
|
ensureConfigDir(fullPath);
|
|
2172
2172
|
try {
|
|
2173
2173
|
const content = JSON.stringify(config, null, 2);
|
|
2174
|
-
|
|
2174
|
+
fs8.writeFileSync(fullPath, content, { mode: 384 });
|
|
2175
2175
|
} catch (error) {
|
|
2176
2176
|
throw new Error(`Failed to save config: ${error instanceof Error ? error.message : String(error)}`);
|
|
2177
2177
|
}
|
|
@@ -2280,7 +2280,7 @@ var require_package = __commonJS({
|
|
|
2280
2280
|
"package.json"(exports2, module2) {
|
|
2281
2281
|
module2.exports = {
|
|
2282
2282
|
name: "episoda",
|
|
2283
|
-
version: "0.2.
|
|
2283
|
+
version: "0.2.16",
|
|
2284
2284
|
description: "CLI tool for Episoda local development workflow orchestration",
|
|
2285
2285
|
main: "dist/index.js",
|
|
2286
2286
|
types: "dist/index.d.ts",
|
|
@@ -2307,6 +2307,7 @@ var require_package = __commonJS({
|
|
|
2307
2307
|
commander: "^11.1.0",
|
|
2308
2308
|
ora: "^5.4.1",
|
|
2309
2309
|
semver: "7.7.3",
|
|
2310
|
+
tar: "7.5.2",
|
|
2310
2311
|
ws: "^8.18.0",
|
|
2311
2312
|
zod: "^4.0.10"
|
|
2312
2313
|
},
|
|
@@ -2314,6 +2315,7 @@ var require_package = __commonJS({
|
|
|
2314
2315
|
"@episoda/core": "*",
|
|
2315
2316
|
"@types/node": "^20.11.24",
|
|
2316
2317
|
"@types/semver": "7.7.1",
|
|
2318
|
+
"@types/tar": "6.1.13",
|
|
2317
2319
|
"@types/ws": "^8.5.10",
|
|
2318
2320
|
tsup: "8.5.1",
|
|
2319
2321
|
typescript: "^5.3.3"
|
|
@@ -3152,12 +3154,645 @@ async function handleExec(command, projectPath) {
|
|
|
3152
3154
|
});
|
|
3153
3155
|
}
|
|
3154
3156
|
|
|
3155
|
-
// src/
|
|
3157
|
+
// src/tunnel/cloudflared-manager.ts
|
|
3158
|
+
var import_child_process4 = require("child_process");
|
|
3156
3159
|
var fs5 = __toESM(require("fs"));
|
|
3157
|
-
var os = __toESM(require("os"));
|
|
3158
3160
|
var path6 = __toESM(require("path"));
|
|
3161
|
+
var os = __toESM(require("os"));
|
|
3162
|
+
var https = __toESM(require("https"));
|
|
3163
|
+
var tar = __toESM(require("tar"));
|
|
3164
|
+
var DOWNLOAD_URLS = {
|
|
3165
|
+
darwin: {
|
|
3166
|
+
arm64: "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-darwin-arm64.tgz",
|
|
3167
|
+
x64: "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-darwin-amd64.tgz"
|
|
3168
|
+
},
|
|
3169
|
+
linux: {
|
|
3170
|
+
arm64: "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-arm64",
|
|
3171
|
+
x64: "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64"
|
|
3172
|
+
},
|
|
3173
|
+
win32: {
|
|
3174
|
+
x64: "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-windows-amd64.exe",
|
|
3175
|
+
ia32: "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-windows-386.exe"
|
|
3176
|
+
}
|
|
3177
|
+
};
|
|
3178
|
+
function getEpisodaBinDir() {
|
|
3179
|
+
return path6.join(os.homedir(), ".episoda", "bin");
|
|
3180
|
+
}
|
|
3181
|
+
function getCloudflaredPath() {
|
|
3182
|
+
const binaryName = os.platform() === "win32" ? "cloudflared.exe" : "cloudflared";
|
|
3183
|
+
return path6.join(getEpisodaBinDir(), binaryName);
|
|
3184
|
+
}
|
|
3185
|
+
function isCloudflaredInPath() {
|
|
3186
|
+
try {
|
|
3187
|
+
const command = os.platform() === "win32" ? "where" : "which";
|
|
3188
|
+
const binaryName = os.platform() === "win32" ? "cloudflared.exe" : "cloudflared";
|
|
3189
|
+
const result = (0, import_child_process4.spawnSync)(command, [binaryName], { encoding: "utf-8" });
|
|
3190
|
+
if (result.status === 0 && result.stdout.trim()) {
|
|
3191
|
+
return result.stdout.trim().split("\n")[0].trim();
|
|
3192
|
+
}
|
|
3193
|
+
} catch {
|
|
3194
|
+
}
|
|
3195
|
+
return null;
|
|
3196
|
+
}
|
|
3197
|
+
function isCloudflaredInstalled() {
|
|
3198
|
+
const cloudflaredPath = getCloudflaredPath();
|
|
3199
|
+
try {
|
|
3200
|
+
fs5.accessSync(cloudflaredPath, fs5.constants.X_OK);
|
|
3201
|
+
return true;
|
|
3202
|
+
} catch {
|
|
3203
|
+
return false;
|
|
3204
|
+
}
|
|
3205
|
+
}
|
|
3206
|
+
function verifyCloudflared(binaryPath) {
|
|
3207
|
+
try {
|
|
3208
|
+
const result = (0, import_child_process4.spawnSync)(binaryPath, ["version"], { encoding: "utf-8", timeout: 5e3 });
|
|
3209
|
+
return result.status === 0 && result.stdout.includes("cloudflared");
|
|
3210
|
+
} catch {
|
|
3211
|
+
return false;
|
|
3212
|
+
}
|
|
3213
|
+
}
|
|
3214
|
+
function getDownloadUrl() {
|
|
3215
|
+
const platform3 = os.platform();
|
|
3216
|
+
const arch3 = os.arch();
|
|
3217
|
+
const platformUrls = DOWNLOAD_URLS[platform3];
|
|
3218
|
+
if (!platformUrls) {
|
|
3219
|
+
return null;
|
|
3220
|
+
}
|
|
3221
|
+
return platformUrls[arch3] || null;
|
|
3222
|
+
}
|
|
3223
|
+
async function downloadFile(url, destPath) {
|
|
3224
|
+
return new Promise((resolve2, reject) => {
|
|
3225
|
+
const followRedirect = (currentUrl, redirectCount = 0) => {
|
|
3226
|
+
if (redirectCount > 5) {
|
|
3227
|
+
reject(new Error("Too many redirects"));
|
|
3228
|
+
return;
|
|
3229
|
+
}
|
|
3230
|
+
const urlObj = new URL(currentUrl);
|
|
3231
|
+
const options = {
|
|
3232
|
+
hostname: urlObj.hostname,
|
|
3233
|
+
path: urlObj.pathname + urlObj.search,
|
|
3234
|
+
headers: {
|
|
3235
|
+
"User-Agent": "episoda-cli"
|
|
3236
|
+
}
|
|
3237
|
+
};
|
|
3238
|
+
https.get(options, (response) => {
|
|
3239
|
+
if (response.statusCode === 301 || response.statusCode === 302) {
|
|
3240
|
+
const redirectUrl = response.headers.location;
|
|
3241
|
+
if (redirectUrl) {
|
|
3242
|
+
followRedirect(redirectUrl, redirectCount + 1);
|
|
3243
|
+
return;
|
|
3244
|
+
}
|
|
3245
|
+
}
|
|
3246
|
+
if (response.statusCode !== 200) {
|
|
3247
|
+
reject(new Error(`Failed to download: HTTP ${response.statusCode}`));
|
|
3248
|
+
return;
|
|
3249
|
+
}
|
|
3250
|
+
const file = fs5.createWriteStream(destPath);
|
|
3251
|
+
response.pipe(file);
|
|
3252
|
+
file.on("finish", () => {
|
|
3253
|
+
file.close();
|
|
3254
|
+
resolve2();
|
|
3255
|
+
});
|
|
3256
|
+
file.on("error", (err) => {
|
|
3257
|
+
fs5.unlinkSync(destPath);
|
|
3258
|
+
reject(err);
|
|
3259
|
+
});
|
|
3260
|
+
}).on("error", reject);
|
|
3261
|
+
};
|
|
3262
|
+
followRedirect(url);
|
|
3263
|
+
});
|
|
3264
|
+
}
|
|
3265
|
+
async function extractTgz(archivePath, destDir) {
|
|
3266
|
+
return tar.x({
|
|
3267
|
+
file: archivePath,
|
|
3268
|
+
cwd: destDir
|
|
3269
|
+
});
|
|
3270
|
+
}
|
|
3271
|
+
async function downloadCloudflared() {
|
|
3272
|
+
const url = getDownloadUrl();
|
|
3273
|
+
if (!url) {
|
|
3274
|
+
throw new Error(`Unsupported platform: ${os.platform()} ${os.arch()}`);
|
|
3275
|
+
}
|
|
3276
|
+
const binDir = getEpisodaBinDir();
|
|
3277
|
+
const cloudflaredPath = getCloudflaredPath();
|
|
3278
|
+
fs5.mkdirSync(binDir, { recursive: true });
|
|
3279
|
+
const isTgz = url.endsWith(".tgz");
|
|
3280
|
+
if (isTgz) {
|
|
3281
|
+
const tempFile = path6.join(binDir, "cloudflared.tgz");
|
|
3282
|
+
console.log(`[Tunnel] Downloading cloudflared from ${url}...`);
|
|
3283
|
+
await downloadFile(url, tempFile);
|
|
3284
|
+
console.log("[Tunnel] Extracting cloudflared...");
|
|
3285
|
+
await extractTgz(tempFile, binDir);
|
|
3286
|
+
fs5.unlinkSync(tempFile);
|
|
3287
|
+
} else {
|
|
3288
|
+
console.log(`[Tunnel] Downloading cloudflared from ${url}...`);
|
|
3289
|
+
await downloadFile(url, cloudflaredPath);
|
|
3290
|
+
}
|
|
3291
|
+
if (os.platform() !== "win32") {
|
|
3292
|
+
fs5.chmodSync(cloudflaredPath, 493);
|
|
3293
|
+
}
|
|
3294
|
+
if (!verifyCloudflared(cloudflaredPath)) {
|
|
3295
|
+
throw new Error("Downloaded cloudflared binary failed verification");
|
|
3296
|
+
}
|
|
3297
|
+
console.log("[Tunnel] cloudflared installed successfully");
|
|
3298
|
+
return cloudflaredPath;
|
|
3299
|
+
}
|
|
3300
|
+
async function ensureCloudflared() {
|
|
3301
|
+
const pathBinary = isCloudflaredInPath();
|
|
3302
|
+
if (pathBinary && verifyCloudflared(pathBinary)) {
|
|
3303
|
+
return pathBinary;
|
|
3304
|
+
}
|
|
3305
|
+
const episodaBinary = getCloudflaredPath();
|
|
3306
|
+
if (isCloudflaredInstalled() && verifyCloudflared(episodaBinary)) {
|
|
3307
|
+
return episodaBinary;
|
|
3308
|
+
}
|
|
3309
|
+
return downloadCloudflared();
|
|
3310
|
+
}
|
|
3311
|
+
|
|
3312
|
+
// src/tunnel/tunnel-manager.ts
|
|
3313
|
+
var import_child_process5 = require("child_process");
|
|
3314
|
+
var import_events = require("events");
|
|
3315
|
+
var TUNNEL_URL_REGEX = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/i;
|
|
3316
|
+
var DEFAULT_RECONNECT_CONFIG = {
|
|
3317
|
+
maxRetries: 5,
|
|
3318
|
+
initialDelayMs: 1e3,
|
|
3319
|
+
maxDelayMs: 3e4,
|
|
3320
|
+
backoffMultiplier: 2
|
|
3321
|
+
};
|
|
3322
|
+
var TunnelManager = class extends import_events.EventEmitter {
|
|
3323
|
+
constructor(config) {
|
|
3324
|
+
super();
|
|
3325
|
+
this.tunnelStates = /* @__PURE__ */ new Map();
|
|
3326
|
+
this.cloudflaredPath = null;
|
|
3327
|
+
this.reconnectConfig = { ...DEFAULT_RECONNECT_CONFIG, ...config };
|
|
3328
|
+
}
|
|
3329
|
+
/**
|
|
3330
|
+
* Emit typed tunnel events
|
|
3331
|
+
*/
|
|
3332
|
+
emitEvent(event) {
|
|
3333
|
+
this.emit("tunnel", event);
|
|
3334
|
+
}
|
|
3335
|
+
/**
|
|
3336
|
+
* Initialize the tunnel manager
|
|
3337
|
+
* Ensures cloudflared is available
|
|
3338
|
+
*/
|
|
3339
|
+
async initialize() {
|
|
3340
|
+
this.cloudflaredPath = await ensureCloudflared();
|
|
3341
|
+
}
|
|
3342
|
+
/**
|
|
3343
|
+
* EP672-9: Calculate delay for exponential backoff
|
|
3344
|
+
*/
|
|
3345
|
+
calculateBackoffDelay(retryCount) {
|
|
3346
|
+
const delay = this.reconnectConfig.initialDelayMs * Math.pow(this.reconnectConfig.backoffMultiplier, retryCount);
|
|
3347
|
+
return Math.min(delay, this.reconnectConfig.maxDelayMs);
|
|
3348
|
+
}
|
|
3349
|
+
/**
|
|
3350
|
+
* EP672-9: Attempt to reconnect a crashed tunnel
|
|
3351
|
+
*/
|
|
3352
|
+
async attemptReconnect(moduleUid) {
|
|
3353
|
+
const state = this.tunnelStates.get(moduleUid);
|
|
3354
|
+
if (!state || state.intentionallyStopped) {
|
|
3355
|
+
return;
|
|
3356
|
+
}
|
|
3357
|
+
if (state.retryCount >= this.reconnectConfig.maxRetries) {
|
|
3358
|
+
console.log(`[Tunnel] Max retries (${this.reconnectConfig.maxRetries}) reached for ${moduleUid}, giving up`);
|
|
3359
|
+
this.emitEvent({
|
|
3360
|
+
type: "error",
|
|
3361
|
+
moduleUid,
|
|
3362
|
+
error: `Tunnel failed after ${this.reconnectConfig.maxRetries} reconnection attempts`
|
|
3363
|
+
});
|
|
3364
|
+
state.options.onStatusChange?.("error", "Max reconnection attempts reached");
|
|
3365
|
+
this.tunnelStates.delete(moduleUid);
|
|
3366
|
+
return;
|
|
3367
|
+
}
|
|
3368
|
+
const delay = this.calculateBackoffDelay(state.retryCount);
|
|
3369
|
+
console.log(`[Tunnel] Reconnecting ${moduleUid} in ${delay}ms (attempt ${state.retryCount + 1}/${this.reconnectConfig.maxRetries})`);
|
|
3370
|
+
this.emitEvent({ type: "reconnecting", moduleUid });
|
|
3371
|
+
state.options.onStatusChange?.("reconnecting");
|
|
3372
|
+
state.retryTimeoutId = setTimeout(async () => {
|
|
3373
|
+
state.retryCount++;
|
|
3374
|
+
const result = await this.startTunnelProcess(state.options, state);
|
|
3375
|
+
if (result.success) {
|
|
3376
|
+
console.log(`[Tunnel] Reconnected ${moduleUid} successfully with new URL: ${result.url}`);
|
|
3377
|
+
state.retryCount = 0;
|
|
3378
|
+
}
|
|
3379
|
+
}, delay);
|
|
3380
|
+
}
|
|
3381
|
+
/**
|
|
3382
|
+
* EP672-9: Internal method to start the tunnel process
|
|
3383
|
+
* Separated from startTunnel to support reconnection
|
|
3384
|
+
*/
|
|
3385
|
+
async startTunnelProcess(options, existingState) {
|
|
3386
|
+
const { moduleUid, port = 3e3, onUrl, onStatusChange } = options;
|
|
3387
|
+
if (!this.cloudflaredPath) {
|
|
3388
|
+
try {
|
|
3389
|
+
this.cloudflaredPath = await ensureCloudflared();
|
|
3390
|
+
} catch (error) {
|
|
3391
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
3392
|
+
return { success: false, error: `Failed to get cloudflared: ${errorMessage}` };
|
|
3393
|
+
}
|
|
3394
|
+
}
|
|
3395
|
+
return new Promise((resolve2) => {
|
|
3396
|
+
const tunnelInfo = {
|
|
3397
|
+
moduleUid,
|
|
3398
|
+
url: "",
|
|
3399
|
+
port,
|
|
3400
|
+
status: "starting",
|
|
3401
|
+
startedAt: /* @__PURE__ */ new Date(),
|
|
3402
|
+
process: null
|
|
3403
|
+
// Will be set below
|
|
3404
|
+
};
|
|
3405
|
+
const process2 = (0, import_child_process5.spawn)(this.cloudflaredPath, [
|
|
3406
|
+
"tunnel",
|
|
3407
|
+
"--url",
|
|
3408
|
+
`http://localhost:${port}`
|
|
3409
|
+
], {
|
|
3410
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
3411
|
+
});
|
|
3412
|
+
tunnelInfo.process = process2;
|
|
3413
|
+
tunnelInfo.pid = process2.pid;
|
|
3414
|
+
const state = existingState || {
|
|
3415
|
+
info: tunnelInfo,
|
|
3416
|
+
options,
|
|
3417
|
+
intentionallyStopped: false,
|
|
3418
|
+
retryCount: 0,
|
|
3419
|
+
retryTimeoutId: null
|
|
3420
|
+
};
|
|
3421
|
+
state.info = tunnelInfo;
|
|
3422
|
+
this.tunnelStates.set(moduleUid, state);
|
|
3423
|
+
let urlFound = false;
|
|
3424
|
+
let stdoutBuffer = "";
|
|
3425
|
+
let stderrBuffer = "";
|
|
3426
|
+
const parseOutput = (data) => {
|
|
3427
|
+
if (urlFound) return;
|
|
3428
|
+
const match = data.match(TUNNEL_URL_REGEX);
|
|
3429
|
+
if (match) {
|
|
3430
|
+
urlFound = true;
|
|
3431
|
+
tunnelInfo.url = match[0];
|
|
3432
|
+
tunnelInfo.status = "connected";
|
|
3433
|
+
onStatusChange?.("connected");
|
|
3434
|
+
onUrl?.(tunnelInfo.url);
|
|
3435
|
+
this.emitEvent({
|
|
3436
|
+
type: "started",
|
|
3437
|
+
moduleUid,
|
|
3438
|
+
url: tunnelInfo.url
|
|
3439
|
+
});
|
|
3440
|
+
resolve2({ success: true, url: tunnelInfo.url });
|
|
3441
|
+
}
|
|
3442
|
+
};
|
|
3443
|
+
process2.stdout?.on("data", (data) => {
|
|
3444
|
+
stdoutBuffer += data.toString();
|
|
3445
|
+
parseOutput(stdoutBuffer);
|
|
3446
|
+
});
|
|
3447
|
+
process2.stderr?.on("data", (data) => {
|
|
3448
|
+
stderrBuffer += data.toString();
|
|
3449
|
+
parseOutput(stderrBuffer);
|
|
3450
|
+
});
|
|
3451
|
+
process2.on("exit", (code, signal) => {
|
|
3452
|
+
const wasConnected = tunnelInfo.status === "connected";
|
|
3453
|
+
tunnelInfo.status = "disconnected";
|
|
3454
|
+
const currentState = this.tunnelStates.get(moduleUid);
|
|
3455
|
+
if (!urlFound) {
|
|
3456
|
+
const errorMsg = `Tunnel process exited with code ${code}`;
|
|
3457
|
+
tunnelInfo.status = "error";
|
|
3458
|
+
tunnelInfo.error = errorMsg;
|
|
3459
|
+
if (currentState && !currentState.intentionallyStopped) {
|
|
3460
|
+
this.attemptReconnect(moduleUid);
|
|
3461
|
+
} else {
|
|
3462
|
+
this.tunnelStates.delete(moduleUid);
|
|
3463
|
+
onStatusChange?.("error", errorMsg);
|
|
3464
|
+
this.emitEvent({ type: "error", moduleUid, error: errorMsg });
|
|
3465
|
+
}
|
|
3466
|
+
resolve2({ success: false, error: errorMsg });
|
|
3467
|
+
} else if (wasConnected) {
|
|
3468
|
+
if (currentState && !currentState.intentionallyStopped) {
|
|
3469
|
+
console.log(`[Tunnel] ${moduleUid} crashed unexpectedly, attempting reconnect...`);
|
|
3470
|
+
onStatusChange?.("reconnecting");
|
|
3471
|
+
this.attemptReconnect(moduleUid);
|
|
3472
|
+
} else {
|
|
3473
|
+
this.tunnelStates.delete(moduleUid);
|
|
3474
|
+
onStatusChange?.("disconnected");
|
|
3475
|
+
this.emitEvent({ type: "stopped", moduleUid });
|
|
3476
|
+
}
|
|
3477
|
+
}
|
|
3478
|
+
});
|
|
3479
|
+
process2.on("error", (error) => {
|
|
3480
|
+
tunnelInfo.status = "error";
|
|
3481
|
+
tunnelInfo.error = error.message;
|
|
3482
|
+
const currentState = this.tunnelStates.get(moduleUid);
|
|
3483
|
+
if (currentState && !currentState.intentionallyStopped) {
|
|
3484
|
+
this.attemptReconnect(moduleUid);
|
|
3485
|
+
} else {
|
|
3486
|
+
this.tunnelStates.delete(moduleUid);
|
|
3487
|
+
onStatusChange?.("error", error.message);
|
|
3488
|
+
this.emitEvent({ type: "error", moduleUid, error: error.message });
|
|
3489
|
+
}
|
|
3490
|
+
if (!urlFound) {
|
|
3491
|
+
resolve2({ success: false, error: error.message });
|
|
3492
|
+
}
|
|
3493
|
+
});
|
|
3494
|
+
setTimeout(() => {
|
|
3495
|
+
if (!urlFound) {
|
|
3496
|
+
process2.kill();
|
|
3497
|
+
const errorMsg = "Tunnel startup timed out after 30 seconds";
|
|
3498
|
+
tunnelInfo.status = "error";
|
|
3499
|
+
tunnelInfo.error = errorMsg;
|
|
3500
|
+
const currentState = this.tunnelStates.get(moduleUid);
|
|
3501
|
+
if (currentState && !currentState.intentionallyStopped) {
|
|
3502
|
+
this.attemptReconnect(moduleUid);
|
|
3503
|
+
} else {
|
|
3504
|
+
this.tunnelStates.delete(moduleUid);
|
|
3505
|
+
onStatusChange?.("error", errorMsg);
|
|
3506
|
+
this.emitEvent({ type: "error", moduleUid, error: errorMsg });
|
|
3507
|
+
}
|
|
3508
|
+
resolve2({ success: false, error: errorMsg });
|
|
3509
|
+
}
|
|
3510
|
+
}, 3e4);
|
|
3511
|
+
});
|
|
3512
|
+
}
|
|
3513
|
+
/**
|
|
3514
|
+
* Start a tunnel for a module
|
|
3515
|
+
*/
|
|
3516
|
+
async startTunnel(options) {
|
|
3517
|
+
const { moduleUid } = options;
|
|
3518
|
+
const existingState = this.tunnelStates.get(moduleUid);
|
|
3519
|
+
if (existingState) {
|
|
3520
|
+
if (existingState.info.status === "connected") {
|
|
3521
|
+
return { success: true, url: existingState.info.url };
|
|
3522
|
+
}
|
|
3523
|
+
await this.stopTunnel(moduleUid);
|
|
3524
|
+
}
|
|
3525
|
+
return this.startTunnelProcess(options);
|
|
3526
|
+
}
|
|
3527
|
+
/**
|
|
3528
|
+
* Stop a tunnel for a module
|
|
3529
|
+
*/
|
|
3530
|
+
async stopTunnel(moduleUid) {
|
|
3531
|
+
const state = this.tunnelStates.get(moduleUid);
|
|
3532
|
+
if (!state) {
|
|
3533
|
+
return;
|
|
3534
|
+
}
|
|
3535
|
+
state.intentionallyStopped = true;
|
|
3536
|
+
if (state.retryTimeoutId) {
|
|
3537
|
+
clearTimeout(state.retryTimeoutId);
|
|
3538
|
+
state.retryTimeoutId = null;
|
|
3539
|
+
}
|
|
3540
|
+
const tunnel = state.info;
|
|
3541
|
+
if (tunnel.process && !tunnel.process.killed) {
|
|
3542
|
+
tunnel.process.kill("SIGTERM");
|
|
3543
|
+
await new Promise((resolve2) => {
|
|
3544
|
+
const timeout = setTimeout(() => {
|
|
3545
|
+
if (tunnel.process && !tunnel.process.killed) {
|
|
3546
|
+
tunnel.process.kill("SIGKILL");
|
|
3547
|
+
}
|
|
3548
|
+
resolve2();
|
|
3549
|
+
}, 3e3);
|
|
3550
|
+
tunnel.process.once("exit", () => {
|
|
3551
|
+
clearTimeout(timeout);
|
|
3552
|
+
resolve2();
|
|
3553
|
+
});
|
|
3554
|
+
});
|
|
3555
|
+
}
|
|
3556
|
+
this.tunnelStates.delete(moduleUid);
|
|
3557
|
+
this.emitEvent({ type: "stopped", moduleUid });
|
|
3558
|
+
}
|
|
3559
|
+
/**
|
|
3560
|
+
* Stop all active tunnels
|
|
3561
|
+
*/
|
|
3562
|
+
async stopAllTunnels() {
|
|
3563
|
+
const moduleUids = Array.from(this.tunnelStates.keys());
|
|
3564
|
+
await Promise.all(moduleUids.map((uid) => this.stopTunnel(uid)));
|
|
3565
|
+
}
|
|
3566
|
+
/**
|
|
3567
|
+
* Get information about an active tunnel
|
|
3568
|
+
*/
|
|
3569
|
+
getTunnel(moduleUid) {
|
|
3570
|
+
const state = this.tunnelStates.get(moduleUid);
|
|
3571
|
+
if (!state) return null;
|
|
3572
|
+
const { process: process2, ...info } = state.info;
|
|
3573
|
+
return info;
|
|
3574
|
+
}
|
|
3575
|
+
/**
|
|
3576
|
+
* Get all active tunnels
|
|
3577
|
+
*/
|
|
3578
|
+
getAllTunnels() {
|
|
3579
|
+
return Array.from(this.tunnelStates.values()).map((state) => {
|
|
3580
|
+
const { process: process2, ...info } = state.info;
|
|
3581
|
+
return info;
|
|
3582
|
+
});
|
|
3583
|
+
}
|
|
3584
|
+
/**
|
|
3585
|
+
* Check if a tunnel is active for a module
|
|
3586
|
+
*/
|
|
3587
|
+
hasTunnel(moduleUid) {
|
|
3588
|
+
return this.tunnelStates.has(moduleUid);
|
|
3589
|
+
}
|
|
3590
|
+
/**
|
|
3591
|
+
* Get the URL for an active tunnel
|
|
3592
|
+
*/
|
|
3593
|
+
getTunnelUrl(moduleUid) {
|
|
3594
|
+
return this.tunnelStates.get(moduleUid)?.info.url || null;
|
|
3595
|
+
}
|
|
3596
|
+
};
|
|
3597
|
+
var tunnelManagerInstance = null;
|
|
3598
|
+
function getTunnelManager() {
|
|
3599
|
+
if (!tunnelManagerInstance) {
|
|
3600
|
+
tunnelManagerInstance = new TunnelManager();
|
|
3601
|
+
}
|
|
3602
|
+
return tunnelManagerInstance;
|
|
3603
|
+
}
|
|
3604
|
+
|
|
3605
|
+
// src/utils/dev-server.ts
|
|
3606
|
+
var import_child_process6 = require("child_process");
|
|
3607
|
+
|
|
3608
|
+
// src/utils/port-check.ts
|
|
3609
|
+
var net2 = __toESM(require("net"));
|
|
3610
|
+
async function isPortInUse(port) {
|
|
3611
|
+
return new Promise((resolve2) => {
|
|
3612
|
+
const server = net2.createServer();
|
|
3613
|
+
server.once("error", (err) => {
|
|
3614
|
+
if (err.code === "EADDRINUSE") {
|
|
3615
|
+
resolve2(true);
|
|
3616
|
+
} else {
|
|
3617
|
+
resolve2(false);
|
|
3618
|
+
}
|
|
3619
|
+
});
|
|
3620
|
+
server.once("listening", () => {
|
|
3621
|
+
server.close();
|
|
3622
|
+
resolve2(false);
|
|
3623
|
+
});
|
|
3624
|
+
server.listen(port);
|
|
3625
|
+
});
|
|
3626
|
+
}
|
|
3627
|
+
|
|
3628
|
+
// src/utils/dev-server.ts
|
|
3629
|
+
var activeServers = /* @__PURE__ */ new Map();
|
|
3630
|
+
async function waitForPort(port, timeoutMs = 3e4) {
|
|
3631
|
+
const startTime = Date.now();
|
|
3632
|
+
const checkInterval = 500;
|
|
3633
|
+
while (Date.now() - startTime < timeoutMs) {
|
|
3634
|
+
if (await isPortInUse(port)) {
|
|
3635
|
+
return true;
|
|
3636
|
+
}
|
|
3637
|
+
await new Promise((resolve2) => setTimeout(resolve2, checkInterval));
|
|
3638
|
+
}
|
|
3639
|
+
return false;
|
|
3640
|
+
}
|
|
3641
|
+
async function startDevServer(projectPath, port = 3e3, moduleUid = "default") {
|
|
3642
|
+
if (await isPortInUse(port)) {
|
|
3643
|
+
console.log(`[DevServer] Server already running on port ${port}`);
|
|
3644
|
+
return { success: true, alreadyRunning: true };
|
|
3645
|
+
}
|
|
3646
|
+
if (activeServers.has(moduleUid)) {
|
|
3647
|
+
const existing = activeServers.get(moduleUid);
|
|
3648
|
+
if (existing && !existing.killed) {
|
|
3649
|
+
console.log(`[DevServer] Process already exists for ${moduleUid}`);
|
|
3650
|
+
return { success: true, alreadyRunning: true };
|
|
3651
|
+
}
|
|
3652
|
+
}
|
|
3653
|
+
console.log(`[DevServer] Starting dev server for ${moduleUid} on port ${port}...`);
|
|
3654
|
+
try {
|
|
3655
|
+
const devProcess = (0, import_child_process6.spawn)("npm", ["run", "dev"], {
|
|
3656
|
+
cwd: projectPath,
|
|
3657
|
+
env: {
|
|
3658
|
+
...process.env,
|
|
3659
|
+
PORT: String(port)
|
|
3660
|
+
},
|
|
3661
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
3662
|
+
detached: false
|
|
3663
|
+
});
|
|
3664
|
+
activeServers.set(moduleUid, devProcess);
|
|
3665
|
+
devProcess.stdout?.on("data", (data) => {
|
|
3666
|
+
const line = data.toString().trim();
|
|
3667
|
+
if (line) {
|
|
3668
|
+
console.log(`[DevServer:${moduleUid}] ${line}`);
|
|
3669
|
+
}
|
|
3670
|
+
});
|
|
3671
|
+
devProcess.stderr?.on("data", (data) => {
|
|
3672
|
+
const line = data.toString().trim();
|
|
3673
|
+
if (line) {
|
|
3674
|
+
console.error(`[DevServer:${moduleUid}] ${line}`);
|
|
3675
|
+
}
|
|
3676
|
+
});
|
|
3677
|
+
devProcess.on("exit", (code, signal) => {
|
|
3678
|
+
console.log(`[DevServer] Process for ${moduleUid} exited with code ${code}, signal ${signal}`);
|
|
3679
|
+
activeServers.delete(moduleUid);
|
|
3680
|
+
});
|
|
3681
|
+
devProcess.on("error", (error) => {
|
|
3682
|
+
console.error(`[DevServer] Process error for ${moduleUid}:`, error);
|
|
3683
|
+
activeServers.delete(moduleUid);
|
|
3684
|
+
});
|
|
3685
|
+
console.log(`[DevServer] Waiting for server to start on port ${port}...`);
|
|
3686
|
+
const serverReady = await waitForPort(port, 6e4);
|
|
3687
|
+
if (!serverReady) {
|
|
3688
|
+
devProcess.kill();
|
|
3689
|
+
activeServers.delete(moduleUid);
|
|
3690
|
+
return { success: false, error: "Dev server failed to start within timeout" };
|
|
3691
|
+
}
|
|
3692
|
+
console.log(`[DevServer] Server started successfully on port ${port}`);
|
|
3693
|
+
return { success: true };
|
|
3694
|
+
} catch (error) {
|
|
3695
|
+
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
3696
|
+
console.error(`[DevServer] Failed to start:`, error);
|
|
3697
|
+
return { success: false, error: errorMsg };
|
|
3698
|
+
}
|
|
3699
|
+
}
|
|
3700
|
+
async function stopDevServer(moduleUid) {
|
|
3701
|
+
const process2 = activeServers.get(moduleUid);
|
|
3702
|
+
if (process2 && !process2.killed) {
|
|
3703
|
+
console.log(`[DevServer] Stopping server for ${moduleUid}`);
|
|
3704
|
+
process2.kill("SIGTERM");
|
|
3705
|
+
await new Promise((resolve2) => setTimeout(resolve2, 2e3));
|
|
3706
|
+
if (!process2.killed) {
|
|
3707
|
+
process2.kill("SIGKILL");
|
|
3708
|
+
}
|
|
3709
|
+
activeServers.delete(moduleUid);
|
|
3710
|
+
}
|
|
3711
|
+
}
|
|
3712
|
+
async function ensureDevServer(projectPath, port = 3e3, moduleUid = "default") {
|
|
3713
|
+
if (await isPortInUse(port)) {
|
|
3714
|
+
return { success: true };
|
|
3715
|
+
}
|
|
3716
|
+
return startDevServer(projectPath, port, moduleUid);
|
|
3717
|
+
}
|
|
3718
|
+
|
|
3719
|
+
// src/utils/port-detect.ts
|
|
3720
|
+
var fs6 = __toESM(require("fs"));
|
|
3721
|
+
var path7 = __toESM(require("path"));
|
|
3722
|
+
var DEFAULT_PORT = 3e3;
|
|
3723
|
+
function detectDevPort(projectPath) {
|
|
3724
|
+
const envPort = getPortFromEnv(projectPath);
|
|
3725
|
+
if (envPort) {
|
|
3726
|
+
console.log(`[PortDetect] Found PORT=${envPort} in .env`);
|
|
3727
|
+
return envPort;
|
|
3728
|
+
}
|
|
3729
|
+
const scriptPort = getPortFromPackageJson(projectPath);
|
|
3730
|
+
if (scriptPort) {
|
|
3731
|
+
console.log(`[PortDetect] Found port ${scriptPort} in package.json dev script`);
|
|
3732
|
+
return scriptPort;
|
|
3733
|
+
}
|
|
3734
|
+
console.log(`[PortDetect] Using default port ${DEFAULT_PORT}`);
|
|
3735
|
+
return DEFAULT_PORT;
|
|
3736
|
+
}
|
|
3737
|
+
function getPortFromEnv(projectPath) {
|
|
3738
|
+
const envPaths = [
|
|
3739
|
+
path7.join(projectPath, ".env"),
|
|
3740
|
+
path7.join(projectPath, ".env.local"),
|
|
3741
|
+
path7.join(projectPath, ".env.development"),
|
|
3742
|
+
path7.join(projectPath, ".env.development.local")
|
|
3743
|
+
];
|
|
3744
|
+
for (const envPath of envPaths) {
|
|
3745
|
+
try {
|
|
3746
|
+
if (!fs6.existsSync(envPath)) continue;
|
|
3747
|
+
const content = fs6.readFileSync(envPath, "utf-8");
|
|
3748
|
+
const lines = content.split("\n");
|
|
3749
|
+
for (const line of lines) {
|
|
3750
|
+
const match = line.match(/^\s*PORT\s*=\s*["']?(\d+)["']?\s*(?:#.*)?$/);
|
|
3751
|
+
if (match) {
|
|
3752
|
+
const port = parseInt(match[1], 10);
|
|
3753
|
+
if (port > 0 && port < 65536) {
|
|
3754
|
+
return port;
|
|
3755
|
+
}
|
|
3756
|
+
}
|
|
3757
|
+
}
|
|
3758
|
+
} catch {
|
|
3759
|
+
}
|
|
3760
|
+
}
|
|
3761
|
+
return null;
|
|
3762
|
+
}
|
|
3763
|
+
function getPortFromPackageJson(projectPath) {
|
|
3764
|
+
const packageJsonPath = path7.join(projectPath, "package.json");
|
|
3765
|
+
try {
|
|
3766
|
+
if (!fs6.existsSync(packageJsonPath)) return null;
|
|
3767
|
+
const content = fs6.readFileSync(packageJsonPath, "utf-8");
|
|
3768
|
+
const pkg = JSON.parse(content);
|
|
3769
|
+
const devScript = pkg.scripts?.dev;
|
|
3770
|
+
if (!devScript) return null;
|
|
3771
|
+
const portMatch = devScript.match(/(?:--port[=\s]|-p[=\s])(\d+)/);
|
|
3772
|
+
if (portMatch) {
|
|
3773
|
+
const port = parseInt(portMatch[1], 10);
|
|
3774
|
+
if (port > 0 && port < 65536) {
|
|
3775
|
+
return port;
|
|
3776
|
+
}
|
|
3777
|
+
}
|
|
3778
|
+
const envMatch = devScript.match(/PORT[=\s](\d+)/);
|
|
3779
|
+
if (envMatch) {
|
|
3780
|
+
const port = parseInt(envMatch[1], 10);
|
|
3781
|
+
if (port > 0 && port < 65536) {
|
|
3782
|
+
return port;
|
|
3783
|
+
}
|
|
3784
|
+
}
|
|
3785
|
+
} catch {
|
|
3786
|
+
}
|
|
3787
|
+
return null;
|
|
3788
|
+
}
|
|
3789
|
+
|
|
3790
|
+
// src/daemon/daemon-process.ts
|
|
3791
|
+
var fs7 = __toESM(require("fs"));
|
|
3792
|
+
var os2 = __toESM(require("os"));
|
|
3793
|
+
var path8 = __toESM(require("path"));
|
|
3159
3794
|
var packageJson = require_package();
|
|
3160
|
-
var Daemon = class {
|
|
3795
|
+
var Daemon = class _Daemon {
|
|
3161
3796
|
constructor() {
|
|
3162
3797
|
this.machineId = "";
|
|
3163
3798
|
this.deviceId = null;
|
|
@@ -3177,8 +3812,16 @@ var Daemon = class {
|
|
|
3177
3812
|
this.pendingConnections = /* @__PURE__ */ new Set();
|
|
3178
3813
|
// projectPath
|
|
3179
3814
|
this.shuttingDown = false;
|
|
3815
|
+
// EP822: Periodic tunnel polling interval
|
|
3816
|
+
this.tunnelPollInterval = null;
|
|
3817
|
+
// 15 seconds
|
|
3818
|
+
// EP822: Prevent concurrent tunnel syncs (backpressure guard)
|
|
3819
|
+
this.tunnelSyncInProgress = false;
|
|
3180
3820
|
this.ipcServer = new IPCServer();
|
|
3181
3821
|
}
|
|
3822
|
+
static {
|
|
3823
|
+
this.TUNNEL_POLL_INTERVAL_MS = 15e3;
|
|
3824
|
+
}
|
|
3182
3825
|
/**
|
|
3183
3826
|
* Start the daemon
|
|
3184
3827
|
*/
|
|
@@ -3195,6 +3838,8 @@ var Daemon = class {
|
|
|
3195
3838
|
console.log("[Daemon] IPC server started");
|
|
3196
3839
|
this.registerIPCHandlers();
|
|
3197
3840
|
await this.restoreConnections();
|
|
3841
|
+
await this.cleanupOrphanedTunnels();
|
|
3842
|
+
this.startTunnelPolling();
|
|
3198
3843
|
this.setupShutdownHandlers();
|
|
3199
3844
|
console.log("[Daemon] Daemon started successfully");
|
|
3200
3845
|
this.checkAndNotifyUpdates();
|
|
@@ -3235,9 +3880,9 @@ var Daemon = class {
|
|
|
3235
3880
|
machineId: this.machineId,
|
|
3236
3881
|
deviceId: this.deviceId,
|
|
3237
3882
|
// EP726: UUID for unified device identification
|
|
3238
|
-
hostname:
|
|
3239
|
-
platform:
|
|
3240
|
-
arch:
|
|
3883
|
+
hostname: os2.hostname(),
|
|
3884
|
+
platform: os2.platform(),
|
|
3885
|
+
arch: os2.arch(),
|
|
3241
3886
|
projects
|
|
3242
3887
|
};
|
|
3243
3888
|
});
|
|
@@ -3462,6 +4107,150 @@ var Daemon = class {
|
|
|
3462
4107
|
}
|
|
3463
4108
|
}
|
|
3464
4109
|
});
|
|
4110
|
+
client.on("tunnel_command", async (message) => {
|
|
4111
|
+
if (message.type === "tunnel_command" && message.command) {
|
|
4112
|
+
const cmd = message.command;
|
|
4113
|
+
console.log(`[Daemon] Received tunnel command for ${projectId}:`, cmd.action);
|
|
4114
|
+
client.updateActivity();
|
|
4115
|
+
try {
|
|
4116
|
+
const tunnelManager = getTunnelManager();
|
|
4117
|
+
let result;
|
|
4118
|
+
if (cmd.action === "start") {
|
|
4119
|
+
const port = cmd.port || detectDevPort(projectPath);
|
|
4120
|
+
const previewUrl = `https://${cmd.moduleUid.toLowerCase()}-${cmd.projectUid.toLowerCase()}.episoda.site`;
|
|
4121
|
+
const reportTunnelStatus = async (data) => {
|
|
4122
|
+
const config2 = await (0, import_core5.loadConfig)();
|
|
4123
|
+
if (config2?.access_token) {
|
|
4124
|
+
try {
|
|
4125
|
+
const apiUrl = config2.api_url || "https://episoda.dev";
|
|
4126
|
+
const response = await fetch(`${apiUrl}/api/modules/${cmd.moduleUid}/tunnel`, {
|
|
4127
|
+
method: "POST",
|
|
4128
|
+
headers: {
|
|
4129
|
+
"Authorization": `Bearer ${config2.access_token}`,
|
|
4130
|
+
"Content-Type": "application/json"
|
|
4131
|
+
},
|
|
4132
|
+
body: JSON.stringify(data)
|
|
4133
|
+
});
|
|
4134
|
+
if (response.ok) {
|
|
4135
|
+
console.log(`[Daemon] Tunnel status reported for ${cmd.moduleUid}`);
|
|
4136
|
+
} else {
|
|
4137
|
+
console.warn(`[Daemon] Failed to report tunnel status: ${response.statusText}`);
|
|
4138
|
+
}
|
|
4139
|
+
} catch (reportError) {
|
|
4140
|
+
console.warn(`[Daemon] Error reporting tunnel status:`, reportError);
|
|
4141
|
+
}
|
|
4142
|
+
}
|
|
4143
|
+
};
|
|
4144
|
+
(async () => {
|
|
4145
|
+
const MAX_RETRIES = 3;
|
|
4146
|
+
const RETRY_DELAY_MS = 2e3;
|
|
4147
|
+
await reportTunnelStatus({
|
|
4148
|
+
tunnel_started_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
4149
|
+
tunnel_error: null
|
|
4150
|
+
// Clear any previous error
|
|
4151
|
+
});
|
|
4152
|
+
try {
|
|
4153
|
+
await tunnelManager.initialize();
|
|
4154
|
+
console.log(`[Daemon] Ensuring dev server is running on port ${port}...`);
|
|
4155
|
+
const devServerResult = await ensureDevServer(projectPath, port, cmd.moduleUid);
|
|
4156
|
+
if (!devServerResult.success) {
|
|
4157
|
+
const errorMsg2 = `Dev server failed to start: ${devServerResult.error}`;
|
|
4158
|
+
console.error(`[Daemon] ${errorMsg2}`);
|
|
4159
|
+
await reportTunnelStatus({ tunnel_error: errorMsg2 });
|
|
4160
|
+
return;
|
|
4161
|
+
}
|
|
4162
|
+
console.log(`[Daemon] Dev server ready on port ${port}`);
|
|
4163
|
+
let lastError;
|
|
4164
|
+
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
|
|
4165
|
+
console.log(`[Daemon] Starting tunnel (attempt ${attempt}/${MAX_RETRIES})...`);
|
|
4166
|
+
const startResult = await tunnelManager.startTunnel({
|
|
4167
|
+
moduleUid: cmd.moduleUid,
|
|
4168
|
+
port,
|
|
4169
|
+
onUrl: async (url) => {
|
|
4170
|
+
console.log(`[Daemon] Tunnel URL for ${cmd.moduleUid}: ${url}`);
|
|
4171
|
+
await reportTunnelStatus({
|
|
4172
|
+
tunnel_url: url,
|
|
4173
|
+
tunnel_error: null
|
|
4174
|
+
// Clear error on success
|
|
4175
|
+
});
|
|
4176
|
+
},
|
|
4177
|
+
onStatusChange: (status, error) => {
|
|
4178
|
+
if (status === "error") {
|
|
4179
|
+
console.error(`[Daemon] Tunnel error for ${cmd.moduleUid}: ${error}`);
|
|
4180
|
+
reportTunnelStatus({ tunnel_error: error || "Tunnel connection error" });
|
|
4181
|
+
} else if (status === "reconnecting") {
|
|
4182
|
+
console.log(`[Daemon] Tunnel reconnecting for ${cmd.moduleUid}...`);
|
|
4183
|
+
}
|
|
4184
|
+
}
|
|
4185
|
+
});
|
|
4186
|
+
if (startResult.success) {
|
|
4187
|
+
console.log(`[Daemon] Tunnel started successfully for ${cmd.moduleUid}`);
|
|
4188
|
+
return;
|
|
4189
|
+
}
|
|
4190
|
+
lastError = startResult.error;
|
|
4191
|
+
console.warn(`[Daemon] Tunnel start attempt ${attempt} failed: ${lastError}`);
|
|
4192
|
+
if (attempt < MAX_RETRIES) {
|
|
4193
|
+
console.log(`[Daemon] Retrying in ${RETRY_DELAY_MS}ms...`);
|
|
4194
|
+
await new Promise((resolve2) => setTimeout(resolve2, RETRY_DELAY_MS));
|
|
4195
|
+
}
|
|
4196
|
+
}
|
|
4197
|
+
const errorMsg = `Tunnel failed after ${MAX_RETRIES} attempts: ${lastError}`;
|
|
4198
|
+
console.error(`[Daemon] ${errorMsg}`);
|
|
4199
|
+
await reportTunnelStatus({ tunnel_error: errorMsg });
|
|
4200
|
+
} catch (error) {
|
|
4201
|
+
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
4202
|
+
console.error(`[Daemon] Async tunnel startup error:`, error);
|
|
4203
|
+
await reportTunnelStatus({ tunnel_error: `Unexpected error: ${errorMsg}` });
|
|
4204
|
+
}
|
|
4205
|
+
})();
|
|
4206
|
+
result = {
|
|
4207
|
+
success: true,
|
|
4208
|
+
previewUrl
|
|
4209
|
+
// Note: actual tunnel URL will be reported via API when ready
|
|
4210
|
+
};
|
|
4211
|
+
} else if (cmd.action === "stop") {
|
|
4212
|
+
await tunnelManager.stopTunnel(cmd.moduleUid);
|
|
4213
|
+
await stopDevServer(cmd.moduleUid);
|
|
4214
|
+
const config2 = await (0, import_core5.loadConfig)();
|
|
4215
|
+
if (config2?.access_token) {
|
|
4216
|
+
try {
|
|
4217
|
+
const apiUrl = config2.api_url || "https://episoda.dev";
|
|
4218
|
+
await fetch(`${apiUrl}/api/modules/${cmd.moduleUid}/tunnel`, {
|
|
4219
|
+
method: "DELETE",
|
|
4220
|
+
headers: {
|
|
4221
|
+
"Authorization": `Bearer ${config2.access_token}`
|
|
4222
|
+
}
|
|
4223
|
+
});
|
|
4224
|
+
console.log(`[Daemon] Tunnel URL cleared for ${cmd.moduleUid}`);
|
|
4225
|
+
} catch {
|
|
4226
|
+
}
|
|
4227
|
+
}
|
|
4228
|
+
result = { success: true };
|
|
4229
|
+
} else {
|
|
4230
|
+
result = {
|
|
4231
|
+
success: false,
|
|
4232
|
+
error: `Unknown tunnel action: ${cmd.action}`
|
|
4233
|
+
};
|
|
4234
|
+
}
|
|
4235
|
+
await client.send({
|
|
4236
|
+
type: "tunnel_result",
|
|
4237
|
+
commandId: message.id,
|
|
4238
|
+
result
|
|
4239
|
+
});
|
|
4240
|
+
console.log(`[Daemon] Tunnel command ${cmd.action} completed for ${cmd.moduleUid}:`, result.success ? "success" : "failed");
|
|
4241
|
+
} catch (error) {
|
|
4242
|
+
await client.send({
|
|
4243
|
+
type: "tunnel_result",
|
|
4244
|
+
commandId: message.id,
|
|
4245
|
+
result: {
|
|
4246
|
+
success: false,
|
|
4247
|
+
error: error instanceof Error ? error.message : String(error)
|
|
4248
|
+
}
|
|
4249
|
+
});
|
|
4250
|
+
console.error(`[Daemon] Tunnel command execution error:`, error);
|
|
4251
|
+
}
|
|
4252
|
+
}
|
|
4253
|
+
});
|
|
3465
4254
|
client.on("shutdown", async (message) => {
|
|
3466
4255
|
const shutdownMessage = message;
|
|
3467
4256
|
const reason = shutdownMessage.reason || "unknown";
|
|
@@ -3497,6 +4286,9 @@ var Daemon = class {
|
|
|
3497
4286
|
this.flyMachineId = authMessage.flyMachineId;
|
|
3498
4287
|
console.log(`[Daemon] Fly Machine ID: ${this.flyMachineId}`);
|
|
3499
4288
|
}
|
|
4289
|
+
this.autoStartTunnelsForProject(projectPath, projectId).catch((error) => {
|
|
4290
|
+
console.error(`[Daemon] EP819: Failed to auto-start tunnels:`, error);
|
|
4291
|
+
});
|
|
3500
4292
|
});
|
|
3501
4293
|
client.on("error", (message) => {
|
|
3502
4294
|
console.error(`[Daemon] Server error for ${projectId}:`, message);
|
|
@@ -3514,8 +4306,8 @@ var Daemon = class {
|
|
|
3514
4306
|
let daemonPid;
|
|
3515
4307
|
try {
|
|
3516
4308
|
const pidPath = getPidFilePath();
|
|
3517
|
-
if (
|
|
3518
|
-
const pidStr =
|
|
4309
|
+
if (fs7.existsSync(pidPath)) {
|
|
4310
|
+
const pidStr = fs7.readFileSync(pidPath, "utf-8").trim();
|
|
3519
4311
|
daemonPid = parseInt(pidStr, 10);
|
|
3520
4312
|
}
|
|
3521
4313
|
} catch (pidError) {
|
|
@@ -3539,9 +4331,9 @@ var Daemon = class {
|
|
|
3539
4331
|
client.once("auth_error", errorHandler);
|
|
3540
4332
|
});
|
|
3541
4333
|
await client.connect(wsUrl, config.access_token, this.machineId, {
|
|
3542
|
-
hostname:
|
|
3543
|
-
osPlatform:
|
|
3544
|
-
osArch:
|
|
4334
|
+
hostname: os2.hostname(),
|
|
4335
|
+
osPlatform: os2.platform(),
|
|
4336
|
+
osArch: os2.arch(),
|
|
3545
4337
|
daemonPid
|
|
3546
4338
|
});
|
|
3547
4339
|
console.log(`[Daemon] Successfully connected to project ${projectId}`);
|
|
@@ -3594,29 +4386,29 @@ var Daemon = class {
|
|
|
3594
4386
|
*/
|
|
3595
4387
|
async configureGitUser(projectPath, userId, workspaceId, machineId, projectId, deviceId) {
|
|
3596
4388
|
try {
|
|
3597
|
-
const { execSync:
|
|
3598
|
-
|
|
4389
|
+
const { execSync: execSync3 } = await import("child_process");
|
|
4390
|
+
execSync3(`git config episoda.userId ${userId}`, {
|
|
3599
4391
|
cwd: projectPath,
|
|
3600
4392
|
encoding: "utf8",
|
|
3601
4393
|
stdio: "pipe"
|
|
3602
4394
|
});
|
|
3603
|
-
|
|
4395
|
+
execSync3(`git config episoda.workspaceId ${workspaceId}`, {
|
|
3604
4396
|
cwd: projectPath,
|
|
3605
4397
|
encoding: "utf8",
|
|
3606
4398
|
stdio: "pipe"
|
|
3607
4399
|
});
|
|
3608
|
-
|
|
4400
|
+
execSync3(`git config episoda.machineId ${machineId}`, {
|
|
3609
4401
|
cwd: projectPath,
|
|
3610
4402
|
encoding: "utf8",
|
|
3611
4403
|
stdio: "pipe"
|
|
3612
4404
|
});
|
|
3613
|
-
|
|
4405
|
+
execSync3(`git config episoda.projectId ${projectId}`, {
|
|
3614
4406
|
cwd: projectPath,
|
|
3615
4407
|
encoding: "utf8",
|
|
3616
4408
|
stdio: "pipe"
|
|
3617
4409
|
});
|
|
3618
4410
|
if (deviceId) {
|
|
3619
|
-
|
|
4411
|
+
execSync3(`git config episoda.deviceId ${deviceId}`, {
|
|
3620
4412
|
cwd: projectPath,
|
|
3621
4413
|
encoding: "utf8",
|
|
3622
4414
|
stdio: "pipe"
|
|
@@ -3636,27 +4428,27 @@ var Daemon = class {
|
|
|
3636
4428
|
*/
|
|
3637
4429
|
async installGitHooks(projectPath) {
|
|
3638
4430
|
const hooks = ["post-checkout", "pre-commit"];
|
|
3639
|
-
const hooksDir =
|
|
3640
|
-
if (!
|
|
4431
|
+
const hooksDir = path8.join(projectPath, ".git", "hooks");
|
|
4432
|
+
if (!fs7.existsSync(hooksDir)) {
|
|
3641
4433
|
console.warn(`[Daemon] Hooks directory not found: ${hooksDir}`);
|
|
3642
4434
|
return;
|
|
3643
4435
|
}
|
|
3644
4436
|
for (const hookName of hooks) {
|
|
3645
4437
|
try {
|
|
3646
|
-
const hookPath =
|
|
3647
|
-
const bundledHookPath =
|
|
3648
|
-
if (!
|
|
4438
|
+
const hookPath = path8.join(hooksDir, hookName);
|
|
4439
|
+
const bundledHookPath = path8.join(__dirname, "..", "hooks", hookName);
|
|
4440
|
+
if (!fs7.existsSync(bundledHookPath)) {
|
|
3649
4441
|
console.warn(`[Daemon] Bundled hook not found: ${bundledHookPath}`);
|
|
3650
4442
|
continue;
|
|
3651
4443
|
}
|
|
3652
|
-
const hookContent =
|
|
3653
|
-
if (
|
|
3654
|
-
const existingContent =
|
|
4444
|
+
const hookContent = fs7.readFileSync(bundledHookPath, "utf-8");
|
|
4445
|
+
if (fs7.existsSync(hookPath)) {
|
|
4446
|
+
const existingContent = fs7.readFileSync(hookPath, "utf-8");
|
|
3655
4447
|
if (existingContent === hookContent) {
|
|
3656
4448
|
continue;
|
|
3657
4449
|
}
|
|
3658
4450
|
}
|
|
3659
|
-
|
|
4451
|
+
fs7.writeFileSync(hookPath, hookContent, { mode: 493 });
|
|
3660
4452
|
console.log(`[Daemon] Installed git hook: ${hookName}`);
|
|
3661
4453
|
} catch (error) {
|
|
3662
4454
|
console.warn(`[Daemon] Failed to install ${hookName} hook:`, error instanceof Error ? error.message : error);
|
|
@@ -3690,6 +4482,295 @@ var Daemon = class {
|
|
|
3690
4482
|
console.warn("[Daemon] Failed to cache device ID:", error instanceof Error ? error.message : error);
|
|
3691
4483
|
}
|
|
3692
4484
|
}
|
|
4485
|
+
/**
|
|
4486
|
+
* EP819: Auto-start tunnels for active local modules on daemon connect/reconnect
|
|
4487
|
+
*
|
|
4488
|
+
* Queries for modules in doing/review state with dev_mode=local that don't have
|
|
4489
|
+
* an active tunnel_url, and starts tunnels for each.
|
|
4490
|
+
*/
|
|
4491
|
+
async autoStartTunnelsForProject(projectPath, projectUid) {
|
|
4492
|
+
console.log(`[Daemon] EP819: Checking for active local modules to auto-start tunnels...`);
|
|
4493
|
+
try {
|
|
4494
|
+
const config = await (0, import_core5.loadConfig)();
|
|
4495
|
+
if (!config?.access_token) {
|
|
4496
|
+
console.warn(`[Daemon] EP819: No access token, skipping tunnel auto-start`);
|
|
4497
|
+
return;
|
|
4498
|
+
}
|
|
4499
|
+
const apiUrl = config.api_url || "https://episoda.dev";
|
|
4500
|
+
const response = await fetch(
|
|
4501
|
+
`${apiUrl}/api/modules?state=doing,review&fields=id,uid,dev_mode,tunnel_url,checkout_machine_id`,
|
|
4502
|
+
{
|
|
4503
|
+
headers: {
|
|
4504
|
+
"Authorization": `Bearer ${config.access_token}`,
|
|
4505
|
+
"Content-Type": "application/json"
|
|
4506
|
+
}
|
|
4507
|
+
}
|
|
4508
|
+
);
|
|
4509
|
+
if (!response.ok) {
|
|
4510
|
+
console.warn(`[Daemon] EP819: Failed to fetch modules: ${response.status}`);
|
|
4511
|
+
return;
|
|
4512
|
+
}
|
|
4513
|
+
const data = await response.json();
|
|
4514
|
+
const modules = data.modules || [];
|
|
4515
|
+
const tunnelManager = getTunnelManager();
|
|
4516
|
+
await tunnelManager.initialize();
|
|
4517
|
+
const localModulesNeedingTunnel = modules.filter(
|
|
4518
|
+
(m) => m.dev_mode === "local" && (!m.checkout_machine_id || m.checkout_machine_id === this.deviceId) && !tunnelManager.hasTunnel(m.uid)
|
|
4519
|
+
);
|
|
4520
|
+
if (localModulesNeedingTunnel.length === 0) {
|
|
4521
|
+
console.log(`[Daemon] EP819: No local modules need tunnel auto-start`);
|
|
4522
|
+
return;
|
|
4523
|
+
}
|
|
4524
|
+
console.log(`[Daemon] EP819: Found ${localModulesNeedingTunnel.length} local modules needing tunnels`);
|
|
4525
|
+
for (const module2 of localModulesNeedingTunnel) {
|
|
4526
|
+
const moduleUid = module2.uid;
|
|
4527
|
+
const port = detectDevPort(projectPath);
|
|
4528
|
+
console.log(`[Daemon] EP819: Auto-starting tunnel for ${moduleUid} on port ${port}`);
|
|
4529
|
+
const reportTunnelStatus = async (statusData) => {
|
|
4530
|
+
try {
|
|
4531
|
+
const statusResponse = await fetch(`${apiUrl}/api/modules/${moduleUid}/tunnel`, {
|
|
4532
|
+
method: "POST",
|
|
4533
|
+
headers: {
|
|
4534
|
+
"Authorization": `Bearer ${config.access_token}`,
|
|
4535
|
+
"Content-Type": "application/json"
|
|
4536
|
+
},
|
|
4537
|
+
body: JSON.stringify(statusData)
|
|
4538
|
+
});
|
|
4539
|
+
if (statusResponse.ok) {
|
|
4540
|
+
console.log(`[Daemon] EP819: Tunnel status reported for ${moduleUid}`);
|
|
4541
|
+
} else {
|
|
4542
|
+
console.warn(`[Daemon] EP819: Failed to report tunnel status: ${statusResponse.statusText}`);
|
|
4543
|
+
}
|
|
4544
|
+
} catch (reportError) {
|
|
4545
|
+
console.warn(`[Daemon] EP819: Error reporting tunnel status:`, reportError);
|
|
4546
|
+
}
|
|
4547
|
+
};
|
|
4548
|
+
(async () => {
|
|
4549
|
+
const MAX_RETRIES = 3;
|
|
4550
|
+
const RETRY_DELAY_MS = 2e3;
|
|
4551
|
+
await reportTunnelStatus({
|
|
4552
|
+
tunnel_started_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
4553
|
+
tunnel_error: null
|
|
4554
|
+
});
|
|
4555
|
+
try {
|
|
4556
|
+
console.log(`[Daemon] EP819: Ensuring dev server is running for ${moduleUid}...`);
|
|
4557
|
+
const devServerResult = await ensureDevServer(projectPath, port, moduleUid);
|
|
4558
|
+
if (!devServerResult.success) {
|
|
4559
|
+
const errorMsg2 = `Dev server failed to start: ${devServerResult.error}`;
|
|
4560
|
+
console.error(`[Daemon] EP819: ${errorMsg2}`);
|
|
4561
|
+
await reportTunnelStatus({ tunnel_error: errorMsg2 });
|
|
4562
|
+
return;
|
|
4563
|
+
}
|
|
4564
|
+
console.log(`[Daemon] EP819: Dev server ready on port ${port}`);
|
|
4565
|
+
let lastError;
|
|
4566
|
+
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
|
|
4567
|
+
console.log(`[Daemon] EP819: Starting tunnel for ${moduleUid} (attempt ${attempt}/${MAX_RETRIES})...`);
|
|
4568
|
+
const startResult = await tunnelManager.startTunnel({
|
|
4569
|
+
moduleUid,
|
|
4570
|
+
port,
|
|
4571
|
+
onUrl: async (url) => {
|
|
4572
|
+
console.log(`[Daemon] EP819: Tunnel URL for ${moduleUid}: ${url}`);
|
|
4573
|
+
await reportTunnelStatus({
|
|
4574
|
+
tunnel_url: url,
|
|
4575
|
+
tunnel_error: null
|
|
4576
|
+
});
|
|
4577
|
+
},
|
|
4578
|
+
onStatusChange: (status, error) => {
|
|
4579
|
+
if (status === "error") {
|
|
4580
|
+
console.error(`[Daemon] EP819: Tunnel error for ${moduleUid}: ${error}`);
|
|
4581
|
+
reportTunnelStatus({ tunnel_error: error || "Tunnel connection error" });
|
|
4582
|
+
} else if (status === "reconnecting") {
|
|
4583
|
+
console.log(`[Daemon] EP819: Tunnel reconnecting for ${moduleUid}...`);
|
|
4584
|
+
}
|
|
4585
|
+
}
|
|
4586
|
+
});
|
|
4587
|
+
if (startResult.success) {
|
|
4588
|
+
console.log(`[Daemon] EP819: Tunnel started successfully for ${moduleUid}`);
|
|
4589
|
+
return;
|
|
4590
|
+
}
|
|
4591
|
+
lastError = startResult.error;
|
|
4592
|
+
console.warn(`[Daemon] EP819: Tunnel start attempt ${attempt} failed: ${lastError}`);
|
|
4593
|
+
if (attempt < MAX_RETRIES) {
|
|
4594
|
+
console.log(`[Daemon] EP819: Retrying in ${RETRY_DELAY_MS}ms...`);
|
|
4595
|
+
await new Promise((resolve2) => setTimeout(resolve2, RETRY_DELAY_MS));
|
|
4596
|
+
}
|
|
4597
|
+
}
|
|
4598
|
+
const errorMsg = `Tunnel failed after ${MAX_RETRIES} attempts: ${lastError}`;
|
|
4599
|
+
console.error(`[Daemon] EP819: ${errorMsg}`);
|
|
4600
|
+
await reportTunnelStatus({ tunnel_error: errorMsg });
|
|
4601
|
+
} catch (error) {
|
|
4602
|
+
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
4603
|
+
console.error(`[Daemon] EP819: Async tunnel startup error:`, error);
|
|
4604
|
+
await reportTunnelStatus({ tunnel_error: `Unexpected error: ${errorMsg}` });
|
|
4605
|
+
}
|
|
4606
|
+
})();
|
|
4607
|
+
}
|
|
4608
|
+
} catch (error) {
|
|
4609
|
+
console.error(`[Daemon] EP819: Error auto-starting tunnels:`, error);
|
|
4610
|
+
}
|
|
4611
|
+
}
|
|
4612
|
+
/**
|
|
4613
|
+
* EP822: Start periodic tunnel polling
|
|
4614
|
+
*
|
|
4615
|
+
* Polls every 30 seconds to detect module state changes and manage tunnels:
|
|
4616
|
+
* - Start tunnels for modules entering doing/review state
|
|
4617
|
+
* - Stop tunnels for modules leaving doing/review state
|
|
4618
|
+
*/
|
|
4619
|
+
startTunnelPolling() {
|
|
4620
|
+
if (this.tunnelPollInterval) {
|
|
4621
|
+
console.log("[Daemon] EP822: Tunnel polling already running");
|
|
4622
|
+
return;
|
|
4623
|
+
}
|
|
4624
|
+
console.log(`[Daemon] EP822: Starting tunnel polling (every ${_Daemon.TUNNEL_POLL_INTERVAL_MS / 1e3}s)`);
|
|
4625
|
+
this.tunnelPollInterval = setInterval(() => {
|
|
4626
|
+
this.syncTunnelsWithActiveModules().catch((error) => {
|
|
4627
|
+
console.error("[Daemon] EP822: Tunnel sync error:", error);
|
|
4628
|
+
});
|
|
4629
|
+
}, _Daemon.TUNNEL_POLL_INTERVAL_MS);
|
|
4630
|
+
}
|
|
4631
|
+
/**
|
|
4632
|
+
* EP822: Stop periodic tunnel polling
|
|
4633
|
+
*/
|
|
4634
|
+
stopTunnelPolling() {
|
|
4635
|
+
if (this.tunnelPollInterval) {
|
|
4636
|
+
clearInterval(this.tunnelPollInterval);
|
|
4637
|
+
this.tunnelPollInterval = null;
|
|
4638
|
+
console.log("[Daemon] EP822: Tunnel polling stopped");
|
|
4639
|
+
}
|
|
4640
|
+
}
|
|
4641
|
+
/**
|
|
4642
|
+
* EP822: Clean up orphaned tunnels from previous daemon runs
|
|
4643
|
+
*
|
|
4644
|
+
* When the daemon crashes or is killed, tunnels may continue running.
|
|
4645
|
+
* This method stops any tunnels that are running but shouldn't be,
|
|
4646
|
+
* ensuring a clean slate on startup.
|
|
4647
|
+
*/
|
|
4648
|
+
async cleanupOrphanedTunnels() {
|
|
4649
|
+
try {
|
|
4650
|
+
const tunnelManager = getTunnelManager();
|
|
4651
|
+
const runningTunnels = tunnelManager.getAllTunnels();
|
|
4652
|
+
if (runningTunnels.length === 0) {
|
|
4653
|
+
return;
|
|
4654
|
+
}
|
|
4655
|
+
console.log(`[Daemon] EP822: Found ${runningTunnels.length} orphaned tunnel(s) from previous run, cleaning up...`);
|
|
4656
|
+
for (const tunnel of runningTunnels) {
|
|
4657
|
+
try {
|
|
4658
|
+
await tunnelManager.stopTunnel(tunnel.moduleUid);
|
|
4659
|
+
await stopDevServer(tunnel.moduleUid);
|
|
4660
|
+
console.log(`[Daemon] EP822: Cleaned up orphaned tunnel for ${tunnel.moduleUid}`);
|
|
4661
|
+
} catch (error) {
|
|
4662
|
+
console.error(`[Daemon] EP822: Failed to clean up tunnel for ${tunnel.moduleUid}:`, error);
|
|
4663
|
+
}
|
|
4664
|
+
}
|
|
4665
|
+
console.log("[Daemon] EP822: Orphaned tunnel cleanup complete");
|
|
4666
|
+
} catch (error) {
|
|
4667
|
+
console.error("[Daemon] EP822: Failed to clean up orphaned tunnels:", error);
|
|
4668
|
+
}
|
|
4669
|
+
}
|
|
4670
|
+
/**
|
|
4671
|
+
* EP822: Sync tunnels with active modules
|
|
4672
|
+
*
|
|
4673
|
+
* Compares running tunnels against modules in doing/review state.
|
|
4674
|
+
* - Starts tunnels for modules that need them
|
|
4675
|
+
* - Stops tunnels for modules that left active zone
|
|
4676
|
+
*
|
|
4677
|
+
* Fixes from peer review:
|
|
4678
|
+
* - Backpressure guard prevents concurrent syncs
|
|
4679
|
+
* - Uses deviceId (UUID) instead of machineId (string) for machine comparison
|
|
4680
|
+
* - Groups modules by project_id for correct multi-project routing
|
|
4681
|
+
*/
|
|
4682
|
+
async syncTunnelsWithActiveModules() {
|
|
4683
|
+
if (this.tunnelSyncInProgress) {
|
|
4684
|
+
console.log("[Daemon] EP822: Sync already in progress, skipping");
|
|
4685
|
+
return;
|
|
4686
|
+
}
|
|
4687
|
+
if (this.liveConnections.size === 0) {
|
|
4688
|
+
return;
|
|
4689
|
+
}
|
|
4690
|
+
this.tunnelSyncInProgress = true;
|
|
4691
|
+
try {
|
|
4692
|
+
const config = await (0, import_core5.loadConfig)();
|
|
4693
|
+
if (!config?.access_token) {
|
|
4694
|
+
return;
|
|
4695
|
+
}
|
|
4696
|
+
const apiUrl = config.api_url || "https://episoda.dev";
|
|
4697
|
+
const tunnelManager = getTunnelManager();
|
|
4698
|
+
const runningTunnels = tunnelManager.getAllTunnels();
|
|
4699
|
+
const runningModuleUids = new Set(runningTunnels.map((t) => t.moduleUid));
|
|
4700
|
+
const response = await fetch(
|
|
4701
|
+
`${apiUrl}/api/modules?state=doing,review&fields=id,uid,dev_mode,tunnel_url,checkout_machine_id,project_id`,
|
|
4702
|
+
{
|
|
4703
|
+
headers: {
|
|
4704
|
+
"Authorization": `Bearer ${config.access_token}`,
|
|
4705
|
+
"Content-Type": "application/json"
|
|
4706
|
+
}
|
|
4707
|
+
}
|
|
4708
|
+
);
|
|
4709
|
+
if (!response.ok) {
|
|
4710
|
+
console.warn(`[Daemon] EP822: Failed to fetch modules: ${response.status}`);
|
|
4711
|
+
return;
|
|
4712
|
+
}
|
|
4713
|
+
const data = await response.json();
|
|
4714
|
+
const modules = data.modules || [];
|
|
4715
|
+
const activeLocalModules = modules.filter(
|
|
4716
|
+
(m) => m.dev_mode === "local" && (!m.checkout_machine_id || m.checkout_machine_id === this.deviceId)
|
|
4717
|
+
);
|
|
4718
|
+
const activeModuleUids = new Set(activeLocalModules.map((m) => m.uid));
|
|
4719
|
+
const modulesNeedingTunnel = activeLocalModules.filter(
|
|
4720
|
+
(m) => !runningModuleUids.has(m.uid)
|
|
4721
|
+
);
|
|
4722
|
+
const tunnelsToStop = runningTunnels.filter(
|
|
4723
|
+
(t) => !activeModuleUids.has(t.moduleUid)
|
|
4724
|
+
);
|
|
4725
|
+
if (modulesNeedingTunnel.length > 0) {
|
|
4726
|
+
console.log(`[Daemon] EP822: Starting tunnels for ${modulesNeedingTunnel.length} module(s)`);
|
|
4727
|
+
const modulesByProject = /* @__PURE__ */ new Map();
|
|
4728
|
+
for (const module2 of modulesNeedingTunnel) {
|
|
4729
|
+
const projectId = module2.project_id;
|
|
4730
|
+
if (!projectId) continue;
|
|
4731
|
+
if (!modulesByProject.has(projectId)) {
|
|
4732
|
+
modulesByProject.set(projectId, []);
|
|
4733
|
+
}
|
|
4734
|
+
modulesByProject.get(projectId).push(module2);
|
|
4735
|
+
}
|
|
4736
|
+
const trackedProjects = getAllProjects();
|
|
4737
|
+
for (const [projectId, projectModules] of modulesByProject) {
|
|
4738
|
+
const project = trackedProjects.find((p) => p.id === projectId);
|
|
4739
|
+
if (project) {
|
|
4740
|
+
console.log(`[Daemon] EP822: Starting ${projectModules.length} tunnel(s) for project ${projectId}`);
|
|
4741
|
+
await this.autoStartTunnelsForProject(project.path, project.id);
|
|
4742
|
+
} else {
|
|
4743
|
+
console.warn(`[Daemon] EP822: Project ${projectId} not tracked locally, skipping ${projectModules.length} module(s)`);
|
|
4744
|
+
}
|
|
4745
|
+
}
|
|
4746
|
+
}
|
|
4747
|
+
if (tunnelsToStop.length > 0) {
|
|
4748
|
+
console.log(`[Daemon] EP822: Stopping ${tunnelsToStop.length} orphaned tunnel(s)`);
|
|
4749
|
+
for (const tunnel of tunnelsToStop) {
|
|
4750
|
+
try {
|
|
4751
|
+
await tunnelManager.stopTunnel(tunnel.moduleUid);
|
|
4752
|
+
await stopDevServer(tunnel.moduleUid);
|
|
4753
|
+
try {
|
|
4754
|
+
await fetch(`${apiUrl}/api/modules/${tunnel.moduleUid}/tunnel`, {
|
|
4755
|
+
method: "DELETE",
|
|
4756
|
+
headers: {
|
|
4757
|
+
"Authorization": `Bearer ${config.access_token}`
|
|
4758
|
+
}
|
|
4759
|
+
});
|
|
4760
|
+
console.log(`[Daemon] EP822: Tunnel stopped and cleared for ${tunnel.moduleUid}`);
|
|
4761
|
+
} catch {
|
|
4762
|
+
}
|
|
4763
|
+
} catch (error) {
|
|
4764
|
+
console.error(`[Daemon] EP822: Failed to stop tunnel for ${tunnel.moduleUid}:`, error);
|
|
4765
|
+
}
|
|
4766
|
+
}
|
|
4767
|
+
}
|
|
4768
|
+
} catch (error) {
|
|
4769
|
+
console.error("[Daemon] EP822: Error syncing tunnels:", error);
|
|
4770
|
+
} finally {
|
|
4771
|
+
this.tunnelSyncInProgress = false;
|
|
4772
|
+
}
|
|
4773
|
+
}
|
|
3693
4774
|
/**
|
|
3694
4775
|
* Gracefully shutdown daemon
|
|
3695
4776
|
*/
|
|
@@ -3697,6 +4778,7 @@ var Daemon = class {
|
|
|
3697
4778
|
if (this.shuttingDown) return;
|
|
3698
4779
|
this.shuttingDown = true;
|
|
3699
4780
|
console.log("[Daemon] Shutting down...");
|
|
4781
|
+
this.stopTunnelPolling();
|
|
3700
4782
|
for (const [projectPath, connection] of this.connections) {
|
|
3701
4783
|
if (connection.reconnectTimer) {
|
|
3702
4784
|
clearTimeout(connection.reconnectTimer);
|
|
@@ -3704,6 +4786,13 @@ var Daemon = class {
|
|
|
3704
4786
|
await connection.client.disconnect();
|
|
3705
4787
|
}
|
|
3706
4788
|
this.connections.clear();
|
|
4789
|
+
try {
|
|
4790
|
+
const tunnelManager = getTunnelManager();
|
|
4791
|
+
await tunnelManager.stopAllTunnels();
|
|
4792
|
+
console.log("[Daemon] All tunnels stopped");
|
|
4793
|
+
} catch (error) {
|
|
4794
|
+
console.error("[Daemon] Failed to stop tunnels:", error);
|
|
4795
|
+
}
|
|
3707
4796
|
await this.ipcServer.stop();
|
|
3708
4797
|
console.log("[Daemon] Shutdown complete");
|
|
3709
4798
|
}
|
|
@@ -3715,8 +4804,8 @@ var Daemon = class {
|
|
|
3715
4804
|
await this.shutdown();
|
|
3716
4805
|
try {
|
|
3717
4806
|
const pidPath = getPidFilePath();
|
|
3718
|
-
if (
|
|
3719
|
-
|
|
4807
|
+
if (fs7.existsSync(pidPath)) {
|
|
4808
|
+
fs7.unlinkSync(pidPath);
|
|
3720
4809
|
console.log("[Daemon] PID file cleaned up");
|
|
3721
4810
|
}
|
|
3722
4811
|
} catch (error) {
|