@vellumai/cli 0.10.11-dev.202607220859.296f4b0 → 0.10.11-dev.202607220955.d4134f8
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__/pair.test.ts +303 -0
- package/src/commands/pair.ts +63 -5
- package/src/lib/ingress-config.ts +14 -0
package/package.json
CHANGED
|
@@ -931,4 +931,307 @@ describe("pair command", () => {
|
|
|
931
931
|
expect(errors.join("\n")).toContain("--qr");
|
|
932
932
|
expect(fetchCalled).toBe(false);
|
|
933
933
|
});
|
|
934
|
+
|
|
935
|
+
test("--qr with no --url uses the tunnel-saved ingress URL", async () => {
|
|
936
|
+
const workspaceDir = mkdtempSync(join(tmpdir(), "vellum-pair-ingress-"));
|
|
937
|
+
// Point the synthesized instance dir at an empty temp XDG root so the
|
|
938
|
+
// resolution deterministically falls through to VELLUM_WORKSPACE_DIR.
|
|
939
|
+
const xdgDir = mkdtempSync(join(tmpdir(), "vellum-pair-xdg-"));
|
|
940
|
+
process.env.XDG_DATA_HOME = xdgDir;
|
|
941
|
+
writeFileSync(
|
|
942
|
+
join(workspaceDir, "config.json"),
|
|
943
|
+
JSON.stringify({
|
|
944
|
+
ingress: {
|
|
945
|
+
publicBaseUrl: "https://saved.example.ts.net",
|
|
946
|
+
enabled: true,
|
|
947
|
+
},
|
|
948
|
+
}),
|
|
949
|
+
);
|
|
950
|
+
process.env.VELLUM_WORKSPACE_DIR = workspaceDir;
|
|
951
|
+
|
|
952
|
+
const calls: string[] = [];
|
|
953
|
+
const origFetch = globalThis.fetch;
|
|
954
|
+
globalThis.fetch = (async (url: string, init?: RequestInit) => {
|
|
955
|
+
calls.push(url);
|
|
956
|
+
if (url === `${LOCAL_URL}/v1/assistants/pair-test/feature-flags`) {
|
|
957
|
+
return new Response(
|
|
958
|
+
JSON.stringify({
|
|
959
|
+
flags: [{ key: "web-remote-ingress", enabled: true }],
|
|
960
|
+
}),
|
|
961
|
+
{ status: 200, headers: { "content-type": "application/json" } },
|
|
962
|
+
);
|
|
963
|
+
}
|
|
964
|
+
if (url === `${LOCAL_URL}/v1/remote-web/pairing-challenge`) {
|
|
965
|
+
// The minted challenge must advertise the SAVED ingress URL.
|
|
966
|
+
expect(JSON.parse(init?.body as string)).toEqual({
|
|
967
|
+
publicBaseUrl: "https://saved.example.ts.net",
|
|
968
|
+
});
|
|
969
|
+
return new Response(
|
|
970
|
+
JSON.stringify({
|
|
971
|
+
deviceCode: "device-code",
|
|
972
|
+
userCode: "ABCD-EFGH",
|
|
973
|
+
verificationUri: "https://saved.example.ts.net/assistant/pair",
|
|
974
|
+
expiresAt: "2026-06-04T00:10:00.000Z",
|
|
975
|
+
expiresInSeconds: 600,
|
|
976
|
+
}),
|
|
977
|
+
{ status: 200, headers: { "content-type": "application/json" } },
|
|
978
|
+
);
|
|
979
|
+
}
|
|
980
|
+
if (url === `${LOCAL_URL}/v1/remote-web/pairing-verification`) {
|
|
981
|
+
return new Response(
|
|
982
|
+
JSON.stringify({
|
|
983
|
+
status: "approved",
|
|
984
|
+
verificationUri: "https://saved.example.ts.net/assistant/pair",
|
|
985
|
+
expiresAt: "2026-06-04T00:10:00.000Z",
|
|
986
|
+
}),
|
|
987
|
+
{ status: 200, headers: { "content-type": "application/json" } },
|
|
988
|
+
);
|
|
989
|
+
}
|
|
990
|
+
return new Response("not found", { status: 404 });
|
|
991
|
+
}) as unknown as typeof fetch;
|
|
992
|
+
|
|
993
|
+
const logs: string[] = [];
|
|
994
|
+
const logSpy = spyOn(console, "log").mockImplementation(
|
|
995
|
+
(...a: unknown[]) => {
|
|
996
|
+
logs.push(a.join(" "));
|
|
997
|
+
},
|
|
998
|
+
);
|
|
999
|
+
|
|
1000
|
+
process.argv = ["bun", "vellum", "pair", "--qr"];
|
|
1001
|
+
try {
|
|
1002
|
+
await pair();
|
|
1003
|
+
} finally {
|
|
1004
|
+
logSpy.mockRestore();
|
|
1005
|
+
globalThis.fetch = origFetch;
|
|
1006
|
+
delete process.env.VELLUM_WORKSPACE_DIR;
|
|
1007
|
+
delete process.env.XDG_DATA_HOME;
|
|
1008
|
+
rmSync(workspaceDir, { recursive: true, force: true });
|
|
1009
|
+
rmSync(xdgDir, { recursive: true, force: true });
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
expect(calls).toContain(`${LOCAL_URL}/v1/remote-web/pairing-challenge`);
|
|
1013
|
+
const output = logs.join("\n");
|
|
1014
|
+
expect(output).toContain(
|
|
1015
|
+
"Using saved ingress URL https://saved.example.ts.net",
|
|
1016
|
+
);
|
|
1017
|
+
expect(output).toContain(
|
|
1018
|
+
"https://saved.example.ts.net/assistant/pair#device_code=device-code",
|
|
1019
|
+
);
|
|
1020
|
+
});
|
|
1021
|
+
|
|
1022
|
+
test("--url beats the saved ingress URL", async () => {
|
|
1023
|
+
const workspaceDir = mkdtempSync(join(tmpdir(), "vellum-pair-ingress-"));
|
|
1024
|
+
// Point the synthesized instance dir at an empty temp XDG root so the
|
|
1025
|
+
// resolution deterministically falls through to VELLUM_WORKSPACE_DIR.
|
|
1026
|
+
const xdgDir = mkdtempSync(join(tmpdir(), "vellum-pair-xdg-"));
|
|
1027
|
+
process.env.XDG_DATA_HOME = xdgDir;
|
|
1028
|
+
writeFileSync(
|
|
1029
|
+
join(workspaceDir, "config.json"),
|
|
1030
|
+
JSON.stringify({
|
|
1031
|
+
ingress: {
|
|
1032
|
+
publicBaseUrl: "https://saved.example.ts.net",
|
|
1033
|
+
enabled: true,
|
|
1034
|
+
},
|
|
1035
|
+
}),
|
|
1036
|
+
);
|
|
1037
|
+
process.env.VELLUM_WORKSPACE_DIR = workspaceDir;
|
|
1038
|
+
|
|
1039
|
+
const origFetch = globalThis.fetch;
|
|
1040
|
+
globalThis.fetch = (async (url: string, init?: RequestInit) => {
|
|
1041
|
+
if (url === `${LOCAL_URL}/v1/assistants/pair-test/feature-flags`) {
|
|
1042
|
+
return new Response(
|
|
1043
|
+
JSON.stringify({
|
|
1044
|
+
flags: [{ key: "web-remote-ingress", enabled: true }],
|
|
1045
|
+
}),
|
|
1046
|
+
{ status: 200, headers: { "content-type": "application/json" } },
|
|
1047
|
+
);
|
|
1048
|
+
}
|
|
1049
|
+
if (url === `${LOCAL_URL}/v1/remote-web/pairing-challenge`) {
|
|
1050
|
+
expect(JSON.parse(init?.body as string)).toEqual({
|
|
1051
|
+
publicBaseUrl: "https://explicit.example.ts.net",
|
|
1052
|
+
});
|
|
1053
|
+
return new Response(
|
|
1054
|
+
JSON.stringify({
|
|
1055
|
+
deviceCode: "device-code",
|
|
1056
|
+
userCode: "ABCD-EFGH",
|
|
1057
|
+
verificationUri: "https://explicit.example.ts.net/assistant/pair",
|
|
1058
|
+
expiresAt: "2026-06-04T00:10:00.000Z",
|
|
1059
|
+
expiresInSeconds: 600,
|
|
1060
|
+
}),
|
|
1061
|
+
{ status: 200, headers: { "content-type": "application/json" } },
|
|
1062
|
+
);
|
|
1063
|
+
}
|
|
1064
|
+
if (url === `${LOCAL_URL}/v1/remote-web/pairing-verification`) {
|
|
1065
|
+
return new Response(
|
|
1066
|
+
JSON.stringify({
|
|
1067
|
+
status: "approved",
|
|
1068
|
+
verificationUri: "https://explicit.example.ts.net/assistant/pair",
|
|
1069
|
+
expiresAt: "2026-06-04T00:10:00.000Z",
|
|
1070
|
+
}),
|
|
1071
|
+
{ status: 200, headers: { "content-type": "application/json" } },
|
|
1072
|
+
);
|
|
1073
|
+
}
|
|
1074
|
+
return new Response("not found", { status: 404 });
|
|
1075
|
+
}) as unknown as typeof fetch;
|
|
1076
|
+
|
|
1077
|
+
const logs: string[] = [];
|
|
1078
|
+
const logSpy = spyOn(console, "log").mockImplementation(
|
|
1079
|
+
(...a: unknown[]) => {
|
|
1080
|
+
logs.push(a.join(" "));
|
|
1081
|
+
},
|
|
1082
|
+
);
|
|
1083
|
+
|
|
1084
|
+
process.argv = [
|
|
1085
|
+
"bun",
|
|
1086
|
+
"vellum",
|
|
1087
|
+
"pair",
|
|
1088
|
+
"--qr",
|
|
1089
|
+
"--url",
|
|
1090
|
+
"https://explicit.example.ts.net",
|
|
1091
|
+
];
|
|
1092
|
+
try {
|
|
1093
|
+
await pair();
|
|
1094
|
+
} finally {
|
|
1095
|
+
logSpy.mockRestore();
|
|
1096
|
+
globalThis.fetch = origFetch;
|
|
1097
|
+
delete process.env.VELLUM_WORKSPACE_DIR;
|
|
1098
|
+
delete process.env.XDG_DATA_HOME;
|
|
1099
|
+
rmSync(workspaceDir, { recursive: true, force: true });
|
|
1100
|
+
rmSync(xdgDir, { recursive: true, force: true });
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
expect(logs.join("\n")).not.toContain("Using saved ingress URL");
|
|
1104
|
+
});
|
|
1105
|
+
|
|
1106
|
+
test("a non-https saved ingress URL is ignored", async () => {
|
|
1107
|
+
const workspaceDir = mkdtempSync(join(tmpdir(), "vellum-pair-ingress-"));
|
|
1108
|
+
// Point the synthesized instance dir at an empty temp XDG root so the
|
|
1109
|
+
// resolution deterministically falls through to VELLUM_WORKSPACE_DIR.
|
|
1110
|
+
const xdgDir = mkdtempSync(join(tmpdir(), "vellum-pair-xdg-"));
|
|
1111
|
+
process.env.XDG_DATA_HOME = xdgDir;
|
|
1112
|
+
writeFileSync(
|
|
1113
|
+
join(workspaceDir, "config.json"),
|
|
1114
|
+
JSON.stringify({
|
|
1115
|
+
ingress: {
|
|
1116
|
+
publicBaseUrl: "http://insecure.example.com",
|
|
1117
|
+
enabled: true,
|
|
1118
|
+
},
|
|
1119
|
+
}),
|
|
1120
|
+
);
|
|
1121
|
+
process.env.VELLUM_WORKSPACE_DIR = workspaceDir;
|
|
1122
|
+
|
|
1123
|
+
const origFetch = globalThis.fetch;
|
|
1124
|
+
let minted = false;
|
|
1125
|
+
globalThis.fetch = (async (url: string) => {
|
|
1126
|
+
if (url === `${LOCAL_URL}/v1/assistants/pair-test/feature-flags`) {
|
|
1127
|
+
return new Response(
|
|
1128
|
+
JSON.stringify({
|
|
1129
|
+
flags: [{ key: "web-remote-ingress", enabled: true }],
|
|
1130
|
+
}),
|
|
1131
|
+
{ status: 200, headers: { "content-type": "application/json" } },
|
|
1132
|
+
);
|
|
1133
|
+
}
|
|
1134
|
+
minted = true;
|
|
1135
|
+
return new Response("{}", { status: 200 });
|
|
1136
|
+
}) as unknown as typeof fetch;
|
|
1137
|
+
|
|
1138
|
+
const errors: string[] = [];
|
|
1139
|
+
const errSpy = spyOn(console, "error").mockImplementation(
|
|
1140
|
+
(...a: unknown[]) => {
|
|
1141
|
+
errors.push(a.join(" "));
|
|
1142
|
+
},
|
|
1143
|
+
);
|
|
1144
|
+
const exitSpy = spyOn(process, "exit").mockImplementation(((
|
|
1145
|
+
code?: number,
|
|
1146
|
+
) => {
|
|
1147
|
+
throw new Error(`exit:${code}`);
|
|
1148
|
+
}) as never);
|
|
1149
|
+
|
|
1150
|
+
process.argv = ["bun", "vellum", "pair", "--qr"];
|
|
1151
|
+
let exited = false;
|
|
1152
|
+
try {
|
|
1153
|
+
await pair();
|
|
1154
|
+
} catch (e) {
|
|
1155
|
+
exited = (e as Error).message === "exit:1";
|
|
1156
|
+
} finally {
|
|
1157
|
+
errSpy.mockRestore();
|
|
1158
|
+
exitSpy.mockRestore();
|
|
1159
|
+
globalThis.fetch = origFetch;
|
|
1160
|
+
delete process.env.VELLUM_WORKSPACE_DIR;
|
|
1161
|
+
delete process.env.XDG_DATA_HOME;
|
|
1162
|
+
rmSync(workspaceDir, { recursive: true, force: true });
|
|
1163
|
+
rmSync(xdgDir, { recursive: true, force: true });
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
// The saved http URL is skipped, so --qr falls through to the (non-https)
|
|
1167
|
+
// runtime URL and refuses — proving the saved value was not advertised.
|
|
1168
|
+
expect(exited).toBe(true);
|
|
1169
|
+
expect(errors.join("\n")).toContain(RUNTIME_URL);
|
|
1170
|
+
expect(minted).toBe(false);
|
|
1171
|
+
});
|
|
1172
|
+
|
|
1173
|
+
test("a saved ingress URL with ingress disabled is ignored", async () => {
|
|
1174
|
+
const workspaceDir = mkdtempSync(join(tmpdir(), "vellum-pair-ingress-"));
|
|
1175
|
+
// Point the synthesized instance dir at an empty temp XDG root so the
|
|
1176
|
+
// resolution deterministically falls through to VELLUM_WORKSPACE_DIR.
|
|
1177
|
+
const xdgDir = mkdtempSync(join(tmpdir(), "vellum-pair-xdg-"));
|
|
1178
|
+
process.env.XDG_DATA_HOME = xdgDir;
|
|
1179
|
+
writeFileSync(
|
|
1180
|
+
join(workspaceDir, "config.json"),
|
|
1181
|
+
JSON.stringify({
|
|
1182
|
+
ingress: {
|
|
1183
|
+
publicBaseUrl: "https://saved.example.ts.net",
|
|
1184
|
+
enabled: false,
|
|
1185
|
+
},
|
|
1186
|
+
}),
|
|
1187
|
+
);
|
|
1188
|
+
process.env.VELLUM_WORKSPACE_DIR = workspaceDir;
|
|
1189
|
+
|
|
1190
|
+
const origFetch = globalThis.fetch;
|
|
1191
|
+
let minted = false;
|
|
1192
|
+
globalThis.fetch = (async (url: string) => {
|
|
1193
|
+
if (url === `${LOCAL_URL}/v1/assistants/pair-test/feature-flags`) {
|
|
1194
|
+
return new Response(
|
|
1195
|
+
JSON.stringify({
|
|
1196
|
+
flags: [{ key: "web-remote-ingress", enabled: true }],
|
|
1197
|
+
}),
|
|
1198
|
+
{ status: 200, headers: { "content-type": "application/json" } },
|
|
1199
|
+
);
|
|
1200
|
+
}
|
|
1201
|
+
minted = true;
|
|
1202
|
+
return new Response("{}", { status: 200 });
|
|
1203
|
+
}) as unknown as typeof fetch;
|
|
1204
|
+
|
|
1205
|
+
const errors: string[] = [];
|
|
1206
|
+
const errSpy = spyOn(console, "error").mockImplementation(
|
|
1207
|
+
(...a: unknown[]) => {
|
|
1208
|
+
errors.push(a.join(" "));
|
|
1209
|
+
},
|
|
1210
|
+
);
|
|
1211
|
+
const exitSpy = spyOn(process, "exit").mockImplementation(((
|
|
1212
|
+
code?: number,
|
|
1213
|
+
) => {
|
|
1214
|
+
throw new Error(`exit:${code}`);
|
|
1215
|
+
}) as never);
|
|
1216
|
+
|
|
1217
|
+
process.argv = ["bun", "vellum", "pair", "--qr"];
|
|
1218
|
+
let exited = false;
|
|
1219
|
+
try {
|
|
1220
|
+
await pair();
|
|
1221
|
+
} catch (e) {
|
|
1222
|
+
exited = (e as Error).message === "exit:1";
|
|
1223
|
+
} finally {
|
|
1224
|
+
errSpy.mockRestore();
|
|
1225
|
+
exitSpy.mockRestore();
|
|
1226
|
+
globalThis.fetch = origFetch;
|
|
1227
|
+
delete process.env.VELLUM_WORKSPACE_DIR;
|
|
1228
|
+
delete process.env.XDG_DATA_HOME;
|
|
1229
|
+
rmSync(workspaceDir, { recursive: true, force: true });
|
|
1230
|
+
rmSync(xdgDir, { recursive: true, force: true });
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
expect(exited).toBe(true);
|
|
1234
|
+
expect(errors.join("\n")).toContain(RUNTIME_URL);
|
|
1235
|
+
expect(minted).toBe(false);
|
|
1236
|
+
});
|
|
934
1237
|
});
|
package/src/commands/pair.ts
CHANGED
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
* independent, separately-revocable device (see `vellum unpair`, forthcoming).
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
|
|
13
15
|
import { nanoid } from "nanoid";
|
|
14
16
|
// Call `qrcodeTerminal.generate` as a method — the library reads its default
|
|
15
17
|
// error-correction level off `this`, so a destructured import renders nothing.
|
|
@@ -33,6 +35,10 @@ import {
|
|
|
33
35
|
isAssistantFeatureFlagEnabled,
|
|
34
36
|
WEB_REMOTE_INGRESS_FLAG,
|
|
35
37
|
} from "../lib/feature-flags.js";
|
|
38
|
+
import {
|
|
39
|
+
getDefaultWorkspaceDir,
|
|
40
|
+
loadIngressUrl,
|
|
41
|
+
} from "../lib/ingress-config.js";
|
|
36
42
|
import { getLocalLanIPv4 } from "../lib/local.js";
|
|
37
43
|
import { isLoopbackUrl, loopbackSafeFetch } from "../lib/loopback-fetch.js";
|
|
38
44
|
|
|
@@ -40,6 +46,45 @@ function assistantDisplayName(entry: AssistantEntry): string {
|
|
|
40
46
|
return entry.name || entry.assistantName || entry.assistantId;
|
|
41
47
|
}
|
|
42
48
|
|
|
49
|
+
/**
|
|
50
|
+
* The tunnel-saved ingress URL, when usable as a remote-web advertised
|
|
51
|
+
* default: https and non-loopback (the bar `--qr`/`--web` URLs must clear).
|
|
52
|
+
* Checks the workspaces a tunnel provider may have written: the instance's
|
|
53
|
+
* own workspace (managed/XDG layouts), then the default workspace (legacy
|
|
54
|
+
* `~/.vellum/workspace` layouts, where the gateway reads its config).
|
|
55
|
+
*/
|
|
56
|
+
function loadUsableIngressUrl(entry: AssistantEntry): string | null {
|
|
57
|
+
const candidates = entry.resources
|
|
58
|
+
? [
|
|
59
|
+
join(entry.resources.instanceDir, ".vellum", "workspace"),
|
|
60
|
+
getDefaultWorkspaceDir(),
|
|
61
|
+
]
|
|
62
|
+
: [getDefaultWorkspaceDir()];
|
|
63
|
+
for (const workspaceDir of candidates) {
|
|
64
|
+
let saved: string | null = null;
|
|
65
|
+
try {
|
|
66
|
+
saved = loadIngressUrl(workspaceDir);
|
|
67
|
+
} catch {
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
if (!saved) {
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
try {
|
|
74
|
+
if (new URL(saved).protocol !== "https:") {
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
} catch {
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
if (isLoopbackUrl(saved)) {
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
return saved;
|
|
84
|
+
}
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
|
|
43
88
|
function printUsage(): void {
|
|
44
89
|
console.log(`vellum pair [beta] - Mint a device-scoped token for another machine
|
|
45
90
|
|
|
@@ -348,17 +393,30 @@ export async function pair(): Promise<void> {
|
|
|
348
393
|
|
|
349
394
|
// Mint over loopback (localUrl avoids mDNS for same-machine calls), but
|
|
350
395
|
// advertise a REACHABLE url in the bundle — the loopback url would point the
|
|
351
|
-
// other machine at its own localhost. Prefer an explicit --url, then
|
|
396
|
+
// other machine at its own localhost. Prefer an explicit --url, then (for
|
|
397
|
+
// the remote-web flows) the ingress URL a tunnel provider saved, then the
|
|
352
398
|
// runtime (LAN/tunnel) url.
|
|
353
399
|
const mintUrl = (
|
|
354
400
|
entry.localUrl ||
|
|
355
401
|
entry.runtimeUrl ||
|
|
356
402
|
`http://127.0.0.1:${GATEWAY_PORT}`
|
|
357
403
|
).replace(/\/+$/, "");
|
|
358
|
-
const
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
404
|
+
const savedIngressUrl =
|
|
405
|
+
!urlOverride && (qrPairing || webPairing)
|
|
406
|
+
? loadUsableIngressUrl(entry)
|
|
407
|
+
: null;
|
|
408
|
+
const advertisedUrl = (
|
|
409
|
+
urlOverride ||
|
|
410
|
+
savedIngressUrl ||
|
|
411
|
+
entry.runtimeUrl ||
|
|
412
|
+
mintUrl
|
|
413
|
+
).replace(/\/+$/, "");
|
|
414
|
+
if (savedIngressUrl && !jsonOutput) {
|
|
415
|
+
console.log(
|
|
416
|
+
`Using saved ingress URL ${savedIngressUrl} ` +
|
|
417
|
+
"(from vellum tunnel; override with --url).",
|
|
418
|
+
);
|
|
419
|
+
}
|
|
362
420
|
|
|
363
421
|
// A local hatch's runtimeUrl is itself loopback (http://localhost:<port>),
|
|
364
422
|
// so without an explicit --url the bundle would point the other machine at
|
|
@@ -51,6 +51,20 @@ export function saveIngressUrl(workspaceDir: string, publicUrl: string): void {
|
|
|
51
51
|
saveRawConfig(workspaceDir, config);
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
/**
|
|
55
|
+
* Read the saved public ingress URL, or null when ingress is unset or
|
|
56
|
+
* explicitly disabled.
|
|
57
|
+
*/
|
|
58
|
+
export function loadIngressUrl(workspaceDir: string): string | null {
|
|
59
|
+
const config = loadRawConfig(workspaceDir);
|
|
60
|
+
const ingress = config.ingress as Record<string, unknown> | undefined;
|
|
61
|
+
if (!ingress || ingress.enabled === false) {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
const url = ingress.publicBaseUrl;
|
|
65
|
+
return typeof url === "string" && url.trim() ? url.trim() : null;
|
|
66
|
+
}
|
|
67
|
+
|
|
54
68
|
/** Clear the ingress public base URL from the workspace config. */
|
|
55
69
|
export function clearIngressUrl(workspaceDir: string): void {
|
|
56
70
|
const config = loadRawConfig(workspaceDir);
|