@vellumai/cli 0.11.2 → 0.11.3-staging.2

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.
Files changed (97) hide show
  1. package/knip.json +1 -0
  2. package/node_modules/@vellumai/environments/package.json +2 -1
  3. package/node_modules/@vellumai/environments/src/__tests__/cloud-assistant-hub-url.test.ts +38 -0
  4. package/node_modules/@vellumai/environments/src/__tests__/install-layout.test.ts +98 -0
  5. package/node_modules/@vellumai/environments/src/__tests__/package-boundary.test.ts +5 -5
  6. package/node_modules/@vellumai/environments/src/index.ts +17 -6
  7. package/node_modules/@vellumai/environments/src/install-layout.ts +49 -0
  8. package/node_modules/@vellumai/environments/src/seeds.ts +29 -0
  9. package/node_modules/@vellumai/ipc-server-utils/package.json +18 -0
  10. package/node_modules/@vellumai/ipc-server-utils/src/endpoint.test.ts +36 -0
  11. package/node_modules/@vellumai/ipc-server-utils/src/endpoint.ts +142 -0
  12. package/node_modules/@vellumai/ipc-server-utils/src/index.ts +18 -0
  13. package/node_modules/@vellumai/ipc-server-utils/src/ipc-framing.ts +295 -0
  14. package/node_modules/@vellumai/ipc-server-utils/src/listen-options.ts +3 -0
  15. package/node_modules/@vellumai/ipc-server-utils/src/socket-watchdog.test.ts +444 -0
  16. package/node_modules/@vellumai/ipc-server-utils/src/socket-watchdog.ts +236 -0
  17. package/node_modules/@vellumai/ipc-server-utils/tsconfig.json +20 -0
  18. package/node_modules/@vellumai/local-mode/node_modules/@vellumai/environments/package.json +2 -1
  19. package/node_modules/@vellumai/local-mode/node_modules/@vellumai/environments/src/__tests__/cloud-assistant-hub-url.test.ts +38 -0
  20. package/node_modules/@vellumai/local-mode/node_modules/@vellumai/environments/src/__tests__/install-layout.test.ts +98 -0
  21. package/node_modules/@vellumai/local-mode/node_modules/@vellumai/environments/src/__tests__/package-boundary.test.ts +5 -5
  22. package/node_modules/@vellumai/local-mode/node_modules/@vellumai/environments/src/index.ts +17 -6
  23. package/node_modules/@vellumai/local-mode/node_modules/@vellumai/environments/src/install-layout.ts +49 -0
  24. package/node_modules/@vellumai/local-mode/node_modules/@vellumai/environments/src/seeds.ts +29 -0
  25. package/node_modules/@vellumai/local-mode/node_modules/nanoid/LICENSE +20 -0
  26. package/node_modules/@vellumai/local-mode/node_modules/nanoid/README.md +38 -0
  27. package/node_modules/@vellumai/local-mode/node_modules/nanoid/bin/nanoid.js +55 -0
  28. package/node_modules/@vellumai/local-mode/node_modules/nanoid/index.browser.js +29 -0
  29. package/node_modules/@vellumai/local-mode/node_modules/nanoid/index.d.ts +106 -0
  30. package/node_modules/@vellumai/local-mode/node_modules/nanoid/index.js +47 -0
  31. package/node_modules/@vellumai/local-mode/node_modules/nanoid/nanoid.js +1 -0
  32. package/node_modules/@vellumai/local-mode/node_modules/nanoid/non-secure/index.d.ts +48 -0
  33. package/node_modules/@vellumai/local-mode/node_modules/nanoid/non-secure/index.js +21 -0
  34. package/node_modules/@vellumai/local-mode/node_modules/nanoid/package.json +46 -0
  35. package/node_modules/@vellumai/local-mode/node_modules/nanoid/url-alphabet/index.js +2 -0
  36. package/node_modules/@vellumai/local-mode/package.json +1 -0
  37. package/node_modules/@vellumai/local-mode/src/__tests__/environment.test.ts +63 -1
  38. package/node_modules/@vellumai/local-mode/src/__tests__/gateway-proxy.test.ts +503 -0
  39. package/node_modules/@vellumai/local-mode/src/__tests__/guardian-token.test.ts +203 -0
  40. package/node_modules/@vellumai/local-mode/src/__tests__/loopback-auth.test.ts +46 -1
  41. package/node_modules/@vellumai/local-mode/src/__tests__/package-boundary.test.ts +6 -4
  42. package/node_modules/@vellumai/local-mode/src/__tests__/pair.test.ts +621 -0
  43. package/node_modules/@vellumai/local-mode/src/__tests__/status.test.ts +5 -0
  44. package/node_modules/@vellumai/local-mode/src/__tests__/unpair.test.ts +232 -0
  45. package/node_modules/@vellumai/local-mode/src/config.ts +126 -20
  46. package/node_modules/@vellumai/local-mode/src/environment.ts +36 -15
  47. package/node_modules/@vellumai/local-mode/src/gateway-proxy.ts +313 -25
  48. package/node_modules/@vellumai/local-mode/src/guardian-token.ts +222 -13
  49. package/node_modules/@vellumai/local-mode/src/index.ts +35 -3
  50. package/node_modules/@vellumai/local-mode/src/lockfile-contract.test.ts +18 -0
  51. package/node_modules/@vellumai/local-mode/src/lockfile-contract.ts +72 -0
  52. package/node_modules/@vellumai/local-mode/src/lockfile.test.ts +130 -0
  53. package/node_modules/@vellumai/local-mode/src/lockfile.ts +134 -45
  54. package/node_modules/@vellumai/local-mode/src/pair.ts +354 -0
  55. package/node_modules/@vellumai/local-mode/src/paths.ts +49 -0
  56. package/node_modules/@vellumai/local-mode/src/status.ts +10 -8
  57. package/node_modules/@vellumai/local-mode/src/unpair.ts +101 -0
  58. package/node_modules/@vellumai/local-mode/src/util.ts +34 -0
  59. package/node_modules/@vellumai/service-contracts/src/channels.ts +39 -0
  60. package/node_modules/@vellumai/service-contracts/src/ingress.ts +10 -0
  61. package/node_modules/@vellumai/service-contracts/src/remote-web-pairing.ts +6 -0
  62. package/package.json +3 -1
  63. package/src/__tests__/client-token.test.ts +26 -1
  64. package/src/__tests__/guardian-token.test.ts +4 -0
  65. package/src/__tests__/nginx-ingress-command.test.ts +176 -16
  66. package/src/__tests__/nginx-ingress.test.ts +1821 -101
  67. package/src/__tests__/pair.test.ts +104 -2
  68. package/src/__tests__/sleep.test.ts +7 -3
  69. package/src/__tests__/tailscale-tunnel.test.ts +0 -1
  70. package/src/__tests__/tunnel.test.ts +1099 -55
  71. package/src/__tests__/wake.test.ts +166 -99
  72. package/src/commands/client.ts +182 -27
  73. package/src/commands/connect/import.ts +35 -156
  74. package/src/commands/nginx-ingress.ts +87 -110
  75. package/src/commands/pair.ts +84 -21
  76. package/src/commands/tunnel.ts +196 -85
  77. package/src/commands/upgrade.ts +3 -2
  78. package/src/commands/wake.ts +5 -163
  79. package/src/index.ts +1 -1
  80. package/src/lib/__tests__/local-ces.test.ts +10 -1
  81. package/src/lib/cloudflare-tunnel.ts +3 -16
  82. package/src/lib/environments/__tests__/paths.test.ts +20 -1
  83. package/src/lib/environments/paths.ts +29 -41
  84. package/src/lib/environments/resolve.ts +7 -5
  85. package/src/lib/guardian-token.ts +22 -62
  86. package/src/lib/ingress-config.ts +25 -0
  87. package/src/lib/local.ts +77 -20
  88. package/src/lib/nginx-ingress.ts +523 -76
  89. package/src/lib/ngrok.ts +154 -61
  90. package/src/lib/orphan-detection.test.ts +167 -0
  91. package/src/lib/orphan-detection.ts +40 -0
  92. package/src/lib/pair.test.ts +87 -0
  93. package/src/lib/pair.ts +55 -0
  94. package/src/lib/platform-client.ts +8 -5
  95. package/src/lib/tailscale-tunnel.ts +7 -18
  96. package/src/lib/tunnel-edge.ts +114 -0
  97. package/src/lib/xdg-log.ts +2 -2
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Pure helpers for `vellum pair`'s remote-web approval flow.
3
+ *
4
+ * Pairing challenge stores are in-memory per gateway process, so approving a
5
+ * code against a different assistant or environment than the one that served
6
+ * the pair page looks identical to a typo'd or expired code. These helpers
7
+ * turn the gateway's error envelope into a diagnostic that names the gateway
8
+ * actually asked.
9
+ */
10
+
11
+ import { REMOTE_WEB_PAIRING_CODE_TTL_MS } from "@vellumai/service-contracts/remote-web-pairing";
12
+
13
+ const CODE_TTL_MINUTES = Math.round(REMOTE_WEB_PAIRING_CODE_TTL_MS / 60_000);
14
+
15
+ /**
16
+ * Extract the `error.code` from a gateway JSON error envelope
17
+ * (`{"error":{"code":...,"message":...}}`). Returns null when the body is not
18
+ * JSON or carries no string code.
19
+ */
20
+ export function parseGatewayErrorCode(body: string): string | null {
21
+ try {
22
+ const parsed = JSON.parse(body) as { error?: { code?: unknown } };
23
+ return typeof parsed.error?.code === "string" ? parsed.error.code : null;
24
+ } catch {
25
+ return null;
26
+ }
27
+ }
28
+
29
+ /**
30
+ * Diagnostic for a failed `--web-approve`, naming which gateway rejected the
31
+ * code and hinting at cross-assistant / cross-environment mismatches. Returns
32
+ * null for error codes this helper doesn't recognize, so the caller falls
33
+ * back to the generic HTTP error text. `assistantReference` should carry the
34
+ * stable assistant ID (see `formatAssistantReference`); `envName` should be
35
+ * the effective environment actually used (see `getCurrentEnvironment`).
36
+ */
37
+ export function formatWebApproveFailure(
38
+ gatewayUrl: string,
39
+ assistantReference: string,
40
+ envName: string,
41
+ errorCode: string | null,
42
+ ): string | null {
43
+ if (errorCode !== "INVALID_USER_CODE" && errorCode !== "EXPIRED_USER_CODE") {
44
+ return null;
45
+ }
46
+ const rejection =
47
+ errorCode === "EXPIRED_USER_CODE"
48
+ ? "Pairing code expired on"
49
+ : "No such pairing code on";
50
+ return [
51
+ `${rejection} ${gatewayUrl} (assistant "${assistantReference}", environment "${envName}").`,
52
+ `Codes are minted by the gateway that serves the pair page, expire after ${CODE_TTL_MINUTES} minutes, and are single-use.`,
53
+ "If the pair page came from a different assistant or environment (e.g. the desktop app's), re-run with that environment's VELLUM_ENVIRONMENT and that assistant's name.",
54
+ ].join("\n");
55
+ }
@@ -9,7 +9,7 @@ import {
9
9
  import { join, dirname } from "path";
10
10
 
11
11
  import { getLockfilePlatformBaseUrl } from "./assistant-config.js";
12
- import { getConfigDir } from "./environments/paths.js";
12
+ import { getConfigDir, getConfigDirs } from "./environments/paths.js";
13
13
  import { getCurrentEnvironment } from "./environments/resolve.js";
14
14
  import { loopbackSafeFetch } from "./loopback-fetch.js";
15
15
 
@@ -55,11 +55,14 @@ export function getWebUrl(): string {
55
55
  }
56
56
 
57
57
  export function readPlatformToken(): string | null {
58
- try {
59
- return readFileSync(getPlatformTokenPath(), "utf-8").trim();
60
- } catch {
61
- return null;
58
+ for (const dir of getConfigDirs(getCurrentEnvironment())) {
59
+ try {
60
+ return readFileSync(join(dir, "platform-token"), "utf-8").trim();
61
+ } catch {
62
+ // Try the next compatible location.
63
+ }
62
64
  }
65
+ return null;
63
66
  }
64
67
 
65
68
  export function savePlatformToken(token: string): void {
@@ -6,7 +6,6 @@ import {
6
6
  getDefaultWorkspaceDir,
7
7
  saveIngressUrl,
8
8
  } from "./ingress-config.js";
9
- import { resolveTunnelTargetPort } from "./nginx-ingress.js";
10
9
 
11
10
  // ── Tailscale CLI discovery + invocation ────────────────────────────────────
12
11
 
@@ -161,12 +160,10 @@ export function shouldClearIngressUrl(
161
160
  // ── Tailscale serve lifecycle ───────────────────────────────────────────────
162
161
 
163
162
  export interface RunTailscaleTunnelOptions {
164
- /** Gateway port to serve. Defaults to the global GATEWAY_PORT. */
163
+ /** Local edge port to serve. Defaults to the global GATEWAY_PORT. */
165
164
  port?: number;
166
165
  /** Workspace directory for config read/write. Defaults to ~/.vellum/workspace. */
167
166
  workspaceDir?: string;
168
- /** Prefer nginx ingress over the gateway port when it is running. */
169
- preferNginxIngress?: boolean;
170
167
  /** Lockfile entry to mirror the ingress URL onto (`ingressUrl`). */
171
168
  assistantId?: string;
172
169
  }
@@ -174,7 +171,6 @@ export interface RunTailscaleTunnelOptions {
174
171
  export interface TailscaleServeInfo {
175
172
  publicUrl: string;
176
173
  port: number;
177
- viaIngress: boolean;
178
174
  binary: string;
179
175
  workspaceDir: string;
180
176
  }
@@ -210,12 +206,7 @@ export async function startTailscaleServe(
210
206
  const publicUrl = `https://${hostname}`;
211
207
 
212
208
  const workspaceDir = opts.workspaceDir ?? getDefaultWorkspaceDir();
213
- const gatewayPort = opts.port ?? GATEWAY_PORT;
214
- const { port, viaIngress } = resolveTunnelTargetPort(
215
- workspaceDir,
216
- gatewayPort,
217
- { preferNginxIngress: opts.preferNginxIngress === true },
218
- );
209
+ const port = opts.port ?? GATEWAY_PORT;
219
210
 
220
211
  const serveResult = deps.run(binary, ["serve", "--bg", String(port)]);
221
212
  if (serveResult.status !== 0) {
@@ -224,7 +215,7 @@ export async function startTailscaleServe(
224
215
 
225
216
  saveIngressUrl(workspaceDir, publicUrl, opts.assistantId);
226
217
 
227
- return { publicUrl, port, viaIngress, binary, workspaceDir };
218
+ return { publicUrl, port, binary, workspaceDir };
228
219
  }
229
220
 
230
221
  /**
@@ -252,12 +243,10 @@ export async function runTailscaleTunnel(
252
243
  const deps = realTailscaleDeps();
253
244
 
254
245
  console.log("Setting up tailscale serve...");
255
- const { publicUrl, port, viaIngress, binary, workspaceDir } =
256
- await startTailscaleServe(opts, deps);
257
-
258
- if (viaIngress) {
259
- console.log(`nginx ingress detected — serving it on 127.0.0.1:${port}.`);
260
- }
246
+ const { publicUrl, port, binary, workspaceDir } = await startTailscaleServe(
247
+ opts,
248
+ deps,
249
+ );
261
250
 
262
251
  console.log("");
263
252
  console.log(`Tunnel established: ${publicUrl}`);
@@ -0,0 +1,114 @@
1
+ import type { ChildProcess } from "child_process";
2
+
3
+ import { loadRawConfig } from "./ingress-config.js";
4
+ import {
5
+ ensureTunnelEdge,
6
+ formatEdgeMode,
7
+ isIngressRunning,
8
+ readIngressState,
9
+ type TunnelEdge,
10
+ } from "./nginx-ingress.js";
11
+ import { hasWebhookIntegrations, maybeStartNgrokTunnel } from "./ngrok.js";
12
+
13
+ /**
14
+ * Retry policy for the flag lookup inside the tunnel-edge restore. The gateway
15
+ * has typically been up for milliseconds at this point and answers
16
+ * `503 {"status":"starting"}` (or refuses connections) until its startup
17
+ * completes, so a single lookup races it.
18
+ */
19
+ export const WEB_INGRESS_FLAG_RETRY = {
20
+ attempts: 15,
21
+ intervalMs: 2_000,
22
+ };
23
+
24
+ /**
25
+ * Whether the workspace ingress config wants the remote-web edge: explicitly
26
+ * enabled with a saved public URL.
27
+ */
28
+ function wantsWebIngress(config: Record<string, unknown>): boolean {
29
+ const ingress = config.ingress as
30
+ { enabled?: unknown; publicBaseUrl?: unknown } | undefined;
31
+ return (
32
+ ingress?.enabled === true &&
33
+ typeof ingress.publicBaseUrl === "string" &&
34
+ ingress.publicBaseUrl.trim() !== ""
35
+ );
36
+ }
37
+
38
+ /**
39
+ * Whether anything wants the nginx edge: webhook integrations or the
40
+ * remote-web ingress config. One config read covers both checks;
41
+ * `maybeStartNgrokTunnel` keeps its own webhook gate for its other callers.
42
+ */
43
+ function wantsTunnelEdge(workspaceDir: string): boolean {
44
+ try {
45
+ const config = loadRawConfig(workspaceDir);
46
+ return hasWebhookIntegrations(config) || wantsWebIngress(config);
47
+ } catch {
48
+ return false;
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Bring the nginx edge back up after a wake or local upgrade and point the
54
+ * webhook auto-tunnel at it. The edge is wanted when webhook integrations are
55
+ * configured or the workspace ingress config is enabled with a saved public
56
+ * URL; `ensureTunnelEdge` picks SPA vs webhooks-only mode off the
57
+ * `web-remote-ingress` flag, retrying the lookup through the gateway's startup
58
+ * window. A healthy edge whose recorded state already targets the requested
59
+ * gateway port is reused without the flag lookup or the `remoteWebConfigHash`
60
+ * comparison `startRemoteWebIngress` performs; both flag-driven mode drift
61
+ * and injected-config drift (a renamed assistant, a changed hub URL) are
62
+ * repaired by the next explicit `vellum tunnel` or `vellum nginx-ingress up`,
63
+ * not by background wakes. Edge failures warn
64
+ * (with the error's install or diagnostic text) and fall back to tunneling the
65
+ * gateway port directly, which `maybeStartNgrokTunnel` only does when webhook
66
+ * integrations are configured, so webhook channels on nginx-less machines
67
+ * keep working, and the caller never fails because of edge problems.
68
+ *
69
+ * Returns the spawned ngrok child (for PID tracking) or null.
70
+ */
71
+ export async function restoreTunnelEdgeAndAutoTunnel(
72
+ assistantId: string,
73
+ gatewayPort: number,
74
+ workspaceDir: string,
75
+ ): Promise<ChildProcess | null> {
76
+ let tunnelTargetPort = gatewayPort;
77
+ if (wantsTunnelEdge(workspaceDir)) {
78
+ const recorded = isIngressRunning(workspaceDir)
79
+ ? readIngressState(workspaceDir)
80
+ : null;
81
+ let edge: TunnelEdge | null = null;
82
+ if (recorded !== null && recorded.gatewayPort === gatewayPort) {
83
+ edge = {
84
+ port: recorded.listenPort,
85
+ started: false,
86
+ includesWebApp: recorded.includeWebApp,
87
+ };
88
+ } else {
89
+ try {
90
+ edge = await ensureTunnelEdge({
91
+ assistantId,
92
+ workspaceDir,
93
+ gatewayPort,
94
+ flagRetry: WEB_INGRESS_FLAG_RETRY,
95
+ });
96
+ } catch (err) {
97
+ console.warn(
98
+ ` Could not restore the tunnel edge: ${
99
+ err instanceof Error ? err.message : String(err)
100
+ } Bring it up manually with \`vellum nginx-ingress up\`.`,
101
+ );
102
+ }
103
+ }
104
+ if (edge) {
105
+ tunnelTargetPort = edge.port;
106
+ console.log(
107
+ ` Tunnel edge ${edge.started ? "started" : "already running"} on 127.0.0.1:${edge.port} (${formatEdgeMode(
108
+ edge.includesWebApp,
109
+ )}).`,
110
+ );
111
+ }
112
+ }
113
+ return maybeStartNgrokTunnel(tunnelTargetPort, workspaceDir);
114
+ }
@@ -11,7 +11,7 @@ import {
11
11
  } from "fs";
12
12
  import { join } from "path";
13
13
 
14
- import { getConfigDir } from "./environments/paths.js";
14
+ import { getLogDir as getEnvironmentLogDir } from "./environments/paths.js";
15
15
  import { getCurrentEnvironment } from "./environments/resolve.js";
16
16
 
17
17
  /** Regex matching pino-pretty's short time prefix, e.g. `[12:07:37.467] `. */
@@ -24,7 +24,7 @@ const PINO_TIME_RE = /^\[\d{2}:\d{2}:\d{2}\.\d{3}\]\s*/;
24
24
  * non-production environments use `$XDG_CONFIG_HOME/vellum-<env>/logs`.
25
25
  */
26
26
  export function getLogDir(): string {
27
- return join(getConfigDir(getCurrentEnvironment()), "logs");
27
+ return getEnvironmentLogDir(getCurrentEnvironment());
28
28
  }
29
29
 
30
30
  /** Open (or create) a log file in append mode, returning the file descriptor.