@vellumai/cli 0.4.57 → 0.5.1
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/package.json +1 -1
- package/src/__tests__/coverage.test.ts +6 -0
- package/src/commands/hatch.ts +40 -29
- package/src/lib/docker.ts +188 -47
- package/src/lib/ngrok.ts +40 -23
package/package.json
CHANGED
|
@@ -5,6 +5,11 @@ import { resolve } from "node:path";
|
|
|
5
5
|
import { expect, test } from "bun:test";
|
|
6
6
|
|
|
7
7
|
const EXCLUDE_PATTERNS = [".test.ts", ".d.ts"];
|
|
8
|
+
const EXCLUDE_DIRS = [
|
|
9
|
+
// Ink components import yoga-layout whose WASM binary crashes
|
|
10
|
+
// intermittently during headless import (null reference in za()).
|
|
11
|
+
"components/",
|
|
12
|
+
];
|
|
8
13
|
const EXCLUDE_FILES = [
|
|
9
14
|
// index.ts calls main() at module level, causing side effects on import
|
|
10
15
|
"index.ts",
|
|
@@ -15,6 +20,7 @@ async function importAllModules(dir: string): Promise<string[]> {
|
|
|
15
20
|
const files = [...glob.scanSync(dir)].filter(
|
|
16
21
|
(f) =>
|
|
17
22
|
!EXCLUDE_PATTERNS.some((pattern) => f.endsWith(pattern)) &&
|
|
23
|
+
!EXCLUDE_DIRS.some((dir) => f.startsWith(dir)) &&
|
|
18
24
|
!EXCLUDE_FILES.some((excluded) => f === excluded) &&
|
|
19
25
|
!f.includes("__tests__"),
|
|
20
26
|
);
|
package/src/commands/hatch.ts
CHANGED
|
@@ -701,32 +701,36 @@ async function hatchLocal(
|
|
|
701
701
|
|
|
702
702
|
await startLocalDaemon(watch, resources);
|
|
703
703
|
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
704
|
+
// When daemonOnly is set, skip gateway and ngrok — the caller only wants
|
|
705
|
+
// the daemon restarted (e.g. macOS app bootstrap retry).
|
|
706
|
+
let runtimeUrl = `http://127.0.0.1:${resources.gatewayPort}`;
|
|
707
|
+
if (!daemonOnly) {
|
|
708
|
+
try {
|
|
709
|
+
runtimeUrl = await startGateway(watch, resources);
|
|
710
|
+
} catch (error) {
|
|
711
|
+
// Gateway failed — stop the daemon we just started so we don't leave
|
|
712
|
+
// orphaned processes with no lock file entry.
|
|
713
|
+
console.error(
|
|
714
|
+
`\n❌ Gateway startup failed — stopping assistant to avoid orphaned processes.`,
|
|
715
|
+
);
|
|
716
|
+
await stopLocalProcesses(resources);
|
|
717
|
+
throw error;
|
|
718
|
+
}
|
|
716
719
|
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
720
|
+
// Auto-start ngrok if webhook integrations (e.g. Telegram, Twilio) are configured.
|
|
721
|
+
// Set BASE_DATA_DIR so ngrok reads the correct instance config.
|
|
722
|
+
const prevBaseDataDir = process.env.BASE_DATA_DIR;
|
|
723
|
+
process.env.BASE_DATA_DIR = resources.instanceDir;
|
|
724
|
+
const ngrokChild = await maybeStartNgrokTunnel(resources.gatewayPort);
|
|
725
|
+
if (ngrokChild?.pid) {
|
|
726
|
+
const ngrokPidFile = join(resources.instanceDir, ".vellum", "ngrok.pid");
|
|
727
|
+
writeFileSync(ngrokPidFile, String(ngrokChild.pid));
|
|
728
|
+
}
|
|
729
|
+
if (prevBaseDataDir !== undefined) {
|
|
730
|
+
process.env.BASE_DATA_DIR = prevBaseDataDir;
|
|
731
|
+
} else {
|
|
732
|
+
delete process.env.BASE_DATA_DIR;
|
|
733
|
+
}
|
|
730
734
|
}
|
|
731
735
|
|
|
732
736
|
const localEntry: AssistantEntry = {
|
|
@@ -757,7 +761,12 @@ async function hatchLocal(
|
|
|
757
761
|
}
|
|
758
762
|
|
|
759
763
|
if (keepAlive) {
|
|
760
|
-
|
|
764
|
+
// When --daemon-only is set, no gateway is running — poll the daemon
|
|
765
|
+
// health endpoint instead of the gateway to avoid self-termination.
|
|
766
|
+
const healthUrl = daemonOnly
|
|
767
|
+
? `http://127.0.0.1:${resources.daemonPort}/healthz`
|
|
768
|
+
: `http://127.0.0.1:${resources.gatewayPort}/healthz`;
|
|
769
|
+
const healthTarget = daemonOnly ? "Assistant" : "Gateway";
|
|
761
770
|
const POLL_INTERVAL_MS = 5000;
|
|
762
771
|
const MAX_FAILURES = 3;
|
|
763
772
|
let consecutiveFailures = 0;
|
|
@@ -771,11 +780,11 @@ async function hatchLocal(
|
|
|
771
780
|
process.on("SIGTERM", () => void shutdown());
|
|
772
781
|
process.on("SIGINT", () => void shutdown());
|
|
773
782
|
|
|
774
|
-
// Poll the
|
|
783
|
+
// Poll the health endpoint until it stops responding.
|
|
775
784
|
while (true) {
|
|
776
785
|
await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
|
|
777
786
|
try {
|
|
778
|
-
const res = await fetch(
|
|
787
|
+
const res = await fetch(healthUrl, {
|
|
779
788
|
signal: AbortSignal.timeout(3000),
|
|
780
789
|
});
|
|
781
790
|
if (res.ok) {
|
|
@@ -787,7 +796,9 @@ async function hatchLocal(
|
|
|
787
796
|
consecutiveFailures++;
|
|
788
797
|
}
|
|
789
798
|
if (consecutiveFailures >= MAX_FAILURES) {
|
|
790
|
-
console.log(
|
|
799
|
+
console.log(
|
|
800
|
+
`\n⚠️ ${healthTarget} stopped responding — shutting down.`,
|
|
801
|
+
);
|
|
791
802
|
await stopLocalProcesses(resources);
|
|
792
803
|
process.exit(1);
|
|
793
804
|
}
|
package/src/lib/docker.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { existsSync, watch as fsWatch } from "fs";
|
|
1
|
+
import { chmodSync, existsSync, mkdirSync, watch as fsWatch } from "fs";
|
|
2
|
+
import { arch, platform } from "os";
|
|
2
3
|
import { dirname, join } from "path";
|
|
3
4
|
|
|
4
5
|
// Direct import — bun embeds this at compile time so it works in compiled binaries.
|
|
@@ -39,60 +40,201 @@ export const GATEWAY_INTERNAL_PORT = 7830;
|
|
|
39
40
|
/** Max time to wait for the assistant container to emit the readiness sentinel. */
|
|
40
41
|
export const DOCKER_READY_TIMEOUT_MS = 3 * 60 * 1000;
|
|
41
42
|
|
|
43
|
+
/** Directory for user-local binary installs (no sudo required). */
|
|
44
|
+
const LOCAL_BIN_DIR = join(
|
|
45
|
+
process.env.HOME || process.env.USERPROFILE || ".",
|
|
46
|
+
".local",
|
|
47
|
+
"bin",
|
|
48
|
+
);
|
|
49
|
+
|
|
42
50
|
/**
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
* Colima if the Docker daemon is not reachable.
|
|
51
|
+
* Returns the macOS architecture suffix used by GitHub release artifacts.
|
|
52
|
+
* Maps Node's `arch()` values to the names used in release URLs.
|
|
46
53
|
*/
|
|
47
|
-
|
|
48
|
-
|
|
54
|
+
function releaseArch(): string {
|
|
55
|
+
const a = arch();
|
|
56
|
+
if (a === "arm64") return "aarch64";
|
|
57
|
+
if (a === "x64") return "x86_64";
|
|
58
|
+
return a;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Downloads a file from `url` to `destPath`, makes it executable, and returns
|
|
63
|
+
* the destination path. Throws on failure.
|
|
64
|
+
*/
|
|
65
|
+
async function downloadBinary(
|
|
66
|
+
url: string,
|
|
67
|
+
destPath: string,
|
|
68
|
+
label: string,
|
|
69
|
+
): Promise<void> {
|
|
70
|
+
console.log(` ⬇ Downloading ${label}...`);
|
|
71
|
+
await exec("bash", [
|
|
72
|
+
"-c",
|
|
73
|
+
`curl -fsSL -o "${destPath}" "${url}" && chmod +x "${destPath}"`,
|
|
74
|
+
]);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Downloads and extracts a `.tar.gz` archive into `destDir`.
|
|
79
|
+
*/
|
|
80
|
+
async function downloadAndExtract(
|
|
81
|
+
url: string,
|
|
82
|
+
destDir: string,
|
|
83
|
+
label: string,
|
|
84
|
+
): Promise<void> {
|
|
85
|
+
console.log(` ⬇ Downloading ${label}...`);
|
|
86
|
+
await exec("bash", ["-c", `curl -fsSL "${url}" | tar xz -C "${destDir}"`]);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Installs Docker CLI, Colima, and Lima by downloading pre-built binaries
|
|
91
|
+
* directly into ~/.vellum/bin/. No Homebrew or sudo required.
|
|
92
|
+
*
|
|
93
|
+
* Falls back to Homebrew if available (e.g. admin users who prefer it).
|
|
94
|
+
*/
|
|
95
|
+
async function installDockerToolchain(): Promise<void> {
|
|
96
|
+
// Try Homebrew first if available — it handles updates and dependencies.
|
|
97
|
+
let hasBrew = false;
|
|
49
98
|
try {
|
|
50
|
-
await execOutput("
|
|
51
|
-
|
|
99
|
+
await execOutput("brew", ["--version"]);
|
|
100
|
+
hasBrew = true;
|
|
52
101
|
} catch {
|
|
53
|
-
//
|
|
102
|
+
// brew not found
|
|
54
103
|
}
|
|
55
104
|
|
|
56
|
-
if (
|
|
57
|
-
|
|
58
|
-
let hasBrew = false;
|
|
105
|
+
if (hasBrew) {
|
|
106
|
+
console.log("🐳 Docker not found. Installing via Homebrew...");
|
|
59
107
|
try {
|
|
60
|
-
await
|
|
61
|
-
|
|
108
|
+
await exec("brew", ["install", "colima", "docker"]);
|
|
109
|
+
return;
|
|
62
110
|
} catch {
|
|
63
|
-
|
|
111
|
+
console.log(
|
|
112
|
+
" ⚠ Homebrew install failed, falling back to direct binary download...",
|
|
113
|
+
);
|
|
64
114
|
}
|
|
115
|
+
}
|
|
65
116
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
"-c",
|
|
71
|
-
'NONINTERACTIVE=1 /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"',
|
|
72
|
-
]);
|
|
73
|
-
} catch (err) {
|
|
74
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
75
|
-
throw new Error(
|
|
76
|
-
`Failed to install Homebrew. Please install Docker manually from https://www.docker.com/products/docker-desktop/\n${message}`,
|
|
77
|
-
);
|
|
78
|
-
}
|
|
117
|
+
// Direct binary install — no sudo required.
|
|
118
|
+
console.log(
|
|
119
|
+
"🐳 Docker not found. Installing Docker, Colima, and Lima to ~/.local/bin/...",
|
|
120
|
+
);
|
|
79
121
|
|
|
80
|
-
|
|
81
|
-
// so subsequent brew/colima/docker invocations work in this session.
|
|
82
|
-
if (!process.env.PATH?.includes("/opt/homebrew")) {
|
|
83
|
-
process.env.PATH = `/opt/homebrew/bin:/opt/homebrew/sbin:${process.env.PATH}`;
|
|
84
|
-
}
|
|
85
|
-
}
|
|
122
|
+
mkdirSync(LOCAL_BIN_DIR, { recursive: true });
|
|
86
123
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
124
|
+
const cpuArch = releaseArch();
|
|
125
|
+
const isMac = platform() === "darwin";
|
|
126
|
+
|
|
127
|
+
if (!isMac) {
|
|
128
|
+
throw new Error(
|
|
129
|
+
"Automatic Docker installation is only supported on macOS. " +
|
|
130
|
+
"Please install Docker manually: https://docs.docker.com/engine/install/",
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// --- Docker CLI ---
|
|
135
|
+
// Docker publishes static binaries at download.docker.com.
|
|
136
|
+
const dockerArch = cpuArch === "aarch64" ? "aarch64" : "x86_64";
|
|
137
|
+
const dockerTarUrl = `https://download.docker.com/mac/static/stable/${dockerArch}/docker-27.5.1.tgz`;
|
|
138
|
+
const dockerTmpDir = join(LOCAL_BIN_DIR, ".docker-tmp");
|
|
139
|
+
mkdirSync(dockerTmpDir, { recursive: true });
|
|
140
|
+
try {
|
|
141
|
+
await downloadAndExtract(dockerTarUrl, dockerTmpDir, "Docker CLI");
|
|
142
|
+
// The archive extracts to docker/docker — move it to our bin dir.
|
|
143
|
+
await exec("mv", [
|
|
144
|
+
join(dockerTmpDir, "docker", "docker"),
|
|
145
|
+
join(LOCAL_BIN_DIR, "docker"),
|
|
146
|
+
]);
|
|
147
|
+
chmodSync(join(LOCAL_BIN_DIR, "docker"), 0o755);
|
|
148
|
+
} finally {
|
|
149
|
+
await exec("rm", ["-rf", dockerTmpDir]).catch(() => {});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// --- Colima ---
|
|
153
|
+
const colimaArch = cpuArch === "aarch64" ? "arm64" : "x86_64";
|
|
154
|
+
const colimaUrl = `https://github.com/abiosoft/colima/releases/latest/download/colima-Darwin-${colimaArch}`;
|
|
155
|
+
await downloadBinary(colimaUrl, join(LOCAL_BIN_DIR, "colima"), "Colima");
|
|
156
|
+
|
|
157
|
+
// --- Lima ---
|
|
158
|
+
// Lima publishes tar.gz archives with bin/limactl and other tools.
|
|
159
|
+
const limaArch = cpuArch === "aarch64" ? "arm64" : "x86_64";
|
|
160
|
+
const limaVersionUrl =
|
|
161
|
+
"https://api.github.com/repos/lima-vm/lima/releases/latest";
|
|
162
|
+
let limaVersion: string;
|
|
163
|
+
try {
|
|
164
|
+
const resp = await fetch(limaVersionUrl);
|
|
165
|
+
if (!resp.ok) {
|
|
92
166
|
throw new Error(
|
|
93
|
-
`
|
|
167
|
+
`GitHub API returned ${resp.status}` +
|
|
168
|
+
(resp.status === 403
|
|
169
|
+
? " (rate-limited) — try again later."
|
|
170
|
+
: `. Check your network connection.`),
|
|
94
171
|
);
|
|
95
172
|
}
|
|
173
|
+
const data = (await resp.json()) as { tag_name?: string };
|
|
174
|
+
if (!data.tag_name) {
|
|
175
|
+
throw new Error("GitHub API response missing tag_name.");
|
|
176
|
+
}
|
|
177
|
+
limaVersion = data.tag_name; // e.g. "v1.0.3"
|
|
178
|
+
} catch (err) {
|
|
179
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
180
|
+
throw new Error(`Failed to fetch latest Lima version: ${message}`);
|
|
181
|
+
}
|
|
182
|
+
const limaVersionNum = limaVersion.replace(/^v/, ""); // "1.0.3"
|
|
183
|
+
const limaTarUrl = `https://github.com/lima-vm/lima/releases/download/${limaVersion}/lima-${limaVersionNum}-Darwin-${limaArch}.tar.gz`;
|
|
184
|
+
// Lima archives contain bin/limactl, bin/lima, share/lima/..., so extract
|
|
185
|
+
// into the parent (~/.local/) so that limactl lands in ~/.local/bin/.
|
|
186
|
+
const localDir = dirname(LOCAL_BIN_DIR);
|
|
187
|
+
await downloadAndExtract(limaTarUrl, localDir, "Lima");
|
|
188
|
+
|
|
189
|
+
// Verify all binaries are in place.
|
|
190
|
+
for (const bin of ["docker", "colima", "limactl"]) {
|
|
191
|
+
if (!existsSync(join(LOCAL_BIN_DIR, bin))) {
|
|
192
|
+
throw new Error(
|
|
193
|
+
`${bin} binary not found after installation. Please install Docker manually.`,
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
console.log(" ✅ Docker toolchain installed to ~/.local/bin/");
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Ensures ~/.local/bin/ is on PATH for this process so that docker, colima,
|
|
203
|
+
* and limactl are discoverable.
|
|
204
|
+
*/
|
|
205
|
+
function ensureLocalBinOnPath(): void {
|
|
206
|
+
const currentPath = process.env.PATH || "";
|
|
207
|
+
if (!currentPath.includes(LOCAL_BIN_DIR)) {
|
|
208
|
+
process.env.PATH = `${LOCAL_BIN_DIR}:${currentPath}`;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Checks whether the `docker` CLI and daemon are available on the system.
|
|
214
|
+
* Installs Colima and Docker via direct binary download if missing (no sudo
|
|
215
|
+
* required), and starts Colima if the Docker daemon is not reachable.
|
|
216
|
+
*/
|
|
217
|
+
async function ensureDockerInstalled(): Promise<void> {
|
|
218
|
+
// Always add ~/.local/bin to PATH so previously installed binaries are found.
|
|
219
|
+
ensureLocalBinOnPath();
|
|
220
|
+
|
|
221
|
+
// Check that docker, colima, and limactl are all available. If any is
|
|
222
|
+
// missing (e.g. partial install from a previous failure), re-run install.
|
|
223
|
+
const toolchainComplete = await (async () => {
|
|
224
|
+
try {
|
|
225
|
+
await execOutput("docker", ["--version"]);
|
|
226
|
+
await execOutput("colima", ["version"]);
|
|
227
|
+
await execOutput("limactl", ["--version"]);
|
|
228
|
+
return true;
|
|
229
|
+
} catch {
|
|
230
|
+
return false;
|
|
231
|
+
}
|
|
232
|
+
})();
|
|
233
|
+
|
|
234
|
+
if (!toolchainComplete) {
|
|
235
|
+
await installDockerToolchain();
|
|
236
|
+
// Re-check PATH after install.
|
|
237
|
+
ensureLocalBinOnPath();
|
|
96
238
|
|
|
97
239
|
try {
|
|
98
240
|
await execOutput("docker", ["--version"]);
|
|
@@ -104,7 +246,7 @@ async function ensureDockerInstalled(): Promise<void> {
|
|
|
104
246
|
}
|
|
105
247
|
}
|
|
106
248
|
|
|
107
|
-
// Verify the Docker daemon is reachable; start Colima if it isn't
|
|
249
|
+
// Verify the Docker daemon is reachable; start Colima if it isn't.
|
|
108
250
|
try {
|
|
109
251
|
await exec("docker", ["info"]);
|
|
110
252
|
} catch {
|
|
@@ -140,10 +282,10 @@ export function dockerResourceNames(instanceName: string) {
|
|
|
140
282
|
return {
|
|
141
283
|
assistantContainer: `${instanceName}-assistant`,
|
|
142
284
|
cesContainer: `${instanceName}-credential-executor`,
|
|
143
|
-
dataVolume:
|
|
285
|
+
dataVolume: `${instanceName}-data`,
|
|
144
286
|
gatewayContainer: `${instanceName}-gateway`,
|
|
145
|
-
network:
|
|
146
|
-
socketVolume:
|
|
287
|
+
network: `${instanceName}-net`,
|
|
288
|
+
socketVolume: `${instanceName}-socket`,
|
|
147
289
|
};
|
|
148
290
|
}
|
|
149
291
|
|
|
@@ -187,7 +329,7 @@ export async function retireDocker(name: string): Promise<void> {
|
|
|
187
329
|
// Also clean up a legacy single-container instance if it exists
|
|
188
330
|
await removeContainer(name);
|
|
189
331
|
|
|
190
|
-
// Remove
|
|
332
|
+
// Remove network and volumes
|
|
191
333
|
try {
|
|
192
334
|
await exec("docker", ["network", "rm", res.network]);
|
|
193
335
|
} catch {
|
|
@@ -378,7 +520,6 @@ export function serviceDockerRunArgs(opts: {
|
|
|
378
520
|
"-d",
|
|
379
521
|
"--name",
|
|
380
522
|
res.cesContainer,
|
|
381
|
-
`--network=${res.network}`,
|
|
382
523
|
"-v",
|
|
383
524
|
`${res.socketVolume}:/run/ces-bootstrap`,
|
|
384
525
|
"-v",
|
|
@@ -654,7 +795,7 @@ export async function hatchDocker(
|
|
|
654
795
|
|
|
655
796
|
const res = dockerResourceNames(instanceName);
|
|
656
797
|
|
|
657
|
-
log("📁 Creating
|
|
798
|
+
log("📁 Creating network and volumes...");
|
|
658
799
|
await exec("docker", ["network", "create", res.network]);
|
|
659
800
|
await exec("docker", ["volume", "create", res.dataVolume]);
|
|
660
801
|
await exec("docker", ["volume", "create", res.socketVolume]);
|
package/src/lib/ngrok.ts
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import { execFileSync, spawn, type ChildProcess } from "node:child_process";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
existsSync,
|
|
4
|
+
mkdirSync,
|
|
5
|
+
openSync,
|
|
6
|
+
readFileSync,
|
|
7
|
+
writeFileSync,
|
|
8
|
+
} from "node:fs";
|
|
3
9
|
import { homedir } from "node:os";
|
|
4
10
|
import { dirname, join } from "node:path";
|
|
5
11
|
|
|
@@ -111,12 +117,29 @@ export async function findExistingTunnel(
|
|
|
111
117
|
|
|
112
118
|
/**
|
|
113
119
|
* Start an ngrok process tunneling HTTP traffic to the given local port.
|
|
120
|
+
*
|
|
121
|
+
* When `logFilePath` is provided, stdout/stderr are redirected to that file
|
|
122
|
+
* instead of being piped. This avoids keeping pipe handles open in the
|
|
123
|
+
* parent process — which would either prevent the CLI from exiting (if
|
|
124
|
+
* handles are left open) or send SIGPIPE to ngrok (if destroyed).
|
|
125
|
+
*
|
|
114
126
|
* Returns the spawned child process.
|
|
115
127
|
*/
|
|
116
|
-
export function startNgrokProcess(
|
|
128
|
+
export function startNgrokProcess(
|
|
129
|
+
targetPort: number,
|
|
130
|
+
logFilePath?: string,
|
|
131
|
+
): ChildProcess {
|
|
132
|
+
let stdio: ("ignore" | "pipe" | number)[] = ["ignore", "pipe", "pipe"];
|
|
133
|
+
if (logFilePath) {
|
|
134
|
+
const dir = dirname(logFilePath);
|
|
135
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
136
|
+
const fd = openSync(logFilePath, "a");
|
|
137
|
+
stdio = ["ignore", fd, fd];
|
|
138
|
+
}
|
|
139
|
+
|
|
117
140
|
const child = spawn("ngrok", ["http", String(targetPort), "--log=stdout"], {
|
|
118
141
|
detached: true,
|
|
119
|
-
stdio
|
|
142
|
+
stdio,
|
|
120
143
|
});
|
|
121
144
|
return child;
|
|
122
145
|
}
|
|
@@ -170,14 +193,16 @@ function clearIngressUrl(): void {
|
|
|
170
193
|
}
|
|
171
194
|
|
|
172
195
|
/**
|
|
173
|
-
* Check whether any webhook-based integrations (e.g. Telegram) are
|
|
174
|
-
* that require a public ingress URL.
|
|
196
|
+
* Check whether any webhook-based integrations (e.g. Telegram, Twilio) are
|
|
197
|
+
* configured that require a public ingress URL.
|
|
175
198
|
*/
|
|
176
199
|
function hasWebhookIntegrationsConfigured(): boolean {
|
|
177
200
|
try {
|
|
178
201
|
const config = loadRawConfig();
|
|
179
202
|
const telegram = config.telegram as Record<string, unknown> | undefined;
|
|
180
203
|
if (telegram?.botUsername) return true;
|
|
204
|
+
const twilio = config.twilio as Record<string, unknown> | undefined;
|
|
205
|
+
if (twilio?.accountSid || twilio?.phoneNumber) return true;
|
|
181
206
|
return false;
|
|
182
207
|
} catch {
|
|
183
208
|
return false;
|
|
@@ -225,31 +250,23 @@ export async function maybeStartNgrokTunnel(
|
|
|
225
250
|
}
|
|
226
251
|
|
|
227
252
|
console.log(` Starting ngrok tunnel for webhook integrations...`);
|
|
228
|
-
const ngrokProcess = startNgrokProcess(targetPort);
|
|
229
253
|
|
|
230
|
-
//
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
254
|
+
// Spawn ngrok with stdout/stderr redirected to a log file instead of pipes.
|
|
255
|
+
// This avoids two problems that occur with piped stdio:
|
|
256
|
+
// 1. If pipe handles are left open, the CLI process hangs after hatch/wake.
|
|
257
|
+
// 2. If pipe handles are destroyed, SIGPIPE kills ngrok on its next write.
|
|
258
|
+
// Writing to a log file sidesteps both issues — the file descriptor is
|
|
259
|
+
// inherited by the detached ngrok process and remains valid after CLI exit.
|
|
260
|
+
const root = join(process.env.BASE_DATA_DIR?.trim() || homedir(), ".vellum");
|
|
261
|
+
const ngrokLogPath = join(root, "workspace", "data", "logs", "ngrok.log");
|
|
262
|
+
const ngrokProcess = startNgrokProcess(targetPort, ngrokLogPath);
|
|
263
|
+
ngrokProcess.unref();
|
|
239
264
|
|
|
240
265
|
try {
|
|
241
266
|
const publicUrl = await waitForNgrokUrl();
|
|
242
267
|
saveIngressUrl(publicUrl);
|
|
243
268
|
console.log(` Tunnel established: ${publicUrl}`);
|
|
244
269
|
|
|
245
|
-
// Detach the ngrok process so the CLI (hatch/wake) can exit without
|
|
246
|
-
// keeping it alive. Remove stdout/stderr listeners and unref all handles.
|
|
247
|
-
ngrokProcess.stdout?.removeAllListeners("data");
|
|
248
|
-
ngrokProcess.stderr?.removeAllListeners("data");
|
|
249
|
-
ngrokProcess.stdout?.destroy();
|
|
250
|
-
ngrokProcess.stderr?.destroy();
|
|
251
|
-
ngrokProcess.unref();
|
|
252
|
-
|
|
253
270
|
return ngrokProcess;
|
|
254
271
|
} catch {
|
|
255
272
|
console.warn(
|