kadi-deploy 0.19.0

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 (52) hide show
  1. package/.env.example +6 -0
  2. package/.prettierrc +6 -0
  3. package/README.md +589 -0
  4. package/agent.json +23 -0
  5. package/index.js +11 -0
  6. package/package.json +42 -0
  7. package/quick-command.txt +92 -0
  8. package/scripts/preflight.js +458 -0
  9. package/scripts/preflight.sh +300 -0
  10. package/src/cli/bid-selector.ts +222 -0
  11. package/src/cli/colors.ts +216 -0
  12. package/src/cli/index.ts +11 -0
  13. package/src/cli/prompts.ts +190 -0
  14. package/src/cli/spinners.ts +165 -0
  15. package/src/commands/deploy-local.ts +475 -0
  16. package/src/commands/deploy.ts +1342 -0
  17. package/src/commands/down.ts +679 -0
  18. package/src/commands/index.ts +10 -0
  19. package/src/commands/lock.ts +571 -0
  20. package/src/config/agent-loader.ts +177 -0
  21. package/src/config/index.ts +9 -0
  22. package/src/display/deployment-info.ts +220 -0
  23. package/src/display/pricing.ts +137 -0
  24. package/src/display/resources.ts +234 -0
  25. package/src/enhanced-registry-manager.ts +892 -0
  26. package/src/index.ts +307 -0
  27. package/src/infrastructure/registry.ts +269 -0
  28. package/src/schemas/profiles.ts +529 -0
  29. package/src/secrets/broker-urls.ts +109 -0
  30. package/src/secrets/handshake.ts +407 -0
  31. package/src/secrets/index.ts +69 -0
  32. package/src/secrets/inject-env.ts +171 -0
  33. package/src/secrets/nonce.ts +31 -0
  34. package/src/secrets/normalize.ts +204 -0
  35. package/src/secrets/prepare.ts +152 -0
  36. package/src/secrets/validate.ts +243 -0
  37. package/src/secrets/vault.ts +80 -0
  38. package/src/types/akash.ts +116 -0
  39. package/src/types/container-registry-ability.d.ts +158 -0
  40. package/src/types/external.ts +49 -0
  41. package/src/types.ts +211 -0
  42. package/src/utils/akt-price.ts +74 -0
  43. package/tests/agent-loader.test.ts +239 -0
  44. package/tests/autonomous.test.ts +244 -0
  45. package/tests/down.test.ts +1143 -0
  46. package/tests/lock.test.ts +1148 -0
  47. package/tests/nonce.test.ts +34 -0
  48. package/tests/normalize.test.ts +270 -0
  49. package/tests/secrets-schema.test.ts +301 -0
  50. package/tests/types.test.ts +198 -0
  51. package/tsconfig.json +18 -0
  52. package/vitest.config.ts +9 -0
@@ -0,0 +1,529 @@
1
+ /**
2
+ * Zod Schemas for Profile Validation
3
+ *
4
+ * Validates agent.json deploy profiles at runtime to catch configuration errors early.
5
+ * Provides clear error messages when profiles are malformed or missing required fields.
6
+ *
7
+ * @module schemas/profiles
8
+ */
9
+
10
+ import { z } from 'zod';
11
+ import type {
12
+ AkashRegion,
13
+ AkashTier,
14
+ } from '@kadi.build/deploy-ability';
15
+
16
+ /**
17
+ * Port exposure configuration for services
18
+ *
19
+ * Supports both simple string format and Akash SDL object format for 'to' field
20
+ */
21
+ const PortExposeSchema = z.object({
22
+ /** Port number to expose */
23
+ port: z.number().int().min(1).max(65535),
24
+
25
+ /** Port number to expose as (required - be explicit about external port) */
26
+ as: z.number().int().min(1).max(65535),
27
+
28
+ /** Where to expose the port - defaults to local only */
29
+ to: z.array(
30
+ z.union([
31
+ z.string(),
32
+ z.object({ global: z.boolean() }).passthrough(),
33
+ z.object({ service: z.string() }).passthrough()
34
+ ])
35
+ ).default([{ global: false }]),
36
+
37
+ /** Protocol (tcp or udp) */
38
+ proto: z.enum(['tcp', 'udp']).optional(),
39
+
40
+ /** Enable global exposure (alternative to using 'to' field) */
41
+ global: z.boolean().optional(),
42
+
43
+ /** Accept list for global exposure */
44
+ accept: z.array(z.string()).optional(),
45
+ });
46
+
47
+ /**
48
+ * Registry credentials for private images
49
+ */
50
+ const RegistryCredentialsSchema = z.object({
51
+ /** Registry host (e.g., "ghcr.io") */
52
+ host: z.string(),
53
+
54
+ /** Registry username */
55
+ username: z.string(),
56
+
57
+ /** Registry password or token */
58
+ password: z.string(),
59
+ });
60
+
61
+ /**
62
+ * Persistent volume specification for Akash Network
63
+ */
64
+ const PersistentVolumeSchema = z.object({
65
+ /** Volume name (must be unique within service) */
66
+ name: z.string(),
67
+
68
+ /** Volume size (e.g., "10Gi") */
69
+ size: z.string(),
70
+
71
+ /** Container mount path (e.g., "/data") */
72
+ mount: z.string(),
73
+
74
+ /** Storage class: beta1 (HDD), beta2 (SSD), beta3 (NVMe), ram */
75
+ class: z.enum(['beta1', 'beta2', 'beta3', 'ram']).optional(),
76
+ });
77
+
78
+ /**
79
+ * GPU configuration schema (shared between Akash and Local)
80
+ *
81
+ * If requesting GPU, you must specify vendor and model details.
82
+ * Example: { units: 1, attributes: { vendor: { nvidia: [{ model: "rtx4090" }] } } }
83
+ */
84
+ const GpuConfigSchema = z.object({
85
+ /** Number of GPU units */
86
+ units: z.number().int().positive(),
87
+
88
+ /**
89
+ * GPU attributes - REQUIRED when requesting GPU
90
+ * Specifies vendor and model requirements
91
+ */
92
+ attributes: z.object({
93
+ vendor: z.record(
94
+ z.string(),
95
+ z.array(
96
+ z.object({
97
+ /** GPU model - required so you know what you're getting */
98
+ model: z.string(),
99
+ ram: z.string().optional(),
100
+ interface: z.enum(['pcie', 'sxm']).optional(),
101
+ })
102
+ )
103
+ ),
104
+ }),
105
+ });
106
+
107
+ /**
108
+ * Resource configuration for Akash deployments
109
+ *
110
+ * cpu and memory are REQUIRED for Akash since the network needs to
111
+ * know what resources to allocate.
112
+ */
113
+ const AkashResourceConfigSchema = z.object({
114
+ /** CPU units (e.g., 0.5 for half a CPU) - REQUIRED */
115
+ cpu: z.number().positive(),
116
+
117
+ /** Memory (RAM) with units (e.g., "512Mi", "2Gi") - REQUIRED */
118
+ memory: z.string(),
119
+
120
+ /** Ephemeral storage for container root filesystem (e.g., "512Mi", "1Gi") */
121
+ ephemeralStorage: z.string().optional(),
122
+
123
+ /** Persistent volumes that survive container restarts */
124
+ persistentVolumes: z.array(PersistentVolumeSchema).optional(),
125
+
126
+ /** GPU configuration */
127
+ gpu: GpuConfigSchema.optional(),
128
+ });
129
+
130
+ /**
131
+ * Resource configuration for Local deployments
132
+ *
133
+ * All fields optional since Docker/Podman handle defaults.
134
+ */
135
+ const LocalResourceConfigSchema = z.object({
136
+ /** CPU units (e.g., 0.5 for half a CPU) */
137
+ cpu: z.number().positive().optional(),
138
+
139
+ /** Memory (RAM) with units (e.g., "512Mi", "2Gi") */
140
+ memory: z.string().optional(),
141
+
142
+ /** Ephemeral storage for container root filesystem (e.g., "512Mi", "1Gi") */
143
+ ephemeralStorage: z.string().optional(),
144
+
145
+ /** Persistent volumes that survive container restarts */
146
+ persistentVolumes: z.array(PersistentVolumeSchema).optional(),
147
+
148
+ /** GPU configuration */
149
+ gpu: GpuConfigSchema.optional(),
150
+ });
151
+
152
+ /**
153
+ * Akash pricing configuration
154
+ */
155
+ const AkashPricingSchema = z.object({
156
+ /** Denomination (e.g., "uakt") */
157
+ denom: z.string(),
158
+
159
+ /** Amount (number or string) */
160
+ amount: z.union([z.number(), z.string()]),
161
+ });
162
+
163
+ /**
164
+ * Valid Akash region values (typed for validation)
165
+ *
166
+ * These are the **actual values used by providers on Akash mainnet**,
167
+ * not the official schema values. Based on provider query results.
168
+ *
169
+ * Provider counts (as of query):
170
+ * - us-west: 5, us-central: 4, us-east: 3
171
+ * - eu-central: 2, ca-central: 2, ca-east: 2
172
+ * - Others: 1-2 each
173
+ */
174
+ const VALID_REGIONS: readonly AkashRegion[] = [
175
+ // United States (most common)
176
+ 'us-west', 'us-central', 'us-east', 'us-west-1', 'us',
177
+ // Canada
178
+ 'ca-central', 'ca-east', 'ca',
179
+ // Europe
180
+ 'eu-central', 'eu-west', 'eu-east', 'europe', 'eu',
181
+ // Asia
182
+ 'asia-east', 'singapore',
183
+ // Other regions (less common)
184
+ 'westmidlands', 'westeurope', 'westcoast',
185
+ ];
186
+
187
+ /**
188
+ * Valid Akash tier values (typed for validation)
189
+ *
190
+ * Provider tiers are one of the most commonly used attributes (37+ providers).
191
+ * - community: 35 providers (standard pricing)
192
+ * - premium: 2 providers (higher SLA)
193
+ */
194
+ const VALID_TIERS: readonly AkashTier[] = [
195
+ 'community', 'premium',
196
+ ];
197
+
198
+ /**
199
+ * Akash placement attributes for geographic targeting
200
+ *
201
+ * Based on **actual provider usage on mainnet**, not the official schema.
202
+ * Only includes attributes that providers actually use.
203
+ *
204
+ * **Available Attributes:**
205
+ * - region: 33 providers use this
206
+ * - tier: 37 providers use this
207
+ */
208
+ const AkashPlacementAttributesSchema = z.object({
209
+ /** Geographic region */
210
+ region: z.string()
211
+ .refine((val): val is AkashRegion => VALID_REGIONS.includes(val as AkashRegion), {
212
+ message: `Invalid region. Valid regions: ${VALID_REGIONS.slice(0, 8).join(', ')}... (${VALID_REGIONS.length} total). Examples: 'us-west' (5 providers), 'us-central' (4 providers), 'eu-central' (2 providers)`,
213
+ })
214
+ .optional(),
215
+
216
+ /** Provider tier (community or premium) */
217
+ tier: z.string()
218
+ .refine((val): val is AkashTier => VALID_TIERS.includes(val as AkashTier), {
219
+ message: `Invalid tier. Valid tiers: ${VALID_TIERS.join(', ')}. Note: 'community' (35 providers), 'premium' (2 providers)`,
220
+ })
221
+ .optional(),
222
+ });
223
+
224
+ /**
225
+ * Auditor signature requirements for provider attributes
226
+ */
227
+ const SignedBySchema = z.object({
228
+ /** All of these auditors must have signed */
229
+ allOf: z.array(z.string()).optional(),
230
+
231
+ /** Any of these auditors must have signed */
232
+ anyOf: z.array(z.string()).optional(),
233
+ });
234
+
235
+ /**
236
+ * Vault name validation (reused across schemas)
237
+ */
238
+ const VaultNameSchema = z.string()
239
+ .min(1, 'Vault name cannot be empty')
240
+ .regex(/^[a-zA-Z0-9_-]+$/, 'Vault name must contain only alphanumeric characters, hyphens, and underscores');
241
+
242
+ /**
243
+ * Individual vault source for multi-vault secrets configuration.
244
+ * Each entry specifies a vault and its associated secret keys.
245
+ */
246
+ const VaultSourceSchema = z.object({
247
+ /** Vault name to read secrets from (must exist in kadi-secret) */
248
+ vault: VaultNameSchema,
249
+
250
+ /** Secret names that must exist before deployment (deploy fails if missing) */
251
+ required: z.array(z.string()).optional(),
252
+
253
+ /** Secret names that will be shared if available (no error if missing) */
254
+ optional: z.array(z.string()).optional(),
255
+ });
256
+
257
+ /**
258
+ * Secrets configuration for deployment
259
+ *
260
+ * Declares which secrets the deployed agent needs. These are validated
261
+ * before deployment to ensure they exist in the specified vault(s) (via kadi-secret).
262
+ *
263
+ * Supports two formats:
264
+ *
265
+ * **Legacy (single vault):**
266
+ * ```json
267
+ * { "vault": "my-app", "required": ["API_KEY"], "delivery": "broker" }
268
+ * ```
269
+ *
270
+ * **Multi-vault:**
271
+ * ```json
272
+ * {
273
+ * "vaults": [
274
+ * { "vault": "my-app", "required": ["API_KEY"] },
275
+ * { "vault": "infra", "required": ["TUNNEL_TOKEN"] }
276
+ * ],
277
+ * "delivery": "broker"
278
+ * }
279
+ * ```
280
+ *
281
+ * Delivery modes:
282
+ * - "env" (default): Secrets injected as plain environment variables
283
+ * - "broker": E2E encrypted handshake via broker (requires KADI CLI/SDK in container)
284
+ */
285
+ const SecretsSchema = z.union([
286
+ // Legacy single-vault format (backwards compatible)
287
+ z.object({
288
+ /** Vault name to read secrets from (must exist in kadi-secret) */
289
+ vault: VaultNameSchema,
290
+
291
+ /** Secret names that must exist before deployment (deploy fails if missing) */
292
+ required: z.array(z.string()).optional(),
293
+
294
+ /** Secret names that will be shared if available (no error if missing) */
295
+ optional: z.array(z.string()).optional(),
296
+
297
+ /**
298
+ * Secret delivery mode:
299
+ * - "env": Inject secrets as plain environment variables (works with any container)
300
+ * - "broker": E2E encrypted handshake via broker (requires KADI CLI or SDK)
301
+ * Default: "env"
302
+ */
303
+ delivery: z.enum(['env', 'broker']).optional(),
304
+ }),
305
+
306
+ // New multi-vault format
307
+ z.object({
308
+ /** Array of vault sources, each with their own required/optional keys */
309
+ vaults: z.array(VaultSourceSchema).min(1, 'At least one vault source is required'),
310
+
311
+ /**
312
+ * Secret delivery mode (applies to all vaults):
313
+ * - "env": Inject secrets as plain environment variables (works with any container)
314
+ * - "broker": E2E encrypted handshake via broker (requires KADI CLI or SDK)
315
+ * Default: "env"
316
+ */
317
+ delivery: z.enum(['env', 'broker']).optional(),
318
+ }),
319
+ ]);
320
+
321
+ /**
322
+ * Akash service definition
323
+ */
324
+ const AkashServiceSchema = z.object({
325
+ /** Container image (e.g., "nginx:latest") */
326
+ image: z.string(),
327
+
328
+ /** Optional command override */
329
+ command: z.array(z.string()).optional(),
330
+
331
+ /** Environment variables (array or object format) */
332
+ env: z.union([
333
+ z.array(z.string()),
334
+ z.record(z.string(), z.string())
335
+ ]).optional(),
336
+
337
+ /** Port exposures */
338
+ expose: z.array(PortExposeSchema).optional(),
339
+
340
+ /** Registry credentials for private images */
341
+ credentials: RegistryCredentialsSchema.optional(),
342
+
343
+ /** Resource requirements */
344
+ resources: AkashResourceConfigSchema.optional(),
345
+
346
+ /** Pricing configuration */
347
+ pricing: AkashPricingSchema.optional(),
348
+
349
+ /** Number of instances */
350
+ count: z.number().int().positive().optional(),
351
+ });
352
+
353
+ /**
354
+ * Local service definition (for Docker/Podman)
355
+ */
356
+ const LocalServiceSchema = z.object({
357
+ /** Container image (e.g., "nginx:latest") */
358
+ image: z.string(),
359
+
360
+ /** Optional command override */
361
+ command: z.array(z.string()).optional(),
362
+
363
+ /** Environment variables (array format) */
364
+ env: z.array(z.string()).optional(),
365
+
366
+ /** Port exposures */
367
+ expose: z.array(PortExposeSchema).optional(),
368
+
369
+ /** Resource requirements */
370
+ resources: LocalResourceConfigSchema.optional(),
371
+
372
+ /** Docker/Podman volume mounts (e.g., "/host/path:/container/path:ro") */
373
+ volumes: z.array(z.string()).optional(),
374
+
375
+ /** Working directory inside the container */
376
+ workingDir: z.string().optional(),
377
+
378
+ /** Services this service depends on */
379
+ dependsOn: z.array(z.string()).optional(),
380
+
381
+ /** Restart policy (e.g., "unless-stopped", "always", "on-failure") */
382
+ restart: z.string().optional(),
383
+ });
384
+
385
+ /**
386
+ * Akash deployment profile schema
387
+ */
388
+ export const AkashProfileSchema = z.object({
389
+ /** Deployment target (must be 'akash') */
390
+ target: z.literal('akash'),
391
+
392
+ /** Network to deploy to */
393
+ network: z.enum(['mainnet', 'testnet']),
394
+
395
+ /** Container engine for local operations */
396
+ engine: z.enum(['docker', 'podman']).optional(),
397
+
398
+ /** Service definitions */
399
+ services: z.record(z.string(), AkashServiceSchema),
400
+
401
+ /** Geographic and facility placement constraints for deployment targeting */
402
+ placement: AkashPlacementAttributesSchema.optional(),
403
+
404
+ /** Custom provider attributes (advanced - prefer using 'placement' for geographic targeting) */
405
+ attributes: z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])).optional(),
406
+
407
+ /** Auditor signature requirements for provider attributes */
408
+ signedBy: SignedBySchema.optional(),
409
+
410
+ /** WalletConnect project ID */
411
+ walletConnectProjectId: z.string().optional(),
412
+
413
+ /** Path to certificate file */
414
+ cert: z.string().optional(),
415
+
416
+ /** Provider blacklist */
417
+ blacklist: z.array(z.string()).optional(),
418
+
419
+ /** Deployment deposit in AKT (default: 5 AKT) */
420
+ deposit: z.coerce.number().positive().optional(),
421
+
422
+ /** CLI options */
423
+ verbose: z.boolean().optional(),
424
+ showCommands: z.boolean().optional(),
425
+ yes: z.boolean().optional(),
426
+ dryRun: z.boolean().optional(),
427
+
428
+ /** Secrets to validate and share with the deployed agent */
429
+ secrets: SecretsSchema.optional(),
430
+ });
431
+
432
+ /**
433
+ * Local deployment profile schema
434
+ */
435
+ export const LocalProfileSchema = z.object({
436
+ /** Deployment target (must be 'local') */
437
+ target: z.literal('local'),
438
+
439
+ /** Container engine (required) */
440
+ engine: z.enum(['docker', 'podman']),
441
+
442
+ /** Docker network name */
443
+ network: z.string().optional(),
444
+
445
+ /** Service definitions */
446
+ services: z.record(z.string(), LocalServiceSchema),
447
+
448
+ /** CLI options */
449
+ verbose: z.boolean().optional(),
450
+ showCommands: z.boolean().optional(),
451
+ yes: z.boolean().optional(),
452
+ dryRun: z.boolean().optional(),
453
+
454
+ /** Secrets to validate and share with the deployed agent */
455
+ secrets: SecretsSchema.optional(),
456
+ });
457
+
458
+ /**
459
+ * Union of all profile types
460
+ */
461
+ export const ProfileSchema = z.union([
462
+ AkashProfileSchema,
463
+ LocalProfileSchema,
464
+ ]);
465
+
466
+ /**
467
+ * Inferred TypeScript types from Zod schemas
468
+ */
469
+ export type AkashProfile = z.infer<typeof AkashProfileSchema>;
470
+ export type LocalProfile = z.infer<typeof LocalProfileSchema>;
471
+ export type Profile = z.infer<typeof ProfileSchema>;
472
+
473
+ /**
474
+ * Validate and parse a profile from agent.json
475
+ *
476
+ * @param profileData - Raw profile data from agent.json
477
+ * @param profileName - Name of the profile (for error messages)
478
+ * @returns Validated profile
479
+ * @throws ZodError with detailed validation errors
480
+ *
481
+ * @example
482
+ * ```typescript
483
+ * try {
484
+ * const profile = validateProfile(rawData, 'production');
485
+ * // profile is now fully typed and validated
486
+ * } catch (error) {
487
+ * if (error instanceof z.ZodError) {
488
+ * console.error('Profile validation failed:', error.format());
489
+ * }
490
+ * }
491
+ * ```
492
+ */
493
+ export function validateProfile(profileData: unknown, profileName: string): Profile {
494
+ try {
495
+ return ProfileSchema.parse(profileData);
496
+ } catch (error) {
497
+ if (error instanceof z.ZodError) {
498
+ // Add context to error message with detailed issue information
499
+ const formattedErrors = error.issues.map((issue: z.ZodIssue) => {
500
+ const path = issue.path.length > 0 ? issue.path.join('.') : 'root';
501
+ const expected = 'expected' in issue ? ` (expected: ${JSON.stringify(issue.expected)})` : '';
502
+ const received = 'received' in issue ? ` (received: ${JSON.stringify(issue.received)})` : '';
503
+ return ` - ${path}: ${issue.message}${expected}${received}`;
504
+ }).join('\n');
505
+
506
+ // Also log the raw profile data for debugging
507
+ console.error('[DEBUG] Raw profile data:', JSON.stringify(profileData, null, 2));
508
+
509
+ throw new Error(
510
+ `Profile "${profileName}" validation failed:\n${formattedErrors}`
511
+ );
512
+ }
513
+ throw error;
514
+ }
515
+ }
516
+
517
+ /**
518
+ * Type guard to check if profile is an Akash profile
519
+ */
520
+ export function isAkashProfile(profile: Profile): profile is AkashProfile {
521
+ return profile.target === 'akash';
522
+ }
523
+
524
+ /**
525
+ * Type guard to check if profile is a Local profile
526
+ */
527
+ export function isLocalProfile(profile: Profile): profile is LocalProfile {
528
+ return profile.target === 'local';
529
+ }
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Broker URL Resolution for Secrets Injection
3
+ *
4
+ * Resolves broker URLs from environment or agent.json configuration.
5
+ * The deployed agent needs broker URLs to request secrets from the deployer.
6
+ *
7
+ * Resolution order:
8
+ * 1. KADI_BROKER_URLS environment variable (comma-separated)
9
+ * 2. agent.json brokers field (object mapping names to URLs)
10
+ * 3. Fail fast with helpful error if neither found
11
+ *
12
+ * @module secrets/broker-urls
13
+ */
14
+
15
+ import type { AgentConfig } from '../types.js';
16
+
17
+ /**
18
+ * Result of broker URL resolution
19
+ */
20
+ export interface BrokerUrlsResult {
21
+ /** Whether broker URLs were found */
22
+ found: boolean;
23
+ /** Comma-separated broker URLs (for KADI_BROKER_URLS env var) */
24
+ urls: string;
25
+ /** Source of the URLs for logging */
26
+ source: 'env' | 'agent.json' | 'none';
27
+ }
28
+
29
+ /**
30
+ * Resolve broker URLs from environment or agent.json
31
+ *
32
+ * @param agentConfig - Loaded agent.json configuration
33
+ * @returns Resolution result with URLs and source
34
+ *
35
+ * @example
36
+ * ```typescript
37
+ * const result = resolveBrokerUrls(agentConfig);
38
+ *
39
+ * if (!result.found) {
40
+ * throw new Error('No broker URLs configured');
41
+ * }
42
+ *
43
+ * // Inject into container env
44
+ * env.push(`KADI_BROKER_URLS=${result.urls}`);
45
+ * ```
46
+ */
47
+ export function resolveBrokerUrls(agentConfig: AgentConfig): BrokerUrlsResult {
48
+ // 1. Check KADI_BROKER_URLS env var first (takes precedence)
49
+ const envUrls = process.env.KADI_BROKER_URLS;
50
+ if (envUrls) {
51
+ const urls = envUrls
52
+ .split(',')
53
+ .map(u => u.trim())
54
+ .filter(u => u.length > 0);
55
+
56
+ if (urls.length > 0) {
57
+ return {
58
+ found: true,
59
+ urls: urls.join(','),
60
+ source: 'env',
61
+ };
62
+ }
63
+ }
64
+
65
+ // 2. Fall back to agent.json brokers field
66
+ const brokers = agentConfig.brokers;
67
+ if (brokers && typeof brokers === 'object') {
68
+ const urls = Object.values(brokers).filter(
69
+ (url): url is string => typeof url === 'string' && url.length > 0
70
+ );
71
+
72
+ if (urls.length > 0) {
73
+ return {
74
+ found: true,
75
+ urls: urls.join(','),
76
+ source: 'agent.json',
77
+ };
78
+ }
79
+ }
80
+
81
+ // 3. Not found
82
+ return {
83
+ found: false,
84
+ urls: '',
85
+ source: 'none',
86
+ };
87
+ }
88
+
89
+ /**
90
+ * Format error message for missing broker URLs
91
+ *
92
+ * @returns Formatted error message with instructions
93
+ */
94
+ export function formatMissingBrokerUrlsError(): string {
95
+ return `No broker URLs configured.
96
+
97
+ The deployed agent needs broker URLs to request secrets.
98
+ Configure them in one of these ways:
99
+
100
+ 1. Set KADI_BROKER_URLS environment variable:
101
+ export KADI_BROKER_URLS=ws://broker.example.com:8080/kadi
102
+
103
+ 2. Add brokers to agent.json:
104
+ {
105
+ "brokers": {
106
+ "default": "ws://broker.example.com:8080/kadi"
107
+ }
108
+ }`;
109
+ }