@vellumai/cli 0.10.12-staging.1 → 0.10.12-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.
@@ -0,0 +1,101 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+
6
+ import {
7
+ crossEnvironmentAssistantHint,
8
+ detectOtherEnvironmentAssistants,
9
+ } from "../detect.js";
10
+ import { getSeed } from "../resolve.js";
11
+
12
+ const prod = getSeed("production")!;
13
+
14
+ /** Create `<XDG_DATA_HOME>/vellum-<env>/assistants/<id>/` for a non-prod env. */
15
+ function seedAssistantDir(dataHome: string, envName: string, id: string): void {
16
+ mkdirSync(join(dataHome, `vellum-${envName}`, "assistants", id), {
17
+ recursive: true,
18
+ });
19
+ }
20
+
21
+ describe("cross-environment assistant detection", () => {
22
+ let dataHome: string;
23
+ let prevXdg: string | undefined;
24
+ let prevEnv: string | undefined;
25
+
26
+ beforeEach(() => {
27
+ prevXdg = process.env.XDG_DATA_HOME;
28
+ prevEnv = process.env.VELLUM_ENVIRONMENT;
29
+ dataHome = mkdtempSync(join(tmpdir(), "cli-detect-xdg-"));
30
+ process.env.XDG_DATA_HOME = dataHome;
31
+ // Pin the current env so the hint's "this CLI targets '<env>'" copy and
32
+ // the current-env skip are deterministic regardless of machine config.
33
+ process.env.VELLUM_ENVIRONMENT = "production";
34
+ });
35
+
36
+ afterEach(() => {
37
+ if (prevXdg === undefined) {
38
+ delete process.env.XDG_DATA_HOME;
39
+ } else {
40
+ process.env.XDG_DATA_HOME = prevXdg;
41
+ }
42
+ if (prevEnv === undefined) {
43
+ delete process.env.VELLUM_ENVIRONMENT;
44
+ } else {
45
+ process.env.VELLUM_ENVIRONMENT = prevEnv;
46
+ }
47
+ rmSync(dataHome, { recursive: true, force: true });
48
+ });
49
+
50
+ test("finds an env whose data dir holds an assistant subdirectory", () => {
51
+ seedAssistantDir(dataHome, "dev", "dev-bot");
52
+ expect(detectOtherEnvironmentAssistants(prod)).toContain("dev");
53
+ });
54
+
55
+ test("reports every other env that holds assistants", () => {
56
+ seedAssistantDir(dataHome, "dev", "dev-bot");
57
+ seedAssistantDir(dataHome, "staging", "staging-bot");
58
+ const others = detectOtherEnvironmentAssistants(prod);
59
+ expect(others).toContain("dev");
60
+ expect(others).toContain("staging");
61
+ });
62
+
63
+ test("skips the current environment even when it holds assistants", () => {
64
+ // production's own multi-instance dir (no env suffix).
65
+ mkdirSync(join(dataHome, "vellum", "assistants", "prod-bot"), {
66
+ recursive: true,
67
+ });
68
+ expect(detectOtherEnvironmentAssistants(prod)).toEqual([]);
69
+ });
70
+
71
+ test("ignores an env dir that holds no assistant subdirectory", () => {
72
+ mkdirSync(join(dataHome, "vellum-dev", "assistants"), { recursive: true });
73
+ expect(detectOtherEnvironmentAssistants(prod)).toEqual([]);
74
+ });
75
+
76
+ test("returns empty when no env data dirs exist", () => {
77
+ expect(detectOtherEnvironmentAssistants(prod)).toEqual([]);
78
+ });
79
+
80
+ test("swallows fs errors (data root is a file, not a dir) and returns empty", () => {
81
+ const filePath = join(dataHome, "not-a-dir");
82
+ writeFileSync(filePath, "x");
83
+ process.env.XDG_DATA_HOME = filePath;
84
+ expect(detectOtherEnvironmentAssistants(prod)).toEqual([]);
85
+ expect(crossEnvironmentAssistantHint()).toBeNull();
86
+ });
87
+
88
+ test("hint names the env, the current env, and both fixes", () => {
89
+ seedAssistantDir(dataHome, "dev", "dev-bot");
90
+ const hint = crossEnvironmentAssistantHint();
91
+ expect(hint).not.toBeNull();
92
+ expect(hint).toContain("'dev'");
93
+ expect(hint).toContain("This CLI targets 'production'");
94
+ expect(hint).toContain("Install vellum Command");
95
+ expect(hint).toContain("VELLUM_ENVIRONMENT=dev");
96
+ });
97
+
98
+ test("hint is null when no other env has assistants", () => {
99
+ expect(crossEnvironmentAssistantHint()).toBeNull();
100
+ });
101
+ });
@@ -0,0 +1,79 @@
1
+ import { readdirSync } from "fs";
2
+
3
+ import { SEEDS, type EnvironmentDefinition } from "@vellumai/environments";
4
+
5
+ import { getMultiInstanceDir } from "./paths.js";
6
+ import { getCurrentEnvironment } from "./resolve.js";
7
+
8
+ /**
9
+ * Names of OTHER environments (every seed except `currentEnv`) whose
10
+ * multi-instance data dir exists and holds at least one assistant
11
+ * subdirectory.
12
+ *
13
+ * Each environment has its own on-host data layout — see
14
+ * {@link getMultiInstanceDir}. A user who hatched via a dev/staging desktop
15
+ * app but runs the production npm CLI (or vice versa) targets a different
16
+ * layout than the app did, so the assistant the app created is invisible to
17
+ * this CLI. This cheap filesystem scan surfaces that split so callers can
18
+ * point the user at the environment that actually holds their assistants.
19
+ *
20
+ * Never throws: a missing or unreadable dir just means that environment has no
21
+ * discoverable assistants, so it is skipped. Returns an empty array on any
22
+ * error.
23
+ */
24
+ export function detectOtherEnvironmentAssistants(
25
+ currentEnv: EnvironmentDefinition,
26
+ ): string[] {
27
+ const found: string[] = [];
28
+ for (const env of Object.values(SEEDS)) {
29
+ if (env.name === currentEnv.name) {
30
+ continue;
31
+ }
32
+ try {
33
+ const hasAssistant = readdirSync(getMultiInstanceDir(env), {
34
+ withFileTypes: true,
35
+ }).some((dirent) => dirent.isDirectory());
36
+ if (hasAssistant) {
37
+ found.push(env.name);
38
+ }
39
+ } catch {
40
+ // Missing or unreadable dir — this environment has no discoverable
41
+ // assistants. Skip it.
42
+ }
43
+ }
44
+ return found;
45
+ }
46
+
47
+ /**
48
+ * One-line hint appended to "no assistant found" and "gateway unreachable"
49
+ * errors when another environment's data dir holds assistants. A first-time
50
+ * user who hatched via a dev/staging desktop app while running the production
51
+ * npm CLI otherwise gets a bare failure with no clue the two tools target
52
+ * different environments.
53
+ *
54
+ * Returns null when nothing is found so callers can append unconditionally:
55
+ * `message + (crossEnvironmentAssistantHint() ?? "")`. Detection failure never
56
+ * propagates — the caller's original error must survive intact.
57
+ */
58
+ export function crossEnvironmentAssistantHint(): string | null {
59
+ let currentEnv: EnvironmentDefinition;
60
+ let others: string[];
61
+ try {
62
+ currentEnv = getCurrentEnvironment();
63
+ others = detectOtherEnvironmentAssistants(currentEnv);
64
+ } catch {
65
+ return null;
66
+ }
67
+ if (others.length === 0) {
68
+ return null;
69
+ }
70
+
71
+ const quoted = others.map((name) => `'${name}'`).join(", ");
72
+ const noun = others.length > 1 ? "environments" : "environment";
73
+ return (
74
+ `\n\nFound assistants in the ${quoted} ${noun} (managed by a dev/staging ` +
75
+ `desktop app). This CLI targets '${currentEnv.name}'. Use the desktop ` +
76
+ `app's 'Install vellum Command…' menu item to get a matching CLI, or ` +
77
+ `set VELLUM_ENVIRONMENT=${others[0]}.`
78
+ );
79
+ }
@@ -17,6 +17,7 @@ import { createRequire } from "node:module";
17
17
  import { dirname, join } from "node:path";
18
18
 
19
19
  import { GATEWAY_PORT } from "./constants.js";
20
+ import { waitForDaemonReady } from "./http-client.js";
20
21
  import { loadRawConfig, saveRawConfig } from "./ingress-config.js";
21
22
 
22
23
  /**
@@ -540,6 +541,95 @@ export async function stopIngressNginx(workspaceDir: string): Promise<boolean> {
540
541
  return true;
541
542
  }
542
543
 
544
+ /** Probe budget for confirming the freshly-spawned edge answers /healthz. */
545
+ export const INGRESS_READY_TIMEOUT_MS = 5_000;
546
+
547
+ /**
548
+ * Outcome of an attempt to bring up the remote-web nginx ingress edge. Callers
549
+ * render their own messaging per variant: `nginx-ingress up` prints
550
+ * install/build guidance and exits non-zero, while the wake restore path warns
551
+ * and continues.
552
+ */
553
+ export type StartRemoteWebIngressResult =
554
+ | {
555
+ status: "started";
556
+ listenPort: number;
557
+ webDistDir: string;
558
+ version: string;
559
+ }
560
+ | { status: "already-running"; listenPort: number }
561
+ | { status: "nginx-missing" }
562
+ | { status: "web-dist-missing" }
563
+ | { status: "unreachable"; listenPort: number; logPath: string };
564
+
565
+ /**
566
+ * Generate the nginx config and start the remote-web ingress edge, then probe
567
+ * /healthz through it to prove the ingress → gateway path is live. A spawned
568
+ * but unreachable nginx is rolled back so a failed attempt leaves no half-up
569
+ * edge behind.
570
+ *
571
+ * Pure mechanism: it performs no console output and never exits the process, so
572
+ * both the `nginx-ingress up` command and the wake restore path can share one
573
+ * implementation and map the returned result to their own UX.
574
+ */
575
+ export async function startRemoteWebIngress(opts: {
576
+ workspaceDir: string;
577
+ gatewayPort: number;
578
+ listenPort?: number;
579
+ readyTimeoutMs?: number;
580
+ /**
581
+ * Invoked once, after every preflight check passes and immediately before
582
+ * nginx is spawned, so callers can emit their own "starting" progress line
583
+ * with the resolved version/dist/port. Never fires on a preflight bail-out
584
+ * (nginx-missing, already-running, web-dist-missing).
585
+ */
586
+ onStarting?: (info: {
587
+ version: string;
588
+ webDistDir: string;
589
+ listenPort: number;
590
+ }) => void;
591
+ }): Promise<StartRemoteWebIngressResult> {
592
+ const listenPort = opts.listenPort ?? getNginxIngressPort();
593
+
594
+ const version = getNginxVersion();
595
+ if (!version) {
596
+ return { status: "nginx-missing" };
597
+ }
598
+
599
+ if (isIngressRunning(opts.workspaceDir)) {
600
+ return { status: "already-running", listenPort };
601
+ }
602
+
603
+ const webDistDir = findWebDistDir();
604
+ if (!webDistDir) {
605
+ return { status: "web-dist-missing" };
606
+ }
607
+
608
+ opts.onStarting?.({ version, webDistDir, listenPort });
609
+
610
+ const child = startIngressNginx({
611
+ workspaceDir: opts.workspaceDir,
612
+ gatewayPort: opts.gatewayPort,
613
+ listenPort,
614
+ remoteWebIngress: { webDistDir },
615
+ });
616
+ child.unref();
617
+
618
+ // /healthz proxies through nginx to the gateway, so a 200 proves the whole
619
+ // ingress → gateway path works.
620
+ const ready = await waitForDaemonReady(
621
+ listenPort,
622
+ opts.readyTimeoutMs ?? INGRESS_READY_TIMEOUT_MS,
623
+ );
624
+ if (!ready) {
625
+ const { logPath } = getIngressPaths(opts.workspaceDir);
626
+ await stopIngressNginx(opts.workspaceDir);
627
+ return { status: "unreachable", listenPort, logPath };
628
+ }
629
+
630
+ return { status: "started", listenPort, webDistDir, version };
631
+ }
632
+
543
633
  /**
544
634
  * Resolve the local port a tunnel should target: the nginx ingress when it is
545
635
  * recorded AND its process is alive, otherwise the gateway port directly
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Shared tail for errors where this CLI doesn't recognize an option or provider
3
+ * the user named. The name may be valid in a newer release, so point the user
4
+ * at the self-update path before they conclude the feature doesn't exist.
5
+ *
6
+ * `bun install -g vellum@latest` refreshes the `vellum` binary directly — the
7
+ * mechanism the installer and the CLI's own self-update use. (`vellum upgrade`
8
+ * targets the assistant runtime, not the CLI binary, for local assistants.)
9
+ */
10
+ export const STALE_CLI_UPDATE_HINT =
11
+ "your CLI may be out of date — update it with `bun install -g vellum@latest` and retry.";