@vellumai/cli 0.10.11-dev.202607220509.38c4eff → 0.10.11-dev.202607220859.296f4b0
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/package.json +1 -1
- package/src/__tests__/flags.test.ts +1 -8
- package/src/__tests__/input-history.test.ts +1 -6
- package/src/__tests__/login-loopback.test.ts +6 -2
- package/src/__tests__/pair.test.ts +132 -1
- package/src/__tests__/platform-client.test.ts +10 -2
- package/src/__tests__/statefulset.test.ts +3 -1
- package/src/__tests__/tailscale-tunnel.test.ts +229 -0
- package/src/__tests__/wake.test.ts +32 -16
- package/src/commands/backup.ts +22 -16
- package/src/commands/exec.ts +5 -1
- package/src/commands/pair.ts +58 -5
- package/src/commands/rollback.ts +2 -1
- package/src/commands/ssh.ts +1 -4
- package/src/commands/tunnel.ts +19 -1
- package/src/lib/__tests__/step-runner.test.ts +6 -1
- package/src/lib/__tests__/terminal-session.test.ts +5 -14
- package/src/lib/cloudflare-tunnel.ts +5 -52
- package/src/lib/device-id.ts +1 -3
- package/src/lib/flag-args.test.ts +1 -6
- package/src/lib/guardian-token.ts +18 -18
- package/src/lib/ingress-config.ts +61 -0
- package/src/lib/nginx-ingress.ts +1 -22
- package/src/lib/ngrok.ts +7 -62
- package/src/lib/retire-apple-container.ts +7 -5
- package/src/lib/tailscale-tunnel.ts +319 -0
- package/src/lib/upgrade-preflight.ts +10 -7
package/package.json
CHANGED
|
@@ -188,14 +188,7 @@ describe("vellum flags --assistant routing", () => {
|
|
|
188
188
|
],
|
|
189
189
|
"alice-1",
|
|
190
190
|
);
|
|
191
|
-
process.argv = [
|
|
192
|
-
"bun",
|
|
193
|
-
"vellum",
|
|
194
|
-
"flags",
|
|
195
|
-
"set",
|
|
196
|
-
"voice-mode",
|
|
197
|
-
"true",
|
|
198
|
-
];
|
|
191
|
+
process.argv = ["bun", "vellum", "flags", "set", "voice-mode", "true"];
|
|
199
192
|
|
|
200
193
|
await flags();
|
|
201
194
|
|
|
@@ -1,10 +1,5 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
-
import {
|
|
3
|
-
existsSync,
|
|
4
|
-
mkdtempSync,
|
|
5
|
-
readFileSync,
|
|
6
|
-
rmSync,
|
|
7
|
-
} from "node:fs";
|
|
2
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
8
3
|
import { homedir, tmpdir } from "node:os";
|
|
9
4
|
import { join } from "node:path";
|
|
10
5
|
|
|
@@ -3,7 +3,9 @@ import { describe, expect, test } from "bun:test";
|
|
|
3
3
|
import { startLoopbackListener } from "../commands/login.js";
|
|
4
4
|
|
|
5
5
|
/** Resolve "settled"/"pending" — proves whether `waitForCode` resolved. */
|
|
6
|
-
async function settleState(
|
|
6
|
+
async function settleState(
|
|
7
|
+
p: Promise<unknown>,
|
|
8
|
+
): Promise<"settled" | "pending"> {
|
|
7
9
|
return Promise.race([
|
|
8
10
|
p.then(
|
|
9
11
|
() => "settled" as const,
|
|
@@ -49,7 +51,9 @@ describe("startLoopbackListener", () => {
|
|
|
49
51
|
() => null,
|
|
50
52
|
(e: Error) => e,
|
|
51
53
|
);
|
|
52
|
-
const res = await fetch(
|
|
54
|
+
const res = await fetch(
|
|
55
|
+
`${listener.redirectUri}?error=access_denied&state=st`,
|
|
56
|
+
);
|
|
53
57
|
expect(res.status).toBe(400);
|
|
54
58
|
const err = await settled;
|
|
55
59
|
expect(err?.message).toMatch(/access_denied/);
|
|
@@ -15,7 +15,7 @@ import { join } from "node:path";
|
|
|
15
15
|
const testDir = mkdtempSync(join(tmpdir(), "pair-command-test-"));
|
|
16
16
|
process.env.VELLUM_LOCKFILE_DIR = testDir;
|
|
17
17
|
|
|
18
|
-
import { pair } from "../commands/pair.js";
|
|
18
|
+
import { buildAppConnectUrl, pair } from "../commands/pair.js";
|
|
19
19
|
|
|
20
20
|
// Distinct loopback (mint) vs reachable (advertised) URLs to verify the split.
|
|
21
21
|
const LOCAL_URL = "http://127.0.0.1:7830";
|
|
@@ -800,4 +800,135 @@ describe("pair command", () => {
|
|
|
800
800
|
`${LOCAL_URL}/v1/assistants/pair-test/feature-flags`,
|
|
801
801
|
);
|
|
802
802
|
});
|
|
803
|
+
|
|
804
|
+
test("buildAppConnectUrl composes and encodes the connect link", () => {
|
|
805
|
+
expect(
|
|
806
|
+
buildAppConnectUrl(
|
|
807
|
+
"vellum-assistant",
|
|
808
|
+
"https://pair.example.ts.net",
|
|
809
|
+
"device-code",
|
|
810
|
+
),
|
|
811
|
+
).toBe(
|
|
812
|
+
"vellum-assistant://connect?url=https%3A%2F%2Fpair.example.ts.net&code=device-code",
|
|
813
|
+
);
|
|
814
|
+
// Path prefixes and fragment-hostile characters survive the encoding.
|
|
815
|
+
expect(
|
|
816
|
+
buildAppConnectUrl(
|
|
817
|
+
"vellum-assistant-dev",
|
|
818
|
+
"https://host.example.ts.net/assistant-123",
|
|
819
|
+
"a+b/c=",
|
|
820
|
+
),
|
|
821
|
+
).toBe(
|
|
822
|
+
"vellum-assistant-dev://connect?url=https%3A%2F%2Fhost.example.ts.net%2Fassistant-123&code=a%2Bb%2Fc%3D",
|
|
823
|
+
);
|
|
824
|
+
});
|
|
825
|
+
|
|
826
|
+
test("--qr --app emits an app connect URL alongside the browser URL", async () => {
|
|
827
|
+
const origFetch = globalThis.fetch;
|
|
828
|
+
globalThis.fetch = (async (url: string) => {
|
|
829
|
+
if (url === `${LOCAL_URL}/v1/assistants/pair-test/feature-flags`) {
|
|
830
|
+
return new Response(
|
|
831
|
+
JSON.stringify({
|
|
832
|
+
flags: [{ key: "web-remote-ingress", enabled: true }],
|
|
833
|
+
}),
|
|
834
|
+
{ status: 200, headers: { "content-type": "application/json" } },
|
|
835
|
+
);
|
|
836
|
+
}
|
|
837
|
+
if (url === `${LOCAL_URL}/v1/remote-web/pairing-challenge`) {
|
|
838
|
+
return new Response(
|
|
839
|
+
JSON.stringify({
|
|
840
|
+
deviceCode: "device-code",
|
|
841
|
+
userCode: "ABCD-EFGH",
|
|
842
|
+
verificationUri: "https://pair.example.ts.net/assistant/pair",
|
|
843
|
+
expiresAt: "2026-06-04T00:10:00.000Z",
|
|
844
|
+
expiresInSeconds: 600,
|
|
845
|
+
}),
|
|
846
|
+
{ status: 200, headers: { "content-type": "application/json" } },
|
|
847
|
+
);
|
|
848
|
+
}
|
|
849
|
+
if (url === `${LOCAL_URL}/v1/remote-web/pairing-verification`) {
|
|
850
|
+
return new Response(
|
|
851
|
+
JSON.stringify({
|
|
852
|
+
status: "approved",
|
|
853
|
+
verificationUri: "https://pair.example.ts.net/assistant/pair",
|
|
854
|
+
expiresAt: "2026-06-04T00:10:00.000Z",
|
|
855
|
+
}),
|
|
856
|
+
{ status: 200, headers: { "content-type": "application/json" } },
|
|
857
|
+
);
|
|
858
|
+
}
|
|
859
|
+
return new Response("not found", { status: 404 });
|
|
860
|
+
}) as unknown as typeof fetch;
|
|
861
|
+
|
|
862
|
+
const logs: string[] = [];
|
|
863
|
+
const logSpy = spyOn(console, "log").mockImplementation(
|
|
864
|
+
(...a: unknown[]) => {
|
|
865
|
+
logs.push(a.join(" "));
|
|
866
|
+
},
|
|
867
|
+
);
|
|
868
|
+
|
|
869
|
+
process.argv = [
|
|
870
|
+
"bun",
|
|
871
|
+
"vellum",
|
|
872
|
+
"pair",
|
|
873
|
+
"--qr",
|
|
874
|
+
"--app",
|
|
875
|
+
"--app-scheme",
|
|
876
|
+
"vellum-assistant-dev",
|
|
877
|
+
"--url",
|
|
878
|
+
"https://pair.example.ts.net",
|
|
879
|
+
"--json",
|
|
880
|
+
];
|
|
881
|
+
try {
|
|
882
|
+
await pair();
|
|
883
|
+
} finally {
|
|
884
|
+
logSpy.mockRestore();
|
|
885
|
+
globalThis.fetch = origFetch;
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
const out = JSON.parse(logs.join("\n"));
|
|
889
|
+
expect(out.appUrl).toBe(
|
|
890
|
+
"vellum-assistant-dev://connect?url=https%3A%2F%2Fpair.example.ts.net&code=device-code",
|
|
891
|
+
);
|
|
892
|
+
// The browser URL stays available as the no-app fallback.
|
|
893
|
+
expect(out.pairUrl).toBe(
|
|
894
|
+
"https://pair.example.ts.net/assistant/pair#device_code=device-code",
|
|
895
|
+
);
|
|
896
|
+
expect(out.deviceCode).toBe("device-code");
|
|
897
|
+
});
|
|
898
|
+
|
|
899
|
+
test("--app without --qr is refused", async () => {
|
|
900
|
+
let fetchCalled = false;
|
|
901
|
+
const origFetch = globalThis.fetch;
|
|
902
|
+
globalThis.fetch = (async () => {
|
|
903
|
+
fetchCalled = true;
|
|
904
|
+
return new Response("{}", { status: 200 });
|
|
905
|
+
}) as unknown as typeof fetch;
|
|
906
|
+
const errors: string[] = [];
|
|
907
|
+
const errSpy = spyOn(console, "error").mockImplementation(
|
|
908
|
+
(...a: unknown[]) => {
|
|
909
|
+
errors.push(a.join(" "));
|
|
910
|
+
},
|
|
911
|
+
);
|
|
912
|
+
const exitSpy = spyOn(process, "exit").mockImplementation(((
|
|
913
|
+
code?: number,
|
|
914
|
+
) => {
|
|
915
|
+
throw new Error(`exit:${code}`);
|
|
916
|
+
}) as never);
|
|
917
|
+
|
|
918
|
+
process.argv = ["bun", "vellum", "pair", "--app"];
|
|
919
|
+
let exited = false;
|
|
920
|
+
try {
|
|
921
|
+
await pair();
|
|
922
|
+
} catch (e) {
|
|
923
|
+
exited = (e as Error).message === "exit:1";
|
|
924
|
+
} finally {
|
|
925
|
+
errSpy.mockRestore();
|
|
926
|
+
exitSpy.mockRestore();
|
|
927
|
+
globalThis.fetch = origFetch;
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
expect(exited).toBe(true);
|
|
931
|
+
expect(errors.join("\n")).toContain("--qr");
|
|
932
|
+
expect(fetchCalled).toBe(false);
|
|
933
|
+
});
|
|
803
934
|
});
|
|
@@ -240,7 +240,11 @@ describe("fetchAssistantDetail / fetchUpgradeInProgress", () => {
|
|
|
240
240
|
current_release_version: "0.7.0",
|
|
241
241
|
release_channel: "preview",
|
|
242
242
|
});
|
|
243
|
-
const detail = await fetchAssistantDetail(
|
|
243
|
+
const detail = await fetchAssistantDetail(
|
|
244
|
+
TOKEN,
|
|
245
|
+
ASSISTANT_ID,
|
|
246
|
+
PLATFORM_URL,
|
|
247
|
+
);
|
|
244
248
|
expect(detail).toEqual({
|
|
245
249
|
currentReleaseVersion: "0.7.0",
|
|
246
250
|
releaseChannel: "preview",
|
|
@@ -251,7 +255,11 @@ describe("fetchAssistantDetail / fetchUpgradeInProgress", () => {
|
|
|
251
255
|
|
|
252
256
|
test("fetchAssistantDetail defaults missing fields", async () => {
|
|
253
257
|
mockFetchJson({});
|
|
254
|
-
const detail = await fetchAssistantDetail(
|
|
258
|
+
const detail = await fetchAssistantDetail(
|
|
259
|
+
TOKEN,
|
|
260
|
+
ASSISTANT_ID,
|
|
261
|
+
PLATFORM_URL,
|
|
262
|
+
);
|
|
255
263
|
expect(detail).toEqual({
|
|
256
264
|
currentReleaseVersion: null,
|
|
257
265
|
releaseChannel: "stable",
|
|
@@ -56,7 +56,9 @@ describe("getBuilderManagedEnvKeys", () => {
|
|
|
56
56
|
|
|
57
57
|
test("gateway hostForwarded equals the three spec host entries", () => {
|
|
58
58
|
const { hostForwarded } = getBuilderManagedEnvKeys("gateway");
|
|
59
|
-
const sorted = [...hostForwarded].sort((a, b) =>
|
|
59
|
+
const sorted = [...hostForwarded].sort((a, b) =>
|
|
60
|
+
a.name.localeCompare(b.name),
|
|
61
|
+
);
|
|
60
62
|
expect(sorted).toEqual([
|
|
61
63
|
{ name: "VELAY_BASE_URL", hostVar: "VELAY_BASE_URL" },
|
|
62
64
|
{ name: "VELLUM_ENVIRONMENT", hostVar: "VELLUM_ENVIRONMENT" },
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
normalizeDnsName,
|
|
8
|
+
resolveServeHostname,
|
|
9
|
+
shouldClearIngressUrl,
|
|
10
|
+
startTailscaleServe,
|
|
11
|
+
stopTailscaleServe,
|
|
12
|
+
type TailscaleCommandResult,
|
|
13
|
+
type TailscaleDeps,
|
|
14
|
+
} from "../lib/tailscale-tunnel.js";
|
|
15
|
+
|
|
16
|
+
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
const tempDirs: string[] = [];
|
|
19
|
+
|
|
20
|
+
function makeWorkspace(): string {
|
|
21
|
+
const dir = mkdtempSync(join(tmpdir(), "vellum-tailscale-test-"));
|
|
22
|
+
tempDirs.push(dir);
|
|
23
|
+
return dir;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const RUNNING_STATUS = JSON.stringify({
|
|
27
|
+
BackendState: "Running",
|
|
28
|
+
Self: { DNSName: "my-host.tail-scale.ts.net.", Online: true },
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const ok = (stdout = "", stderr = ""): TailscaleCommandResult => ({
|
|
32
|
+
status: 0,
|
|
33
|
+
stdout,
|
|
34
|
+
stderr,
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Build a fake {@link TailscaleDeps}. `binary` defaults to a present binary;
|
|
39
|
+
* pass `null` to simulate a missing install. `responses` is keyed by the
|
|
40
|
+
* space-joined args; unmatched calls return an empty success result.
|
|
41
|
+
*/
|
|
42
|
+
function makeDeps(opts: {
|
|
43
|
+
binary?: string | null;
|
|
44
|
+
responses?: Record<string, TailscaleCommandResult>;
|
|
45
|
+
}): { deps: TailscaleDeps; calls: string[][] } {
|
|
46
|
+
const calls: string[][] = [];
|
|
47
|
+
const binary = opts.binary === undefined ? "tailscale" : opts.binary;
|
|
48
|
+
const deps: TailscaleDeps = {
|
|
49
|
+
findBinary: () => binary,
|
|
50
|
+
run: (_bin, args) => {
|
|
51
|
+
calls.push(args);
|
|
52
|
+
return opts.responses?.[args.join(" ")] ?? ok();
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
return { deps, calls };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
afterEach(() => {
|
|
59
|
+
for (const dir of tempDirs.splice(0)) {
|
|
60
|
+
rmSync(dir, { recursive: true, force: true });
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// ── normalizeDnsName ────────────────────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
describe("normalizeDnsName", () => {
|
|
67
|
+
test("trims the trailing MagicDNS dot", () => {
|
|
68
|
+
expect(normalizeDnsName("host.tailnet.ts.net.")).toBe(
|
|
69
|
+
"host.tailnet.ts.net",
|
|
70
|
+
);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("leaves a name without a trailing dot unchanged", () => {
|
|
74
|
+
expect(normalizeDnsName("host.tailnet.ts.net")).toBe("host.tailnet.ts.net");
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// ── resolveServeHostname ──────────────────────────────────────────────────────
|
|
79
|
+
|
|
80
|
+
describe("resolveServeHostname", () => {
|
|
81
|
+
test("returns the trimmed Self.DNSName for a running node", () => {
|
|
82
|
+
expect(resolveServeHostname(RUNNING_STATUS)).toBe(
|
|
83
|
+
"my-host.tail-scale.ts.net",
|
|
84
|
+
);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("throws a login hint when logged out", () => {
|
|
88
|
+
const json = JSON.stringify({ BackendState: "NeedsLogin", Self: {} });
|
|
89
|
+
expect(() => resolveServeHostname(json)).toThrow("not logged in");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("throws when the backend is not running", () => {
|
|
93
|
+
const json = JSON.stringify({
|
|
94
|
+
BackendState: "Stopped",
|
|
95
|
+
Self: { DNSName: "host.ts.net." },
|
|
96
|
+
});
|
|
97
|
+
expect(() => resolveServeHostname(json)).toThrow("not ready");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("throws when the DNS name is missing", () => {
|
|
101
|
+
const json = JSON.stringify({ BackendState: "Running", Self: {} });
|
|
102
|
+
expect(() => resolveServeHostname(json)).toThrow("DNS name");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("throws on unparseable JSON", () => {
|
|
106
|
+
expect(() => resolveServeHostname("not json")).toThrow("Could not parse");
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// ── startTailscaleServe ───────────────────────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
describe("startTailscaleServe", () => {
|
|
113
|
+
test("throws install guidance when the binary is missing", async () => {
|
|
114
|
+
const { deps, calls } = makeDeps({ binary: null });
|
|
115
|
+
await expect(
|
|
116
|
+
startTailscaleServe({ port: 7840, workspaceDir: makeWorkspace() }, deps),
|
|
117
|
+
).rejects.toThrow("Tailscale is not installed");
|
|
118
|
+
expect(calls).toHaveLength(0);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("throws a login hint when logged out and never calls serve", async () => {
|
|
122
|
+
const { deps, calls } = makeDeps({
|
|
123
|
+
responses: {
|
|
124
|
+
"status --json": ok(
|
|
125
|
+
JSON.stringify({ BackendState: "NeedsLogin", Self: {} }),
|
|
126
|
+
),
|
|
127
|
+
},
|
|
128
|
+
});
|
|
129
|
+
await expect(
|
|
130
|
+
startTailscaleServe({ port: 7840, workspaceDir: makeWorkspace() }, deps),
|
|
131
|
+
).rejects.toThrow("not logged in");
|
|
132
|
+
expect(calls).not.toContainEqual(["serve", "--bg", "7840"]);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("throws when the status query fails (daemon down)", async () => {
|
|
136
|
+
const { deps } = makeDeps({
|
|
137
|
+
responses: {
|
|
138
|
+
"status --json": {
|
|
139
|
+
status: 1,
|
|
140
|
+
stdout: "",
|
|
141
|
+
stderr: "failed to connect to local tailscaled",
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
});
|
|
145
|
+
await expect(
|
|
146
|
+
startTailscaleServe({ port: 7840, workspaceDir: makeWorkspace() }, deps),
|
|
147
|
+
).rejects.toThrow("Could not query Tailscale");
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("serves the target port and persists the ingress URL on success", async () => {
|
|
151
|
+
const workspaceDir = makeWorkspace();
|
|
152
|
+
const { deps, calls } = makeDeps({
|
|
153
|
+
responses: {
|
|
154
|
+
"status --json": ok(RUNNING_STATUS),
|
|
155
|
+
"serve --bg 7840": ok(),
|
|
156
|
+
},
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
const info = await startTailscaleServe({ port: 7840, workspaceDir }, deps);
|
|
160
|
+
|
|
161
|
+
expect(info.publicUrl).toBe("https://my-host.tail-scale.ts.net");
|
|
162
|
+
expect(info.port).toBe(7840);
|
|
163
|
+
expect(info.viaIngress).toBe(false);
|
|
164
|
+
expect(calls).toContainEqual(["serve", "--bg", "7840"]);
|
|
165
|
+
|
|
166
|
+
const config = JSON.parse(
|
|
167
|
+
readFileSync(join(workspaceDir, "config.json"), "utf-8"),
|
|
168
|
+
);
|
|
169
|
+
expect(config.ingress.publicBaseUrl).toBe(
|
|
170
|
+
"https://my-host.tail-scale.ts.net",
|
|
171
|
+
);
|
|
172
|
+
expect(config.ingress.enabled).toBe(true);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test("surfaces tailscale's enable-URL guidance when serve is not enabled", async () => {
|
|
176
|
+
const workspaceDir = makeWorkspace();
|
|
177
|
+
const enableUrl = "https://login.tailscale.com/f/serve?node=abc123";
|
|
178
|
+
const { deps } = makeDeps({
|
|
179
|
+
responses: {
|
|
180
|
+
"status --json": ok(RUNNING_STATUS),
|
|
181
|
+
"serve --bg 7840": {
|
|
182
|
+
status: 1,
|
|
183
|
+
stdout: "",
|
|
184
|
+
stderr: `error: Serve is not enabled on your tailnet.\n\nTo enable, visit:\n ${enableUrl}`,
|
|
185
|
+
},
|
|
186
|
+
},
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
await expect(
|
|
190
|
+
startTailscaleServe({ port: 7840, workspaceDir }, deps),
|
|
191
|
+
).rejects.toThrow(enableUrl);
|
|
192
|
+
|
|
193
|
+
// A failed serve must not persist an ingress URL.
|
|
194
|
+
expect(existsSync(join(workspaceDir, "config.json"))).toBe(false);
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
// ── stopTailscaleServe ────────────────────────────────────────────────────────
|
|
199
|
+
|
|
200
|
+
describe("stopTailscaleServe", () => {
|
|
201
|
+
test("turns off the HTTPS:443 serve (narrow off form)", () => {
|
|
202
|
+
const { deps, calls } = makeDeps({
|
|
203
|
+
responses: { "serve --https=443 off": ok() },
|
|
204
|
+
});
|
|
205
|
+
const result = stopTailscaleServe("tailscale", deps);
|
|
206
|
+
expect(result.status).toBe(0);
|
|
207
|
+
expect(calls).toContainEqual(["serve", "--https=443", "off"]);
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
// ── shouldClearIngressUrl ─────────────────────────────────────────────────────
|
|
212
|
+
|
|
213
|
+
describe("shouldClearIngressUrl", () => {
|
|
214
|
+
test("clears only after a confirmed successful stop", () => {
|
|
215
|
+
expect(shouldClearIngressUrl({ status: 0, stdout: "", stderr: "" })).toBe(
|
|
216
|
+
true,
|
|
217
|
+
);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test("keeps the URL when the stop command fails", () => {
|
|
221
|
+
expect(
|
|
222
|
+
shouldClearIngressUrl({ status: 1, stdout: "", stderr: "boom" }),
|
|
223
|
+
).toBe(false);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test("keeps the URL when the stop command threw before running", () => {
|
|
227
|
+
expect(shouldClearIngressUrl(null)).toBe(false);
|
|
228
|
+
});
|
|
229
|
+
});
|
|
@@ -27,9 +27,8 @@ const realLocal = { ...local };
|
|
|
27
27
|
const realNgrok = { ...ngrok };
|
|
28
28
|
const realProcessLib = { ...processLib };
|
|
29
29
|
|
|
30
|
-
const resolveTargetAssistantMock =
|
|
31
|
-
typeof assistantConfig.resolveTargetAssistant
|
|
32
|
-
>();
|
|
30
|
+
const resolveTargetAssistantMock =
|
|
31
|
+
mock<typeof assistantConfig.resolveTargetAssistant>();
|
|
33
32
|
const saveAssistantEntryMock = mock<typeof assistantConfig.saveAssistantEntry>(
|
|
34
33
|
() => {},
|
|
35
34
|
);
|
|
@@ -61,9 +60,10 @@ const seedGuardianTokenFromSiblingEnvMock = mock<
|
|
|
61
60
|
// Default: a token exists, so the re-provision recovery path is skipped. Tests
|
|
62
61
|
// that exercise recovery override loadGuardianToken to return null.
|
|
63
62
|
const loadGuardianTokenMock = mock<typeof guardianToken.loadGuardianToken>(
|
|
64
|
-
() =>
|
|
65
|
-
|
|
66
|
-
|
|
63
|
+
() =>
|
|
64
|
+
({ accessToken: "existing" }) as ReturnType<
|
|
65
|
+
typeof guardianToken.loadGuardianToken
|
|
66
|
+
>,
|
|
67
67
|
);
|
|
68
68
|
const resetGuardianBootstrapMock = mock<
|
|
69
69
|
typeof guardianToken.resetGuardianBootstrap
|
|
@@ -111,7 +111,9 @@ const isAssistantWatchModeAvailableMock = mock<
|
|
|
111
111
|
const isGatewayWatchModeAvailableMock = mock<
|
|
112
112
|
typeof local.isGatewayWatchModeAvailable
|
|
113
113
|
>(() => false);
|
|
114
|
-
const startLocalDaemonMock = mock<typeof local.startLocalDaemon>(
|
|
114
|
+
const startLocalDaemonMock = mock<typeof local.startLocalDaemon>(
|
|
115
|
+
async () => {},
|
|
116
|
+
);
|
|
115
117
|
const startGatewayMock = mock<typeof local.startGateway>(
|
|
116
118
|
async () => "http://127.0.0.1:7830",
|
|
117
119
|
);
|
|
@@ -175,10 +177,12 @@ beforeEach(() => {
|
|
|
175
177
|
join(resources!.instanceDir, ".vellum", "daemon.pid"),
|
|
176
178
|
);
|
|
177
179
|
resolveProcessStateMock.mockReset();
|
|
178
|
-
resolveProcessStateMock.mockImplementation(
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
180
|
+
resolveProcessStateMock.mockImplementation(
|
|
181
|
+
async (_pidFile, _port, label) => ({
|
|
182
|
+
status: "healthy",
|
|
183
|
+
pid: label === "Gateway" ? 456 : 123,
|
|
184
|
+
}),
|
|
185
|
+
);
|
|
182
186
|
stopProcessByPidFileMock.mockReset();
|
|
183
187
|
stopProcessByPidFileMock.mockResolvedValue(true);
|
|
184
188
|
generateLocalSigningKeyMock.mockReset();
|
|
@@ -198,9 +202,9 @@ beforeEach(() => {
|
|
|
198
202
|
seedGuardianTokenFromSiblingEnvMock.mockReset();
|
|
199
203
|
seedGuardianTokenFromSiblingEnvMock.mockReturnValue(false);
|
|
200
204
|
loadGuardianTokenMock.mockReset();
|
|
201
|
-
loadGuardianTokenMock.mockReturnValue({
|
|
202
|
-
|
|
203
|
-
>);
|
|
205
|
+
loadGuardianTokenMock.mockReturnValue({
|
|
206
|
+
accessToken: "existing",
|
|
207
|
+
} as ReturnType<typeof guardianToken.loadGuardianToken>);
|
|
204
208
|
resetGuardianBootstrapMock.mockReset();
|
|
205
209
|
resetGuardianBootstrapMock.mockResolvedValue(undefined);
|
|
206
210
|
leaseGuardianTokenMock.mockReset();
|
|
@@ -252,7 +256,13 @@ describe("vellum wake", () => {
|
|
|
252
256
|
});
|
|
253
257
|
|
|
254
258
|
test("re-provisions the guardian token when missing and --repair-guardian is passed", async () => {
|
|
255
|
-
process.argv = [
|
|
259
|
+
process.argv = [
|
|
260
|
+
"bun",
|
|
261
|
+
"vellum",
|
|
262
|
+
"wake",
|
|
263
|
+
"--repair-guardian",
|
|
264
|
+
"local-assistant",
|
|
265
|
+
];
|
|
256
266
|
loadGuardianTokenMock.mockReturnValue(null);
|
|
257
267
|
|
|
258
268
|
await wake();
|
|
@@ -288,7 +298,13 @@ describe("vellum wake", () => {
|
|
|
288
298
|
// (revoked, mis-seeded, wrong principal). The user explicitly confirmed
|
|
289
299
|
// the destructive repair, so the flag forces a re-lease instead of
|
|
290
300
|
// guessing from local token state and recreating the no-op loop.
|
|
291
|
-
process.argv = [
|
|
301
|
+
process.argv = [
|
|
302
|
+
"bun",
|
|
303
|
+
"vellum",
|
|
304
|
+
"wake",
|
|
305
|
+
"--repair-guardian",
|
|
306
|
+
"local-assistant",
|
|
307
|
+
];
|
|
292
308
|
// loadGuardianToken returns a healthy-looking token by default.
|
|
293
309
|
await wake();
|
|
294
310
|
|
package/src/commands/backup.ts
CHANGED
|
@@ -134,15 +134,18 @@ export async function backup(): Promise<void> {
|
|
|
134
134
|
// Call the export endpoint
|
|
135
135
|
let response: Response;
|
|
136
136
|
try {
|
|
137
|
-
response = await loopbackSafeFetch(
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
137
|
+
response = await loopbackSafeFetch(
|
|
138
|
+
`${entry.runtimeUrl}/v1/migrations/export`,
|
|
139
|
+
{
|
|
140
|
+
method: "POST",
|
|
141
|
+
headers: {
|
|
142
|
+
Authorization: `Bearer ${accessToken}`,
|
|
143
|
+
"Content-Type": "application/json",
|
|
144
|
+
},
|
|
145
|
+
body: JSON.stringify({ description: "CLI backup" }),
|
|
146
|
+
signal: AbortSignal.timeout(exportTimeoutMs),
|
|
142
147
|
},
|
|
143
|
-
|
|
144
|
-
signal: AbortSignal.timeout(exportTimeoutMs),
|
|
145
|
-
});
|
|
148
|
+
);
|
|
146
149
|
|
|
147
150
|
// Retry once with a fresh token on 401 — the cached token may be stale
|
|
148
151
|
// after a container restart that generated a new gateway signing key.
|
|
@@ -160,15 +163,18 @@ export async function backup(): Promise<void> {
|
|
|
160
163
|
}
|
|
161
164
|
if (refreshedToken) {
|
|
162
165
|
accessToken = refreshedToken;
|
|
163
|
-
response = await loopbackSafeFetch(
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
166
|
+
response = await loopbackSafeFetch(
|
|
167
|
+
`${entry.runtimeUrl}/v1/migrations/export`,
|
|
168
|
+
{
|
|
169
|
+
method: "POST",
|
|
170
|
+
headers: {
|
|
171
|
+
Authorization: `Bearer ${accessToken}`,
|
|
172
|
+
"Content-Type": "application/json",
|
|
173
|
+
},
|
|
174
|
+
body: JSON.stringify({ description: "CLI backup" }),
|
|
175
|
+
signal: AbortSignal.timeout(exportTimeoutMs),
|
|
168
176
|
},
|
|
169
|
-
|
|
170
|
-
signal: AbortSignal.timeout(exportTimeoutMs),
|
|
171
|
-
});
|
|
177
|
+
);
|
|
172
178
|
}
|
|
173
179
|
}
|
|
174
180
|
} catch (err) {
|
package/src/commands/exec.ts
CHANGED
|
@@ -220,7 +220,11 @@ export async function exec(): Promise<void> {
|
|
|
220
220
|
|
|
221
221
|
if (interactive) {
|
|
222
222
|
// Interactive mode: shell-escape argv and delegate to full terminal
|
|
223
|
-
await interactiveSession(
|
|
223
|
+
await interactiveSession(
|
|
224
|
+
assistant,
|
|
225
|
+
shellEscapeArgs(command),
|
|
226
|
+
serviceParam,
|
|
227
|
+
);
|
|
224
228
|
return;
|
|
225
229
|
}
|
|
226
230
|
|