@vellumai/cli 0.4.10 → 0.4.11
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 +23 -0
- package/package.json +1 -1
- package/src/__tests__/coverage.test.ts +41 -0
- package/src/commands/config.ts +8 -73
- package/src/commands/retire.ts +27 -10
- package/src/commands/tunnel.ts +88 -0
- package/src/index.ts +3 -0
- package/src/lib/config.ts +73 -0
- package/src/lib/local.ts +69 -30
- package/src/lib/ngrok.ts +263 -0
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
|
@@ -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
|
+
});
|
package/src/commands/config.ts
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
|
-
import { existsSync,
|
|
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[] {
|
package/src/commands/retire.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { spawn } from "child_process";
|
|
2
|
-
import {
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
|
75
|
-
console.warn("
|
|
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
|
-
|
|
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
|
|
|
@@ -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
|
@@ -21,6 +21,7 @@ import { retire } from "./commands/retire";
|
|
|
21
21
|
import { skills } from "./commands/skills";
|
|
22
22
|
import { sleep } from "./commands/sleep";
|
|
23
23
|
import { ssh } from "./commands/ssh";
|
|
24
|
+
import { tunnel } from "./commands/tunnel";
|
|
24
25
|
import { wake } from "./commands/wake";
|
|
25
26
|
|
|
26
27
|
const commands = {
|
|
@@ -39,6 +40,7 @@ const commands = {
|
|
|
39
40
|
skills,
|
|
40
41
|
sleep,
|
|
41
42
|
ssh,
|
|
43
|
+
tunnel,
|
|
42
44
|
wake,
|
|
43
45
|
whoami,
|
|
44
46
|
} as const;
|
|
@@ -99,6 +101,7 @@ async function main() {
|
|
|
99
101
|
console.log(" skills Browse and install skills from the Vellum catalog");
|
|
100
102
|
console.log(" sleep Stop the daemon process");
|
|
101
103
|
console.log(" ssh SSH into a remote assistant instance");
|
|
104
|
+
console.log(" tunnel Create a tunnel for a locally hosted assistant");
|
|
102
105
|
console.log(" wake Start the daemon and gateway");
|
|
103
106
|
console.log(" whoami Show current logged-in user");
|
|
104
107
|
process.exit(0);
|
|
@@ -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
|
|
258
|
-
if (cloud
|
|
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
|
-
//
|
|
287
|
-
//
|
|
288
|
-
|
|
289
|
-
|
|
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
|
-
|
|
293
|
-
|
|
294
|
-
|
|
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
|
|
308
|
+
// Final fallback to localhost when no LAN address could be discovered.
|
|
298
309
|
return `http://localhost:${GATEWAY_PORT}`;
|
|
299
310
|
}
|
|
300
311
|
|
|
301
|
-
/**
|
|
302
|
-
*
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
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
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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
|
|
package/src/lib/ngrok.ts
ADDED
|
@@ -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
|
+
}
|