@vellumai/cli 0.8.2 → 0.8.3
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/README.md +24 -32
- package/package.json +1 -1
- package/src/__tests__/config-utils.test.ts +31 -1
- package/src/__tests__/hatch-provider-secrets.test.ts +284 -0
- package/src/__tests__/setup.test.ts +65 -1
- package/src/__tests__/teleport.test.ts +1 -0
- package/src/commands/hatch.ts +53 -20
- package/src/commands/setup.ts +46 -12
- package/src/commands/teleport.ts +20 -2
- package/src/lib/__tests__/docker.test.ts +106 -0
- package/src/lib/assistant-config.ts +2 -0
- package/src/lib/config-utils.ts +18 -0
- package/src/lib/docker.ts +180 -19
- package/src/lib/hatch-local.ts +42 -3
- package/src/lib/hatch-next-steps.ts +12 -0
- package/src/lib/provider-secrets.ts +151 -0
- package/src/shared/provider-env-vars.ts +0 -3
package/src/lib/docker.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { randomBytes } from "crypto";
|
|
2
2
|
import { chmodSync, existsSync, mkdirSync, watch as fsWatch } from "fs";
|
|
3
3
|
import { arch, platform } from "os";
|
|
4
|
-
import { dirname, join } from "path";
|
|
4
|
+
import { dirname, join, resolve } from "path";
|
|
5
5
|
|
|
6
6
|
// Direct import — bun embeds this at compile time so it works in compiled binaries.
|
|
7
7
|
import cliPkg from "../../package.json";
|
|
@@ -12,15 +12,21 @@ import {
|
|
|
12
12
|
setActiveAssistant,
|
|
13
13
|
} from "./assistant-config";
|
|
14
14
|
import type { AssistantEntry } from "./assistant-config";
|
|
15
|
-
import { writeInitialConfig } from "./config-utils";
|
|
15
|
+
import { buildHatchConfigValues, writeInitialConfig } from "./config-utils";
|
|
16
16
|
import { buildServiceRunArgs } from "./statefulset.js";
|
|
17
17
|
import type { Species } from "./constants";
|
|
18
18
|
import { getDefaultPorts } from "./environments/paths.js";
|
|
19
19
|
import { getCurrentEnvironment } from "./environments/resolve.js";
|
|
20
20
|
import { leaseGuardianToken } from "./guardian-token";
|
|
21
|
+
import { logHatchNextSteps } from "./hatch-next-steps.js";
|
|
21
22
|
import { isVellumProcess, stopProcess } from "./process";
|
|
22
23
|
import { generateInstanceName } from "./random-name";
|
|
23
24
|
import { resolveImageRefs } from "./platform-releases.js";
|
|
25
|
+
import {
|
|
26
|
+
configureHatchProviderApiKey,
|
|
27
|
+
formatProviderName,
|
|
28
|
+
resolveHatchProvider,
|
|
29
|
+
} from "./provider-secrets.js";
|
|
24
30
|
import { exec, execOutput } from "./step-runner";
|
|
25
31
|
import {
|
|
26
32
|
closeLogFile,
|
|
@@ -40,10 +46,13 @@ export const DOCKERHUB_IMAGES: Record<ServiceName, string> = {
|
|
|
40
46
|
};
|
|
41
47
|
|
|
42
48
|
/** Internal ports exposed by each service's Dockerfile. Re-exported from environments/paths.ts. */
|
|
43
|
-
export {
|
|
49
|
+
export {
|
|
50
|
+
ASSISTANT_INTERNAL_PORT,
|
|
51
|
+
GATEWAY_INTERNAL_PORT,
|
|
52
|
+
} from "./environments/paths.js";
|
|
44
53
|
|
|
45
54
|
/** Max time to wait for the assistant container to emit the readiness sentinel. */
|
|
46
|
-
export const DOCKER_READY_TIMEOUT_MS =
|
|
55
|
+
export const DOCKER_READY_TIMEOUT_MS = 5 * 60 * 1000;
|
|
47
56
|
|
|
48
57
|
/** Default virtual-camera device path. Overridable via `VELLUM_AVATAR_DEVICE`. */
|
|
49
58
|
const DEFAULT_AVATAR_DEVICE_PATH = "/dev/video10";
|
|
@@ -247,6 +256,28 @@ function ensureLocalBinOnPath(): void {
|
|
|
247
256
|
}
|
|
248
257
|
}
|
|
249
258
|
|
|
259
|
+
export interface HatchDockerOptions {
|
|
260
|
+
setupProviderCredentials?: boolean;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export type DockerProviderCredentialSetupAction =
|
|
264
|
+
| "configure"
|
|
265
|
+
| "defer"
|
|
266
|
+
| "missing-token"
|
|
267
|
+
| "skip";
|
|
268
|
+
|
|
269
|
+
export function resolveDockerProviderCredentialSetupAction(options: {
|
|
270
|
+
provider: string | null | undefined;
|
|
271
|
+
guardianAccessToken?: string;
|
|
272
|
+
detached: boolean;
|
|
273
|
+
}): DockerProviderCredentialSetupAction {
|
|
274
|
+
if (options.provider === undefined) return "skip";
|
|
275
|
+
if (options.provider === null) return options.detached ? "skip" : "configure";
|
|
276
|
+
if (options.detached) return "defer";
|
|
277
|
+
if (!options.guardianAccessToken) return "missing-token";
|
|
278
|
+
return "configure";
|
|
279
|
+
}
|
|
280
|
+
|
|
250
281
|
/**
|
|
251
282
|
* Checks whether the `docker` CLI and daemon are available on the system.
|
|
252
283
|
* Installs Colima and Docker via direct binary download if missing (no sudo
|
|
@@ -461,6 +492,37 @@ function hasFullSourceTree(root: string): boolean {
|
|
|
461
492
|
return existsSync(join(root, "assistant", "package.json"));
|
|
462
493
|
}
|
|
463
494
|
|
|
495
|
+
/**
|
|
496
|
+
* Decide which image-source path `hatchDocker` should take given the user
|
|
497
|
+
* flags and a probe result for the source tree.
|
|
498
|
+
*
|
|
499
|
+
* - `watch` always wants source-build *and* file-watcher (when the source
|
|
500
|
+
* tree is available).
|
|
501
|
+
* - `buildFromSource` wants source-build but no watcher — used by evals so
|
|
502
|
+
* each run picks up fresh CLI changes from the repo.
|
|
503
|
+
* - Without either flag we pull the published images.
|
|
504
|
+
* - If either flag was set but the source tree is missing (e.g. the CLI is
|
|
505
|
+
* running from a packaged .app bundle), fall back to pulling and surface
|
|
506
|
+
* the reason so the caller can log a warning.
|
|
507
|
+
*
|
|
508
|
+
* Returning a plain record keeps this trivially unit-testable — see
|
|
509
|
+
* `__tests__/docker.test.ts`.
|
|
510
|
+
*/
|
|
511
|
+
export function resolveDockerHatchMode(opts: {
|
|
512
|
+
watch: boolean;
|
|
513
|
+
buildFromSource: boolean;
|
|
514
|
+
fullSourceTreeAvailable: boolean;
|
|
515
|
+
}): { build: boolean; watcher: boolean; fellBackToPull: boolean } {
|
|
516
|
+
const requested = opts.watch || opts.buildFromSource;
|
|
517
|
+
if (!requested) {
|
|
518
|
+
return { build: false, watcher: false, fellBackToPull: false };
|
|
519
|
+
}
|
|
520
|
+
if (!opts.fullSourceTreeAvailable) {
|
|
521
|
+
return { build: false, watcher: false, fellBackToPull: true };
|
|
522
|
+
}
|
|
523
|
+
return { build: true, watcher: opts.watch, fellBackToPull: false };
|
|
524
|
+
}
|
|
525
|
+
|
|
464
526
|
/**
|
|
465
527
|
* Locate the repository root by walking up from `cli/src/lib/` until we
|
|
466
528
|
* find a directory containing the expected Dockerfiles.
|
|
@@ -581,7 +643,10 @@ export async function startContainers(
|
|
|
581
643
|
},
|
|
582
644
|
log: (msg: string) => void,
|
|
583
645
|
): Promise<void> {
|
|
584
|
-
const runArgs = buildServiceRunArgs({
|
|
646
|
+
const runArgs = buildServiceRunArgs({
|
|
647
|
+
...opts,
|
|
648
|
+
avatarDevicePath: resolveAvatarDevicePath(),
|
|
649
|
+
});
|
|
585
650
|
for (const service of SERVICE_START_ORDER) {
|
|
586
651
|
log(`🚀 Starting ${service} container...`);
|
|
587
652
|
await exec("docker", runArgs[service]());
|
|
@@ -870,14 +935,32 @@ function startFileWatcher(opts: {
|
|
|
870
935
|
};
|
|
871
936
|
}
|
|
872
937
|
|
|
938
|
+
export interface HatchDockerOptions {
|
|
939
|
+
/**
|
|
940
|
+
* Path to a local source tree to build images from before hatching. When
|
|
941
|
+
* provided, this path is used directly as the repo root and no file
|
|
942
|
+
* watcher is started — useful for callers (e.g. evals) that want each
|
|
943
|
+
* run to pick up local CLI changes without keeping a long-lived watcher
|
|
944
|
+
* process around. `--watch` independently auto-detects the repo root and
|
|
945
|
+
* also enables hot-reload.
|
|
946
|
+
*/
|
|
947
|
+
sourcePath?: string | null;
|
|
948
|
+
analyze?: boolean;
|
|
949
|
+
}
|
|
950
|
+
|
|
873
951
|
export async function hatchDocker(
|
|
874
952
|
species: Species,
|
|
875
953
|
detached: boolean,
|
|
876
954
|
name: string | null,
|
|
877
955
|
watch: boolean = false,
|
|
878
956
|
configValues: Record<string, string> = {},
|
|
957
|
+
options: HatchDockerOptions = {},
|
|
879
958
|
): Promise<void> {
|
|
880
959
|
resetLogFile("hatch.log");
|
|
960
|
+
const provider =
|
|
961
|
+
options.setupProviderCredentials === false
|
|
962
|
+
? undefined
|
|
963
|
+
: resolveHatchProvider(configValues);
|
|
881
964
|
|
|
882
965
|
let logFd = openLogFile("hatch.log");
|
|
883
966
|
const log = (msg: string): void => {
|
|
@@ -898,25 +981,42 @@ export async function hatchDocker(
|
|
|
898
981
|
gateway: "",
|
|
899
982
|
};
|
|
900
983
|
|
|
984
|
+
const sourcePath =
|
|
985
|
+
typeof options.sourcePath === "string" && options.sourcePath.length > 0
|
|
986
|
+
? options.sourcePath
|
|
987
|
+
: null;
|
|
988
|
+
const buildFromSource = sourcePath !== null;
|
|
901
989
|
let repoRoot: string | undefined;
|
|
990
|
+
let fullSourceTreeAvailable = false;
|
|
902
991
|
|
|
903
|
-
if (watch) {
|
|
904
|
-
|
|
992
|
+
if (watch || buildFromSource) {
|
|
993
|
+
// When --source <path> is supplied, trust the caller and use it
|
|
994
|
+
// directly. Otherwise (the --watch case) walk up from known locations
|
|
995
|
+
// to find the repo root.
|
|
996
|
+
repoRoot = sourcePath ? resolve(sourcePath) : findRepoRoot();
|
|
905
997
|
|
|
906
998
|
// When running from a packaged .app bundle, the Dockerfiles are
|
|
907
999
|
// present (so findRepoRoot succeeds) but the full source tree is
|
|
908
1000
|
// not — we can't build images locally. Fall back to pulling
|
|
909
1001
|
// pre-built images instead.
|
|
910
|
-
|
|
1002
|
+
fullSourceTreeAvailable = hasFullSourceTree(repoRoot);
|
|
1003
|
+
if (!fullSourceTreeAvailable) {
|
|
911
1004
|
log(
|
|
912
1005
|
"⚠️ Dockerfiles found but no source tree — falling back to image pull",
|
|
913
1006
|
);
|
|
914
|
-
watch = false;
|
|
915
1007
|
repoRoot = undefined;
|
|
916
1008
|
}
|
|
917
1009
|
}
|
|
918
1010
|
|
|
919
|
-
|
|
1011
|
+
const mode = resolveDockerHatchMode({
|
|
1012
|
+
watch,
|
|
1013
|
+
buildFromSource,
|
|
1014
|
+
fullSourceTreeAvailable,
|
|
1015
|
+
});
|
|
1016
|
+
// Honour the resolved mode for the rest of the flow.
|
|
1017
|
+
watch = mode.watcher;
|
|
1018
|
+
|
|
1019
|
+
if (mode.build && repoRoot) {
|
|
920
1020
|
emitProgress(2, 6, "Building images...");
|
|
921
1021
|
const localTag = `local-${instanceName}`;
|
|
922
1022
|
imageTags.assistant = `vellum-assistant:${localTag}`;
|
|
@@ -926,7 +1026,9 @@ export async function hatchDocker(
|
|
|
926
1026
|
|
|
927
1027
|
log(`🥚 Hatching Docker assistant: ${instanceName}`);
|
|
928
1028
|
log(` Species: ${species}`);
|
|
929
|
-
log(
|
|
1029
|
+
log(
|
|
1030
|
+
` Mode: ${mode.watcher ? "development (watch)" : "build-from-source"}`,
|
|
1031
|
+
);
|
|
930
1032
|
log(` Repo: ${repoRoot}`);
|
|
931
1033
|
log(` Images (local build):`);
|
|
932
1034
|
log(` assistant: ${imageTags.assistant}`);
|
|
@@ -938,7 +1040,7 @@ export async function hatchDocker(
|
|
|
938
1040
|
log("✅ Docker images built");
|
|
939
1041
|
}
|
|
940
1042
|
|
|
941
|
-
if (!
|
|
1043
|
+
if (!mode.build || !repoRoot) {
|
|
942
1044
|
emitProgress(2, 6, "Pulling images...");
|
|
943
1045
|
|
|
944
1046
|
// Allow explicit image overrides via environment variables.
|
|
@@ -1013,7 +1115,8 @@ export async function hatchDocker(
|
|
|
1013
1115
|
|
|
1014
1116
|
// Write --config key=value pairs to a temp file that gets bind-mounted
|
|
1015
1117
|
// into the assistant container and read via VELLUM_DEFAULT_WORKSPACE_CONFIG_PATH.
|
|
1016
|
-
const
|
|
1118
|
+
const hatchConfigValues = buildHatchConfigValues(configValues, provider);
|
|
1119
|
+
const defaultWorkspaceConfigPath = writeInitialConfig(hatchConfigValues);
|
|
1017
1120
|
|
|
1018
1121
|
const cesServiceToken = randomBytes(32).toString("hex");
|
|
1019
1122
|
const signingKey = randomBytes(32).toString("hex");
|
|
@@ -1043,6 +1146,7 @@ export async function hatchDocker(
|
|
|
1043
1146
|
},
|
|
1044
1147
|
log,
|
|
1045
1148
|
);
|
|
1149
|
+
const containersUpAt = Date.now();
|
|
1046
1150
|
|
|
1047
1151
|
const imageDigests = await captureImageRefs(res);
|
|
1048
1152
|
|
|
@@ -1053,6 +1157,7 @@ export async function hatchDocker(
|
|
|
1053
1157
|
cloud: "docker",
|
|
1054
1158
|
species,
|
|
1055
1159
|
hatchedAt: new Date().toISOString(),
|
|
1160
|
+
guardianBootstrapSecret: ownSecret,
|
|
1056
1161
|
containerInfo: {
|
|
1057
1162
|
assistantImage: imageTags.assistant,
|
|
1058
1163
|
gatewayImage: imageTags.gateway,
|
|
@@ -1068,19 +1173,59 @@ export async function hatchDocker(
|
|
|
1068
1173
|
setActiveAssistant(instanceName);
|
|
1069
1174
|
|
|
1070
1175
|
emitProgress(6, 6, "Waiting for services...");
|
|
1071
|
-
const
|
|
1176
|
+
const waitDetached = watch ? false : detached;
|
|
1177
|
+
const { ready, guardianAccessToken } = await waitForGatewayAndLease({
|
|
1072
1178
|
bootstrapSecret: ownSecret,
|
|
1073
1179
|
containerName: res.assistantContainer,
|
|
1074
|
-
detached:
|
|
1180
|
+
detached: waitDetached,
|
|
1075
1181
|
instanceName,
|
|
1076
1182
|
logFd,
|
|
1077
1183
|
runtimeUrl,
|
|
1184
|
+
containersUpAt,
|
|
1185
|
+
analyze: options.analyze ?? false,
|
|
1078
1186
|
});
|
|
1079
1187
|
|
|
1080
1188
|
if (!ready && !(watch && repoRoot)) {
|
|
1081
1189
|
throw new Error("Timed out waiting for assistant to become ready");
|
|
1082
1190
|
}
|
|
1083
1191
|
|
|
1192
|
+
if (ready) {
|
|
1193
|
+
const providerSetupAction = resolveDockerProviderCredentialSetupAction({
|
|
1194
|
+
provider,
|
|
1195
|
+
guardianAccessToken,
|
|
1196
|
+
detached: waitDetached,
|
|
1197
|
+
});
|
|
1198
|
+
|
|
1199
|
+
if (providerSetupAction === "defer" && provider !== null) {
|
|
1200
|
+
log(
|
|
1201
|
+
`Provider credential setup deferred in detached mode.\n` +
|
|
1202
|
+
`Run \`vellum setup --provider ${provider}\` after the assistant is ready.`,
|
|
1203
|
+
);
|
|
1204
|
+
} else if (providerSetupAction === "missing-token" && provider !== null) {
|
|
1205
|
+
log(
|
|
1206
|
+
`⚠️ Provider credential setup skipped because the guardian token was not leased.\n` +
|
|
1207
|
+
` The assistant is still hatched. Run \`vellum setup --provider ${provider}\` after fixing the connection.`,
|
|
1208
|
+
);
|
|
1209
|
+
} else if (
|
|
1210
|
+
providerSetupAction === "configure" &&
|
|
1211
|
+
provider !== undefined
|
|
1212
|
+
) {
|
|
1213
|
+
log(
|
|
1214
|
+
provider === null
|
|
1215
|
+
? "Checking provider credentials..."
|
|
1216
|
+
: `Checking ${formatProviderName(provider)} credentials...`,
|
|
1217
|
+
);
|
|
1218
|
+
await configureHatchProviderApiKey({
|
|
1219
|
+
gatewayUrl: runtimeUrl,
|
|
1220
|
+
provider,
|
|
1221
|
+
bearerToken: guardianAccessToken,
|
|
1222
|
+
env: process.env,
|
|
1223
|
+
log,
|
|
1224
|
+
});
|
|
1225
|
+
}
|
|
1226
|
+
logHatchNextSteps(log, instanceName);
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1084
1229
|
if (watch && repoRoot) {
|
|
1085
1230
|
saveAssistantEntry({ ...dockerEntry, watcherPid: process.pid });
|
|
1086
1231
|
|
|
@@ -1136,7 +1281,9 @@ async function waitForGatewayAndLease(opts: {
|
|
|
1136
1281
|
instanceName: string;
|
|
1137
1282
|
logFd: number | "ignore";
|
|
1138
1283
|
runtimeUrl: string;
|
|
1139
|
-
|
|
1284
|
+
containersUpAt: number;
|
|
1285
|
+
analyze: boolean;
|
|
1286
|
+
}): Promise<{ ready: boolean; guardianAccessToken?: string }> {
|
|
1140
1287
|
const {
|
|
1141
1288
|
bootstrapSecret,
|
|
1142
1289
|
containerName,
|
|
@@ -1144,6 +1291,8 @@ async function waitForGatewayAndLease(opts: {
|
|
|
1144
1291
|
instanceName,
|
|
1145
1292
|
logFd,
|
|
1146
1293
|
runtimeUrl,
|
|
1294
|
+
containersUpAt,
|
|
1295
|
+
analyze,
|
|
1147
1296
|
} = opts;
|
|
1148
1297
|
|
|
1149
1298
|
const log = (msg: string): void => {
|
|
@@ -1168,7 +1317,7 @@ async function waitForGatewayAndLease(opts: {
|
|
|
1168
1317
|
log("Waiting for assistant to become ready...");
|
|
1169
1318
|
|
|
1170
1319
|
const readyUrl = `${runtimeUrl}/readyz`;
|
|
1171
|
-
const start =
|
|
1320
|
+
const start = containersUpAt;
|
|
1172
1321
|
let ready = false;
|
|
1173
1322
|
|
|
1174
1323
|
while (Date.now() - start < DOCKER_READY_TIMEOUT_MS) {
|
|
@@ -1204,8 +1353,18 @@ async function waitForGatewayAndLease(opts: {
|
|
|
1204
1353
|
return { ready: false };
|
|
1205
1354
|
}
|
|
1206
1355
|
|
|
1207
|
-
const
|
|
1356
|
+
const readyAt = Date.now();
|
|
1357
|
+
const containersUpToReadyMs = readyAt - start;
|
|
1358
|
+
const elapsedSec = (containersUpToReadyMs / 1000).toFixed(1);
|
|
1208
1359
|
log(`Assistant ready after ${elapsedSec}s`);
|
|
1360
|
+
if (analyze) {
|
|
1361
|
+
console.info(
|
|
1362
|
+
`[vellum-hatch-timing] ${JSON.stringify({
|
|
1363
|
+
containers_up_to_ready_ms: containersUpToReadyMs,
|
|
1364
|
+
instance: instanceName,
|
|
1365
|
+
})}`,
|
|
1366
|
+
);
|
|
1367
|
+
}
|
|
1209
1368
|
|
|
1210
1369
|
// Lease guardian token. The /readyz check confirms both gateway and
|
|
1211
1370
|
// assistant are reachable. Retry with backoff in case there is a brief
|
|
@@ -1215,6 +1374,7 @@ async function waitForGatewayAndLease(opts: {
|
|
|
1215
1374
|
const leaseDeadline = start + DOCKER_READY_TIMEOUT_MS;
|
|
1216
1375
|
let leaseSuccess = false;
|
|
1217
1376
|
let lastLeaseError: string | undefined;
|
|
1377
|
+
let guardianAccessToken: string | undefined;
|
|
1218
1378
|
|
|
1219
1379
|
while (Date.now() < leaseDeadline) {
|
|
1220
1380
|
try {
|
|
@@ -1227,6 +1387,7 @@ async function waitForGatewayAndLease(opts: {
|
|
|
1227
1387
|
log(
|
|
1228
1388
|
`Guardian token lease: success after ${leaseElapsed}s (principalId=${tokenData.guardianPrincipalId}, expiresAt=${tokenData.accessTokenExpiresAt})`,
|
|
1229
1389
|
);
|
|
1390
|
+
guardianAccessToken = tokenData.accessToken;
|
|
1230
1391
|
leaseSuccess = true;
|
|
1231
1392
|
break;
|
|
1232
1393
|
} catch (err) {
|
|
@@ -1257,5 +1418,5 @@ async function waitForGatewayAndLease(opts: {
|
|
|
1257
1418
|
log(` Name: ${instanceName}`);
|
|
1258
1419
|
log(` Runtime: ${runtimeUrl}`);
|
|
1259
1420
|
log("");
|
|
1260
|
-
return { ready: true };
|
|
1421
|
+
return { ready: true, guardianAccessToken };
|
|
1261
1422
|
}
|
package/src/lib/hatch-local.ts
CHANGED
|
@@ -22,7 +22,7 @@ import {
|
|
|
22
22
|
} from "./assistant-config.js";
|
|
23
23
|
import type { AssistantEntry } from "./assistant-config.js";
|
|
24
24
|
import type { Species } from "./constants.js";
|
|
25
|
-
import { writeInitialConfig } from "./config-utils.js";
|
|
25
|
+
import { buildHatchConfigValues, writeInitialConfig } from "./config-utils.js";
|
|
26
26
|
import {
|
|
27
27
|
generateLocalSigningKey,
|
|
28
28
|
startLocalDaemon,
|
|
@@ -34,6 +34,12 @@ import { generateInstanceName } from "./random-name.js";
|
|
|
34
34
|
import { leaseGuardianToken } from "./guardian-token.js";
|
|
35
35
|
import { archiveLogFile, resetLogFile } from "./xdg-log.js";
|
|
36
36
|
import { emitProgress } from "./desktop-progress.js";
|
|
37
|
+
import {
|
|
38
|
+
configureHatchProviderApiKey,
|
|
39
|
+
formatProviderName,
|
|
40
|
+
resolveHatchProvider,
|
|
41
|
+
} from "./provider-secrets.js";
|
|
42
|
+
import { logHatchNextSteps } from "./hatch-next-steps.js";
|
|
37
43
|
|
|
38
44
|
/**
|
|
39
45
|
* Attempts to place a symlink at the given path pointing to cliBinary.
|
|
@@ -125,13 +131,22 @@ function installCLISymlink(): void {
|
|
|
125
131
|
);
|
|
126
132
|
}
|
|
127
133
|
|
|
134
|
+
export interface HatchLocalOptions {
|
|
135
|
+
setupProviderCredentials?: boolean;
|
|
136
|
+
}
|
|
137
|
+
|
|
128
138
|
export async function hatchLocal(
|
|
129
139
|
species: Species,
|
|
130
140
|
name: string | null,
|
|
131
141
|
watch: boolean = false,
|
|
132
142
|
keepAlive: boolean = false,
|
|
133
143
|
configValues: Record<string, string> = {},
|
|
144
|
+
options: HatchLocalOptions = {},
|
|
134
145
|
): Promise<void> {
|
|
146
|
+
const provider =
|
|
147
|
+
options.setupProviderCredentials === false
|
|
148
|
+
? undefined
|
|
149
|
+
: resolveHatchProvider(configValues);
|
|
135
150
|
const instanceName = generateInstanceName(
|
|
136
151
|
species,
|
|
137
152
|
name ?? process.env.VELLUM_ASSISTANT_NAME,
|
|
@@ -168,7 +183,8 @@ export async function hatchLocal(
|
|
|
168
183
|
}
|
|
169
184
|
|
|
170
185
|
emitProgress(2, 6, "Writing configuration...");
|
|
171
|
-
const
|
|
186
|
+
const hatchConfigValues = buildHatchConfigValues(configValues, provider);
|
|
187
|
+
const defaultWorkspaceConfigPath = writeInitialConfig(hatchConfigValues);
|
|
172
188
|
|
|
173
189
|
emitProgress(3, 6, "Starting assistant...");
|
|
174
190
|
const signingKey = generateLocalSigningKey();
|
|
@@ -198,9 +214,11 @@ export async function hatchLocal(
|
|
|
198
214
|
emitProgress(5, 6, "Securing connection...");
|
|
199
215
|
const loopbackUrl = `http://127.0.0.1:${resources.gatewayPort}`;
|
|
200
216
|
const maxLeaseAttempts = 3;
|
|
217
|
+
let guardianAccessToken: string | undefined;
|
|
201
218
|
for (let attempt = 1; attempt <= maxLeaseAttempts; attempt++) {
|
|
202
219
|
try {
|
|
203
|
-
await leaseGuardianToken(loopbackUrl, instanceName);
|
|
220
|
+
const tokenData = await leaseGuardianToken(loopbackUrl, instanceName);
|
|
221
|
+
guardianAccessToken = tokenData.accessToken;
|
|
204
222
|
break;
|
|
205
223
|
} catch (err) {
|
|
206
224
|
if (attempt < maxLeaseAttempts) {
|
|
@@ -238,6 +256,26 @@ export async function hatchLocal(
|
|
|
238
256
|
installCLISymlink();
|
|
239
257
|
}
|
|
240
258
|
|
|
259
|
+
if (provider !== undefined && provider !== null && !guardianAccessToken) {
|
|
260
|
+
console.error(
|
|
261
|
+
`⚠️ Provider credential setup skipped because the guardian token was not leased.\n` +
|
|
262
|
+
` The assistant is still hatched. Run \`vellum setup --provider ${provider}\` after fixing the connection.`,
|
|
263
|
+
);
|
|
264
|
+
} else if (provider !== undefined) {
|
|
265
|
+
console.log("");
|
|
266
|
+
console.log(
|
|
267
|
+
provider === null
|
|
268
|
+
? "Checking provider credentials..."
|
|
269
|
+
: `Checking ${formatProviderName(provider)} credentials...`,
|
|
270
|
+
);
|
|
271
|
+
await configureHatchProviderApiKey({
|
|
272
|
+
gatewayUrl: loopbackUrl,
|
|
273
|
+
provider,
|
|
274
|
+
bearerToken: guardianAccessToken,
|
|
275
|
+
env: process.env,
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
|
|
241
279
|
console.log("");
|
|
242
280
|
console.log(`✅ Local assistant hatched!`);
|
|
243
281
|
console.log("");
|
|
@@ -245,6 +283,7 @@ export async function hatchLocal(
|
|
|
245
283
|
console.log(` Name: ${instanceName}`);
|
|
246
284
|
console.log(` Runtime: ${runtimeUrl}`);
|
|
247
285
|
console.log("");
|
|
286
|
+
logHatchNextSteps(console.log, instanceName);
|
|
248
287
|
|
|
249
288
|
if (keepAlive) {
|
|
250
289
|
const healthUrl = `http://127.0.0.1:${resources.gatewayPort}/healthz`;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export function logHatchNextSteps(
|
|
2
|
+
log: (message: string) => void,
|
|
3
|
+
instanceName: string,
|
|
4
|
+
): void {
|
|
5
|
+
log("Next steps:");
|
|
6
|
+
log(" vellum client");
|
|
7
|
+
log(' vellum message "hello"');
|
|
8
|
+
log(" vellum events");
|
|
9
|
+
log(" vellum ps");
|
|
10
|
+
log(` vellum use ${instanceName}`);
|
|
11
|
+
log("");
|
|
12
|
+
}
|
|
@@ -52,6 +52,19 @@ export interface GatewayApiKeyReadResult {
|
|
|
52
52
|
unreachable: boolean;
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
export interface HatchProviderApiKeyOptions {
|
|
56
|
+
gatewayUrl: string;
|
|
57
|
+
provider: LlmProviderId | null;
|
|
58
|
+
bearerToken?: string;
|
|
59
|
+
env?: NodeJS.ProcessEnv;
|
|
60
|
+
fetchImpl?: ProviderSecretFetch;
|
|
61
|
+
log?: (message: string) => void;
|
|
62
|
+
prompt?: (prompt: string) => Promise<string>;
|
|
63
|
+
stdinIsTTY?: boolean;
|
|
64
|
+
input?: NodeJS.ReadStream;
|
|
65
|
+
output?: NodeJS.WriteStream;
|
|
66
|
+
}
|
|
67
|
+
|
|
55
68
|
const PROVIDER_LABELS: Record<LlmProviderId, string> = {
|
|
56
69
|
anthropic: "Anthropic",
|
|
57
70
|
openai: "OpenAI",
|
|
@@ -70,6 +83,84 @@ export function isSupportedLlmProvider(
|
|
|
70
83
|
return Object.hasOwn(LLM_PROVIDER_ENV_VAR_NAMES, provider);
|
|
71
84
|
}
|
|
72
85
|
|
|
86
|
+
export function resolveHatchProvider(
|
|
87
|
+
configValues: Record<string, string | undefined>,
|
|
88
|
+
): LlmProviderId | null {
|
|
89
|
+
const provider = (
|
|
90
|
+
resolveConfiguredMainAgentProvider(configValues) || "anthropic"
|
|
91
|
+
).toLowerCase();
|
|
92
|
+
|
|
93
|
+
if (provider === "ollama") {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (!isSupportedLlmProvider(provider)) {
|
|
98
|
+
throw new Error(
|
|
99
|
+
`Provider '${provider}' does not have a supported API-key setup flow.`,
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return provider;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function resolveConfiguredMainAgentProvider(
|
|
107
|
+
configValues: Record<string, string | undefined>,
|
|
108
|
+
): string | undefined {
|
|
109
|
+
// Fresh hatches seed the active custom profile from llm.default and then
|
|
110
|
+
// that active profile wins over static mainAgent call-site defaults. Match
|
|
111
|
+
// that startup behavior so hatch prompts for the provider the assistant will
|
|
112
|
+
// actually use on first chat.
|
|
113
|
+
return (
|
|
114
|
+
resolveProfileProvider(
|
|
115
|
+
configValues,
|
|
116
|
+
readConfigValue(configValues, "llm.activeProfile"),
|
|
117
|
+
) ??
|
|
118
|
+
resolveFragmentProvider(configValues, "llm.default") ??
|
|
119
|
+
resolveFragmentProvider(configValues, "llm.callSites.mainAgent") ??
|
|
120
|
+
resolveProfileProvider(
|
|
121
|
+
configValues,
|
|
122
|
+
readConfigValue(configValues, "llm.callSites.mainAgent.profile"),
|
|
123
|
+
)
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function resolveProfileProvider(
|
|
128
|
+
configValues: Record<string, string | undefined>,
|
|
129
|
+
profileName: string | undefined,
|
|
130
|
+
): string | undefined {
|
|
131
|
+
if (!profileName) return undefined;
|
|
132
|
+
return resolveFragmentProvider(configValues, `llm.profiles.${profileName}`);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function resolveFragmentProvider(
|
|
136
|
+
configValues: Record<string, string | undefined>,
|
|
137
|
+
prefix: string,
|
|
138
|
+
): string | undefined {
|
|
139
|
+
const provider = readConfigValue(configValues, `${prefix}.provider`);
|
|
140
|
+
if (provider) return provider;
|
|
141
|
+
|
|
142
|
+
const model = readConfigValue(configValues, `${prefix}.model`);
|
|
143
|
+
return model ? inferProviderFromModel(model) : undefined;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function readConfigValue(
|
|
147
|
+
configValues: Record<string, string | undefined>,
|
|
148
|
+
key: string,
|
|
149
|
+
): string | undefined {
|
|
150
|
+
const value = configValues[key]?.trim();
|
|
151
|
+
return value && value.length > 0 ? value : undefined;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function inferProviderFromModel(model: string): string | undefined {
|
|
155
|
+
if (model.startsWith("claude-")) return "anthropic";
|
|
156
|
+
if (model.startsWith("gpt-")) return "openai";
|
|
157
|
+
if (model.startsWith("gemini-")) return "gemini";
|
|
158
|
+
if (model.startsWith("accounts/fireworks/models/")) return "fireworks";
|
|
159
|
+
if (model.includes("/")) return "openrouter";
|
|
160
|
+
if (model === "llama3.2" || model === "mistral") return "ollama";
|
|
161
|
+
return undefined;
|
|
162
|
+
}
|
|
163
|
+
|
|
73
164
|
function gatewayUrlWithPath(gatewayUrl: string, path: string): string {
|
|
74
165
|
return `${gatewayUrl.replace(/\/+$/, "")}${path}`;
|
|
75
166
|
}
|
|
@@ -411,3 +502,63 @@ export async function ensureProviderApiKey(
|
|
|
411
502
|
source,
|
|
412
503
|
};
|
|
413
504
|
}
|
|
505
|
+
|
|
506
|
+
export async function configureHatchProviderApiKey(
|
|
507
|
+
options: HatchProviderApiKeyOptions,
|
|
508
|
+
): Promise<void> {
|
|
509
|
+
const log = options.log ?? console.log;
|
|
510
|
+
const { provider } = options;
|
|
511
|
+
|
|
512
|
+
if (provider === null) {
|
|
513
|
+
log("Provider credentials not required for the selected provider.");
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
try {
|
|
518
|
+
const result = await ensureProviderApiKey({
|
|
519
|
+
gatewayUrl: options.gatewayUrl,
|
|
520
|
+
provider,
|
|
521
|
+
bearerToken: options.bearerToken,
|
|
522
|
+
env: options.env,
|
|
523
|
+
fetchImpl: options.fetchImpl,
|
|
524
|
+
prompt: options.prompt,
|
|
525
|
+
stdinIsTTY: options.stdinIsTTY,
|
|
526
|
+
input: options.input,
|
|
527
|
+
output: options.output,
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
if (result.status === "already_configured") {
|
|
531
|
+
log(
|
|
532
|
+
`Provider credentials already configured for ${formatProviderName(result.provider)}.`,
|
|
533
|
+
);
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
if (result.status === "configured") {
|
|
538
|
+
if (result.source === "env") {
|
|
539
|
+
log(
|
|
540
|
+
`Configured ${formatProviderName(result.provider)} credentials from ${LLM_PROVIDER_ENV_VAR_NAMES[result.provider]}.`,
|
|
541
|
+
);
|
|
542
|
+
} else {
|
|
543
|
+
log(`Configured ${formatProviderName(result.provider)} credentials.`);
|
|
544
|
+
}
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
if (result.status === "skipped") {
|
|
549
|
+
log(result.message);
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
log(
|
|
554
|
+
`⚠️ Provider credential setup skipped: ${result.message}\n` +
|
|
555
|
+
` The assistant is still hatched. Run \`vellum setup --provider ${provider}\` to finish setup.`,
|
|
556
|
+
);
|
|
557
|
+
} catch (error) {
|
|
558
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
559
|
+
log(
|
|
560
|
+
`⚠️ Provider credential setup failed: ${message}\n` +
|
|
561
|
+
` The assistant is still hatched. Run \`vellum setup --provider ${provider}\` after fixing the issue.`,
|
|
562
|
+
);
|
|
563
|
+
}
|
|
564
|
+
}
|
|
@@ -26,9 +26,6 @@ export const LLM_PROVIDER_ENV_VAR_NAMES: Record<string, string> = {
|
|
|
26
26
|
gemini: "GEMINI_API_KEY",
|
|
27
27
|
fireworks: "FIREWORKS_API_KEY",
|
|
28
28
|
openrouter: "OPENROUTER_API_KEY",
|
|
29
|
-
zai: "ZAI_API_KEY",
|
|
30
|
-
deepseek: "DEEPSEEK_API_KEY",
|
|
31
|
-
minimax: "MINIMAX_API_KEY",
|
|
32
29
|
};
|
|
33
30
|
|
|
34
31
|
/** Search-provider env var names. Mirrors `SEARCH_PROVIDER_CATALOG` BYOK entries. */
|