@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.
@@ -93,10 +93,10 @@ export interface ContainerInfo {
93
93
  * validation boundary — the same toolchain writes and reads them — so they are
94
94
  * modeled as plain types here rather than re-validated.
95
95
  *
96
- * `runtimeUrl` is required (`readAssistants` only returns entries that have one)
96
+ * `runtimeUrl` is required (`loadAllAssistants` only returns entries that have one)
97
97
  * and `resources` is the richer host shape. `cloud` is inherited from
98
98
  * `LockfileAssistant` (always present; normalized at the read seam — see
99
- * `readAssistants`). The index signature preserves unknown on-disk fields so a
99
+ * `loadAllAssistants`). The index signature preserves unknown on-disk fields so a
100
100
  * read→write round-trip never drops a newer writer's data.
101
101
  */
102
102
  export type AssistantEntry = Omit<
@@ -330,7 +330,7 @@ export function migrateLegacyEntry(raw: Record<string, unknown>): boolean {
330
330
  return mutated;
331
331
  }
332
332
 
333
- function readAssistants(): AssistantEntry[] {
333
+ export function loadAllAssistants(): AssistantEntry[] {
334
334
  const data = readLockfile();
335
335
  const entries = data.assistants;
336
336
  if (!Array.isArray(entries)) {
@@ -384,7 +384,7 @@ function writeAssistants(entries: AssistantEntry[]): void {
384
384
  }
385
385
 
386
386
  export function loadLatestAssistant(): AssistantEntry | null {
387
- const entries = readAssistants();
387
+ const entries = loadAllAssistants();
388
388
  if (entries.length === 0) {
389
389
  return null;
390
390
  }
@@ -397,7 +397,7 @@ export function loadLatestAssistant(): AssistantEntry | null {
397
397
  }
398
398
 
399
399
  export function findAssistantByName(name: string): AssistantEntry | null {
400
- return readAssistants().find((entry) => entry.assistantId === name) ?? null;
400
+ return loadAllAssistants().find((entry) => entry.assistantId === name) ?? null;
401
401
  }
402
402
 
403
403
  export function getAssistantDisplayName(entry: AssistantEntry): string {
@@ -430,7 +430,7 @@ function getAssistantDisplayNameCandidates(entry: AssistantEntry): string[] {
430
430
  export function lookupAssistantByIdentifier(
431
431
  identifier: string,
432
432
  ): AssistantLookupResult {
433
- const entries = readAssistants();
433
+ const entries = loadAllAssistants();
434
434
  const exactId = entries.find((entry) => entry.assistantId === identifier);
435
435
  if (exactId) {
436
436
  return { status: "found", entry: exactId };
@@ -483,10 +483,6 @@ export function removeAssistantEntry(assistantId: string): void {
483
483
  });
484
484
  }
485
485
 
486
- export function loadAllAssistants(): AssistantEntry[] {
487
- return readAssistants();
488
- }
489
-
490
486
  /**
491
487
  * Read the first existing lockfile for an explicitly-provided environment,
492
488
  * without applying legacy migrations. This is the cross-env read path used by
@@ -585,7 +581,7 @@ export function resolveAssistant(nameArg?: string): AssistantEntry | null {
585
581
  // Active assistant no longer exists in lockfile — fall through
586
582
  }
587
583
 
588
- const all = readAssistants();
584
+ const all = loadAllAssistants();
589
585
  if (all.length === 1) return all[0];
590
586
 
591
587
  return null;
@@ -617,7 +613,7 @@ export function resolveTargetAssistant(nameArg?: string): AssistantEntry {
617
613
  // Active assistant no longer exists in lockfile — fall through.
618
614
  }
619
615
 
620
- const all = readAssistants();
616
+ const all = loadAllAssistants();
621
617
  if (all.length === 1) return all[0];
622
618
 
623
619
  if (all.length === 0) {
@@ -648,7 +644,7 @@ export function extractHostFromUrl(url: string): string {
648
644
 
649
645
  export function saveAssistantEntry(entry: AssistantEntry): void {
650
646
  withCliLockfileLock(() => {
651
- const entries = readAssistants().filter(
647
+ const entries = loadAllAssistants().filter(
652
648
  (e) => e.assistantId !== entry.assistantId,
653
649
  );
654
650
  entries.unshift(entry);
@@ -7,7 +7,6 @@ import {
7
7
  unlinkSync,
8
8
  writeFileSync,
9
9
  } from "fs";
10
- import { homedir } from "os";
11
10
  import { dirname, join } from "path";
12
11
 
13
12
  import {
@@ -17,12 +16,12 @@ import {
17
16
  } from "./bundle-staging.js";
18
17
  import { loadGuardianToken, refreshGuardianToken } from "./guardian-token.js";
19
18
  import { loopbackSafeFetch } from "./loopback-fetch.js";
19
+ import { getStateDir } from "./environments/paths.js";
20
+ import { getCurrentEnvironment } from "./environments/resolve.js";
20
21
 
21
22
  /** Default backup directory following XDG convention */
22
23
  export function getBackupsDir(): string {
23
- const dataHome =
24
- process.env.XDG_DATA_HOME?.trim() || join(homedir(), ".local", "share");
25
- return join(dataHome, "cadet", "backups");
24
+ return join(getStateDir(getCurrentEnvironment()), "backups");
26
25
  }
27
26
 
28
27
  /** Human-readable file size */
@@ -1,25 +1,24 @@
1
1
  /**
2
2
  * Stable per-install client identity for the CLI.
3
3
  *
4
- * Generates a UUID on first use and persists it to
5
- * `~/.config/cadet/client-id` so the daemon's event hub can
4
+ * Generates a UUID on first use and persists it to the environment's
5
+ * config dir (`client-id`) so the daemon's event hub can
6
6
  * track this terminal across SSE reconnects and CLI restarts.
7
7
  */
8
8
 
9
9
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
10
10
  import { randomUUID } from "crypto";
11
- import { homedir } from "os";
12
11
  import { join } from "path";
13
12
 
13
+ import { getConfigDir } from "./environments/paths.js";
14
+ import { getCurrentEnvironment } from "./environments/resolve.js";
15
+
14
16
  export const CLI_INTERFACE_ID = "cli";
15
17
  export const WEB_INTERFACE_ID = "web";
16
18
 
17
19
  let cached: string | null = null;
18
20
 
19
- function getConfigDir(): string {
20
- const configHome = process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
21
- return join(configHome, "cadet");
22
- }
21
+
23
22
 
24
23
  /**
25
24
  * Returns a stable UUID identifying this CLI installation.
@@ -28,8 +27,7 @@ function getConfigDir(): string {
28
27
  export function getClientId(): string {
29
28
  if (cached) return cached;
30
29
 
31
- const configDir = getConfigDir();
32
- const idFile = join(configDir, "client-id");
30
+ const idFile = join(getConfigDir(getCurrentEnvironment()), "client-id");
33
31
 
34
32
  try {
35
33
  if (existsSync(idFile)) {
@@ -45,7 +43,7 @@ export function getClientId(): string {
45
43
 
46
44
  const id = randomUUID();
47
45
  try {
48
- mkdirSync(configDir, { recursive: true });
46
+ mkdirSync(getConfigDir(getCurrentEnvironment()), { recursive: true });
49
47
  writeFileSync(idFile, id, "utf-8");
50
48
  } catch {
51
49
  /* best-effort persist — transient id still works for this session */
@@ -28,6 +28,39 @@ import {
28
28
  import { getConfigDir, getConfigDirs } from "./environments/paths.js";
29
29
  import { getCurrentEnvironment } from "./environments/resolve.js";
30
30
  import { loopbackSafeFetch } from "./loopback-fetch.js";
31
+ import { z } from "zod";
32
+
33
+ /**
34
+ * The guardian token fields as they arrive untrusted (gateway refresh/init
35
+ * responses, persisted token files). Every field is independently optional
36
+ * and independently caught: one malformed field must not invalidate the
37
+ * others — callers fall back per field.
38
+ */
39
+ const GuardianTokenFieldsSchema = z
40
+ .object({
41
+ guardianPrincipalId: z.string().optional().catch(undefined),
42
+ accessToken: z.string().optional().catch(undefined),
43
+ accessTokenExpiresAt: z
44
+ .union([z.string(), z.number()])
45
+ .optional()
46
+ .catch(undefined),
47
+ refreshToken: z.string().optional().catch(undefined),
48
+ refreshTokenExpiresAt: z
49
+ .union([z.string(), z.number()])
50
+ .optional()
51
+ .catch(undefined),
52
+ refreshAfter: z.string().optional().catch(undefined),
53
+ isNew: z.boolean().optional().catch(undefined),
54
+ deviceId: z.string().optional().catch(undefined),
55
+ })
56
+ .catch({});
57
+
58
+ /** Narrow untrusted JSON to the token fields whose values have the right
59
+ * shape; fields absent or malformed come back undefined for callers to
60
+ * fall back on or reject. */
61
+ function parseGuardianTokenFields(value: unknown): Partial<GuardianTokenData> {
62
+ return GuardianTokenFieldsSchema.parse(value);
63
+ }
31
64
 
32
65
  const DEVICE_ID_SALT = "mycadet-host-id";
33
66
 
@@ -470,19 +503,24 @@ export async function refreshGuardianTokenResult(
470
503
  return classifyRefreshHttpStatus(response.status);
471
504
  }
472
505
 
473
- const json = (await response.json()) as Record<string, unknown>;
506
+ const parsed = parseGuardianTokenFields(await response.json());
507
+ if (parsed.accessToken == null) {
508
+ return {
509
+ ok: false,
510
+ status: 502,
511
+ error: "Assistant gateway returned a malformed token refresh response",
512
+ };
513
+ }
474
514
  const refreshed: GuardianTokenData = {
475
515
  guardianPrincipalId:
476
- (json.guardianPrincipalId as string) ?? tokenData.guardianPrincipalId,
477
- accessToken: json.accessToken as string,
516
+ parsed.guardianPrincipalId ?? tokenData.guardianPrincipalId,
517
+ accessToken: parsed.accessToken,
478
518
  accessTokenExpiresAt:
479
- (json.accessTokenExpiresAt as string | number) ??
480
- tokenData.accessTokenExpiresAt,
481
- refreshToken: (json.refreshToken as string) ?? tokenData.refreshToken,
519
+ parsed.accessTokenExpiresAt ?? tokenData.accessTokenExpiresAt,
520
+ refreshToken: parsed.refreshToken ?? tokenData.refreshToken,
482
521
  refreshTokenExpiresAt:
483
- (json.refreshTokenExpiresAt as string | number) ??
484
- tokenData.refreshTokenExpiresAt,
485
- refreshAfter: (json.refreshAfter as string) ?? tokenData.refreshAfter,
522
+ parsed.refreshTokenExpiresAt ?? tokenData.refreshTokenExpiresAt,
523
+ refreshAfter: parsed.refreshAfter ?? tokenData.refreshAfter,
486
524
  isNew: false,
487
525
  deviceId: tokenData.deviceId,
488
526
  leasedAt: new Date().toISOString(),
@@ -583,15 +621,28 @@ export async function leaseGuardianToken(
583
621
  throw new Error(`guardian/init failed (${response.status}): ${body}`);
584
622
  }
585
623
 
586
- const json = (await response.json()) as Record<string, unknown>;
624
+ const parsed = parseGuardianTokenFields(await response.json());
625
+ if (
626
+ parsed.guardianPrincipalId == null ||
627
+ parsed.accessToken == null ||
628
+ parsed.accessTokenExpiresAt == null ||
629
+ parsed.refreshToken == null ||
630
+ parsed.refreshTokenExpiresAt == null ||
631
+ parsed.refreshAfter == null ||
632
+ parsed.isNew == null
633
+ ) {
634
+ throw new Error(
635
+ `guardian/init returned a malformed token response (${response.status})`,
636
+ );
637
+ }
587
638
  const tokenData: GuardianTokenData = {
588
- guardianPrincipalId: json.guardianPrincipalId as string,
589
- accessToken: json.accessToken as string,
590
- accessTokenExpiresAt: json.accessTokenExpiresAt as string | number,
591
- refreshToken: json.refreshToken as string,
592
- refreshTokenExpiresAt: json.refreshTokenExpiresAt as string | number,
593
- refreshAfter: json.refreshAfter as string,
594
- isNew: json.isNew as boolean,
639
+ guardianPrincipalId: parsed.guardianPrincipalId,
640
+ accessToken: parsed.accessToken,
641
+ accessTokenExpiresAt: parsed.accessTokenExpiresAt,
642
+ refreshToken: parsed.refreshToken,
643
+ refreshTokenExpiresAt: parsed.refreshTokenExpiresAt,
644
+ refreshAfter: parsed.refreshAfter,
645
+ isNew: parsed.isNew,
595
646
  deviceId,
596
647
  leasedAt: new Date().toISOString(),
597
648
  };
@@ -675,7 +726,10 @@ export function seedGuardianTokenFromSiblingEnv(assistantId: string): boolean {
675
726
  for (const { path: sibling } of candidates) {
676
727
  try {
677
728
  const raw = readFileSync(sibling);
678
- const parsed = JSON.parse(raw.toString("utf-8")) as GuardianTokenData;
729
+ const parsed = parseGuardianTokenFields(
730
+ JSON.parse(raw.toString("utf-8")),
731
+ );
732
+ if (parsed.refreshTokenExpiresAt == null) continue;
679
733
  const refreshExpiry = new Date(parsed.refreshTokenExpiresAt).getTime();
680
734
  if (!Number.isFinite(refreshExpiry) || refreshExpiry <= now) continue;
681
735
  const dir = dirname(destPath);
@@ -1,12 +1,13 @@
1
1
  import { loopbackSafeFetch } from "./loopback-fetch.js";
2
+ import { z } from "zod";
2
3
 
3
- export const HEALTH_CHECK_TIMEOUT_MS = 1500;
4
+ const HealthResponseSchema = z.object({
5
+ status: z.string().optional(),
6
+ message: z.string().optional(),
7
+ version: z.string().optional(),
8
+ });
4
9
 
5
- interface HealthResponse {
6
- status: string;
7
- message?: string;
8
- version?: string;
9
- }
10
+ export const HEALTH_CHECK_TIMEOUT_MS = 1500;
10
11
 
11
12
  export interface HealthCheckResult {
12
13
  status: string;
@@ -57,7 +58,9 @@ export async function checkManagedHealth(
57
58
  return { status: `error (${response.status})`, detail: null };
58
59
  }
59
60
 
60
- const data = (await response.json()) as HealthResponse;
61
+ const data = HealthResponseSchema
62
+ .catch({})
63
+ .parse(await response.json());
61
64
  const status = data.status || "unknown";
62
65
  return {
63
66
  status,
@@ -168,12 +171,12 @@ export async function fetchManagedPs(
168
171
  }
169
172
  }
170
173
 
171
- interface LegacyConnectionStatus {
172
- state: string;
173
- is_awake: boolean;
174
- pod_status: string | null;
175
- detail: string | null;
176
- }
174
+ const LegacyConnectionStatusSchema = z.object({
175
+ state: z.string(),
176
+ is_awake: z.boolean(),
177
+ pod_status: z.string().nullable(),
178
+ detail: z.string().nullable(),
179
+ });
177
180
 
178
181
  async function fetchLegacyConnectionStatus(
179
182
  runtimeUrl: string,
@@ -194,7 +197,11 @@ async function fetchLegacyConnectionStatus(
194
197
  clearTimeout(timeoutId);
195
198
  if (!response.ok) return null;
196
199
 
197
- const data = (await response.json()) as LegacyConnectionStatus;
200
+ const parsed = LegacyConnectionStatusSchema.safeParse(
201
+ await response.json(),
202
+ );
203
+ if (!parsed.success) return null;
204
+ const data = parsed.data;
198
205
 
199
206
  // Translate legacy shape into the ps process tree
200
207
  const status: ManagedProcessEntry["status"] = data.is_awake
@@ -240,7 +247,9 @@ export async function checkHealth(
240
247
  return { status: `error (${response.status})`, detail: null };
241
248
  }
242
249
 
243
- const data = (await response.json()) as HealthResponse;
250
+ const data = HealthResponseSchema
251
+ .catch({})
252
+ .parse(await response.json());
244
253
  const status = data.status || "unknown";
245
254
  return {
246
255
  status,
@@ -1,5 +1,6 @@
1
1
  import { execFileSync } from "child_process";
2
2
  import { createHash, randomUUID } from "crypto";
3
+ import { z } from "zod";
3
4
  import {
4
5
  closeSync,
5
6
  mkdirSync,
@@ -57,7 +58,7 @@ function processIsAlive(pid: number): boolean {
57
58
  process.kill(pid, 0);
58
59
  return true;
59
60
  } catch (error) {
60
- return (error as NodeJS.ErrnoException).code === "EPERM";
61
+ return errnoCode(error) === "EPERM";
61
62
  }
62
63
  }
63
64
 
@@ -87,30 +88,21 @@ function processStartedAt(pid: number): string | undefined {
87
88
  }
88
89
  }
89
90
 
91
+ function errnoCode(error: unknown): string | undefined {
92
+ if (!(error instanceof Error)) return undefined;
93
+ if (!("code" in error) || typeof error.code !== "string") return undefined;
94
+ return error.code;
95
+ }
96
+
97
+ const LockOwnerFileSchema = z.object({
98
+ pid: z.number().int().positive(),
99
+ processStartedAt: z.string().min(1).optional().catch(undefined),
100
+ token: z.string().min(1),
101
+ });
102
+
90
103
  function parseOwner(raw: string): LockOwner | undefined {
91
104
  try {
92
- const parsed = JSON.parse(raw) as {
93
- pid?: unknown;
94
- processStartedAt?: unknown;
95
- token?: unknown;
96
- };
97
- if (
98
- typeof parsed.pid === "number" &&
99
- Number.isInteger(parsed.pid) &&
100
- parsed.pid > 0 &&
101
- typeof parsed.token === "string" &&
102
- parsed.token.length > 0
103
- ) {
104
- return {
105
- pid: parsed.pid,
106
- processStartedAt:
107
- typeof parsed.processStartedAt === "string" &&
108
- parsed.processStartedAt.length > 0
109
- ? parsed.processStartedAt
110
- : undefined,
111
- token: parsed.token,
112
- };
113
- }
105
+ return LockOwnerFileSchema.parse(JSON.parse(raw));
114
106
  } catch {}
115
107
  return undefined;
116
108
  }
@@ -195,7 +187,7 @@ function acquireRecoveryClaim(
195
187
  writeOwnerFile(claimPath, owner);
196
188
  return () => releaseLock(claimPath, owner.token);
197
189
  } catch (error) {
198
- if ((error as NodeJS.ErrnoException).code !== "EEXIST") {
190
+ if (errnoCode(error) !== "EEXIST") {
199
191
  throw error;
200
192
  }
201
193
  if (
@@ -248,7 +240,7 @@ function removeStaleLock(
248
240
  unlinkSync(lockPath);
249
241
  return true;
250
242
  } catch (error) {
251
- return (error as NodeJS.ErrnoException).code === "ENOENT";
243
+ return errnoCode(error) === "ENOENT";
252
244
  }
253
245
  } finally {
254
246
  releaseRecoveryClaim();
@@ -262,7 +254,7 @@ function releaseLock(lockPath: string, token: string): void {
262
254
  try {
263
255
  unlinkSync(lockPath);
264
256
  } catch (error) {
265
- if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
257
+ if (errnoCode(error) !== "ENOENT") {
266
258
  throw error;
267
259
  }
268
260
  }
@@ -306,7 +298,7 @@ export async function withLocalHatchLock<T>(
306
298
  try {
307
299
  writeOwnerFile(lockPath, owner);
308
300
  } catch (error) {
309
- if ((error as NodeJS.ErrnoException).code !== "EEXIST") {
301
+ if (errnoCode(error) !== "EEXIST") {
310
302
  throw error;
311
303
  }
312
304
  if (removeStaleLock(lockPath, owner, liveOwnerRecoveryGraceMs)) {
@@ -11,6 +11,9 @@ import {
11
11
  resolveRuntimeUrl,
12
12
  } from "./runtime-url.js";
13
13
  import { loopbackSafeFetch } from "./loopback-fetch.js";
14
+ import { z } from "zod";
15
+
16
+ const RuntimeIdentitySchema = z.object({ version: z.string().min(1) });
14
17
 
15
18
  /**
16
19
  * Thrown when the local runtime returns 409 for an export/import request
@@ -66,15 +69,23 @@ async function migrationRequestHeaders(
66
69
  return bearerHeaders(token);
67
70
  }
68
71
 
69
- interface Raw409Body {
70
- detail?: string;
72
+ const Raw409BodySchema = z.object({
73
+ detail: z.string().optional(),
71
74
  // The runtime's current 409 contract nests the payload under `error`:
72
75
  // { error: { code: "export_in_progress" | "import_in_progress", job_id } }
73
76
  // We also tolerate a legacy flat shape ({ code, job_id }) for resilience.
74
- error?: string | { code?: string; job_id?: string };
75
- code?: string;
76
- job_id?: string;
77
- }
77
+ error: z
78
+ .union([
79
+ z.string(),
80
+ z.object({
81
+ code: z.string().optional(),
82
+ job_id: z.string().optional(),
83
+ }),
84
+ ])
85
+ .optional(),
86
+ code: z.string().optional(),
87
+ job_id: z.string().optional(),
88
+ });
78
89
 
79
90
  /** Common 409 → MigrationInProgressError parsing used by the two POST helpers. */
80
91
  async function throwIfInProgress(
@@ -82,11 +93,11 @@ async function throwIfInProgress(
82
93
  defaultKind: "export_in_progress" | "import_in_progress",
83
94
  ): Promise<void> {
84
95
  if (response.status !== 409) return;
85
- const body = (await response.json().catch(() => ({}))) as Raw409Body;
86
- const nested =
87
- typeof body.error === "object" && body.error !== null
88
- ? body.error
89
- : undefined;
96
+ const parsed = Raw409BodySchema.safeParse(
97
+ await response.json().catch(() => ({})),
98
+ );
99
+ const body = parsed.success ? parsed.data : {};
100
+ const nested = typeof body.error === "object" ? body.error : undefined;
90
101
  const jobId = nested?.job_id ?? body.job_id ?? "";
91
102
  const rawKind =
92
103
  nested?.code ??
@@ -355,9 +366,9 @@ export async function localRuntimeIdentity(
355
366
  `Failed to fetch runtime identity: ${response.status} ${response.statusText}`,
356
367
  );
357
368
  }
358
- const body = (await response.json()) as { version?: unknown };
359
- if (typeof body.version !== "string" || !body.version) {
369
+ const parsed = RuntimeIdentitySchema.safeParse(await response.json());
370
+ if (!parsed.success) {
360
371
  throw new Error("Runtime identity response missing version");
361
372
  }
362
- return { version: body.version };
373
+ return { version: parsed.data.version };
363
374
  }
package/src/lib/ngrok.ts CHANGED
@@ -11,21 +11,36 @@ import {
11
11
  saveIngressUrl,
12
12
  saveNgrokDomain,
13
13
  } from "./ingress-config.js";
14
- import { isJsonObject } from "./json";
15
14
  import { loopbackSafeFetch } from "./loopback-fetch.js";
15
+ import { z } from "zod";
16
16
 
17
17
  const NGROK_API_URL = "http://127.0.0.1:4040/api/tunnels";
18
18
  const NGROK_POLL_INTERVAL_MS = 500;
19
19
  const NGROK_POLL_TIMEOUT_MS = 15_000;
20
20
 
21
- interface NgrokTunnel {
22
- public_url: string;
23
- config?: { addr?: string };
24
- }
21
+ const NgrokTunnelSchema = z.object({
22
+ public_url: z.string(),
23
+ config: z
24
+ .object({
25
+ addr: z.string().optional(),
26
+ })
27
+ .optional(),
28
+ });
25
29
 
26
- interface NgrokTunnelsResponse {
27
- tunnels: NgrokTunnel[];
28
- }
30
+ type NgrokTunnel = z.infer<typeof NgrokTunnelSchema>;
31
+
32
+ const NgrokTunnelsResponseSchema = z.object({
33
+ tunnels: z.array(NgrokTunnelSchema).optional(),
34
+ });
35
+
36
+ /**
37
+ * The ingress block of the workspace config, as read by the tunnel edge:
38
+ * explicitly enabled with a saved public URL.
39
+ */
40
+ export const IngressConfigSchema = z.object({
41
+ enabled: z.boolean().optional(),
42
+ publicBaseUrl: z.string().optional(),
43
+ });
29
44
 
30
45
  /**
31
46
  * Check whether ngrok is installed and accessible on the PATH.
@@ -55,8 +70,8 @@ async function queryNgrokTunnels(): Promise<NgrokTunnel[] | null> {
55
70
  signal: AbortSignal.timeout(2_000),
56
71
  });
57
72
  if (!res.ok) return null;
58
- const data = (await res.json()) as NgrokTunnelsResponse;
59
- return data.tunnels ?? [];
73
+ const parsed = NgrokTunnelsResponseSchema.safeParse(await res.json());
74
+ return parsed.success ? (parsed.data.tunnels ?? []) : null;
60
75
  } catch {
61
76
  return null;
62
77
  }
@@ -195,13 +210,27 @@ export async function waitForNgrokUrl(
195
210
  * Check whether an already-loaded workspace config has webhook-based
196
211
  * integrations (e.g. Telegram, Twilio) that require a public ingress URL.
197
212
  */
213
+ const TelegramIntegrationSchema = z.object({
214
+ botUsername: z.unknown().optional(),
215
+ });
216
+
217
+ const TwilioIntegrationSchema = z.object({
218
+ accountSid: z.unknown().optional(),
219
+ phoneNumber: z.unknown().optional(),
220
+ });
221
+
198
222
  export function hasWebhookIntegrations(
199
223
  config: Record<string, unknown>,
200
224
  ): boolean {
201
- const telegram = isJsonObject(config.telegram) ? config.telegram : undefined;
202
- if (telegram?.botUsername) return true;
203
- const twilio = isJsonObject(config.twilio) ? config.twilio : undefined;
204
- if (twilio?.accountSid || twilio?.phoneNumber) return true;
225
+ const telegram = TelegramIntegrationSchema.safeParse(config.telegram);
226
+ if (telegram.success && telegram.data.botUsername) return true;
227
+ const twilio = TwilioIntegrationSchema.safeParse(config.twilio);
228
+ if (
229
+ twilio.success &&
230
+ (twilio.data.accountSid || twilio.data.phoneNumber)
231
+ ) {
232
+ return true;
233
+ }
205
234
  return false;
206
235
  }
207
236
 
@@ -227,9 +256,11 @@ export function hasWebhookIntegrationsConfigured(
227
256
  function hasNonNgrokIngressUrl(workspaceDir: string): boolean {
228
257
  try {
229
258
  const config = loadRawConfig(workspaceDir);
230
- const ingress = isJsonObject(config.ingress) ? config.ingress : undefined;
231
- const publicBaseUrl = ingress?.publicBaseUrl;
232
- if (!publicBaseUrl || typeof publicBaseUrl !== "string") return false;
259
+ const parsed = IngressConfigSchema.safeParse(config.ingress);
260
+ const publicBaseUrl = parsed.success
261
+ ? parsed.data.publicBaseUrl
262
+ : undefined;
263
+ if (!publicBaseUrl) return false;
233
264
  return !publicBaseUrl.includes("ngrok");
234
265
  } catch {
235
266
  return false;