@vellumai/cli 0.5.3 → 0.5.5
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/AGENTS.md +12 -0
- package/package.json +1 -1
- package/src/__tests__/assistant-config.test.ts +46 -0
- package/src/adapters/openclaw.ts +4 -2
- package/src/commands/backup.ts +151 -0
- package/src/commands/hatch.ts +14 -4
- package/src/commands/restore.ts +310 -0
- package/src/commands/sleep.ts +9 -1
- package/src/commands/ssh.ts +11 -1
- package/src/commands/upgrade.ts +51 -0
- package/src/commands/wake.ts +10 -1
- package/src/index.ts +6 -0
- package/src/lib/assistant-config.ts +29 -0
- package/src/lib/aws.ts +13 -5
- package/src/lib/constants.ts +12 -0
- package/src/lib/docker.ts +250 -11
- package/src/lib/gcp.ts +18 -6
package/src/lib/docker.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { randomBytes } from "crypto";
|
|
1
2
|
import { chmodSync, existsSync, mkdirSync, watch as fsWatch } from "fs";
|
|
2
3
|
import { arch, platform } from "os";
|
|
3
4
|
import { dirname, join } from "path";
|
|
@@ -11,7 +12,7 @@ import {
|
|
|
11
12
|
setActiveAssistant,
|
|
12
13
|
} from "./assistant-config";
|
|
13
14
|
import type { AssistantEntry } from "./assistant-config";
|
|
14
|
-
import { DEFAULT_GATEWAY_PORT } from "./constants";
|
|
15
|
+
import { DEFAULT_GATEWAY_PORT, PROVIDER_ENV_VAR_NAMES } from "./constants";
|
|
15
16
|
import type { Species } from "./constants";
|
|
16
17
|
import { leaseGuardianToken } from "./guardian-token";
|
|
17
18
|
import { isVellumProcess, stopProcess } from "./process";
|
|
@@ -282,10 +283,14 @@ export function dockerResourceNames(instanceName: string) {
|
|
|
282
283
|
return {
|
|
283
284
|
assistantContainer: `${instanceName}-assistant`,
|
|
284
285
|
cesContainer: `${instanceName}-credential-executor`,
|
|
286
|
+
cesSecurityVolume: `${instanceName}-ces-sec`,
|
|
287
|
+
/** @deprecated Legacy — no longer created for new instances. Retained for migration of existing instances. */
|
|
285
288
|
dataVolume: `${instanceName}-data`,
|
|
286
289
|
gatewayContainer: `${instanceName}-gateway`,
|
|
290
|
+
gatewaySecurityVolume: `${instanceName}-gateway-sec`,
|
|
287
291
|
network: `${instanceName}-net`,
|
|
288
292
|
socketVolume: `${instanceName}-socket`,
|
|
293
|
+
workspaceVolume: `${instanceName}-workspace`,
|
|
289
294
|
};
|
|
290
295
|
}
|
|
291
296
|
|
|
@@ -335,7 +340,13 @@ export async function retireDocker(name: string): Promise<void> {
|
|
|
335
340
|
} catch {
|
|
336
341
|
// network may not exist
|
|
337
342
|
}
|
|
338
|
-
for (const vol of [
|
|
343
|
+
for (const vol of [
|
|
344
|
+
res.dataVolume,
|
|
345
|
+
res.socketVolume,
|
|
346
|
+
res.workspaceVolume,
|
|
347
|
+
res.cesSecurityVolume,
|
|
348
|
+
res.gatewaySecurityVolume,
|
|
349
|
+
]) {
|
|
339
350
|
try {
|
|
340
351
|
await exec("docker", ["volume", "rm", vol]);
|
|
341
352
|
} catch {
|
|
@@ -453,13 +464,21 @@ async function buildAllImages(
|
|
|
453
464
|
* can be restarted independently.
|
|
454
465
|
*/
|
|
455
466
|
export function serviceDockerRunArgs(opts: {
|
|
467
|
+
cesServiceToken?: string;
|
|
456
468
|
extraAssistantEnv?: Record<string, string>;
|
|
457
469
|
gatewayPort: number;
|
|
458
470
|
imageTags: Record<ServiceName, string>;
|
|
459
471
|
instanceName: string;
|
|
460
472
|
res: ReturnType<typeof dockerResourceNames>;
|
|
461
473
|
}): Record<ServiceName, () => string[]> {
|
|
462
|
-
const {
|
|
474
|
+
const {
|
|
475
|
+
cesServiceToken,
|
|
476
|
+
extraAssistantEnv,
|
|
477
|
+
gatewayPort,
|
|
478
|
+
imageTags,
|
|
479
|
+
instanceName,
|
|
480
|
+
res,
|
|
481
|
+
} = opts;
|
|
463
482
|
return {
|
|
464
483
|
assistant: () => {
|
|
465
484
|
const args: string[] = [
|
|
@@ -470,15 +489,27 @@ export function serviceDockerRunArgs(opts: {
|
|
|
470
489
|
res.assistantContainer,
|
|
471
490
|
`--network=${res.network}`,
|
|
472
491
|
"-v",
|
|
473
|
-
`${res.
|
|
492
|
+
`${res.workspaceVolume}:/workspace`,
|
|
474
493
|
"-v",
|
|
475
494
|
`${res.socketVolume}:/run/ces-bootstrap`,
|
|
476
495
|
"-e",
|
|
477
496
|
`VELLUM_ASSISTANT_NAME=${instanceName}`,
|
|
478
497
|
"-e",
|
|
479
498
|
"RUNTIME_HTTP_HOST=0.0.0.0",
|
|
499
|
+
"-e",
|
|
500
|
+
"WORKSPACE_DIR=/workspace",
|
|
501
|
+
"-e",
|
|
502
|
+
`CES_CREDENTIAL_URL=http://${res.cesContainer}:8090`,
|
|
503
|
+
"-e",
|
|
504
|
+
`GATEWAY_INTERNAL_URL=http://${res.gatewayContainer}:${GATEWAY_INTERNAL_PORT}`,
|
|
480
505
|
];
|
|
481
|
-
|
|
506
|
+
if (cesServiceToken) {
|
|
507
|
+
args.push("-e", `CES_SERVICE_TOKEN=${cesServiceToken}`);
|
|
508
|
+
}
|
|
509
|
+
for (const envVar of [
|
|
510
|
+
...Object.values(PROVIDER_ENV_VAR_NAMES),
|
|
511
|
+
"VELLUM_PLATFORM_URL",
|
|
512
|
+
]) {
|
|
482
513
|
if (process.env[envVar]) {
|
|
483
514
|
args.push("-e", `${envVar}=${process.env[envVar]}`);
|
|
484
515
|
}
|
|
@@ -501,9 +532,13 @@ export function serviceDockerRunArgs(opts: {
|
|
|
501
532
|
"-p",
|
|
502
533
|
`${gatewayPort}:${GATEWAY_INTERNAL_PORT}`,
|
|
503
534
|
"-v",
|
|
504
|
-
`${res.
|
|
535
|
+
`${res.workspaceVolume}:/workspace`,
|
|
536
|
+
"-v",
|
|
537
|
+
`${res.gatewaySecurityVolume}:/gateway-security`,
|
|
505
538
|
"-e",
|
|
506
|
-
"
|
|
539
|
+
"WORKSPACE_DIR=/workspace",
|
|
540
|
+
"-e",
|
|
541
|
+
"GATEWAY_SECURITY_DIR=/gateway-security",
|
|
507
542
|
"-e",
|
|
508
543
|
`GATEWAY_PORT=${GATEWAY_INTERNAL_PORT}`,
|
|
509
544
|
"-e",
|
|
@@ -512,6 +547,11 @@ export function serviceDockerRunArgs(opts: {
|
|
|
512
547
|
`RUNTIME_HTTP_PORT=${ASSISTANT_INTERNAL_PORT}`,
|
|
513
548
|
"-e",
|
|
514
549
|
"RUNTIME_PROXY_ENABLED=true",
|
|
550
|
+
"-e",
|
|
551
|
+
`CES_CREDENTIAL_URL=http://${res.cesContainer}:8090`,
|
|
552
|
+
...(cesServiceToken
|
|
553
|
+
? ["-e", `CES_SERVICE_TOKEN=${cesServiceToken}`]
|
|
554
|
+
: []),
|
|
515
555
|
imageTags.gateway,
|
|
516
556
|
],
|
|
517
557
|
"credential-executor": () => [
|
|
@@ -520,21 +560,171 @@ export function serviceDockerRunArgs(opts: {
|
|
|
520
560
|
"-d",
|
|
521
561
|
"--name",
|
|
522
562
|
res.cesContainer,
|
|
563
|
+
`--network=${res.network}`,
|
|
523
564
|
"-v",
|
|
524
565
|
`${res.socketVolume}:/run/ces-bootstrap`,
|
|
525
566
|
"-v",
|
|
526
|
-
`${res.
|
|
567
|
+
`${res.workspaceVolume}:/workspace:ro`,
|
|
568
|
+
"-v",
|
|
569
|
+
`${res.cesSecurityVolume}:/ces-security`,
|
|
527
570
|
"-e",
|
|
528
571
|
"CES_MODE=managed",
|
|
529
572
|
"-e",
|
|
573
|
+
"WORKSPACE_DIR=/workspace",
|
|
574
|
+
"-e",
|
|
530
575
|
"CES_BOOTSTRAP_SOCKET_DIR=/run/ces-bootstrap",
|
|
531
576
|
"-e",
|
|
532
|
-
"
|
|
577
|
+
"CREDENTIAL_SECURITY_DIR=/ces-security",
|
|
578
|
+
...(cesServiceToken
|
|
579
|
+
? ["-e", `CES_SERVICE_TOKEN=${cesServiceToken}`]
|
|
580
|
+
: []),
|
|
533
581
|
imageTags["credential-executor"],
|
|
534
582
|
],
|
|
535
583
|
};
|
|
536
584
|
}
|
|
537
585
|
|
|
586
|
+
/**
|
|
587
|
+
* Check whether a Docker volume exists.
|
|
588
|
+
* Returns true if the volume exists, false otherwise.
|
|
589
|
+
*/
|
|
590
|
+
async function dockerVolumeExists(volumeName: string): Promise<boolean> {
|
|
591
|
+
try {
|
|
592
|
+
await execOutput("docker", ["volume", "inspect", volumeName]);
|
|
593
|
+
return true;
|
|
594
|
+
} catch {
|
|
595
|
+
return false;
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/**
|
|
600
|
+
* Migrate trust.json and actor-token-signing-key from the data volume
|
|
601
|
+
* (old location: /data/.vellum/protected/) to the gateway security volume
|
|
602
|
+
* (new location: /gateway-security/).
|
|
603
|
+
*
|
|
604
|
+
* Uses a temporary busybox container that mounts both volumes. The migration
|
|
605
|
+
* is idempotent: it only copies a file when the source exists on the data
|
|
606
|
+
* volume and the destination does not yet exist on the gateway security volume.
|
|
607
|
+
*
|
|
608
|
+
* Skips migration entirely if the data volume does not exist (new instances
|
|
609
|
+
* no longer create one).
|
|
610
|
+
*/
|
|
611
|
+
export async function migrateGatewaySecurityFiles(
|
|
612
|
+
res: ReturnType<typeof dockerResourceNames>,
|
|
613
|
+
log: (msg: string) => void,
|
|
614
|
+
): Promise<void> {
|
|
615
|
+
// New instances don't have a data volume — nothing to migrate.
|
|
616
|
+
if (!(await dockerVolumeExists(res.dataVolume))) {
|
|
617
|
+
log(" No data volume found — skipping gateway security migration.");
|
|
618
|
+
return;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
const migrationContainer = `${res.gatewayContainer}-migration`;
|
|
622
|
+
const filesToMigrate = ["trust.json", "actor-token-signing-key"];
|
|
623
|
+
|
|
624
|
+
// Remove any leftover migration container from a previous interrupted run.
|
|
625
|
+
try {
|
|
626
|
+
await exec("docker", ["rm", "-f", migrationContainer]);
|
|
627
|
+
} catch {
|
|
628
|
+
// container may not exist
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
for (const fileName of filesToMigrate) {
|
|
632
|
+
const src = `/data/.vellum/protected/${fileName}`;
|
|
633
|
+
const dst = `/gateway-security/${fileName}`;
|
|
634
|
+
|
|
635
|
+
try {
|
|
636
|
+
// Run a busybox container that checks source exists and destination
|
|
637
|
+
// does not, then copies. The shell exits 0 whether or not a copy
|
|
638
|
+
// happens, so the migration is always safe to re-run.
|
|
639
|
+
await exec("docker", [
|
|
640
|
+
"run",
|
|
641
|
+
"--rm",
|
|
642
|
+
"--name",
|
|
643
|
+
migrationContainer,
|
|
644
|
+
"-v",
|
|
645
|
+
`${res.dataVolume}:/data:ro`,
|
|
646
|
+
"-v",
|
|
647
|
+
`${res.gatewaySecurityVolume}:/gateway-security`,
|
|
648
|
+
"busybox",
|
|
649
|
+
"sh",
|
|
650
|
+
"-c",
|
|
651
|
+
`if [ -f "${src}" ] && [ ! -f "${dst}" ]; then cp "${src}" "${dst}" && echo "migrated"; else echo "skipped"; fi`,
|
|
652
|
+
]);
|
|
653
|
+
log(` ${fileName}: checked`);
|
|
654
|
+
} catch (err) {
|
|
655
|
+
// Non-fatal — log and continue. The gateway will create fresh files
|
|
656
|
+
// if they don't exist.
|
|
657
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
658
|
+
log(` ${fileName}: migration failed (${message}), continuing...`);
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
/**
|
|
664
|
+
* Migrate keys.enc and store.key from the data volume
|
|
665
|
+
* (old location: /data/.vellum/protected/) to the CES security volume
|
|
666
|
+
* (new location: /ces-security/).
|
|
667
|
+
*
|
|
668
|
+
* Uses a temporary busybox container that mounts both volumes. The migration
|
|
669
|
+
* is idempotent: it only copies a file when the source exists on the data
|
|
670
|
+
* volume and the destination does not yet exist on the CES security volume.
|
|
671
|
+
* Migrated files are chowned to 1001:1001 (the CES service user).
|
|
672
|
+
*
|
|
673
|
+
* Skips migration entirely if the data volume does not exist (new instances
|
|
674
|
+
* no longer create one).
|
|
675
|
+
*/
|
|
676
|
+
export async function migrateCesSecurityFiles(
|
|
677
|
+
res: ReturnType<typeof dockerResourceNames>,
|
|
678
|
+
log: (msg: string) => void,
|
|
679
|
+
): Promise<void> {
|
|
680
|
+
// New instances don't have a data volume — nothing to migrate.
|
|
681
|
+
if (!(await dockerVolumeExists(res.dataVolume))) {
|
|
682
|
+
log(" No data volume found — skipping CES security migration.");
|
|
683
|
+
return;
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
const migrationContainer = `${res.cesContainer}-migration`;
|
|
687
|
+
const filesToMigrate = ["keys.enc", "store.key"];
|
|
688
|
+
|
|
689
|
+
// Remove any leftover migration container from a previous interrupted run.
|
|
690
|
+
try {
|
|
691
|
+
await exec("docker", ["rm", "-f", migrationContainer]);
|
|
692
|
+
} catch {
|
|
693
|
+
// container may not exist
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
for (const fileName of filesToMigrate) {
|
|
697
|
+
const src = `/data/.vellum/protected/${fileName}`;
|
|
698
|
+
const dst = `/ces-security/${fileName}`;
|
|
699
|
+
|
|
700
|
+
try {
|
|
701
|
+
// Run a busybox container that checks source exists and destination
|
|
702
|
+
// does not, then copies and sets ownership. The shell exits 0 whether
|
|
703
|
+
// or not a copy happens, so the migration is always safe to re-run.
|
|
704
|
+
await exec("docker", [
|
|
705
|
+
"run",
|
|
706
|
+
"--rm",
|
|
707
|
+
"--name",
|
|
708
|
+
migrationContainer,
|
|
709
|
+
"-v",
|
|
710
|
+
`${res.dataVolume}:/data:ro`,
|
|
711
|
+
"-v",
|
|
712
|
+
`${res.cesSecurityVolume}:/ces-security`,
|
|
713
|
+
"busybox",
|
|
714
|
+
"sh",
|
|
715
|
+
"-c",
|
|
716
|
+
`if [ -f "${src}" ] && [ ! -f "${dst}" ]; then cp "${src}" "${dst}" && chown 1001:1001 "${dst}" && echo "migrated"; else echo "skipped"; fi`,
|
|
717
|
+
]);
|
|
718
|
+
log(` ${fileName}: checked`);
|
|
719
|
+
} catch (err) {
|
|
720
|
+
// Non-fatal — log and continue. The CES will start without
|
|
721
|
+
// credentials if they don't exist.
|
|
722
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
723
|
+
log(` ${fileName}: migration failed (${message}), continuing...`);
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
|
|
538
728
|
/** The order in which services must be started. */
|
|
539
729
|
export const SERVICE_START_ORDER: ServiceName[] = [
|
|
540
730
|
"assistant",
|
|
@@ -545,6 +735,7 @@ export const SERVICE_START_ORDER: ServiceName[] = [
|
|
|
545
735
|
/** Start all three containers in dependency order. */
|
|
546
736
|
export async function startContainers(
|
|
547
737
|
opts: {
|
|
738
|
+
cesServiceToken?: string;
|
|
548
739
|
extraAssistantEnv?: Record<string, string>;
|
|
549
740
|
gatewayPort: number;
|
|
550
741
|
imageTags: Record<ServiceName, string>;
|
|
@@ -569,6 +760,36 @@ export async function stopContainers(
|
|
|
569
760
|
await removeContainer(res.assistantContainer);
|
|
570
761
|
}
|
|
571
762
|
|
|
763
|
+
/** Stop containers without removing them (preserves state for `docker start`). */
|
|
764
|
+
export async function sleepContainers(
|
|
765
|
+
res: ReturnType<typeof dockerResourceNames>,
|
|
766
|
+
): Promise<void> {
|
|
767
|
+
for (const container of [
|
|
768
|
+
res.cesContainer,
|
|
769
|
+
res.gatewayContainer,
|
|
770
|
+
res.assistantContainer,
|
|
771
|
+
]) {
|
|
772
|
+
try {
|
|
773
|
+
await exec("docker", ["stop", container]);
|
|
774
|
+
} catch {
|
|
775
|
+
// container may not exist or already stopped
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
/** Start existing stopped containers. */
|
|
781
|
+
export async function wakeContainers(
|
|
782
|
+
res: ReturnType<typeof dockerResourceNames>,
|
|
783
|
+
): Promise<void> {
|
|
784
|
+
for (const container of [
|
|
785
|
+
res.assistantContainer,
|
|
786
|
+
res.gatewayContainer,
|
|
787
|
+
res.cesContainer,
|
|
788
|
+
]) {
|
|
789
|
+
await exec("docker", ["start", container]);
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
|
|
572
793
|
/**
|
|
573
794
|
* Capture the current image references for running service containers.
|
|
574
795
|
* Returns a complete record of service → immutable image ID (sha256 digest)
|
|
@@ -845,10 +1066,18 @@ export async function hatchDocker(
|
|
|
845
1066
|
|
|
846
1067
|
log("📁 Creating network and volumes...");
|
|
847
1068
|
await exec("docker", ["network", "create", res.network]);
|
|
848
|
-
await exec("docker", ["volume", "create", res.dataVolume]);
|
|
849
1069
|
await exec("docker", ["volume", "create", res.socketVolume]);
|
|
1070
|
+
await exec("docker", ["volume", "create", res.workspaceVolume]);
|
|
1071
|
+
await exec("docker", ["volume", "create", res.cesSecurityVolume]);
|
|
1072
|
+
await exec("docker", ["volume", "create", res.gatewaySecurityVolume]);
|
|
1073
|
+
|
|
1074
|
+
const cesServiceToken = randomBytes(32).toString("hex");
|
|
1075
|
+
await startContainers(
|
|
1076
|
+
{ cesServiceToken, gatewayPort, imageTags, instanceName, res },
|
|
1077
|
+
log,
|
|
1078
|
+
);
|
|
850
1079
|
|
|
851
|
-
|
|
1080
|
+
const imageDigests = await captureImageRefs(res);
|
|
852
1081
|
|
|
853
1082
|
const runtimeUrl = `http://localhost:${gatewayPort}`;
|
|
854
1083
|
const dockerEntry: AssistantEntry = {
|
|
@@ -858,6 +1087,16 @@ export async function hatchDocker(
|
|
|
858
1087
|
species,
|
|
859
1088
|
hatchedAt: new Date().toISOString(),
|
|
860
1089
|
volume: res.dataVolume,
|
|
1090
|
+
serviceGroupVersion: cliPkg.version ? `v${cliPkg.version}` : undefined,
|
|
1091
|
+
containerInfo: {
|
|
1092
|
+
assistantImage: imageTags.assistant,
|
|
1093
|
+
gatewayImage: imageTags.gateway,
|
|
1094
|
+
cesImage: imageTags["credential-executor"],
|
|
1095
|
+
assistantDigest: imageDigests?.assistant,
|
|
1096
|
+
gatewayDigest: imageDigests?.gateway,
|
|
1097
|
+
cesDigest: imageDigests?.["credential-executor"],
|
|
1098
|
+
networkName: res.network,
|
|
1099
|
+
},
|
|
861
1100
|
};
|
|
862
1101
|
saveAssistantEntry(dockerEntry);
|
|
863
1102
|
setActiveAssistant(instanceName);
|
package/src/lib/gcp.ts
CHANGED
|
@@ -4,7 +4,11 @@ import { join } from "path";
|
|
|
4
4
|
|
|
5
5
|
import { saveAssistantEntry, setActiveAssistant } from "./assistant-config";
|
|
6
6
|
import type { AssistantEntry } from "./assistant-config";
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
FIREWALL_TAG,
|
|
9
|
+
GATEWAY_PORT,
|
|
10
|
+
PROVIDER_ENV_VAR_NAMES,
|
|
11
|
+
} from "./constants";
|
|
8
12
|
import type { Species } from "./constants";
|
|
9
13
|
import { leaseGuardianToken } from "./guardian-token";
|
|
10
14
|
import { generateInstanceName } from "./random-name";
|
|
@@ -448,7 +452,7 @@ export async function hatchGcp(
|
|
|
448
452
|
buildStartupScript: (
|
|
449
453
|
species: Species,
|
|
450
454
|
sshUser: string,
|
|
451
|
-
|
|
455
|
+
providerApiKeys: Record<string, string>,
|
|
452
456
|
instanceName: string,
|
|
453
457
|
cloud: "gcp",
|
|
454
458
|
) => Promise<string>,
|
|
@@ -500,17 +504,25 @@ export async function hatchGcp(
|
|
|
500
504
|
|
|
501
505
|
const sshUser = userInfo().username;
|
|
502
506
|
const hatchedBy = process.env.VELLUM_HATCHED_BY;
|
|
503
|
-
const
|
|
504
|
-
|
|
507
|
+
const providerApiKeys: Record<string, string> = {};
|
|
508
|
+
for (const [, envVar] of Object.entries(PROVIDER_ENV_VAR_NAMES)) {
|
|
509
|
+
const value = process.env[envVar];
|
|
510
|
+
if (value) {
|
|
511
|
+
providerApiKeys[envVar] = value;
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
if (Object.keys(providerApiKeys).length === 0) {
|
|
505
515
|
console.error(
|
|
506
|
-
"Error:
|
|
516
|
+
"Error: No provider API key environment variable is set. " +
|
|
517
|
+
"Set at least one of: " +
|
|
518
|
+
Object.values(PROVIDER_ENV_VAR_NAMES).join(", "),
|
|
507
519
|
);
|
|
508
520
|
process.exit(1);
|
|
509
521
|
}
|
|
510
522
|
const startupScript = await buildStartupScript(
|
|
511
523
|
species,
|
|
512
524
|
sshUser,
|
|
513
|
-
|
|
525
|
+
providerApiKeys,
|
|
514
526
|
instanceName,
|
|
515
527
|
"gcp",
|
|
516
528
|
);
|