@mycadet/cli 0.12.6-dev.202610091338.ee7f3e3 → 0.12.7

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.
@@ -16,6 +16,15 @@ import { getLockfilePlatformBaseUrl } from "./assistant-config.js";
16
16
  import { getConfigDir, getConfigDirs } from "./environments/paths.js";
17
17
  import { getCurrentEnvironment } from "./environments/resolve.js";
18
18
  import { loopbackSafeFetch } from "./loopback-fetch.js";
19
+ import { z } from "zod";
20
+
21
+ const PlatformErrorDetailSchema = z.object({
22
+ detail: z.string().optional(),
23
+ });
24
+
25
+ const InProgressResponseSchema = z.object({
26
+ in_progress: z.boolean().optional(),
27
+ });
19
28
 
20
29
  function getPlatformTokenPath(): string {
21
30
  return join(getConfigDir(getCurrentEnvironment()), "platform-token");
@@ -200,6 +209,95 @@ export interface HatchedAssistant {
200
209
  status: string;
201
210
  }
202
211
 
212
+ const HatchedAssistantSchema = z.object({
213
+ id: z.string(),
214
+ name: z.string(),
215
+ status: z.string(),
216
+ });
217
+
218
+ const HatchedListResponseSchema = z.object({
219
+ results: z.array(HatchedAssistantSchema).optional(),
220
+ });
221
+
222
+ const EnsureRegistrationResponseSchema = z.object({
223
+ assistant: z.object({ id: z.string(), name: z.string() }),
224
+ registration: z.object({
225
+ client_installation_id: z.string(),
226
+ runtime_assistant_id: z.string(),
227
+ client_platform: z.string(),
228
+ }),
229
+ assistant_api_key: z.string().nullable(),
230
+ webhook_secret: z.string(),
231
+ });
232
+
233
+ const ReprovisionApiKeyResponseSchema = z.object({
234
+ provisioning: z.object({ assistant_api_key: z.string() }),
235
+ });
236
+
237
+ const SecretReadResponseSchema = z
238
+ .object({
239
+ found: z.boolean(),
240
+ value: z.string().optional(),
241
+ unreachable: z.boolean().optional(),
242
+ })
243
+ .catch({ found: false });
244
+
245
+ const ReleaseChannelResponseSchema = z.object({
246
+ current_release_version: z.string().nullable().optional(),
247
+ release_channel: z.string().optional(),
248
+ });
249
+
250
+ const OrganizationListResponseSchema = z.object({
251
+ results: z.array(z.object({ id: z.string(), name: z.string() })).optional(),
252
+ });
253
+
254
+ const AllauthSessionResponseSchema = z.object({
255
+ status: z.number(),
256
+ data: z.object({
257
+ user: z.object({ id: z.string(), email: z.string(), display: z.string() }),
258
+ }),
259
+ });
260
+
261
+ const VersionCheckResponseSchema = z
262
+ .object({
263
+ detail: z.string().optional(),
264
+ version: z.string().nullable().optional(),
265
+ })
266
+ .catch({});
267
+
268
+ const PassthroughBodySchema = z.record(z.string(), z.unknown()).catch({});
269
+
270
+ const BundleUploadUrlSchema = z.object({
271
+ url: z.string(),
272
+ bundle_key: z.string(),
273
+ expires_at: z.string(),
274
+ max_content_length: z.number().optional(),
275
+ });
276
+
277
+ const BundleImportErrorSchema = z
278
+ .object({
279
+ detail: z.string().optional(),
280
+ reason: z.string().optional(),
281
+ bundle_compat: z
282
+ .object({
283
+ min_runtime_version: z.string(),
284
+ max_runtime_version: z.string().nullable(),
285
+ })
286
+ .optional()
287
+ .catch(undefined),
288
+ target_runtime_version: z.string().optional(),
289
+ })
290
+ .catch({});
291
+
292
+ const RawUnifiedJobStatusSchema = z.object({
293
+ job_id: z.string(),
294
+ type: z.enum(["export", "import"]),
295
+ status: z.enum(["processing", "complete", "failed"]),
296
+ bundle_key: z.string().optional(),
297
+ result: z.unknown().optional(),
298
+ error: z.string().optional(),
299
+ });
300
+
203
301
  export interface HatchAssistantResult {
204
302
  assistant: HatchedAssistant;
205
303
  /** true when the platform returned an existing assistant (HTTP 200) */
@@ -276,7 +374,7 @@ export async function ensureSelfHostedLocalRegistration(
276
374
  );
277
375
  }
278
376
 
279
- return (await response.json()) as EnsureRegistrationResponse;
377
+ return EnsureRegistrationResponseSchema.parse(await response.json());
280
378
  }
281
379
 
282
380
  // ---------------------------------------------------------------------------
@@ -342,7 +440,7 @@ export async function reprovisionAssistantApiKey(
342
440
  );
343
441
  }
344
442
 
345
- return (await response.json()) as ReprovisionApiKeyResponse;
443
+ return ReprovisionApiKeyResponseSchema.parse(await response.json());
346
444
  }
347
445
 
348
446
  // ---------------------------------------------------------------------------
@@ -442,11 +540,7 @@ export async function readGatewayCredential(
442
540
  return { value: null, unreachable: response.status >= 500 };
443
541
  }
444
542
 
445
- const json = (await response.json()) as {
446
- found: boolean;
447
- value?: string;
448
- unreachable?: boolean;
449
- };
543
+ const json = SecretReadResponseSchema.parse(await response.json());
450
544
  // The daemon's /v1/secrets/read returns `unreachable: true` when the
451
545
  // credential backend (CES) can't be reached. Respect that signal.
452
546
  if (json.unreachable) {
@@ -553,14 +647,14 @@ export async function hatchAssistant(
553
647
  });
554
648
 
555
649
  if (response.ok) {
556
- const assistant = (await response.json()) as HatchedAssistant;
650
+ const assistant = HatchedAssistantSchema.parse(await response.json());
557
651
  return { assistant, reusedExisting: response.status === 200 };
558
652
  }
559
653
 
560
654
  if (response.status === 401 || response.status === 403) {
561
- const detail = (await response.json().catch(() => ({}))) as {
562
- detail?: string;
563
- };
655
+ const detail = PlatformErrorDetailSchema
656
+ .catch({})
657
+ .parse(await response.json().catch(() => ({})));
564
658
  throw new Error(
565
659
  detail.detail ??
566
660
  "Invalid or expired token. Run `cadet login` to re-authenticate.",
@@ -571,9 +665,9 @@ export async function hatchAssistant(
571
665
  throw new Error("Insufficient balance to hatch a new assistant.");
572
666
  }
573
667
 
574
- const errorBody = (await response.json().catch(() => ({}))) as {
575
- detail?: string;
576
- };
668
+ const errorBody = PlatformErrorDetailSchema
669
+ .catch({})
670
+ .parse(await response.json().catch(() => ({})));
577
671
  throw new Error(
578
672
  errorBody.detail ??
579
673
  `Platform API error: ${response.status} ${response.statusText}`,
@@ -614,11 +708,9 @@ export async function checkExistingPlatformAssistant(
614
708
  return null;
615
709
  }
616
710
 
617
- const body = (await response.json()) as {
618
- results?: HatchedAssistant[];
619
- };
620
- const active = body.results?.find((a) => a.status === "active");
621
- return active ?? null;
711
+ const parsed = HatchedListResponseSchema.safeParse(await response.json());
712
+ const results = parsed.success ? parsed.data.results : undefined;
713
+ return results?.find((a) => a.status === "active") ?? null;
622
714
  } finally {
623
715
  clearTimeout(timeoutId);
624
716
  }
@@ -649,11 +741,10 @@ export async function fetchPlatformAssistants(
649
741
 
650
742
  if (!response.ok) return [];
651
743
 
652
- const body = (await response.json()) as {
653
- results?: HatchedAssistant[];
654
- };
744
+ const parsed = HatchedListResponseSchema.safeParse(await response.json());
745
+ const results = parsed.success ? (parsed.data.results ?? []) : [];
655
746
 
656
- return (body.results ?? []).filter((a) => a.status === "active");
747
+ return results.filter((a) => a.status === "active");
657
748
  } finally {
658
749
  clearTimeout(timeoutId);
659
750
  }
@@ -685,10 +776,10 @@ export async function fetchAssistantDetail(
685
776
 
686
777
  if (!response.ok) return null;
687
778
 
688
- const body = (await response.json()) as {
689
- current_release_version?: string | null;
690
- release_channel?: string;
691
- };
779
+ const parsed = ReleaseChannelResponseSchema.safeParse(
780
+ await response.json(),
781
+ );
782
+ const body = parsed.success ? parsed.data : {};
692
783
  return {
693
784
  currentReleaseVersion: body.current_release_version ?? null,
694
785
  releaseChannel: body.release_channel === "preview" ? "preview" : "stable",
@@ -720,8 +811,10 @@ export async function fetchUpgradeInProgress(
720
811
 
721
812
  if (!response.ok) return null;
722
813
 
723
- const body = (await response.json()) as { in_progress?: boolean };
724
- return typeof body.in_progress === "boolean" ? body.in_progress : null;
814
+ const parsed = InProgressResponseSchema.safeParse(await response.json());
815
+ return parsed.success && parsed.data.in_progress !== undefined
816
+ ? parsed.data.in_progress
817
+ : null;
725
818
  } catch {
726
819
  return null;
727
820
  }
@@ -733,10 +826,6 @@ export interface PlatformUser {
733
826
  display: string;
734
827
  }
735
828
 
736
- interface OrganizationListResponse {
737
- results: { id: string; name: string }[];
738
- }
739
-
740
829
  export async function fetchOrganizationId(
741
830
  token: string,
742
831
  platformUrl?: string,
@@ -762,8 +851,10 @@ export async function fetchOrganizationId(
762
851
  );
763
852
  }
764
853
 
765
- const body = (await response.json()) as OrganizationListResponse;
766
- const orgId = body.results?.[0]?.id;
854
+ const parsed = OrganizationListResponseSchema.safeParse(
855
+ await response.json(),
856
+ );
857
+ const orgId = parsed.success ? parsed.data.results?.[0]?.id : undefined;
767
858
  if (!orgId) {
768
859
  throw new Error("No organization found for this account.");
769
860
  }
@@ -773,17 +864,6 @@ export async function fetchOrganizationId(
773
864
  }
774
865
  }
775
866
 
776
- interface AllauthSessionResponse {
777
- status: number;
778
- data: {
779
- user: {
780
- id: string;
781
- email: string;
782
- display: string;
783
- };
784
- };
785
- }
786
-
787
867
  export async function fetchCurrentUser(
788
868
  token: string,
789
869
  platformUrl?: string,
@@ -816,7 +896,7 @@ export async function fetchCurrentUser(
816
896
  );
817
897
  }
818
898
 
819
- const body = (await response.json()) as AllauthSessionResponse;
899
+ const body = AllauthSessionResponseSchema.parse(await response.json());
820
900
  return body.data.user;
821
901
  } finally {
822
902
  clearTimeout(timeoutId);
@@ -843,10 +923,9 @@ export async function rollbackPlatformAssistant(
843
923
  },
844
924
  );
845
925
 
846
- const body = (await response.json().catch(() => ({}))) as {
847
- detail?: string;
848
- version?: string | null;
849
- };
926
+ const body = VersionCheckResponseSchema.parse(
927
+ await response.json().catch(() => ({})),
928
+ );
850
929
 
851
930
  if (response.status === 200) {
852
931
  return { detail: body.detail ?? "", version: body.version ?? null };
@@ -907,10 +986,9 @@ export async function platformImportPreflightFromGcs(
907
986
  },
908
987
  );
909
988
 
910
- const body = (await response.json().catch(() => ({}))) as Record<
911
- string,
912
- unknown
913
- >;
989
+ const body = PassthroughBodySchema.parse(
990
+ await response.json().catch(() => ({})),
991
+ );
914
992
  return { statusCode: response.status, body };
915
993
  }
916
994
 
@@ -934,10 +1012,9 @@ export async function platformImportBundleFromGcs(
934
1012
  throw new Error("Bundle too large to import");
935
1013
  }
936
1014
 
937
- const body = (await response.json().catch(() => ({}))) as Record<
938
- string,
939
- unknown
940
- >;
1015
+ const body = PassthroughBodySchema.parse(
1016
+ await response.json().catch(() => ({})),
1017
+ );
941
1018
  return { statusCode: response.status, body };
942
1019
  }
943
1020
 
@@ -1128,12 +1205,7 @@ export async function platformRequestSignedUrl(
1128
1205
  }
1129
1206
 
1130
1207
  if (response.status === 201 || response.status === 200) {
1131
- const json = (await response.json()) as {
1132
- url: string;
1133
- bundle_key: string;
1134
- expires_at: string;
1135
- max_content_length?: number;
1136
- };
1208
+ const json = BundleUploadUrlSchema.parse(await response.json());
1137
1209
  return {
1138
1210
  url: json.url,
1139
1211
  bundleKey: json.bundle_key,
@@ -1145,12 +1217,9 @@ export async function platformRequestSignedUrl(
1145
1217
  // Non-success body. Read once and reuse for both the 422 version-mismatch
1146
1218
  // branch and the generic-error fallthrough — `response.json()` consumes
1147
1219
  // the body, so a second read would always return undefined.
1148
- const errorBody = (await response.json().catch(() => ({}))) as {
1149
- detail?: string;
1150
- reason?: string;
1151
- bundle_compat?: BundleCompatibility;
1152
- target_runtime_version?: string;
1153
- };
1220
+ const errorBody = BundleImportErrorSchema.parse(
1221
+ await response.json().catch(() => ({})),
1222
+ );
1154
1223
 
1155
1224
  if (
1156
1225
  response.status === 422 &&
@@ -1197,6 +1266,6 @@ export async function platformPollJobStatus(
1197
1266
  );
1198
1267
  }
1199
1268
 
1200
- const raw = (await response.json()) as RawUnifiedJobStatus;
1269
+ const raw = RawUnifiedJobStatusSchema.parse(await response.json());
1201
1270
  return parseUnifiedJobStatus(raw);
1202
1271
  }
@@ -1,7 +1,9 @@
1
1
  import { mkdirSync } from "fs";
2
- import { homedir } from "os";
3
2
  import { join, posix, win32 } from "path";
4
3
 
4
+ import { getStateDir } from "./environments/paths.js";
5
+ import { getCurrentEnvironment } from "./environments/resolve.js";
6
+
5
7
  function isPathInside(
6
8
  parent: string,
7
9
  candidate: string,
@@ -17,9 +19,7 @@ function isPathInside(
17
19
  }
18
20
 
19
21
  export function getRetiredDir(): string {
20
- const xdgData =
21
- process.env.XDG_DATA_HOME?.trim() || join(homedir(), ".local", "share");
22
- const dir = join(xdgData, "cadet", "retired");
22
+ const dir = join(getStateDir(getCurrentEnvironment()), "retired");
23
23
  mkdirSync(dir, { recursive: true });
24
24
  return dir;
25
25
  }
@@ -128,34 +128,11 @@ export async function retireLocal(
128
128
 
129
129
  // Stop gateway via PID file — use a longer timeout because the gateway has a
130
130
  // drain window (5s) before it exits.
131
- const gatewayPidFile = join(cadetDir, "gateway.pid");
132
- await stopProcessByPidFile(gatewayPidFile, "gateway", undefined, 7000);
131
+ await stopProcessByPidFile(join(cadetDir, "gateway.pid"), "gateway", undefined, 7000);
133
132
 
134
133
  // Stop the CES sibling — it is stopped by its PID file, a no-op when the
135
134
  // PID file is absent (e.g. the sibling was never started or already exited).
136
- const cesPidFile = join(cadetDir, "ces.pid");
137
- const cesStopped = await stopProcessByPidFile(
138
- cesPidFile,
139
- "credential-executor",
140
- );
141
- if (cesStopped) {
142
- reporter.log("credential-executor stopped.");
143
- }
144
-
145
- // Stop Qdrant. The daemon's graceful shutdown tries to stop it via
146
- // qdrantManager.stop(), but a daemon that never completes that shutdown
147
- // (crash, OOM kill, or the SIGKILL ceiling above) leaves Qdrant running as
148
- // an orphan. Check both the current PID file location and the legacy one.
149
- const qdrantPidFile = join(
150
- cadetDir,
151
- "workspace",
152
- "data",
153
- "qdrant",
154
- "qdrant.pid",
155
- );
156
- const qdrantLegacyPidFile = join(cadetDir, "qdrant.pid");
157
- await stopProcessByPidFile(qdrantPidFile, "qdrant", undefined, 5000);
158
- await stopProcessByPidFile(qdrantLegacyPidFile, "qdrant", undefined, 5000);
135
+ await stopProcessByPidFile(join(cadetDir, "ces.pid"), "credential-executor");
159
136
 
160
137
  // Stop the nginx ingress if one is fronting this gateway — it would
161
138
  // otherwise be orphaned when the instance directory is archived.
@@ -23,8 +23,23 @@ import {
23
23
 
24
24
  import type { AssistantEntry } from "./assistant-config.js";
25
25
  import { loopbackSafeFetch } from "./loopback-fetch.js";
26
- import { isJsonObject } from "./json";
27
26
  import { authHeaders, invalidateOrgIdCache } from "./platform-client.js";
27
+ import { z } from "zod";
28
+
29
+ const ManagedBackupEntrySchema = z.object({
30
+ snapshot_name: z.string().optional(),
31
+ created_at: z.string().optional(),
32
+ ready_to_use: z.boolean().optional(),
33
+ });
34
+
35
+ const ManagedBackupsResponseSchema = z.object({
36
+ backups: z.array(ManagedBackupEntrySchema).optional(),
37
+ });
38
+
39
+ const CreatedBackupResponseSchema = z.object({
40
+ snapshot_name: z.string().optional(),
41
+ ready_to_use: z.boolean().optional(),
42
+ });
28
43
 
29
44
  type PlatformEntry = Pick<AssistantEntry, "runtimeUrl" | "assistantId">;
30
45
 
@@ -73,8 +88,8 @@ async function fetchPlatformBackups(
73
88
  `Cadet Cloud backup list failed ${await errorSuffix(response)}`,
74
89
  );
75
90
  }
76
- const body = (await response.json()) as { backups?: ManagedBackupEntry[] };
77
- return body.backups ?? [];
91
+ const parsed = ManagedBackupsResponseSchema.safeParse(await response.json());
92
+ return parsed.success ? (parsed.data.backups ?? []) : [];
78
93
  }
79
94
 
80
95
  /**
@@ -115,20 +130,14 @@ export async function createPlatformBackup(
115
130
  );
116
131
  }
117
132
  const body: unknown = await response.json().catch(() => null);
118
- const created = isJsonObject(body)
119
- ? {
120
- snapshot_name:
121
- typeof body.snapshot_name === "string" ? body.snapshot_name : undefined,
122
- ready_to_use: body.ready_to_use === true ? true : undefined,
123
- }
124
- : null;
125
- const snapshotName = created?.snapshot_name;
126
- if (!snapshotName) {
133
+ const created = CreatedBackupResponseSchema.safeParse(body);
134
+ if (!created.success || !created.data.snapshot_name) {
127
135
  throw new Error("Cadet Cloud backup create returned no snapshot name");
128
136
  }
129
- if (created?.ready_to_use === true) {
137
+ if (created.data.ready_to_use === true) {
130
138
  return;
131
139
  }
140
+ const snapshotName = created.data.snapshot_name;
132
141
 
133
142
  const deadline = Date.now() + timeoutMs;
134
143
  while (Date.now() < deadline) {
@@ -6,9 +6,19 @@
6
6
  * the web UI's xterm.js terminal uses.
7
7
  */
8
8
 
9
- import { authHeaders, getPlatformUrl } from "./platform-client.js";
10
9
  import { loopbackSafeFetch } from "./loopback-fetch.js";
11
- import { isJsonObject } from "./json";
10
+ import { authHeaders, getPlatformUrl } from "./platform-client.js";
11
+ import { z } from "zod";
12
+
13
+ const TerminalSessionResponseSchema = z.object({ session_id: z.string() });
14
+
15
+ const TerminalOutputEventSchema = z.object({
16
+ seq: z.number(),
17
+ /** Base64-encoded PTY output bytes. */
18
+ data: z.string(),
19
+ });
20
+
21
+ export type TerminalOutputEvent = z.infer<typeof TerminalOutputEventSchema>;
12
22
 
13
23
  // ---------------------------------------------------------------------------
14
24
  // Create / Close
@@ -41,11 +51,11 @@ export async function createTerminalSession(
41
51
  `Failed to create terminal session (${response.status}): ${detail || response.statusText}`,
42
52
  );
43
53
  }
44
- const payloadBody: unknown = await response.json();
45
- if (!isJsonObject(payloadBody) || typeof payloadBody.session_id !== "string") {
54
+ const parsed = TerminalSessionResponseSchema.safeParse(await response.json());
55
+ if (!parsed.success) {
46
56
  throw new Error("Terminal session response did not include a session_id.");
47
57
  }
48
- return { session_id: payloadBody.session_id };
58
+ return parsed.data;
49
59
  }
50
60
 
51
61
  export async function closeTerminalSession(
@@ -125,12 +135,6 @@ export async function resizeTerminalSession(
125
135
  // SSE event stream
126
136
  // ---------------------------------------------------------------------------
127
137
 
128
- export interface TerminalOutputEvent {
129
- seq: number;
130
- /** Base64-encoded PTY output bytes. */
131
- data: string;
132
- }
133
-
134
138
  /**
135
139
  * Subscribe to the terminal output SSE stream. Yields parsed events as they
136
140
  * arrive. The generator completes when the stream ends or is aborted.
@@ -175,7 +179,10 @@ export async function* subscribeTerminalEvents(
175
179
  const trimmed = line.trimEnd();
176
180
  if (trimmed.startsWith("data: ")) {
177
181
  try {
178
- yield JSON.parse(trimmed.slice(6)) as TerminalOutputEvent;
182
+ const frame = TerminalOutputEventSchema.safeParse(
183
+ JSON.parse(trimmed.slice(6)),
184
+ );
185
+ if (frame.success) yield frame.data;
179
186
  } catch {
180
187
  // Skip malformed SSE frames
181
188
  }
@@ -14,7 +14,7 @@ import {
14
14
  loadRawConfig,
15
15
  parseGatewayPortFromEntryUrls,
16
16
  } from "./ingress-config.js";
17
- import { isJsonObject } from "./json";
17
+ import { IngressConfigSchema } from "./ngrok.js";
18
18
  import {
19
19
  ensureTunnelEdge,
20
20
  formatEdgeMode,
@@ -34,12 +34,11 @@ export const DOCKER_GATEWAY_READY_TIMEOUT_MS = 5 * 60_000;
34
34
  * enabled with a saved public URL.
35
35
  */
36
36
  function wantsWebIngress(config: Record<string, unknown>): boolean {
37
- const ingress = config.ingress;
37
+ const parsed = IngressConfigSchema.safeParse(config.ingress);
38
38
  return (
39
- isJsonObject(ingress) &&
40
- ingress.enabled === true &&
41
- typeof ingress.publicBaseUrl === "string" &&
42
- ingress.publicBaseUrl.trim() !== ""
39
+ parsed.success &&
40
+ parsed.data.enabled === true &&
41
+ (parsed.data.publicBaseUrl?.trim() ?? "") !== ""
43
42
  );
44
43
  }
45
44
 
@@ -189,17 +188,16 @@ function ownsSharedIngress(
189
188
  ) {
190
189
  return false;
191
190
  }
192
- let publicBaseUrl: unknown;
191
+ let publicBaseUrl: string | undefined;
193
192
  try {
194
- publicBaseUrl = (
195
- loadRawConfig(workspaceDir).ingress as
196
- | { publicBaseUrl?: unknown }
197
- | undefined
198
- )?.publicBaseUrl;
193
+ const parsed = IngressConfigSchema.safeParse(
194
+ loadRawConfig(workspaceDir).ingress,
195
+ );
196
+ publicBaseUrl = parsed.success ? parsed.data.publicBaseUrl : undefined;
199
197
  } catch {
200
198
  return true;
201
199
  }
202
- if (typeof publicBaseUrl !== "string" || !publicBaseUrl.trim()) {
200
+ if (!publicBaseUrl || !publicBaseUrl.trim()) {
203
201
  return true;
204
202
  }
205
203
  if (entry.ingressUrl === publicBaseUrl) {
@@ -25,6 +25,16 @@ import { getCurrentEnvironment } from "./environments/resolve.js";
25
25
  import { loadGuardianToken } from "./guardian-token.js";
26
26
  import { classifyReadyzResponse } from "./http-client.js";
27
27
  import { fetchReleases, resolveImageRefs } from "./platform-releases.js";
28
+ import { z } from "zod";
29
+
30
+ const GatewayHealthSchema = z.object({
31
+ version: z.string().optional(),
32
+ });
33
+
34
+ const MigrationGateBodySchema = z.object({
35
+ status: z.string().optional(),
36
+ reason: z.string().optional(),
37
+ });
28
38
  import {
29
39
  getBuilderManagedEnvKeys,
30
40
  type DockerStatefulSetSpec,
@@ -289,8 +299,8 @@ export async function fetchCurrentVersion(
289
299
  signal: AbortSignal.timeout(5000),
290
300
  });
291
301
  if (resp.ok) {
292
- const body = (await resp.json()) as { version?: string };
293
- return body.version;
302
+ const parsed = GatewayHealthSchema.safeParse(await resp.json());
303
+ return parsed.success ? parsed.data.version : undefined;
294
304
  }
295
305
  } catch {
296
306
  // Best-effort
@@ -532,9 +542,11 @@ export async function rollbackMigrations(
532
542
  // The daemon's running-migrations 503 also says "starting" but
533
543
  // carries a `reason` field — don't retry against that one (the
534
544
  // rollback stays blocked until migrations leave the running state).
535
- const parsed = JSON.parse(text) as { status?: string; reason?: string };
545
+ const parsed = MigrationGateBodySchema.safeParse(JSON.parse(text));
536
546
  gatewayStarting =
537
- parsed.status === "starting" && parsed.reason === undefined;
547
+ parsed.success &&
548
+ parsed.data.status === "starting" &&
549
+ parsed.data.reason === undefined;
538
550
  } catch {
539
551
  // Non-JSON error body — not the starting gate.
540
552
  }