@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.
- package/node_modules/@vellumai/local-mode/src/lockfile-contract.test.ts +25 -0
- package/node_modules/@vellumai/local-mode/src/lockfile-contract.ts +7 -0
- package/node_modules/@vellumai/local-mode/src/lockfile.test.ts +24 -0
- package/node_modules/@vellumai/service-contracts/src/remote-web-pairing.ts +69 -1
- package/package.json +1 -1
- package/src/__tests__/assistant-config.test.ts +118 -2
- package/src/__tests__/flags.test.ts +77 -0
- package/src/__tests__/pair.test.ts +146 -0
- package/src/__tests__/tunnel.test.ts +53 -0
- package/src/__tests__/wake.test.ts +178 -0
- package/src/commands/flags.ts +4 -1
- package/src/commands/nginx-ingress.ts +57 -71
- package/src/commands/pair.ts +25 -0
- package/src/commands/tunnel.ts +15 -2
- package/src/commands/wake.ts +118 -0
- package/src/lib/assistant-config.ts +5 -2
- package/src/lib/environments/__tests__/detect.test.ts +101 -0
- package/src/lib/environments/detect.ts +79 -0
- package/src/lib/nginx-ingress.ts +90 -0
- package/src/lib/stale-cli-hint.ts +11 -0
|
@@ -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
|
+
}
|
package/src/lib/nginx-ingress.ts
CHANGED
|
@@ -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.";
|