@vellumai/cli 0.4.10 → 0.4.12

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/README.md CHANGED
@@ -14,6 +14,29 @@ bun run ./src/index.ts <command> [options]
14
14
 
15
15
  ## Commands
16
16
 
17
+ ### Lifecycle: `ps`, `sleep`, `wake`
18
+
19
+ Day-to-day process management for the daemon and gateway.
20
+
21
+ | Command | Description |
22
+ |---------|-------------|
23
+ | `vellum ps` | List assistants and per-assistant process status (daemon, gateway PIDs and health). |
24
+ | `vellum sleep` | Stop daemon and gateway processes. Directory-agnostic — works from anywhere. |
25
+ | `vellum wake` | Start the daemon and gateway from the current checkout. |
26
+
27
+ ```bash
28
+ # Start everything
29
+ vellum wake
30
+
31
+ # Check what's running
32
+ vellum ps
33
+
34
+ # Stop everything
35
+ vellum sleep
36
+ ```
37
+
38
+ > **Note:** `vellum wake` requires a hatched assistant. Run `vellum hatch` first, or launch the macOS app which handles hatching automatically.
39
+
17
40
  ### `hatch`
18
41
 
19
42
  Provision a new assistant instance and bootstrap the Vellum runtime on it.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/cli",
3
- "version": "0.4.10",
3
+ "version": "0.4.12",
4
4
  "description": "CLI tools for vellum-assistant",
5
5
  "type": "module",
6
6
  "exports": {
@@ -0,0 +1,41 @@
1
+ // Bun's coverage reporter only tracks files that are actually loaded during
2
+ // test execution. There is no config option to include all source files.
3
+ // See: https://github.com/oven-sh/bun/issues/5928
4
+ import { resolve } from "node:path";
5
+ import { expect, test } from "bun:test";
6
+
7
+ const EXCLUDE_PATTERNS = [".test.ts", ".d.ts"];
8
+ const EXCLUDE_FILES = [
9
+ // index.ts calls main() at module level, causing side effects on import
10
+ "index.ts",
11
+ ];
12
+
13
+ async function importAllModules(dir: string): Promise<string[]> {
14
+ const glob = new Bun.Glob("**/*.{ts,tsx}");
15
+ const files = [...glob.scanSync(dir)].filter(
16
+ (f) =>
17
+ !EXCLUDE_PATTERNS.some((pattern) => f.endsWith(pattern)) &&
18
+ !EXCLUDE_FILES.some((excluded) => f === excluded) &&
19
+ !f.includes("__tests__"),
20
+ );
21
+
22
+ await Promise.all(files.map((relPath) => import(resolve(dir, relPath))));
23
+
24
+ return files;
25
+ }
26
+
27
+ test("imports all source modules for coverage tracking", async () => {
28
+ /**
29
+ * Ensures all source files are loaded so Bun's coverage reporter
30
+ * includes them in the report, not just files touched by other tests.
31
+ */
32
+
33
+ // GIVEN the src directory containing all source modules
34
+ const srcDir = resolve(import.meta.dir, "..");
35
+
36
+ // WHEN we dynamically import every source module
37
+ const files = await importAllModules(srcDir);
38
+
39
+ // THEN at least one file should have been imported
40
+ expect(files.length).toBeGreaterThan(0);
41
+ });
@@ -1,8 +1,13 @@
1
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
- import { homedir } from "node:os";
3
- import { dirname, join } from "node:path";
1
+ import { existsSync, readFileSync } from "node:fs";
4
2
 
5
3
  import { syncConfigToLockfile } from "../lib/assistant-config";
4
+ import {
5
+ getAllowlistPath,
6
+ getNestedValue,
7
+ loadRawConfig,
8
+ saveRawConfig,
9
+ setNestedValue,
10
+ } from "../lib/config";
6
11
 
7
12
  interface AllowlistConfig {
8
13
  values?: string[];
@@ -16,76 +21,6 @@ interface AllowlistValidationError {
16
21
  message: string;
17
22
  }
18
23
 
19
- function getRootDir(): string {
20
- return join(process.env.BASE_DATA_DIR?.trim() || homedir(), ".vellum");
21
- }
22
-
23
- function getConfigPath(): string {
24
- return join(getRootDir(), "workspace", "config.json");
25
- }
26
-
27
- function getAllowlistPath(): string {
28
- return join(getRootDir(), "protected", "secret-allowlist.json");
29
- }
30
-
31
- function loadRawConfig(): Record<string, unknown> {
32
- const configPath = getConfigPath();
33
- if (!existsSync(configPath)) {
34
- return {};
35
- }
36
- const raw = readFileSync(configPath, "utf-8");
37
- return JSON.parse(raw) as Record<string, unknown>;
38
- }
39
-
40
- function saveRawConfig(config: Record<string, unknown>): void {
41
- const configPath = getConfigPath();
42
- const dir = dirname(configPath);
43
- if (!existsSync(dir)) {
44
- mkdirSync(dir, { recursive: true });
45
- }
46
- writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
47
- }
48
-
49
- function getNestedValue(
50
- obj: Record<string, unknown>,
51
- path: string,
52
- ): unknown {
53
- const keys = path.split(".");
54
- let current: unknown = obj;
55
- for (const key of keys) {
56
- if (
57
- current === null ||
58
- current === undefined ||
59
- typeof current !== "object"
60
- ) {
61
- return undefined;
62
- }
63
- current = (current as Record<string, unknown>)[key];
64
- }
65
- return current;
66
- }
67
-
68
- function setNestedValue(
69
- obj: Record<string, unknown>,
70
- path: string,
71
- value: unknown,
72
- ): void {
73
- const keys = path.split(".");
74
- let current = obj;
75
- for (let i = 0; i < keys.length - 1; i++) {
76
- const key = keys[i];
77
- if (
78
- current[key] === undefined ||
79
- current[key] === null ||
80
- typeof current[key] !== "object"
81
- ) {
82
- current[key] = {};
83
- }
84
- current = current[key] as Record<string, unknown>;
85
- }
86
- current[keys[keys.length - 1]] = value;
87
- }
88
-
89
24
  function validateAllowlist(
90
25
  allowlistConfig: AllowlistConfig,
91
26
  ): AllowlistValidationError[] {
@@ -1,5 +1,5 @@
1
1
  import { spawn } from "child_process";
2
- import { rmSync, writeFileSync } from "fs";
2
+ import { renameSync, writeFileSync } from "fs";
3
3
  import { homedir } from "os";
4
4
  import { basename, dirname, join } from "path";
5
5
 
@@ -63,20 +63,37 @@ async function retireLocal(name: string, entry: AssistantEntry): Promise<void> {
63
63
  } catch {}
64
64
  }
65
65
 
66
- // Archive ~/.vellum before deleting
66
+ // Move ~/.vellum out of the way so the path is immediately available for the
67
+ // next hatch, then kick off the tar archive in the background.
68
+ const archivePath = getArchivePath(name);
69
+ const metadataPath = getMetadataPath(name);
70
+ const stagingDir = `${archivePath}.staging`;
71
+
67
72
  try {
68
- const archivePath = getArchivePath(name);
69
- const metadataPath = getMetadataPath(name);
70
- await exec("tar", ["czf", archivePath, "-C", dirname(vellumDir), basename(vellumDir)]);
71
- writeFileSync(metadataPath, JSON.stringify(entry, null, 2) + "\n");
72
- console.log(`📦 Archived to ${archivePath}`);
73
+ renameSync(vellumDir, stagingDir);
73
74
  } catch (err) {
74
- console.warn(`⚠️ Failed to archive: ${err instanceof Error ? err.message : err}`);
75
- console.warn("Proceeding with permanent deletion.");
75
+ console.warn(`⚠️ Failed to move ${vellumDir}: ${err instanceof Error ? err.message : err}`);
76
+ console.warn("Skipping archive.");
77
+ console.log("\u2705 Local instance retired.");
78
+ return;
76
79
  }
77
80
 
78
- rmSync(vellumDir, { recursive: true, force: true });
81
+ writeFileSync(metadataPath, JSON.stringify(entry, null, 2) + "\n");
82
+
83
+ // Spawn tar + cleanup in the background and detach so the CLI can exit
84
+ // immediately. The staging directory is removed once the archive is written.
85
+ const tarCmd = [
86
+ `tar czf ${JSON.stringify(archivePath)} -C ${JSON.stringify(dirname(stagingDir))} ${JSON.stringify(basename(stagingDir))}`,
87
+ `rm -rf ${JSON.stringify(stagingDir)}`,
88
+ ].join(" && ");
89
+
90
+ const child = spawn("sh", ["-c", tarCmd], {
91
+ stdio: "ignore",
92
+ detached: true,
93
+ });
94
+ child.unref();
79
95
 
96
+ console.log(`📦 Archiving to ${archivePath} in the background.`);
80
97
  console.log("\u2705 Local instance retired.");
81
98
  }
82
99
 
@@ -1,7 +1,6 @@
1
1
  import { homedir } from "os";
2
2
  import { join } from "path";
3
3
 
4
- import { loadAllAssistants } from "../lib/assistant-config";
5
4
  import { stopProcessByPidFile } from "../lib/process";
6
5
 
7
6
  export async function sleep(): Promise<void> {
@@ -13,20 +12,15 @@ export async function sleep(): Promise<void> {
13
12
  process.exit(0);
14
13
  }
15
14
 
16
- const assistants = loadAllAssistants();
17
- const hasLocal = assistants.some((a) => a.cloud === "local");
18
- if (!hasLocal) {
19
- console.error("Error: No local assistant found in lock file. Run 'vellum hatch local' first.");
20
- process.exit(1);
21
- }
22
-
23
15
  const vellumDir = join(homedir(), ".vellum");
24
16
  const daemonPidFile = join(vellumDir, "vellum.pid");
25
17
  const socketFile = join(vellumDir, "vellum.sock");
26
18
  const gatewayPidFile = join(vellumDir, "gateway.pid");
27
19
 
28
20
  // Stop daemon
29
- const daemonStopped = await stopProcessByPidFile(daemonPidFile, "daemon", [socketFile]);
21
+ const daemonStopped = await stopProcessByPidFile(daemonPidFile, "daemon", [
22
+ socketFile,
23
+ ]);
30
24
  if (!daemonStopped) {
31
25
  console.log("Daemon is not running.");
32
26
  } else {
@@ -0,0 +1,88 @@
1
+ import { findAssistantByName, loadLatestAssistant } from "../lib/assistant-config";
2
+ import { runNgrokTunnel } from "../lib/ngrok";
3
+
4
+ const VALID_PROVIDERS = ["vellum", "ngrok", "cloudflare", "tailscale"] as const;
5
+ type TunnelProvider = (typeof VALID_PROVIDERS)[number];
6
+
7
+ const DEFAULT_PROVIDER: TunnelProvider = "vellum";
8
+
9
+ interface TunnelArgs {
10
+ assistantName: string | null;
11
+ provider: TunnelProvider;
12
+ }
13
+
14
+ function parseArgs(): TunnelArgs {
15
+ const args = process.argv.slice(3);
16
+ let assistantName: string | null = null;
17
+ let provider: TunnelProvider = DEFAULT_PROVIDER;
18
+
19
+ for (let i = 0; i < args.length; i++) {
20
+ const arg = args[i];
21
+ if (arg === "--help" || arg === "-h") {
22
+ console.log("Usage: vellum tunnel [<name>] [options]");
23
+ console.log("");
24
+ console.log("Create a tunnel for a locally hosted assistant.");
25
+ console.log("");
26
+ console.log("Arguments:");
27
+ console.log(
28
+ " <name> Name of the assistant (defaults to latest)",
29
+ );
30
+ console.log("");
31
+ console.log("Options:");
32
+ console.log(
33
+ ` --provider <provider> Tunnel provider: ${VALID_PROVIDERS.join(", ")} (default: ${DEFAULT_PROVIDER})`,
34
+ );
35
+ process.exit(0);
36
+ } else if (arg === "--provider") {
37
+ const next = args[i + 1];
38
+ if (!next || !VALID_PROVIDERS.includes(next as TunnelProvider)) {
39
+ console.error(
40
+ `Error: --provider requires one of: ${VALID_PROVIDERS.join(", ")}`,
41
+ );
42
+ process.exit(1);
43
+ }
44
+ provider = next as TunnelProvider;
45
+ i++;
46
+ } else if (arg.startsWith("-")) {
47
+ console.error(`Error: Unknown option '${arg}'.`);
48
+ process.exit(1);
49
+ } else if (!assistantName) {
50
+ assistantName = arg;
51
+ } else {
52
+ console.error(`Error: Unexpected argument '${arg}'.`);
53
+ process.exit(1);
54
+ }
55
+ }
56
+
57
+ return { assistantName, provider };
58
+ }
59
+
60
+ export async function tunnel(): Promise<void> {
61
+ const { assistantName, provider } = parseArgs();
62
+
63
+ const entry = assistantName
64
+ ? findAssistantByName(assistantName)
65
+ : loadLatestAssistant();
66
+
67
+ if (!entry) {
68
+ if (assistantName) {
69
+ console.error(
70
+ `No assistant instance found with name '${assistantName}'.`,
71
+ );
72
+ } else {
73
+ console.error(
74
+ "No assistant instance found. Run `vellum hatch` first.",
75
+ );
76
+ }
77
+ process.exit(1);
78
+ }
79
+
80
+ if (provider === "ngrok") {
81
+ await runNgrokTunnel();
82
+ return;
83
+ }
84
+
85
+ throw new Error(
86
+ `Tunnel provider '${provider}' is not yet implemented.`,
87
+ );
88
+ }
package/src/index.ts CHANGED
@@ -18,9 +18,9 @@ import { pair } from "./commands/pair";
18
18
  import { ps } from "./commands/ps";
19
19
  import { recover } from "./commands/recover";
20
20
  import { retire } from "./commands/retire";
21
- import { skills } from "./commands/skills";
22
21
  import { sleep } from "./commands/sleep";
23
22
  import { ssh } from "./commands/ssh";
23
+ import { tunnel } from "./commands/tunnel";
24
24
  import { wake } from "./commands/wake";
25
25
 
26
26
  const commands = {
@@ -36,9 +36,9 @@ const commands = {
36
36
  ps,
37
37
  recover,
38
38
  retire,
39
- skills,
40
39
  sleep,
41
40
  ssh,
41
+ tunnel,
42
42
  wake,
43
43
  whoami,
44
44
  } as const;
@@ -49,9 +49,8 @@ function resolveAssistantEntry(): string | undefined {
49
49
  // When installed globally, resolve from node_modules
50
50
  try {
51
51
  const require = createRequire(import.meta.url);
52
- const assistantPkgPath = require.resolve(
53
- "@vellumai/assistant/package.json"
54
- );
52
+ const assistantPkgPath =
53
+ require.resolve("@vellumai/assistant/package.json");
55
54
  return join(dirname(assistantPkgPath), "src", "index.ts");
56
55
  } catch {
57
56
  // For local development, resolve from sibling directory
@@ -62,7 +61,7 @@ function resolveAssistantEntry(): string | undefined {
62
61
  "..",
63
62
  "assistant",
64
63
  "src",
65
- "index.ts"
64
+ "index.ts",
66
65
  );
67
66
  if (existsSync(localPath)) {
68
67
  return localPath;
@@ -93,12 +92,14 @@ async function main() {
93
92
  console.log(" login Log in to the Vellum platform");
94
93
  console.log(" logout Log out of the Vellum platform");
95
94
  console.log(" pair Pair with a remote assistant via QR code");
96
- console.log(" ps List assistants (or processes for a specific assistant)");
95
+ console.log(
96
+ " ps List assistants (or processes for a specific assistant)",
97
+ );
97
98
  console.log(" recover Restore a previously retired local assistant");
98
99
  console.log(" retire Delete an assistant instance");
99
- console.log(" skills Browse and install skills from the Vellum catalog");
100
100
  console.log(" sleep Stop the daemon process");
101
101
  console.log(" ssh SSH into a remote assistant instance");
102
+ console.log(" tunnel Create a tunnel for a locally hosted assistant");
102
103
  console.log(" wake Start the daemon and gateway");
103
104
  console.log(" whoami Show current logged-in user");
104
105
  process.exit(0);
@@ -117,9 +118,7 @@ async function main() {
117
118
  });
118
119
  } else {
119
120
  console.error(`Unknown command: ${commandName}`);
120
- console.error(
121
- "Install the full stack with: bun install -g vellum"
122
- );
121
+ console.error("Install the full stack with: bun install -g vellum");
123
122
  process.exit(1);
124
123
  }
125
124
  return;
@@ -0,0 +1,73 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join } from "node:path";
4
+
5
+ function getRootDir(): string {
6
+ return join(process.env.BASE_DATA_DIR?.trim() || homedir(), ".vellum");
7
+ }
8
+
9
+ export function getConfigPath(): string {
10
+ return join(getRootDir(), "workspace", "config.json");
11
+ }
12
+
13
+ export function getAllowlistPath(): string {
14
+ return join(getRootDir(), "protected", "secret-allowlist.json");
15
+ }
16
+
17
+ export function loadRawConfig(): Record<string, unknown> {
18
+ const configPath = getConfigPath();
19
+ if (!existsSync(configPath)) {
20
+ return {};
21
+ }
22
+ const raw = readFileSync(configPath, "utf-8");
23
+ return JSON.parse(raw) as Record<string, unknown>;
24
+ }
25
+
26
+ export function saveRawConfig(config: Record<string, unknown>): void {
27
+ const configPath = getConfigPath();
28
+ const dir = dirname(configPath);
29
+ if (!existsSync(dir)) {
30
+ mkdirSync(dir, { recursive: true });
31
+ }
32
+ writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
33
+ }
34
+
35
+ export function getNestedValue(
36
+ obj: Record<string, unknown>,
37
+ path: string,
38
+ ): unknown {
39
+ const keys = path.split(".");
40
+ let current: unknown = obj;
41
+ for (const key of keys) {
42
+ if (
43
+ current === null ||
44
+ current === undefined ||
45
+ typeof current !== "object"
46
+ ) {
47
+ return undefined;
48
+ }
49
+ current = (current as Record<string, unknown>)[key];
50
+ }
51
+ return current;
52
+ }
53
+
54
+ export function setNestedValue(
55
+ obj: Record<string, unknown>,
56
+ path: string,
57
+ value: unknown,
58
+ ): void {
59
+ const keys = path.split(".");
60
+ let current = obj;
61
+ for (let i = 0; i < keys.length - 1; i++) {
62
+ const key = keys[i];
63
+ if (
64
+ current[key] === undefined ||
65
+ current[key] === null ||
66
+ typeof current[key] !== "object"
67
+ ) {
68
+ current[key] = {};
69
+ }
70
+ current = current[key] as Record<string, unknown>;
71
+ }
72
+ current[keys[keys.length - 1]] = value;
73
+ }
package/src/lib/local.ts CHANGED
@@ -2,7 +2,7 @@ import { execFileSync, spawn } from "child_process";
2
2
  import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "fs";
3
3
  import { createRequire } from "module";
4
4
  import { createConnection } from "net";
5
- import { homedir } from "os";
5
+ import { homedir, hostname, networkInterfaces, platform } from "os";
6
6
  import { dirname, join } from "path";
7
7
 
8
8
  import { loadLatestAssistant } from "./assistant-config.js";
@@ -254,8 +254,8 @@ async function discoverPublicUrl(): Promise<string | undefined> {
254
254
 
255
255
  let externalIp: string | undefined;
256
256
 
257
- // Try cloud-specific metadata services first for GCP and AWS.
258
- if (cloud && cloud !== "local") {
257
+ // Try cloud-specific metadata services for GCP and AWS.
258
+ if (cloud === "gcp" || cloud === "aws") {
259
259
  try {
260
260
  if (cloud === "gcp") {
261
261
  const resp = await fetch(
@@ -281,46 +281,85 @@ async function discoverPublicUrl(): Promise<string | undefined> {
281
281
  } catch {
282
282
  // metadata service not reachable
283
283
  }
284
+
285
+ if (externalIp) {
286
+ console.log(` Discovered external IP: ${externalIp}`);
287
+ return `http://${externalIp}:${GATEWAY_PORT}`;
288
+ }
284
289
  }
285
290
 
286
- // Fall back to a public IP discovery service for all environments
287
- // (local, custom, or when cloud-specific metadata didn't resolve).
288
- if (!externalIp) {
289
- externalIp = await discoverPublicIpFallback();
291
+ // For local and custom environments, use the local LAN address.
292
+ // On macOS, prefer the .local hostname (Bonjour/mDNS) so other devices on
293
+ // the same network can reach the gateway by name.
294
+ if (platform() === "darwin") {
295
+ const localHostname = getMacLocalHostname();
296
+ if (localHostname) {
297
+ console.log(` Discovered macOS local hostname: ${localHostname}`);
298
+ return `http://${localHostname}:${GATEWAY_PORT}`;
299
+ }
290
300
  }
291
301
 
292
- if (externalIp) {
293
- console.log(` Discovered external IP: ${externalIp}`);
294
- return `http://${externalIp}:${GATEWAY_PORT}`;
302
+ const lanIp = getLocalLanIPv4();
303
+ if (lanIp) {
304
+ console.log(` Discovered LAN IP: ${lanIp}`);
305
+ return `http://${lanIp}:${GATEWAY_PORT}`;
295
306
  }
296
307
 
297
- // Final fallback to localhost when no public IP could be discovered.
308
+ // Final fallback to localhost when no LAN address could be discovered.
298
309
  return `http://localhost:${GATEWAY_PORT}`;
299
310
  }
300
311
 
301
- /** Try to discover the machine's public IP using external services.
302
- * Attempts multiple providers for resilience. */
303
- async function discoverPublicIpFallback(): Promise<string | undefined> {
304
- const services = [
305
- "https://api.ipify.org",
306
- "https://ifconfig.me/ip",
307
- "https://icanhazip.com",
308
- ];
312
+ /**
313
+ * Returns the macOS Bonjour/mDNS `.local` hostname (e.g. "Vargass-Mac-Mini.local"),
314
+ * or undefined if not running on macOS or the hostname cannot be determined.
315
+ */
316
+ function getMacLocalHostname(): string | undefined {
317
+ const host = hostname();
318
+ if (!host) return undefined;
319
+ // macOS hostnames already end with .local when Bonjour is active
320
+ if (host.endsWith(".local")) return host;
321
+ // Otherwise, append .local — macOS resolves <ComputerName>.local via mDNS
322
+ return `${host}.local`;
323
+ }
309
324
 
310
- for (const url of services) {
311
- try {
312
- const resp = await fetch(url, { signal: AbortSignal.timeout(3000) });
313
- if (resp.ok) {
314
- const ip = (await resp.text()).trim();
315
- // Basic validation: must look like an IPv4 or IPv6 address
316
- if (ip && /^[\d.:a-fA-F]+$/.test(ip)) {
317
- return ip;
318
- }
325
+ /**
326
+ * Returns the local IPv4 address most likely to be reachable from other
327
+ * devices on the same LAN.
328
+ *
329
+ * Priority order:
330
+ * 1. en0 (Wi-Fi on macOS)
331
+ * 2. en1 (secondary network on macOS)
332
+ * 3. First non-loopback IPv4 on any interface
333
+ *
334
+ * Skips link-local addresses (169.254.x.x) and IPv6.
335
+ * Returns undefined if no suitable address is found.
336
+ */
337
+ function getLocalLanIPv4(): string | undefined {
338
+ const ifaces = networkInterfaces();
339
+
340
+ // Priority interfaces in order
341
+ const priorityInterfaces = ["en0", "en1"];
342
+
343
+ for (const ifName of priorityInterfaces) {
344
+ const addrs = ifaces[ifName];
345
+ if (!addrs) continue;
346
+ for (const addr of addrs) {
347
+ if (addr.family === "IPv4" && !addr.internal && !addr.address.startsWith("169.254.")) {
348
+ return addr.address;
349
+ }
350
+ }
351
+ }
352
+
353
+ // Fallback: first non-loopback, non-link-local IPv4 on any interface
354
+ for (const [, addrs] of Object.entries(ifaces)) {
355
+ if (!addrs) continue;
356
+ for (const addr of addrs) {
357
+ if (addr.family === "IPv4" && !addr.internal && !addr.address.startsWith("169.254.")) {
358
+ return addr.address;
319
359
  }
320
- } catch {
321
- // Service unreachable, try the next one
322
360
  }
323
361
  }
362
+
324
363
  return undefined;
325
364
  }
326
365
 
@@ -0,0 +1,263 @@
1
+ import { execFileSync, spawn, type ChildProcess } from "node:child_process";
2
+
3
+ import { loadRawConfig, saveRawConfig } from "./config";
4
+ import { GATEWAY_PORT } from "./constants";
5
+
6
+ const NGROK_API_URL = "http://127.0.0.1:4040/api/tunnels";
7
+ const NGROK_POLL_INTERVAL_MS = 500;
8
+ const NGROK_POLL_TIMEOUT_MS = 15_000;
9
+
10
+ interface NgrokTunnel {
11
+ public_url: string;
12
+ config?: { addr?: string };
13
+ }
14
+
15
+ interface NgrokTunnelsResponse {
16
+ tunnels: NgrokTunnel[];
17
+ }
18
+
19
+ /**
20
+ * Check whether ngrok is installed and accessible on the PATH.
21
+ * Returns the version string if installed, null otherwise.
22
+ */
23
+ export function getNgrokVersion(): string | null {
24
+ try {
25
+ const output = execFileSync("ngrok", ["version"], {
26
+ encoding: "utf-8",
27
+ timeout: 5_000,
28
+ stdio: ["ignore", "pipe", "ignore"],
29
+ });
30
+ return output.trim();
31
+ } catch {
32
+ return null;
33
+ }
34
+ }
35
+
36
+ /**
37
+ * Query the ngrok local API for running tunnels.
38
+ * Returns the list of tunnels, or null if the API is unreachable.
39
+ */
40
+ async function queryNgrokTunnels(): Promise<NgrokTunnel[] | null> {
41
+ try {
42
+ const res = await fetch(NGROK_API_URL, {
43
+ signal: AbortSignal.timeout(2_000),
44
+ });
45
+ if (!res.ok) return null;
46
+ const data = (await res.json()) as NgrokTunnelsResponse;
47
+ return data.tunnels ?? [];
48
+ } catch {
49
+ return null;
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Find an existing ngrok tunnel that targets the given local address.
55
+ * Returns the HTTPS public URL if found, null otherwise.
56
+ */
57
+ export async function findExistingTunnel(
58
+ targetPort: number,
59
+ ): Promise<string | null> {
60
+ const tunnels = await queryNgrokTunnels();
61
+ if (!tunnels || tunnels.length === 0) return null;
62
+
63
+ const targetAddrs = [
64
+ `localhost:${targetPort}`,
65
+ `127.0.0.1:${targetPort}`,
66
+ `http://localhost:${targetPort}`,
67
+ `http://127.0.0.1:${targetPort}`,
68
+ ];
69
+
70
+ // Prefer HTTPS tunnel
71
+ for (const t of tunnels) {
72
+ const addr = t.config?.addr ?? "";
73
+ if (targetAddrs.includes(addr) && t.public_url.startsWith("https://")) {
74
+ return t.public_url;
75
+ }
76
+ }
77
+
78
+ // Fall back to any tunnel pointing at the target
79
+ for (const t of tunnels) {
80
+ const addr = t.config?.addr ?? "";
81
+ if (targetAddrs.includes(addr) && t.public_url) {
82
+ return t.public_url;
83
+ }
84
+ }
85
+
86
+ return null;
87
+ }
88
+
89
+ /**
90
+ * Start an ngrok process tunneling HTTP traffic to the given local port.
91
+ * Returns the spawned child process.
92
+ */
93
+ export function startNgrokProcess(targetPort: number): ChildProcess {
94
+ const child = spawn("ngrok", ["http", String(targetPort), "--log=stdout"], {
95
+ stdio: ["ignore", "pipe", "pipe"],
96
+ });
97
+ return child;
98
+ }
99
+
100
+ /**
101
+ * Poll the ngrok local API until an HTTPS tunnel URL appears.
102
+ * Returns the public URL, or throws if the timeout is exceeded.
103
+ */
104
+ export async function waitForNgrokUrl(
105
+ timeoutMs: number = NGROK_POLL_TIMEOUT_MS,
106
+ ): Promise<string> {
107
+ const start = Date.now();
108
+ while (Date.now() - start < timeoutMs) {
109
+ const tunnels = await queryNgrokTunnels();
110
+ if (tunnels && tunnels.length > 0) {
111
+ // Prefer HTTPS
112
+ const httpsTunnel = tunnels.find((t) =>
113
+ t.public_url.startsWith("https://"),
114
+ );
115
+ if (httpsTunnel) return httpsTunnel.public_url;
116
+ if (tunnels[0]?.public_url) return tunnels[0].public_url;
117
+ }
118
+ await new Promise((r) => setTimeout(r, NGROK_POLL_INTERVAL_MS));
119
+ }
120
+ throw new Error(
121
+ `ngrok tunnel did not become available within ${timeoutMs / 1000}s. Check ngrok logs for errors.`,
122
+ );
123
+ }
124
+
125
+ /**
126
+ * Persist a public ingress URL to the workspace config and enable ingress.
127
+ */
128
+ function saveIngressUrl(publicUrl: string): void {
129
+ const config = loadRawConfig();
130
+ const ingress = (config.ingress ?? {}) as Record<string, unknown>;
131
+ ingress.publicBaseUrl = publicUrl;
132
+ ingress.enabled = true;
133
+ config.ingress = ingress;
134
+ saveRawConfig(config);
135
+ }
136
+
137
+ /**
138
+ * Clear the ingress public base URL from the workspace config.
139
+ */
140
+ function clearIngressUrl(): void {
141
+ const config = loadRawConfig();
142
+ const ingress = (config.ingress ?? {}) as Record<string, unknown>;
143
+ delete ingress.publicBaseUrl;
144
+ config.ingress = ingress;
145
+ saveRawConfig(config);
146
+ }
147
+
148
+ /**
149
+ * Run the ngrok tunnel workflow: check installation, find or start a tunnel,
150
+ * save the public URL to config, and block until exit or signal.
151
+ */
152
+ export async function runNgrokTunnel(): Promise<void> {
153
+ const version = getNgrokVersion();
154
+ if (!version) {
155
+ console.error("Error: ngrok is not installed.");
156
+ console.error("");
157
+ console.error("Install ngrok:");
158
+ console.error(" macOS: brew install ngrok/ngrok/ngrok");
159
+ console.error(" Linux: sudo snap install ngrok");
160
+ console.error("");
161
+ console.error(
162
+ "Then authenticate: ngrok config add-authtoken <your-token>",
163
+ );
164
+ console.error(
165
+ " Get your token at: https://dashboard.ngrok.com/get-started/your-authtoken",
166
+ );
167
+ process.exit(1);
168
+ }
169
+
170
+ console.log(`Using ${version}`);
171
+
172
+ const port = GATEWAY_PORT;
173
+
174
+ // Check for an existing ngrok tunnel pointing at the gateway
175
+ const existingUrl = await findExistingTunnel(port);
176
+ if (existingUrl) {
177
+ console.log(`Found existing ngrok tunnel: ${existingUrl}`);
178
+ saveIngressUrl(existingUrl);
179
+ console.log("Ingress URL saved to config.");
180
+ console.log("");
181
+ console.log(
182
+ "Tunnel is already running. Press Ctrl+C to detach (tunnel stays active).",
183
+ );
184
+
185
+ // Block until SIGINT/SIGTERM
186
+ await new Promise<void>((resolve) => {
187
+ process.on("SIGINT", () => resolve());
188
+ process.on("SIGTERM", () => resolve());
189
+ });
190
+ return;
191
+ }
192
+
193
+ console.log(`Starting ngrok tunnel to localhost:${port}...`);
194
+
195
+ let publicUrl: string | undefined;
196
+
197
+ const ngrokProcess = startNgrokProcess(port);
198
+
199
+ const cleanup = () => {
200
+ if (!ngrokProcess.killed) {
201
+ ngrokProcess.kill("SIGTERM");
202
+ }
203
+ if (publicUrl) {
204
+ console.log("\nClearing ingress URL from config...");
205
+ clearIngressUrl();
206
+ }
207
+ };
208
+
209
+ process.on("SIGINT", () => {
210
+ cleanup();
211
+ process.exit(0);
212
+ });
213
+ process.on("SIGTERM", () => {
214
+ cleanup();
215
+ process.exit(0);
216
+ });
217
+
218
+ ngrokProcess.on("error", (err: Error) => {
219
+ console.error(`ngrok process error: ${err.message}`);
220
+ process.exit(1);
221
+ });
222
+
223
+ ngrokProcess.on("exit", (code: number | null) => {
224
+ if (code !== null && code !== 0) {
225
+ console.error(`ngrok exited with code ${code}.`);
226
+ console.error(
227
+ "Check that ngrok is authenticated: ngrok config add-authtoken <token>",
228
+ );
229
+ process.exit(1);
230
+ }
231
+ });
232
+
233
+ // Pipe ngrok stdout/stderr to console for visibility
234
+ ngrokProcess.stdout?.on("data", (data: Buffer) => {
235
+ const line = data.toString().trim();
236
+ if (line) console.log(`[ngrok] ${line}`);
237
+ });
238
+ ngrokProcess.stderr?.on("data", (data: Buffer) => {
239
+ const line = data.toString().trim();
240
+ if (line) console.error(`[ngrok] ${line}`);
241
+ });
242
+
243
+ try {
244
+ publicUrl = await waitForNgrokUrl();
245
+ } catch (err) {
246
+ cleanup();
247
+ throw err;
248
+ }
249
+
250
+ console.log("");
251
+ console.log(`Tunnel established: ${publicUrl}`);
252
+ console.log(`Forwarding to: localhost:${port}`);
253
+
254
+ saveIngressUrl(publicUrl);
255
+ console.log("Ingress URL saved to config.");
256
+ console.log("");
257
+ console.log("Press Ctrl+C to stop the tunnel and clear the ingress URL.");
258
+
259
+ // Keep running until the ngrok process exits or we receive a signal
260
+ await new Promise<void>((resolve) => {
261
+ ngrokProcess.on("exit", () => resolve());
262
+ });
263
+ }
@@ -1,355 +0,0 @@
1
- import {
2
- existsSync,
3
- mkdirSync,
4
- readFileSync,
5
- renameSync,
6
- writeFileSync,
7
- } from "node:fs";
8
- import { homedir } from "node:os";
9
- import { join, dirname } from "node:path";
10
- import { gunzipSync } from "node:zlib";
11
- import { randomUUID } from "node:crypto";
12
-
13
- // ---------------------------------------------------------------------------
14
- // Path helpers
15
- // ---------------------------------------------------------------------------
16
-
17
- function getRootDir(): string {
18
- return join(process.env.BASE_DATA_DIR?.trim() || homedir(), ".vellum");
19
- }
20
-
21
- function getSkillsDir(): string {
22
- return join(getRootDir(), "workspace", "skills");
23
- }
24
-
25
- function getSkillsIndexPath(): string {
26
- return join(getSkillsDir(), "SKILLS.md");
27
- }
28
-
29
- // ---------------------------------------------------------------------------
30
- // Platform API client
31
- // ---------------------------------------------------------------------------
32
-
33
- function getConfigPlatformUrl(): string | undefined {
34
- try {
35
- const configPath = join(getRootDir(), "workspace", "config.json");
36
- if (!existsSync(configPath)) return undefined;
37
- const raw = JSON.parse(readFileSync(configPath, "utf-8")) as Record<
38
- string,
39
- unknown
40
- >;
41
- const platform = raw.platform as Record<string, unknown> | undefined;
42
- const baseUrl = platform?.baseUrl;
43
- if (typeof baseUrl === "string" && baseUrl.trim()) return baseUrl.trim();
44
- } catch {
45
- // ignore
46
- }
47
- return undefined;
48
- }
49
-
50
- function getPlatformUrl(): string {
51
- return (
52
- process.env.VELLUM_ASSISTANT_PLATFORM_URL ??
53
- getConfigPlatformUrl() ??
54
- "https://platform.vellum.ai"
55
- );
56
- }
57
-
58
- function getPlatformToken(): string | null {
59
- try {
60
- return readFileSync(join(getRootDir(), "platform-token"), "utf-8").trim();
61
- } catch {
62
- return null;
63
- }
64
- }
65
-
66
- function buildHeaders(): Record<string, string> {
67
- const headers: Record<string, string> = {};
68
- const token = getPlatformToken();
69
- if (token) {
70
- headers["X-Session-Token"] = token;
71
- }
72
- return headers;
73
- }
74
-
75
- // ---------------------------------------------------------------------------
76
- // Types
77
- // ---------------------------------------------------------------------------
78
-
79
- interface CatalogSkill {
80
- id: string;
81
- name: string;
82
- description: string;
83
- emoji?: string;
84
- includes?: string[];
85
- version?: string;
86
- }
87
-
88
- interface CatalogManifest {
89
- version: number;
90
- skills: CatalogSkill[];
91
- }
92
-
93
- // ---------------------------------------------------------------------------
94
- // Catalog operations
95
- // ---------------------------------------------------------------------------
96
-
97
- async function fetchCatalog(): Promise<CatalogSkill[]> {
98
- const url = `${getPlatformUrl()}/v1/skills/`;
99
- const response = await fetch(url, {
100
- headers: buildHeaders(),
101
- signal: AbortSignal.timeout(10000),
102
- });
103
-
104
- if (!response.ok) {
105
- throw new Error(`Platform API error ${response.status}: ${response.statusText}`);
106
- }
107
-
108
- const manifest = (await response.json()) as CatalogManifest;
109
- if (!Array.isArray(manifest.skills)) {
110
- throw new Error("Platform catalog has invalid skills array");
111
- }
112
- return manifest.skills;
113
- }
114
-
115
- /**
116
- * Extract SKILL.md content from a tar archive (uncompressed).
117
- */
118
- function extractSkillMdFromTar(tarBuffer: Buffer): string | null {
119
- let offset = 0;
120
- while (offset + 512 <= tarBuffer.length) {
121
- const header = tarBuffer.subarray(offset, offset + 512);
122
-
123
- // End-of-archive (two consecutive zero blocks)
124
- if (header.every((b) => b === 0)) break;
125
-
126
- // Filename (bytes 0-99, null-terminated)
127
- const nameEnd = header.indexOf(0, 0);
128
- const name = header
129
- .subarray(0, Math.min(nameEnd >= 0 ? nameEnd : 100, 100))
130
- .toString("utf-8");
131
-
132
- // File size (bytes 124-135, octal)
133
- const sizeStr = header.subarray(124, 136).toString("utf-8").trim();
134
- const size = parseInt(sizeStr, 8) || 0;
135
-
136
- offset += 512; // past header
137
-
138
- if (name.endsWith("SKILL.md") || name === "SKILL.md") {
139
- return tarBuffer.subarray(offset, offset + size).toString("utf-8");
140
- }
141
-
142
- // Skip to next header (data padded to 512 bytes)
143
- offset += Math.ceil(size / 512) * 512;
144
- }
145
- return null;
146
- }
147
-
148
- async function fetchSkillContent(skillId: string): Promise<string> {
149
- const url = `${getPlatformUrl()}/v1/skills/${encodeURIComponent(skillId)}/`;
150
- const response = await fetch(url, {
151
- headers: buildHeaders(),
152
- signal: AbortSignal.timeout(15000),
153
- });
154
-
155
- if (!response.ok) {
156
- throw new Error(
157
- `Failed to fetch skill "${skillId}": HTTP ${response.status}`,
158
- );
159
- }
160
-
161
- const gzipBuffer = Buffer.from(await response.arrayBuffer());
162
- const tarBuffer = gunzipSync(gzipBuffer);
163
- const skillMd = extractSkillMdFromTar(tarBuffer);
164
-
165
- if (!skillMd) {
166
- throw new Error(`SKILL.md not found in archive for "${skillId}"`);
167
- }
168
-
169
- return skillMd;
170
- }
171
-
172
- // ---------------------------------------------------------------------------
173
- // Managed skill installation
174
- // ---------------------------------------------------------------------------
175
-
176
- function atomicWriteFile(filePath: string, content: string): void {
177
- const dir = dirname(filePath);
178
- mkdirSync(dir, { recursive: true });
179
- const tmpPath = join(dir, `.tmp-${randomUUID()}`);
180
- writeFileSync(tmpPath, content, "utf-8");
181
- renameSync(tmpPath, filePath);
182
- }
183
-
184
- function upsertSkillsIndex(id: string): void {
185
- const indexPath = getSkillsIndexPath();
186
- let lines: string[] = [];
187
- if (existsSync(indexPath)) {
188
- lines = readFileSync(indexPath, "utf-8").split("\n");
189
- }
190
-
191
- const escaped = id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
192
- const pattern = new RegExp(`^[-*]\\s+(?:\`)?${escaped}(?:\`)?\\s*$`);
193
- if (lines.some((line) => pattern.test(line))) return;
194
-
195
- const nonEmpty = lines.filter((l) => l.trim());
196
- nonEmpty.push(`- ${id}`);
197
- const content = nonEmpty.join("\n");
198
- atomicWriteFile(indexPath, content.endsWith("\n") ? content : content + "\n");
199
- }
200
-
201
- function installSkillLocally(
202
- skillId: string,
203
- skillMdContent: string,
204
- catalogEntry: CatalogSkill,
205
- overwrite: boolean,
206
- ): void {
207
- const skillDir = join(getSkillsDir(), skillId);
208
- const skillFilePath = join(skillDir, "SKILL.md");
209
-
210
- if (existsSync(skillFilePath) && !overwrite) {
211
- throw new Error(
212
- `Skill "${skillId}" is already installed. Use --overwrite to replace it.`,
213
- );
214
- }
215
-
216
- mkdirSync(skillDir, { recursive: true });
217
- atomicWriteFile(skillFilePath, skillMdContent);
218
-
219
- // Write version metadata
220
- if (catalogEntry.version) {
221
- const meta = {
222
- version: catalogEntry.version,
223
- installedAt: new Date().toISOString(),
224
- };
225
- atomicWriteFile(
226
- join(skillDir, "version.json"),
227
- JSON.stringify(meta, null, 2) + "\n",
228
- );
229
- }
230
-
231
- upsertSkillsIndex(skillId);
232
- }
233
-
234
- // ---------------------------------------------------------------------------
235
- // Helpers
236
- // ---------------------------------------------------------------------------
237
-
238
- function hasFlag(args: string[], flag: string): boolean {
239
- return args.includes(flag);
240
- }
241
-
242
- // ---------------------------------------------------------------------------
243
- // Usage
244
- // ---------------------------------------------------------------------------
245
-
246
- function printUsage(): void {
247
- console.log("Usage: vellum skills <subcommand> [options]");
248
- console.log("");
249
- console.log("Subcommands:");
250
- console.log(" list List available catalog skills");
251
- console.log(
252
- " install <skill-id> [--overwrite] Install a skill from the catalog",
253
- );
254
- console.log("");
255
- console.log("Options:");
256
- console.log(" --json Machine-readable JSON output");
257
- }
258
-
259
- // ---------------------------------------------------------------------------
260
- // Command entry point
261
- // ---------------------------------------------------------------------------
262
-
263
- export async function skills(): Promise<void> {
264
- const args = process.argv.slice(3);
265
- const subcommand = args[0];
266
- const json = hasFlag(args, "--json");
267
-
268
- if (!subcommand || subcommand === "--help" || subcommand === "-h") {
269
- printUsage();
270
- return;
271
- }
272
-
273
- switch (subcommand) {
274
- case "list": {
275
- try {
276
- const catalog = await fetchCatalog();
277
-
278
- if (json) {
279
- console.log(JSON.stringify({ ok: true, skills: catalog }));
280
- return;
281
- }
282
-
283
- if (catalog.length === 0) {
284
- console.log("No skills available in the catalog.");
285
- return;
286
- }
287
-
288
- console.log(`Available skills (${catalog.length}):\n`);
289
- for (const s of catalog) {
290
- const emoji = s.emoji ? `${s.emoji} ` : "";
291
- const deps = s.includes?.length
292
- ? ` (requires: ${s.includes.join(", ")})`
293
- : "";
294
- console.log(` ${emoji}${s.id}`);
295
- console.log(` ${s.name} — ${s.description}${deps}`);
296
- }
297
- } catch (err) {
298
- const msg = err instanceof Error ? err.message : String(err);
299
- if (json) {
300
- console.log(JSON.stringify({ ok: false, error: msg }));
301
- } else {
302
- console.error(`Error: ${msg}`);
303
- }
304
- process.exitCode = 1;
305
- }
306
- break;
307
- }
308
-
309
- case "install": {
310
- const skillId = args.find((a) => !a.startsWith("--") && a !== "install");
311
- if (!skillId) {
312
- console.error("Usage: vellum skills install <skill-id>");
313
- process.exit(1);
314
- }
315
-
316
- const overwrite = hasFlag(args, "--overwrite");
317
-
318
- try {
319
- // Verify skill exists in catalog
320
- const catalog = await fetchCatalog();
321
- const entry = catalog.find((s) => s.id === skillId);
322
- if (!entry) {
323
- throw new Error(`Skill "${skillId}" not found in the Vellum catalog`);
324
- }
325
-
326
- // Fetch SKILL.md from platform
327
- const content = await fetchSkillContent(skillId);
328
-
329
- // Install locally
330
- installSkillLocally(skillId, content, entry, overwrite);
331
-
332
- if (json) {
333
- console.log(JSON.stringify({ ok: true, skillId }));
334
- } else {
335
- console.log(`Installed skill "${skillId}".`);
336
- }
337
- } catch (err) {
338
- const msg = err instanceof Error ? err.message : String(err);
339
- if (json) {
340
- console.log(JSON.stringify({ ok: false, error: msg }));
341
- } else {
342
- console.error(`Error: ${msg}`);
343
- }
344
- process.exitCode = 1;
345
- }
346
- break;
347
- }
348
-
349
- default: {
350
- console.error(`Unknown skills subcommand: ${subcommand}`);
351
- printUsage();
352
- process.exit(1);
353
- }
354
- }
355
- }