@vellumai/cli 0.7.0 → 0.7.1

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.
Files changed (40) hide show
  1. package/README.md +49 -0
  2. package/package.json +1 -1
  3. package/src/__tests__/backup.test.ts +475 -0
  4. package/src/__tests__/config-utils.test.ts +35 -48
  5. package/src/__tests__/teleport.test.ts +86 -28
  6. package/src/commands/backup.ts +117 -71
  7. package/src/commands/client.ts +10 -9
  8. package/src/commands/exec.ts +21 -8
  9. package/src/commands/hatch.ts +2 -6
  10. package/src/commands/login.ts +15 -33
  11. package/src/commands/logs.ts +2 -7
  12. package/src/commands/ps.ts +41 -6
  13. package/src/commands/restore.ts +26 -47
  14. package/src/commands/ssh.ts +2 -5
  15. package/src/commands/teleport.ts +38 -24
  16. package/src/commands/tunnel.ts +2 -7
  17. package/src/commands/upgrade.ts +108 -7
  18. package/src/components/DefaultMainScreen.tsx +25 -3
  19. package/src/index.ts +2 -7
  20. package/src/lib/__tests__/local-runtime-client.test.ts +122 -25
  21. package/src/lib/__tests__/platform-client-signed-url.test.ts +2 -2
  22. package/src/lib/__tests__/runtime-url.test.ts +87 -0
  23. package/src/lib/__tests__/terminal-session.test.ts +202 -0
  24. package/src/lib/assistant-client.ts +5 -21
  25. package/src/lib/assistant-config.ts +34 -16
  26. package/src/lib/cli-error.ts +1 -0
  27. package/src/lib/client-identity.ts +1 -1
  28. package/src/lib/config-utils.ts +1 -97
  29. package/src/lib/docker.ts +2 -2
  30. package/src/lib/job-polling.ts +1 -1
  31. package/src/lib/local-runtime-client.ts +81 -28
  32. package/src/lib/local.ts +27 -58
  33. package/src/lib/platform-client.ts +1 -220
  34. package/src/lib/platform-releases.ts +23 -0
  35. package/src/lib/runtime-url.ts +30 -0
  36. package/src/lib/sync-cloud-assistants.ts +126 -0
  37. package/src/lib/terminal-client.ts +6 -1
  38. package/src/lib/terminal-session.ts +127 -48
  39. package/src/lib/tui-log.ts +60 -0
  40. package/src/lib/xdg-log.ts +10 -4
@@ -665,182 +665,10 @@ export async function rollbackPlatformAssistant(
665
665
  throw new Error(`Rollback failed: ${response.status} ${response.statusText}`);
666
666
  }
667
667
 
668
- // ---------------------------------------------------------------------------
669
- // Migration export
670
- // ---------------------------------------------------------------------------
671
-
672
- export async function platformInitiateExport(
673
- token: string,
674
- description?: string,
675
- platformUrl?: string,
676
- ): Promise<{ jobId: string; status: string }> {
677
- const resolvedUrl = platformUrl || getPlatformUrl();
678
- const response = await fetch(`${resolvedUrl}/v1/migrations/export/`, {
679
- method: "POST",
680
- headers: await authHeaders(token, platformUrl),
681
- body: JSON.stringify({ description: description ?? "CLI backup" }),
682
- });
683
-
684
- if (response.status !== 201) {
685
- const body = (await response.json().catch(() => ({}))) as {
686
- detail?: string;
687
- };
688
- throw new Error(
689
- body.detail ??
690
- `Export initiation failed: ${response.status} ${response.statusText}`,
691
- );
692
- }
693
-
694
- const body = (await response.json()) as {
695
- job_id: string;
696
- status: string;
697
- };
698
- return { jobId: body.job_id, status: body.status };
699
- }
700
-
701
- export async function platformPollExportStatus(
702
- jobId: string,
703
- token: string,
704
- platformUrl?: string,
705
- ): Promise<{ status: string; downloadUrl?: string; error?: string }> {
706
- const resolvedUrl = platformUrl || getPlatformUrl();
707
- const response = await fetch(
708
- `${resolvedUrl}/v1/migrations/export/${jobId}/status/`,
709
- {
710
- headers: await authHeaders(token, platformUrl),
711
- },
712
- );
713
-
714
- if (response.status === 404) {
715
- throw new Error("Export job not found");
716
- }
717
-
718
- if (!response.ok) {
719
- throw new Error(
720
- `Export status check failed: ${response.status} ${response.statusText}`,
721
- );
722
- }
723
-
724
- const body = (await response.json()) as {
725
- status: string;
726
- download_url?: string;
727
- error?: string;
728
- };
729
- return {
730
- status: body.status,
731
- downloadUrl: body.download_url,
732
- error: body.error,
733
- };
734
- }
735
-
736
- export async function platformDownloadExport(
737
- downloadUrl: string,
738
- ): Promise<Response> {
739
- const response = await fetch(downloadUrl);
740
- if (!response.ok) {
741
- throw new Error(
742
- `Download failed: ${response.status} ${response.statusText}`,
743
- );
744
- }
745
- return response;
746
- }
747
-
748
- // ---------------------------------------------------------------------------
749
- // Migration import
750
- // ---------------------------------------------------------------------------
751
-
752
- export async function platformImportPreflight(
753
- bundleData: Uint8Array<ArrayBuffer>,
754
- token: string,
755
- platformUrl?: string,
756
- ): Promise<{ statusCode: number; body: Record<string, unknown> }> {
757
- const resolvedUrl = platformUrl || getPlatformUrl();
758
- const response = await fetch(
759
- `${resolvedUrl}/v1/migrations/import-preflight/`,
760
- {
761
- method: "POST",
762
- headers: {
763
- ...(await authHeaders(token, platformUrl)),
764
- "Content-Type": "application/octet-stream",
765
- },
766
- body: new Blob([bundleData]),
767
- signal: AbortSignal.timeout(120_000),
768
- },
769
- );
770
-
771
- const body = (await response.json().catch(() => ({}))) as Record<
772
- string,
773
- unknown
774
- >;
775
- return { statusCode: response.status, body };
776
- }
777
-
778
- export async function platformImportBundle(
779
- bundleData: Uint8Array<ArrayBuffer>,
780
- token: string,
781
- platformUrl?: string,
782
- ): Promise<{ statusCode: number; body: Record<string, unknown> }> {
783
- const resolvedUrl = platformUrl || getPlatformUrl();
784
- const response = await fetch(`${resolvedUrl}/v1/migrations/import/`, {
785
- method: "POST",
786
- headers: {
787
- ...(await authHeaders(token, platformUrl)),
788
- "Content-Type": "application/octet-stream",
789
- },
790
- body: new Blob([bundleData]),
791
- signal: AbortSignal.timeout(300_000),
792
- });
793
-
794
- const body = (await response.json().catch(() => ({}))) as Record<
795
- string,
796
- unknown
797
- >;
798
- return { statusCode: response.status, body };
799
- }
800
-
801
668
  // ---------------------------------------------------------------------------
802
669
  // Signed-URL upload flow
803
670
  // ---------------------------------------------------------------------------
804
671
 
805
- export async function platformRequestUploadUrl(
806
- token: string,
807
- platformUrl?: string,
808
- ): Promise<{ uploadUrl: string; bundleKey: string; expiresAt: string }> {
809
- const resolvedUrl = platformUrl || getPlatformUrl();
810
- const response = await fetch(`${resolvedUrl}/v1/migrations/upload-url/`, {
811
- method: "POST",
812
- headers: await authHeaders(token, platformUrl),
813
- body: JSON.stringify({ content_type: "application/octet-stream" }),
814
- });
815
-
816
- if (response.status === 201) {
817
- const body = (await response.json()) as {
818
- upload_url: string;
819
- bundle_key: string;
820
- expires_at: string;
821
- };
822
- return {
823
- uploadUrl: body.upload_url,
824
- bundleKey: body.bundle_key,
825
- expiresAt: body.expires_at,
826
- };
827
- }
828
-
829
- if (response.status === 404 || response.status === 503) {
830
- throw new Error(
831
- "Signed uploads are not available on this platform instance",
832
- );
833
- }
834
-
835
- const errorBody = (await response.json().catch(() => ({}))) as {
836
- detail?: string;
837
- };
838
- throw new Error(
839
- errorBody.detail ??
840
- `Failed to request upload URL: ${response.status} ${response.statusText}`,
841
- );
842
- }
843
-
844
672
  export async function platformUploadToSignedUrl(
845
673
  uploadUrl: string,
846
674
  bundleData: Uint8Array<ArrayBuffer>,
@@ -911,46 +739,6 @@ export async function platformImportBundleFromGcs(
911
739
  return { statusCode: response.status, body };
912
740
  }
913
741
 
914
- export async function platformPollImportStatus(
915
- jobId: string,
916
- token: string,
917
- platformUrl?: string,
918
- ): Promise<{
919
- status: string;
920
- result?: Record<string, unknown>;
921
- error?: string;
922
- }> {
923
- const resolvedUrl = platformUrl || getPlatformUrl();
924
- const response = await fetch(
925
- `${resolvedUrl}/v1/migrations/import/${jobId}/status/`,
926
- {
927
- headers: await authHeaders(token, platformUrl),
928
- },
929
- );
930
-
931
- if (response.status === 404) {
932
- throw new Error("Import job not found");
933
- }
934
-
935
- if (!response.ok) {
936
- throw new Error(
937
- `Import status check failed: ${response.status} ${response.statusText}`,
938
- );
939
- }
940
-
941
- const body = (await response.json()) as {
942
- status: string;
943
- job_id?: string;
944
- result?: Record<string, unknown>;
945
- error?: string;
946
- };
947
- return {
948
- status: body.status,
949
- result: body.result,
950
- error: body.error,
951
- };
952
- }
953
-
954
742
  // ---------------------------------------------------------------------------
955
743
  // Unified signed-url + job-status endpoints (teleport-gcs-unify)
956
744
  // ---------------------------------------------------------------------------
@@ -1027,8 +815,7 @@ export function parseUnifiedJobStatus(
1027
815
  * runtime can GET the bundle from during an import-from-GCS flow.
1028
816
  *
1029
817
  * Retries once with a fresh org-ID cache on 401 to match the retry pattern
1030
- * used by other authenticated platform helpers. 503 is bubbled up so
1031
- * callers can decide to fall back (e.g. legacy inline upload).
818
+ * used by other authenticated platform helpers.
1032
819
  */
1033
820
  export async function platformRequestSignedUrl(
1034
821
  params: {
@@ -1086,12 +873,6 @@ export async function platformRequestSignedUrl(
1086
873
  };
1087
874
  }
1088
875
 
1089
- if (response.status === 503) {
1090
- throw new Error(
1091
- `Signed URL endpoint unavailable (503) — caller may fall back`,
1092
- );
1093
- }
1094
-
1095
876
  const errorBody = (await response.json().catch(() => ({}))) as {
1096
877
  detail?: string;
1097
878
  };
@@ -7,6 +7,29 @@ export interface ResolvedImageRefs {
7
7
  source: "platform" | "dockerhub";
8
8
  }
9
9
 
10
+ /**
11
+ * Fetch the latest stable release version from the platform API.
12
+ * Returns the version string (e.g. "0.7.0") or null if unavailable.
13
+ * The releases endpoint returns entries ordered newest-first.
14
+ */
15
+ export async function fetchLatestStableVersion(): Promise<string | null> {
16
+ try {
17
+ const platformUrl = getPlatformUrl();
18
+ const response = await fetch(`${platformUrl}/v1/releases/?stable=true`, {
19
+ signal: AbortSignal.timeout(10_000),
20
+ });
21
+ if (!response.ok) return null;
22
+
23
+ const releases = (await response.json()) as Array<{
24
+ version?: string;
25
+ }>;
26
+ const first = releases[0];
27
+ return first?.version ?? null;
28
+ } catch {
29
+ return null;
30
+ }
31
+ }
32
+
10
33
  /**
11
34
  * Resolve image references for a given version.
12
35
  *
@@ -0,0 +1,30 @@
1
+ import type { AssistantEntry } from "./assistant-config.js";
2
+
3
+ /**
4
+ * Resolve the URL for a runtime migration endpoint, taking the assistant's
5
+ * topology into account.
6
+ *
7
+ * - For local/docker assistants, `runtimeUrl` is the loopback gateway and
8
+ * the runtime serves `/v1/migrations/<subpath>` directly. The CLI hits
9
+ * that path with guardian-token bearer auth.
10
+ * - For platform-managed (cloud="vellum") assistants, `runtimeUrl` is the
11
+ * platform host (e.g. `https://platform.vellum.ai`). The platform's
12
+ * `MigrationViewSet` does NOT expose `export-to-gcs` or arbitrary runtime
13
+ * migration paths under `/v1/migrations/...`. The wildcard runtime proxy
14
+ * at `/v1/assistants/<id>/<path:rest>` is what forwards arbitrary runtime
15
+ * paths to the managed runtime — vembda's unified proxy bootstraps the
16
+ * guardian token internally for the runtime call. From the CLI side it's
17
+ * user-session auth.
18
+ *
19
+ * The `subpath` is appended to the migrations namespace verbatim
20
+ * (e.g. `"export-to-gcs"`, `"import-from-gcs"`, `\`jobs/${jobId}\``).
21
+ */
22
+ export function resolveRuntimeMigrationUrl(
23
+ entry: Pick<AssistantEntry, "cloud" | "runtimeUrl" | "assistantId">,
24
+ subpath: string,
25
+ ): string {
26
+ if (entry.cloud === "vellum") {
27
+ return `${entry.runtimeUrl}/v1/assistants/${entry.assistantId}/migrations/${subpath}`;
28
+ }
29
+ return `${entry.runtimeUrl}/v1/migrations/${subpath}`;
30
+ }
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Sync cloud-managed (platform) assistants into the local lockfile.
3
+ *
4
+ * - Adds new platform assistants that aren't in the lockfile yet.
5
+ * - Removes lockfile entries whose IDs are no longer returned by the platform
6
+ * (e.g. retired assistants).
7
+ *
8
+ * Used by both `vellum login` and `vellum ps` to keep the lockfile fresh.
9
+ */
10
+
11
+ import {
12
+ loadAllAssistants,
13
+ removeAssistantEntry,
14
+ saveAssistantEntry,
15
+ } from "./assistant-config.js";
16
+ import {
17
+ fetchCurrentUser,
18
+ fetchPlatformAssistants,
19
+ getPlatformUrl,
20
+ readPlatformToken,
21
+ } from "./platform-client.js";
22
+
23
+ export type SyncLogger = (message: string) => void;
24
+
25
+ export interface SyncResult {
26
+ added: number;
27
+ removed: number;
28
+ email?: string;
29
+ }
30
+
31
+ export interface SyncOptions {
32
+ log?: SyncLogger;
33
+ }
34
+
35
+ /**
36
+ * Fetch platform assistants and reconcile against the lockfile.
37
+ * Returns the number of entries added/removed, or `null` if the user
38
+ * is not logged in or the fetch fails.
39
+ */
40
+ export async function syncCloudAssistants(
41
+ options?: SyncOptions,
42
+ ): Promise<SyncResult | null> {
43
+ const log = options?.log;
44
+ const platformUrl = getPlatformUrl();
45
+ log?.(`Platform URL: ${platformUrl}`);
46
+
47
+ const token = readPlatformToken();
48
+ if (!token) {
49
+ log?.("No platform token found — skipping cloud sync");
50
+ return null;
51
+ }
52
+ log?.(
53
+ `Token found (${token.length} chars, prefix: ${token.slice(0, 6)}…)`,
54
+ );
55
+
56
+ // Fetch user info for the login status line
57
+ let email: string | undefined;
58
+ try {
59
+ log?.("Fetching current user…");
60
+ const user = await fetchCurrentUser(token);
61
+ email = user.email;
62
+ log?.(`Authenticated as ${user.email} (${user.id})`);
63
+ } catch (err) {
64
+ const msg = err instanceof Error ? err.message : String(err);
65
+ log?.(`Failed to fetch current user: ${msg}`);
66
+ }
67
+
68
+ let platformAssistants: { id: string; name: string; status: string }[];
69
+ try {
70
+ log?.("Fetching platform assistants…");
71
+ platformAssistants = await fetchPlatformAssistants(token);
72
+ log?.(
73
+ `Platform returned ${platformAssistants.length} assistant(s): ${platformAssistants.map((a) => a.name || a.id).join(", ") || "(none)"}`,
74
+ );
75
+ } catch (err) {
76
+ const msg = err instanceof Error ? err.message : String(err);
77
+ log?.(`fetchPlatformAssistants failed: ${msg}`);
78
+ return null;
79
+ }
80
+
81
+ if (platformAssistants.length === 0) {
82
+ log?.(
83
+ "Platform returned 0 assistants — this may mean the API returned a non-ok status (check token validity)",
84
+ );
85
+ }
86
+
87
+ const platformIds = new Set(platformAssistants.map((a) => a.id));
88
+
89
+ // Add new platform assistants not yet in the lockfile
90
+ const existingCloudIds = new Set(
91
+ loadAllAssistants()
92
+ .filter((a) => a.cloud === "vellum")
93
+ .map((a) => a.assistantId),
94
+ );
95
+ log?.(
96
+ `Lockfile has ${existingCloudIds.size} cloud assistant(s): ${[...existingCloudIds].join(", ") || "(none)"}`,
97
+ );
98
+
99
+ let added = 0;
100
+ for (const pa of platformAssistants) {
101
+ if (!existingCloudIds.has(pa.id)) {
102
+ log?.(`Adding ${pa.name || pa.id} to lockfile`);
103
+ saveAssistantEntry({
104
+ assistantId: pa.id,
105
+ runtimeUrl: getPlatformUrl(),
106
+ cloud: "vellum",
107
+ species: "vellum",
108
+ hatchedAt: new Date().toISOString(),
109
+ });
110
+ added++;
111
+ }
112
+ }
113
+
114
+ // Remove stale lockfile entries that the platform no longer knows about
115
+ let removed = 0;
116
+ for (const id of existingCloudIds) {
117
+ if (!platformIds.has(id)) {
118
+ log?.(`Removing stale entry ${id} from lockfile`);
119
+ removeAssistantEntry(id);
120
+ removed++;
121
+ }
122
+ }
123
+
124
+ log?.(`Sync complete: ${added} added, ${removed} removed`);
125
+ return { added, removed, email };
126
+ }
@@ -18,14 +18,19 @@ export async function createTerminalSession(
18
18
  cols: number,
19
19
  rows: number,
20
20
  platformUrl?: string,
21
+ service?: string,
21
22
  ): Promise<{ session_id: string }> {
22
23
  const baseUrl = platformUrl || getPlatformUrl();
24
+ const body: Record<string, unknown> = { cols, rows };
25
+ if (service) {
26
+ body.service = service;
27
+ }
23
28
  const response = await fetch(
24
29
  `${baseUrl}/v1/assistants/${assistantId}/terminal/sessions/`,
25
30
  {
26
31
  method: "POST",
27
32
  headers: await authHeaders(token, platformUrl),
28
- body: JSON.stringify({ cols, rows }),
33
+ body: JSON.stringify(body),
29
34
  },
30
35
  );
31
36
  if (!response.ok) {