@vellumai/cli 0.10.11-dev.202607240043.b26a41c → 0.10.11-dev.202607240256.a0c3131
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/commands/flags.ts +4 -1
- package/src/commands/pair.ts +25 -0
- package/src/commands/tunnel.ts +15 -2
- 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/stale-cli-hint.ts +11 -0
|
@@ -24,6 +24,7 @@ describe("parseLockfile", () => {
|
|
|
24
24
|
platformAssistantId: "platform-assistant-1",
|
|
25
25
|
platformBaseUrl: "https://platform.example.com",
|
|
26
26
|
platformOrganizationId: "org_1",
|
|
27
|
+
ingressUrl: "https://tunnel.example.ts.net",
|
|
27
28
|
resources: { gatewayPort: 7777, daemonPort: 7778 },
|
|
28
29
|
},
|
|
29
30
|
],
|
|
@@ -186,6 +187,30 @@ describe("parseLockfile", () => {
|
|
|
186
187
|
]);
|
|
187
188
|
});
|
|
188
189
|
|
|
190
|
+
test("keeps a well-typed ingressUrl and drops it when mistyped", () => {
|
|
191
|
+
// The tunnel-recorded public URL rides the entry so remote-web pairing
|
|
192
|
+
// surfaces can prefill it; a mistyped value is dropped, entry preserved.
|
|
193
|
+
const raw = {
|
|
194
|
+
assistants: [
|
|
195
|
+
{
|
|
196
|
+
assistantId: "asst_1",
|
|
197
|
+
cloud: "local",
|
|
198
|
+
ingressUrl: "https://tunnel.example.ts.net",
|
|
199
|
+
},
|
|
200
|
+
{ assistantId: "asst_2", cloud: "local", ingressUrl: 7 }, // mistyped
|
|
201
|
+
],
|
|
202
|
+
activeAssistant: null,
|
|
203
|
+
};
|
|
204
|
+
expect(parseLockfile(raw).assistants).toEqual([
|
|
205
|
+
{
|
|
206
|
+
assistantId: "asst_1",
|
|
207
|
+
cloud: "local",
|
|
208
|
+
ingressUrl: "https://tunnel.example.ts.net",
|
|
209
|
+
},
|
|
210
|
+
{ assistantId: "asst_2", cloud: "local" },
|
|
211
|
+
]);
|
|
212
|
+
});
|
|
213
|
+
|
|
189
214
|
test("drops a resources object missing its numeric ports", () => {
|
|
190
215
|
const raw = {
|
|
191
216
|
assistants: [
|
|
@@ -115,6 +115,13 @@ export const LockfileAssistantSchema = z.object({
|
|
|
115
115
|
platformBaseUrl: z.string().optional(),
|
|
116
116
|
/** Platform organization UUID used for a self-hosted local assistant registration. */
|
|
117
117
|
platformOrganizationId: z.string().optional(),
|
|
118
|
+
/**
|
|
119
|
+
* Public https URL a `vellum tunnel` provider recorded for this assistant
|
|
120
|
+
* (mirrored from the workspace ingress config). A public address, so it is
|
|
121
|
+
* renderer-safe: remote-web pairing surfaces (the CLI `--qr` flow, the web
|
|
122
|
+
* "Pair a device" card) default to it instead of asking the user to retype it.
|
|
123
|
+
*/
|
|
124
|
+
ingressUrl: z.string().optional(),
|
|
118
125
|
resources: LocalAssistantResourcesSchema.optional(),
|
|
119
126
|
});
|
|
120
127
|
|
|
@@ -81,6 +81,30 @@ describe("getLockfileData", () => {
|
|
|
81
81
|
}
|
|
82
82
|
});
|
|
83
83
|
|
|
84
|
+
test("surfaces a tunnel-recorded ingressUrl to the renderer", () => {
|
|
85
|
+
// The exact read the "Pair a device" card relies on: `vellum tunnel` stamps
|
|
86
|
+
// ingressUrl onto the entry, and the host read must carry it through.
|
|
87
|
+
writeOnDisk({
|
|
88
|
+
activeAssistant: "asst_1",
|
|
89
|
+
assistants: [
|
|
90
|
+
{
|
|
91
|
+
assistantId: "asst_1",
|
|
92
|
+
cloud: "local",
|
|
93
|
+
runtimeUrl: "http://127.0.0.1:7777",
|
|
94
|
+
ingressUrl: "https://tunnel.example.ts.net",
|
|
95
|
+
},
|
|
96
|
+
],
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
const result = getLockfileData([lockfilePath]);
|
|
100
|
+
expect(result.ok).toBe(true);
|
|
101
|
+
if (result.ok) {
|
|
102
|
+
expect(result.data.assistants[0]?.ingressUrl).toBe(
|
|
103
|
+
"https://tunnel.example.ts.net",
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
|
|
84
108
|
test("fails with status 500 on malformed JSON", () => {
|
|
85
109
|
fs.writeFileSync(lockfilePath, "{ not json");
|
|
86
110
|
const result = getLockfileData([lockfilePath]);
|
|
@@ -109,12 +109,77 @@ export type RemoteWebPairingTokenResponse =
|
|
|
109
109
|
// only) so both Node and browser callers share one implementation.
|
|
110
110
|
|
|
111
111
|
/** Why a public base URL can't be advertised in a pairing challenge. */
|
|
112
|
-
export type PublicBaseUrlRejection =
|
|
112
|
+
export type PublicBaseUrlRejection =
|
|
113
|
+
| "unparseable"
|
|
114
|
+
| "loopback"
|
|
115
|
+
| "non-https"
|
|
116
|
+
| "service-website";
|
|
113
117
|
|
|
114
118
|
export type PublicBaseUrlResult =
|
|
115
119
|
| { ok: true; url: string }
|
|
116
120
|
| { ok: false; reason: PublicBaseUrlRejection };
|
|
117
121
|
|
|
122
|
+
/**
|
|
123
|
+
* Hosts that are a tunnel/ingress vendor's own website, not a user's assistant
|
|
124
|
+
* endpoint. These are the tunnel vendors our docs mention, and their sites are
|
|
125
|
+
* exactly where a lost user grabs a URL from — e.g. a Tailscale admin invite
|
|
126
|
+
* link (`login.tailscale.com/admin/invite/…`), which is https and non-loopback
|
|
127
|
+
* and so clears every other check. Pairing refuses them with a targeted message
|
|
128
|
+
* rather than minting a challenge the scanning device can never reach.
|
|
129
|
+
*/
|
|
130
|
+
export const TUNNEL_PROVIDER_WEBSITE_HOSTS = [
|
|
131
|
+
"login.tailscale.com",
|
|
132
|
+
"tailscale.com",
|
|
133
|
+
"www.tailscale.com",
|
|
134
|
+
"ngrok.com",
|
|
135
|
+
"dashboard.ngrok.com",
|
|
136
|
+
"dash.cloudflare.com",
|
|
137
|
+
"cloudflare.com",
|
|
138
|
+
"www.cloudflare.com",
|
|
139
|
+
] as const;
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* A URL whose exact host is a tunnel/ingress vendor's own website (see
|
|
143
|
+
* {@link TUNNEL_PROVIDER_WEBSITE_HOSTS}). Exact-host only: a user's real
|
|
144
|
+
* Tailscale endpoint (`*.ts.net`) or Cloudflare-fronted domain is never a
|
|
145
|
+
* listed host, so a legitimate address is never mistaken for a vendor site.
|
|
146
|
+
*/
|
|
147
|
+
export function isTunnelProviderWebsiteUrl(url: string): boolean {
|
|
148
|
+
let hostname: string;
|
|
149
|
+
try {
|
|
150
|
+
// WHATWG URL lowercases the hostname during parsing.
|
|
151
|
+
hostname = new URL(url).hostname;
|
|
152
|
+
} catch {
|
|
153
|
+
return false;
|
|
154
|
+
}
|
|
155
|
+
return (TUNNEL_PROVIDER_WEBSITE_HOSTS as readonly string[]).includes(
|
|
156
|
+
hostname,
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* The display name of the tunnel/ingress vendor a service-website URL points
|
|
162
|
+
* at (`"Tailscale"` / `"ngrok"` / `"Cloudflare"`), or null when the host is not
|
|
163
|
+
* a known vendor site. Drives the "This is <Name>'s website" pairing guidance.
|
|
164
|
+
*/
|
|
165
|
+
export function tunnelProviderWebsiteName(url: string): string | null {
|
|
166
|
+
let hostname: string;
|
|
167
|
+
try {
|
|
168
|
+
hostname = new URL(url).hostname;
|
|
169
|
+
} catch {
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
if (
|
|
173
|
+
!(TUNNEL_PROVIDER_WEBSITE_HOSTS as readonly string[]).includes(hostname)
|
|
174
|
+
) {
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
if (hostname.includes("tailscale")) return "Tailscale";
|
|
178
|
+
if (hostname.includes("ngrok")) return "ngrok";
|
|
179
|
+
if (hostname.includes("cloudflare")) return "Cloudflare";
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
|
|
118
183
|
/**
|
|
119
184
|
* A loopback URL — `localhost`, `[::1]`, or `127.x.x.x`. A pairing link that
|
|
120
185
|
* encodes a loopback address is unreachable from the scanning device.
|
|
@@ -167,6 +232,9 @@ export function resolvePublicBaseUrl(raw: string): PublicBaseUrlResult {
|
|
|
167
232
|
if (isLoopbackPublicUrl(normalized)) {
|
|
168
233
|
return { ok: false, reason: "loopback" };
|
|
169
234
|
}
|
|
235
|
+
if (isTunnelProviderWebsiteUrl(normalized)) {
|
|
236
|
+
return { ok: false, reason: "service-website" };
|
|
237
|
+
}
|
|
170
238
|
if (new URL(normalized).protocol !== "https:") {
|
|
171
239
|
return { ok: false, reason: "non-https" };
|
|
172
240
|
}
|
package/package.json
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
1
|
+
import {
|
|
2
|
+
describe,
|
|
3
|
+
test,
|
|
4
|
+
expect,
|
|
5
|
+
beforeEach,
|
|
6
|
+
afterEach,
|
|
7
|
+
afterAll,
|
|
8
|
+
spyOn,
|
|
9
|
+
} from "bun:test";
|
|
10
|
+
import {
|
|
11
|
+
mkdirSync,
|
|
12
|
+
mkdtempSync,
|
|
13
|
+
readFileSync,
|
|
14
|
+
rmSync,
|
|
15
|
+
writeFileSync,
|
|
16
|
+
} from "node:fs";
|
|
3
17
|
import { tmpdir } from "node:os";
|
|
4
18
|
import { join } from "node:path";
|
|
5
19
|
|
|
@@ -16,6 +30,7 @@ import {
|
|
|
16
30
|
lookupAssistantByIdentifier,
|
|
17
31
|
removeAssistantEntry,
|
|
18
32
|
loadAllAssistants,
|
|
33
|
+
resolveTargetAssistant,
|
|
19
34
|
saveAssistantEntry,
|
|
20
35
|
getActiveAssistant,
|
|
21
36
|
migrateLegacyEntry,
|
|
@@ -700,3 +715,104 @@ describe("env-scoped lockfile and migration", () => {
|
|
|
700
715
|
}
|
|
701
716
|
});
|
|
702
717
|
});
|
|
718
|
+
|
|
719
|
+
describe("cross-environment hint on resolution failure", () => {
|
|
720
|
+
let dataHome: string;
|
|
721
|
+
let prevXdg: string | undefined;
|
|
722
|
+
let prevEnv: string | undefined;
|
|
723
|
+
|
|
724
|
+
beforeEach(() => {
|
|
725
|
+
prevXdg = process.env.XDG_DATA_HOME;
|
|
726
|
+
prevEnv = process.env.VELLUM_ENVIRONMENT;
|
|
727
|
+
dataHome = mkdtempSync(join(tmpdir(), "cli-config-xdg-"));
|
|
728
|
+
process.env.XDG_DATA_HOME = dataHome;
|
|
729
|
+
// Pin the current env to production so the detector skips it and the hint
|
|
730
|
+
// copy is deterministic. The current-env lockfile is emptied so resolution
|
|
731
|
+
// genuinely finds nothing.
|
|
732
|
+
process.env.VELLUM_ENVIRONMENT = "production";
|
|
733
|
+
rmSync(join(testDir, ".vellum.lock.json"), { force: true });
|
|
734
|
+
rmSync(join(testDir, ".vellum.lockfile.json"), { force: true });
|
|
735
|
+
});
|
|
736
|
+
|
|
737
|
+
afterEach(() => {
|
|
738
|
+
if (prevXdg === undefined) {
|
|
739
|
+
delete process.env.XDG_DATA_HOME;
|
|
740
|
+
} else {
|
|
741
|
+
process.env.XDG_DATA_HOME = prevXdg;
|
|
742
|
+
}
|
|
743
|
+
if (prevEnv === undefined) {
|
|
744
|
+
delete process.env.VELLUM_ENVIRONMENT;
|
|
745
|
+
} else {
|
|
746
|
+
process.env.VELLUM_ENVIRONMENT = prevEnv;
|
|
747
|
+
}
|
|
748
|
+
rmSync(dataHome, { recursive: true, force: true });
|
|
749
|
+
});
|
|
750
|
+
|
|
751
|
+
// A dev-env assistant lives under the dev data dir, invisible to this
|
|
752
|
+
// production CLI — the exact split the hint explains.
|
|
753
|
+
function seedDevAssistant(): void {
|
|
754
|
+
mkdirSync(join(dataHome, "vellum-dev", "assistants", "dev-bot"), {
|
|
755
|
+
recursive: true,
|
|
756
|
+
});
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
function captureResolveTargetAssistantError(): string {
|
|
760
|
+
const errors: string[] = [];
|
|
761
|
+
const errorSpy = spyOn(console, "error").mockImplementation(
|
|
762
|
+
(...args: unknown[]) => {
|
|
763
|
+
errors.push(args.join(" "));
|
|
764
|
+
},
|
|
765
|
+
);
|
|
766
|
+
const exitSpy = spyOn(process, "exit").mockImplementation(((
|
|
767
|
+
_code?: number,
|
|
768
|
+
) => {
|
|
769
|
+
throw new Error("exit");
|
|
770
|
+
}) as typeof process.exit);
|
|
771
|
+
try {
|
|
772
|
+
expect(() => resolveTargetAssistant()).toThrow("exit");
|
|
773
|
+
} finally {
|
|
774
|
+
errorSpy.mockRestore();
|
|
775
|
+
exitSpy.mockRestore();
|
|
776
|
+
}
|
|
777
|
+
return errors.join("\n");
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
test("formatAssistantLookupError appends the hint when another env has assistants", () => {
|
|
781
|
+
seedDevAssistant();
|
|
782
|
+
const msg = formatAssistantLookupError("ghost", { status: "not_found" });
|
|
783
|
+
expect(msg).toContain("No assistant found with name or ID 'ghost'.");
|
|
784
|
+
expect(msg).toContain("Install vellum Command");
|
|
785
|
+
expect(msg).toContain("VELLUM_ENVIRONMENT=dev");
|
|
786
|
+
});
|
|
787
|
+
|
|
788
|
+
test("formatAssistantLookupError stays bare when no other env has assistants", () => {
|
|
789
|
+
// dataHome is empty — detection finds nothing, so the message is intact.
|
|
790
|
+
const msg = formatAssistantLookupError("ghost", { status: "not_found" });
|
|
791
|
+
expect(msg).toBe("No assistant found with name or ID 'ghost'.");
|
|
792
|
+
});
|
|
793
|
+
|
|
794
|
+
test("ambiguous lookup never gets the hint", () => {
|
|
795
|
+
seedDevAssistant();
|
|
796
|
+
const msg = formatAssistantLookupError("Alice", {
|
|
797
|
+
status: "ambiguous",
|
|
798
|
+
matches: [
|
|
799
|
+
makeEntry("assistant-1", "http://localhost:7821", { name: "Alice" }),
|
|
800
|
+
makeEntry("assistant-2", "http://localhost:7822", { name: "Alice" }),
|
|
801
|
+
],
|
|
802
|
+
});
|
|
803
|
+
expect(msg).not.toContain("Install vellum Command");
|
|
804
|
+
});
|
|
805
|
+
|
|
806
|
+
test("resolveTargetAssistant zero-assistants error carries the hint", () => {
|
|
807
|
+
seedDevAssistant();
|
|
808
|
+
const combined = captureResolveTargetAssistantError();
|
|
809
|
+
expect(combined).toContain("No assistant found. Run 'vellum hatch' first.");
|
|
810
|
+
expect(combined).toContain("Install vellum Command");
|
|
811
|
+
});
|
|
812
|
+
|
|
813
|
+
test("resolveTargetAssistant zero-assistants error stays bare without other envs", () => {
|
|
814
|
+
const combined = captureResolveTargetAssistantError();
|
|
815
|
+
expect(combined).toContain("No assistant found. Run 'vellum hatch' first.");
|
|
816
|
+
expect(combined).not.toContain("Install vellum Command");
|
|
817
|
+
});
|
|
818
|
+
});
|
|
@@ -239,3 +239,80 @@ describe("vellum flags --assistant routing", () => {
|
|
|
239
239
|
expect(fetchCalls.length).toBe(0);
|
|
240
240
|
});
|
|
241
241
|
});
|
|
242
|
+
|
|
243
|
+
describe("vellum flags cross-environment hint", () => {
|
|
244
|
+
let xdgDataHome: string;
|
|
245
|
+
let prevXdg: string | undefined;
|
|
246
|
+
let prevEnv: string | undefined;
|
|
247
|
+
|
|
248
|
+
beforeEach(() => {
|
|
249
|
+
process.env.VELLUM_LOCKFILE_DIR = testDir;
|
|
250
|
+
rmSync(join(testDir, ".vellum.lock.json"), { force: true });
|
|
251
|
+
prevXdg = process.env.XDG_DATA_HOME;
|
|
252
|
+
prevEnv = process.env.VELLUM_ENVIRONMENT;
|
|
253
|
+
xdgDataHome = mkdtempSync(join(tmpdir(), "cli-flags-hint-xdg-"));
|
|
254
|
+
process.env.XDG_DATA_HOME = xdgDataHome;
|
|
255
|
+
// Pin production so the detector skips it and the lockfile routes to
|
|
256
|
+
// testDir; the VELLUM_ENVIRONMENT var beats this machine's env config.
|
|
257
|
+
process.env.VELLUM_ENVIRONMENT = "production";
|
|
258
|
+
// An unreachable gateway: every request throws like a dead socket would.
|
|
259
|
+
// Cast through `unknown` since an always-throwing stub returns
|
|
260
|
+
// `Promise<never>`, which does not structurally overlap with `fetch`.
|
|
261
|
+
globalThis.fetch = (async () => {
|
|
262
|
+
throw new TypeError("fetch failed");
|
|
263
|
+
}) as unknown as typeof globalThis.fetch;
|
|
264
|
+
process.exit = ((code?: number) => {
|
|
265
|
+
throw new Error(`process.exit:${code}`);
|
|
266
|
+
}) as typeof process.exit;
|
|
267
|
+
consoleLogSpy = spyOn(console, "log").mockImplementation(() => {});
|
|
268
|
+
consoleErrorSpy = spyOn(console, "error").mockImplementation(() => {});
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
afterEach(() => {
|
|
272
|
+
process.argv = originalArgv;
|
|
273
|
+
process.exit = originalExit;
|
|
274
|
+
globalThis.fetch = originalFetch;
|
|
275
|
+
consoleLogSpy.mockRestore();
|
|
276
|
+
consoleErrorSpy.mockRestore();
|
|
277
|
+
if (prevXdg === undefined) {
|
|
278
|
+
delete process.env.XDG_DATA_HOME;
|
|
279
|
+
} else {
|
|
280
|
+
process.env.XDG_DATA_HOME = prevXdg;
|
|
281
|
+
}
|
|
282
|
+
if (prevEnv === undefined) {
|
|
283
|
+
delete process.env.VELLUM_ENVIRONMENT;
|
|
284
|
+
} else {
|
|
285
|
+
process.env.VELLUM_ENVIRONMENT = prevEnv;
|
|
286
|
+
}
|
|
287
|
+
rmSync(xdgDataHome, { recursive: true, force: true });
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
// A dev-env assistant lives under the dev data dir, invisible to this
|
|
291
|
+
// production CLI — the split the hint explains.
|
|
292
|
+
function seedDevAssistant(): void {
|
|
293
|
+
mkdirSync(join(xdgDataHome, "vellum-dev", "assistants", "dev-bot"), {
|
|
294
|
+
recursive: true,
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
test("gateway-unreachable error gains the hint when another env has assistants", async () => {
|
|
299
|
+
writeLockfile([makeEntry("alice-1", { name: "Alice" })], "alice-1");
|
|
300
|
+
seedDevAssistant();
|
|
301
|
+
process.argv = ["bun", "vellum", "flags", "set", "voice-mode", "true"];
|
|
302
|
+
await expect(flags()).rejects.toThrow(/Install vellum Command/);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
test("gateway-unreachable error stays bare when no other env has assistants", async () => {
|
|
306
|
+
writeLockfile([makeEntry("alice-1", { name: "Alice" })], "alice-1");
|
|
307
|
+
// xdgDataHome is empty — detection finds nothing, so the message is intact.
|
|
308
|
+
process.argv = ["bun", "vellum", "flags", "set", "voice-mode", "true"];
|
|
309
|
+
let error: Error | undefined;
|
|
310
|
+
try {
|
|
311
|
+
await flags();
|
|
312
|
+
} catch (e) {
|
|
313
|
+
error = e as Error;
|
|
314
|
+
}
|
|
315
|
+
expect(error?.message).toContain("Could not reach the assistant gateway");
|
|
316
|
+
expect(error?.message).not.toContain("Install vellum Command");
|
|
317
|
+
});
|
|
318
|
+
});
|
|
@@ -159,6 +159,100 @@ describe("pair command", () => {
|
|
|
159
159
|
expect(fetchCalled).toBe(true);
|
|
160
160
|
});
|
|
161
161
|
|
|
162
|
+
test("rejects an unknown --flag before any network call", async () => {
|
|
163
|
+
let fetchCalled = false;
|
|
164
|
+
const origFetch = globalThis.fetch;
|
|
165
|
+
globalThis.fetch = (async () => {
|
|
166
|
+
fetchCalled = true;
|
|
167
|
+
return new Response("{}", { status: 200 });
|
|
168
|
+
}) as unknown as typeof fetch;
|
|
169
|
+
const errors: string[] = [];
|
|
170
|
+
const errSpy = spyOn(console, "error").mockImplementation(
|
|
171
|
+
(...a: unknown[]) => {
|
|
172
|
+
errors.push(a.join(" "));
|
|
173
|
+
},
|
|
174
|
+
);
|
|
175
|
+
const exitSpy = spyOn(process, "exit").mockImplementation(((
|
|
176
|
+
code?: number,
|
|
177
|
+
) => {
|
|
178
|
+
throw new Error(`exit:${code}`);
|
|
179
|
+
}) as never);
|
|
180
|
+
|
|
181
|
+
process.argv = ["bun", "vellum", "pair", "--frobnicate"];
|
|
182
|
+
let exited = false;
|
|
183
|
+
try {
|
|
184
|
+
await pair();
|
|
185
|
+
} catch (e) {
|
|
186
|
+
exited = (e as Error).message === "exit:1";
|
|
187
|
+
} finally {
|
|
188
|
+
errSpy.mockRestore();
|
|
189
|
+
exitSpy.mockRestore();
|
|
190
|
+
globalThis.fetch = origFetch;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// A `--` flag this version doesn't know is a hard error, and it points at
|
|
194
|
+
// the CLI self-update path — never a silent fall-through to another flow.
|
|
195
|
+
expect(exited).toBe(true);
|
|
196
|
+
const joined = errors.join("\n");
|
|
197
|
+
expect(joined).toContain("unknown option '--frobnicate'");
|
|
198
|
+
expect(joined).toContain("your CLI may be out of date");
|
|
199
|
+
expect(joined).toContain("bun install -g vellum@latest");
|
|
200
|
+
expect(fetchCalled).toBe(false);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test("rejects an unknown --flag even alongside a multi-word positional name", async () => {
|
|
204
|
+
writeFileSync(
|
|
205
|
+
join(testDir, ".vellum.lock.json"),
|
|
206
|
+
JSON.stringify({
|
|
207
|
+
assistants: [
|
|
208
|
+
{
|
|
209
|
+
assistantId: "pair-test",
|
|
210
|
+
name: "My Assistant",
|
|
211
|
+
runtimeUrl: RUNTIME_URL,
|
|
212
|
+
localUrl: LOCAL_URL,
|
|
213
|
+
cloud: "local",
|
|
214
|
+
},
|
|
215
|
+
],
|
|
216
|
+
activeAssistant: "pair-test",
|
|
217
|
+
}),
|
|
218
|
+
);
|
|
219
|
+
|
|
220
|
+
let fetchCalled = false;
|
|
221
|
+
const origFetch = globalThis.fetch;
|
|
222
|
+
globalThis.fetch = (async () => {
|
|
223
|
+
fetchCalled = true;
|
|
224
|
+
return new Response("{}", { status: 200 });
|
|
225
|
+
}) as unknown as typeof fetch;
|
|
226
|
+
const errors: string[] = [];
|
|
227
|
+
const errSpy = spyOn(console, "error").mockImplementation(
|
|
228
|
+
(...a: unknown[]) => {
|
|
229
|
+
errors.push(a.join(" "));
|
|
230
|
+
},
|
|
231
|
+
);
|
|
232
|
+
const exitSpy = spyOn(process, "exit").mockImplementation(((
|
|
233
|
+
code?: number,
|
|
234
|
+
) => {
|
|
235
|
+
throw new Error(`exit:${code}`);
|
|
236
|
+
}) as never);
|
|
237
|
+
|
|
238
|
+
// Positional name tokens must not rescue an unknown flag from rejection.
|
|
239
|
+
process.argv = ["bun", "vellum", "pair", "My", "Assistant", "--frobnicate"];
|
|
240
|
+
let exited = false;
|
|
241
|
+
try {
|
|
242
|
+
await pair();
|
|
243
|
+
} catch (e) {
|
|
244
|
+
exited = (e as Error).message === "exit:1";
|
|
245
|
+
} finally {
|
|
246
|
+
errSpy.mockRestore();
|
|
247
|
+
exitSpy.mockRestore();
|
|
248
|
+
globalThis.fetch = origFetch;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
expect(exited).toBe(true);
|
|
252
|
+
expect(errors.join("\n")).toContain("unknown option '--frobnicate'");
|
|
253
|
+
expect(fetchCalled).toBe(false);
|
|
254
|
+
});
|
|
255
|
+
|
|
162
256
|
test("refuses to advertise a loopback URL without --url (suggests the assistant's own port)", async () => {
|
|
163
257
|
// Local hatch on a NON-default gateway port (e.g. a 2nd instance).
|
|
164
258
|
const LOOPBACK_CUSTOM = "http://127.0.0.1:7842";
|
|
@@ -211,6 +305,11 @@ describe("pair command", () => {
|
|
|
211
305
|
expect(errors.join("\n")).toContain(":7842");
|
|
212
306
|
expect(errors.join("\n")).not.toContain(":7830");
|
|
213
307
|
|
|
308
|
+
// The refusal cross-points to the QR flow (the phone-pairing path) and its
|
|
309
|
+
// https prerequisite, so a user who wanted a QR isn't left at a dead end.
|
|
310
|
+
expect(errors.join("\n")).toContain("vellum pair --qr");
|
|
311
|
+
expect(errors.join("\n")).toContain("vellum tunnel --provider tailscale");
|
|
312
|
+
|
|
214
313
|
// Exited with an error before minting — no token created for a dead URL.
|
|
215
314
|
expect(exited).toBe(true);
|
|
216
315
|
expect(fetchCalled).toBe(false);
|
|
@@ -748,6 +847,53 @@ describe("pair command", () => {
|
|
|
748
847
|
expect(fetchCalled).toBe(false);
|
|
749
848
|
});
|
|
750
849
|
|
|
850
|
+
test("--qr refuses a tunnel-provider website URL (Tailscale admin invite) without minting", async () => {
|
|
851
|
+
let fetchCalled = false;
|
|
852
|
+
const origFetch = globalThis.fetch;
|
|
853
|
+
globalThis.fetch = (async () => {
|
|
854
|
+
fetchCalled = true;
|
|
855
|
+
return new Response("{}", { status: 200 });
|
|
856
|
+
}) as unknown as typeof fetch;
|
|
857
|
+
const errors: string[] = [];
|
|
858
|
+
const errSpy = spyOn(console, "error").mockImplementation(
|
|
859
|
+
(...a: unknown[]) => {
|
|
860
|
+
errors.push(a.join(" "));
|
|
861
|
+
},
|
|
862
|
+
);
|
|
863
|
+
const exitSpy = spyOn(process, "exit").mockImplementation(((
|
|
864
|
+
code?: number,
|
|
865
|
+
) => {
|
|
866
|
+
throw new Error(`exit:${code}`);
|
|
867
|
+
}) as never);
|
|
868
|
+
|
|
869
|
+
process.argv = [
|
|
870
|
+
"bun",
|
|
871
|
+
"vellum",
|
|
872
|
+
"pair",
|
|
873
|
+
"--qr",
|
|
874
|
+
"--url",
|
|
875
|
+
"https://login.tailscale.com/admin/invite/abc123",
|
|
876
|
+
];
|
|
877
|
+
let exited = false;
|
|
878
|
+
try {
|
|
879
|
+
await pair();
|
|
880
|
+
} catch (e) {
|
|
881
|
+
exited = (e as Error).message === "exit:1";
|
|
882
|
+
} finally {
|
|
883
|
+
errSpy.mockRestore();
|
|
884
|
+
exitSpy.mockRestore();
|
|
885
|
+
globalThis.fetch = origFetch;
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
// The admin-invite link is a vendor website — named as such, not mislabeled
|
|
889
|
+
// as non-https, and refused before any challenge is minted.
|
|
890
|
+
expect(exited).toBe(true);
|
|
891
|
+
const joined = errors.join("\n");
|
|
892
|
+
expect(joined).toContain("Tailscale's website");
|
|
893
|
+
expect(joined).not.toContain("is not https");
|
|
894
|
+
expect(fetchCalled).toBe(false);
|
|
895
|
+
});
|
|
896
|
+
|
|
751
897
|
test("--qr refuses when the web remote ingress feature flag is off", async () => {
|
|
752
898
|
const calls: Array<[string, RequestInit | undefined]> = [];
|
|
753
899
|
const origFetch = globalThis.fetch;
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
describe,
|
|
6
6
|
expect,
|
|
7
7
|
mock,
|
|
8
|
+
spyOn,
|
|
8
9
|
test,
|
|
9
10
|
} from "bun:test";
|
|
10
11
|
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
@@ -162,4 +163,56 @@ describe("tunnel nginx ingress feature flag", () => {
|
|
|
162
163
|
expect(runNgrokTunnelMock).not.toHaveBeenCalled();
|
|
163
164
|
expect(runCloudflareTunnelMock).not.toHaveBeenCalled();
|
|
164
165
|
});
|
|
166
|
+
|
|
167
|
+
test("rejects an unknown --provider with a stale-CLI hint", async () => {
|
|
168
|
+
process.argv = ["bun", "vellum", "tunnel", "--provider", "bogus"];
|
|
169
|
+
const errors: string[] = [];
|
|
170
|
+
const errSpy = spyOn(console, "error").mockImplementation(
|
|
171
|
+
(...a: unknown[]) => {
|
|
172
|
+
errors.push(a.join(" "));
|
|
173
|
+
},
|
|
174
|
+
);
|
|
175
|
+
const exitSpy = spyOn(process, "exit").mockImplementation(((
|
|
176
|
+
code?: number,
|
|
177
|
+
) => {
|
|
178
|
+
throw new Error(`exit:${code}`);
|
|
179
|
+
}) as never);
|
|
180
|
+
|
|
181
|
+
let exited = false;
|
|
182
|
+
try {
|
|
183
|
+
await tunnel();
|
|
184
|
+
} catch (e) {
|
|
185
|
+
exited = (e as Error).message === "exit:1";
|
|
186
|
+
} finally {
|
|
187
|
+
errSpy.mockRestore();
|
|
188
|
+
exitSpy.mockRestore();
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
expect(exited).toBe(true);
|
|
192
|
+
const joined = errors.join("\n");
|
|
193
|
+
expect(joined).toContain("unknown tunnel provider 'bogus'");
|
|
194
|
+
expect(joined).toContain("your CLI may be out of date");
|
|
195
|
+
expect(joined).toContain("bun install -g vellum@latest");
|
|
196
|
+
expect(runNgrokTunnelMock).not.toHaveBeenCalled();
|
|
197
|
+
expect(runCloudflareTunnelMock).not.toHaveBeenCalled();
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test("a not-yet-implemented provider error carries the stale-CLI hint", async () => {
|
|
201
|
+
// The default `vellum` provider has no runtime yet, so it exercises the
|
|
202
|
+
// not-yet-implemented path without any network call.
|
|
203
|
+
process.argv = ["bun", "vellum", "tunnel", "--provider", "vellum"];
|
|
204
|
+
|
|
205
|
+
let err: Error | undefined;
|
|
206
|
+
try {
|
|
207
|
+
await tunnel();
|
|
208
|
+
} catch (e) {
|
|
209
|
+
err = e as Error;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
expect(err?.message).toContain("is not yet implemented");
|
|
213
|
+
expect(err?.message).toContain("your CLI may be out of date");
|
|
214
|
+
expect(err?.message).toContain("bun install -g vellum@latest");
|
|
215
|
+
expect(runNgrokTunnelMock).not.toHaveBeenCalled();
|
|
216
|
+
expect(runCloudflareTunnelMock).not.toHaveBeenCalled();
|
|
217
|
+
});
|
|
165
218
|
});
|
package/src/commands/flags.ts
CHANGED
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
formatAssistantLookupError,
|
|
5
5
|
lookupAssistantByIdentifier,
|
|
6
6
|
} from "../lib/assistant-config.js";
|
|
7
|
+
import { crossEnvironmentAssistantHint } from "../lib/environments/detect.js";
|
|
7
8
|
|
|
8
9
|
type FeatureFlagEntry = {
|
|
9
10
|
key: string;
|
|
@@ -132,7 +133,9 @@ function rethrowFetchError(err: unknown): never {
|
|
|
132
133
|
(err.message.includes("fetch") || err.message.includes("connect"))
|
|
133
134
|
) {
|
|
134
135
|
throw new Error(
|
|
135
|
-
|
|
136
|
+
`Could not reach the assistant gateway. Is it running? Try 'vellum wake'.${
|
|
137
|
+
crossEnvironmentAssistantHint() ?? ""
|
|
138
|
+
}`,
|
|
136
139
|
);
|
|
137
140
|
}
|
|
138
141
|
throw err;
|
package/src/commands/pair.ts
CHANGED
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
buildRemoteWebPairingUrl,
|
|
20
20
|
normalizePublicBaseUrl,
|
|
21
21
|
resolvePublicBaseUrl,
|
|
22
|
+
tunnelProviderWebsiteName,
|
|
22
23
|
type PublicBaseUrlRejection,
|
|
23
24
|
type RemoteWebPairingChallengeRequest,
|
|
24
25
|
type RemoteWebPairingChallengeResponse,
|
|
@@ -46,6 +47,7 @@ import {
|
|
|
46
47
|
} from "../lib/feature-flags.js";
|
|
47
48
|
import { getLocalLanIPv4 } from "../lib/local.js";
|
|
48
49
|
import { isLoopbackUrl, loopbackSafeFetch } from "../lib/loopback-fetch.js";
|
|
50
|
+
import { STALE_CLI_UPDATE_HINT } from "../lib/stale-cli-hint.js";
|
|
49
51
|
|
|
50
52
|
function assistantDisplayName(entry: AssistantEntry): string {
|
|
51
53
|
return entry.name || entry.assistantName || entry.assistantId;
|
|
@@ -277,6 +279,22 @@ export async function pair(): Promise<void> {
|
|
|
277
279
|
);
|
|
278
280
|
args = afterAppScheme;
|
|
279
281
|
|
|
282
|
+
// Any `--`-prefixed token left after known-flag extraction is an option this
|
|
283
|
+
// CLI version doesn't support. Fail loud before any network call rather than
|
|
284
|
+
// letting it fall through: an unknown flag would otherwise be silently
|
|
285
|
+
// dropped and the command would run the wrong flow (e.g. `--qr` on an older
|
|
286
|
+
// CLI minting a copy-paste bundle instead of a QR). Positional names never
|
|
287
|
+
// start with `--`, so multi-word assistant targets are unaffected.
|
|
288
|
+
const unknownFlag = args.find((a) => a.startsWith("--"));
|
|
289
|
+
if (unknownFlag) {
|
|
290
|
+
console.error(`Error: unknown option '${unknownFlag}'.`);
|
|
291
|
+
console.error("Run `vellum pair --help` to see available options.");
|
|
292
|
+
console.error(
|
|
293
|
+
`If this option is from newer docs, ${STALE_CLI_UPDATE_HINT}`,
|
|
294
|
+
);
|
|
295
|
+
process.exit(1);
|
|
296
|
+
}
|
|
297
|
+
|
|
280
298
|
if (webPairing && webApproveCode) {
|
|
281
299
|
console.error("Error: use either --web or --web-approve, not both.");
|
|
282
300
|
process.exit(1);
|
|
@@ -371,6 +389,10 @@ export async function pair(): Promise<void> {
|
|
|
371
389
|
console.error(
|
|
372
390
|
`Re-run with a reachable URL, e.g.:\n vellum pair --url ${suggestion}`,
|
|
373
391
|
);
|
|
392
|
+
console.error(
|
|
393
|
+
"Pairing a phone by QR instead? Use: vellum pair --qr " +
|
|
394
|
+
"(needs an https URL — run `vellum tunnel --provider tailscale` first).",
|
|
395
|
+
);
|
|
374
396
|
process.exit(1);
|
|
375
397
|
}
|
|
376
398
|
|
|
@@ -451,6 +473,9 @@ export async function pair(): Promise<void> {
|
|
|
451
473
|
unparseable: `${advertisedUrl} isn't a valid URL`,
|
|
452
474
|
loopback: `${advertisedUrl} is a loopback address`,
|
|
453
475
|
"non-https": `${advertisedUrl} is not https`,
|
|
476
|
+
"service-website": `${advertisedUrl} is ${
|
|
477
|
+
tunnelProviderWebsiteName(advertisedUrl) ?? "a tunnel provider"
|
|
478
|
+
}'s website, not your assistant's address`,
|
|
454
479
|
};
|
|
455
480
|
console.error(
|
|
456
481
|
"Error: --qr needs a public https URL the phone can open — " +
|
package/src/commands/tunnel.ts
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
WEB_REMOTE_INGRESS_FLAG,
|
|
9
9
|
} from "../lib/feature-flags.js";
|
|
10
10
|
import { runNgrokTunnel } from "../lib/ngrok";
|
|
11
|
+
import { STALE_CLI_UPDATE_HINT } from "../lib/stale-cli-hint.js";
|
|
11
12
|
import { runTailscaleTunnel } from "../lib/tailscale-tunnel.js";
|
|
12
13
|
|
|
13
14
|
const VALID_PROVIDERS = ["vellum", "ngrok", "cloudflare", "tailscale"] as const;
|
|
@@ -78,12 +79,21 @@ function parseArgs(): TunnelArgs {
|
|
|
78
79
|
process.exit(0);
|
|
79
80
|
} else if (arg === "--provider") {
|
|
80
81
|
const next = args[i + 1];
|
|
81
|
-
if (!next ||
|
|
82
|
+
if (!next || next.startsWith("-")) {
|
|
82
83
|
console.error(
|
|
83
84
|
`Error: --provider requires one of: ${VALID_PROVIDERS.join(", ")}`,
|
|
84
85
|
);
|
|
85
86
|
process.exit(1);
|
|
86
87
|
}
|
|
88
|
+
if (!VALID_PROVIDERS.includes(next as TunnelProvider)) {
|
|
89
|
+
console.error(
|
|
90
|
+
`Error: unknown tunnel provider '${next}'. Valid providers: ${VALID_PROVIDERS.join(", ")}.`,
|
|
91
|
+
);
|
|
92
|
+
console.error(
|
|
93
|
+
`If this provider is documented, ${STALE_CLI_UPDATE_HINT}`,
|
|
94
|
+
);
|
|
95
|
+
process.exit(1);
|
|
96
|
+
}
|
|
87
97
|
provider = next as TunnelProvider;
|
|
88
98
|
i++;
|
|
89
99
|
} else if (arg.startsWith("-")) {
|
|
@@ -199,5 +209,8 @@ export async function tunnel(): Promise<void> {
|
|
|
199
209
|
return;
|
|
200
210
|
}
|
|
201
211
|
|
|
202
|
-
throw new Error(
|
|
212
|
+
throw new Error(
|
|
213
|
+
`Tunnel provider '${provider}' is not yet implemented. ` +
|
|
214
|
+
`If this provider is documented, ${STALE_CLI_UPDATE_HINT}`,
|
|
215
|
+
);
|
|
203
216
|
}
|
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
} from "@vellumai/local-mode/contract";
|
|
19
19
|
|
|
20
20
|
import { DAEMON_INTERNAL_ASSISTANT_ID } from "./constants.js";
|
|
21
|
+
import { crossEnvironmentAssistantHint } from "./environments/detect.js";
|
|
21
22
|
import {
|
|
22
23
|
getDefaultPorts,
|
|
23
24
|
getLockfilePath,
|
|
@@ -423,7 +424,7 @@ export function formatAssistantLookupError(
|
|
|
423
424
|
return `Multiple assistants match '${identifier}': ${matches}. Use the assistant ID to disambiguate.`;
|
|
424
425
|
}
|
|
425
426
|
|
|
426
|
-
return `No assistant found with name or ID '${identifier}'
|
|
427
|
+
return `No assistant found with name or ID '${identifier}'.${crossEnvironmentAssistantHint() ?? ""}`;
|
|
427
428
|
}
|
|
428
429
|
|
|
429
430
|
export function removeAssistantEntry(assistantId: string): void {
|
|
@@ -580,7 +581,9 @@ export function resolveTargetAssistant(nameArg?: string): AssistantEntry {
|
|
|
580
581
|
if (all.length === 1) return all[0];
|
|
581
582
|
|
|
582
583
|
if (all.length === 0) {
|
|
583
|
-
console.error(
|
|
584
|
+
console.error(
|
|
585
|
+
`No assistant found. Run 'vellum hatch' first.${crossEnvironmentAssistantHint() ?? ""}`,
|
|
586
|
+
);
|
|
584
587
|
} else {
|
|
585
588
|
console.error(
|
|
586
589
|
`Multiple assistants found. Set an active assistant with 'vellum use <name>'.`,
|
|
@@ -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
|
+
}
|
|
@@ -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.";
|