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,1342 @@
1
+ /**
2
+ * Deploy Command - Akash Network Integration
3
+ *
4
+ * Main deployment command that uses deploy-ability library for Akash deployments.
5
+ * Handles the UX (QR codes, spinners, confirmations) while deploy-ability does the work.
6
+ *
7
+ * @module commands/deploy
8
+ */
9
+
10
+ // Polyfill localStorage for WalletConnect (requires browser API in Node.js)
11
+ import { LocalStorage } from 'node-localstorage';
12
+ import os from 'node:os';
13
+ import path from 'node:path';
14
+
15
+ const kadiHome = path.join(os.homedir(), '.kadi');
16
+ (globalThis as any).localStorage = new LocalStorage(kadiHome);
17
+
18
+ import {
19
+ deployToAkash,
20
+ connectWallet,
21
+ disconnectWallet,
22
+ AkashClient,
23
+ type EnhancedBid,
24
+ type BidFilterCriteria,
25
+ type SecretsProvider,
26
+ } from '@kadi.build/deploy-ability/akash';
27
+ import type { AkashDeploymentData, AkashDryRunData, AutonomousDeploymentConfig } from '@kadi.build/deploy-ability/types';
28
+
29
+ import { loadAgentConfig, getProfile, getFirstProfile } from '../config/agent-loader.js';
30
+ import {
31
+ waitForSecretRequest,
32
+ shareSecrets,
33
+ sendRejection,
34
+ readSecretsFromCli,
35
+ readSecretFromCli,
36
+ prepareSecretsForDeployment,
37
+ performSecretHandshake,
38
+ type WaitForSecretsResult,
39
+ } from '../secrets/index.js';
40
+ import { normalizeSecrets, hasAnySecrets, type NormalizedVaultSource } from '../secrets/normalize.js';
41
+ import { startSpinner, succeedSpinner, failSpinner } from '../cli/spinners.js';
42
+ import { confirmPrompt, selectPrompt, textPrompt } from '../cli/prompts.js';
43
+ import { error as errorColor, warning, success as successColor, dim, bold, highlight } from '../cli/colors.js';
44
+ import { selectBidInteractively, sortBidsByQuality } from '../cli/bid-selector.js';
45
+ import { fetchAktPriceUSD } from '../utils/akt-price.js';
46
+ import fs from 'node:fs/promises';
47
+ import { readFileSync } from 'node:fs';
48
+ import {
49
+ displayDeploymentInfo,
50
+ displayDeploymentSummary,
51
+ displayDryRunInfo,
52
+ displayProfileInfo
53
+ } from '../display/deployment-info.js';
54
+ import { setupRegistryIfNeeded } from '../infrastructure/registry.js';
55
+ import { buildAkashLock, writeLockFile } from './lock.js';
56
+
57
+ import QRCode from 'qrcode-terminal';
58
+ import type { DeploymentContext } from '../types.js';
59
+
60
+ /**
61
+ * Executes Akash Network deployment using deploy-ability
62
+ *
63
+ * Flow mirrors old kadi-deploy but uses deploy-ability library:
64
+ * 1. Load profile
65
+ * 2. Connect wallet (WalletConnect QR code)
66
+ * 3. Get signing client
67
+ * 4. Ensure certificate exists
68
+ * 5. Deploy (deploy-ability does the heavy lifting)
69
+ * 6. Display results
70
+ *
71
+ * @param ctx - Deployment context from CLI
72
+ */
73
+ export async function executeAkashDeployment(ctx: DeploymentContext): Promise<void> {
74
+ const { logger, projectRoot, flags } = ctx;
75
+
76
+ // Track exit code to force process exit after cleanup (0 = success, 1 = failure, null = don't exit)
77
+ // This is necessary because background timers in the registry keep Node.js alive
78
+ let exitCode: number | null = null;
79
+
80
+ // ----------------------------------------------------------------
81
+ // 1. Load agent configuration
82
+ // ----------------------------------------------------------------
83
+ let spinner = startSpinner('Loading agent configuration...');
84
+
85
+ let agentConfig;
86
+ try {
87
+ agentConfig = await loadAgentConfig(projectRoot);
88
+ succeedSpinner(spinner, `Loaded agent: ${agentConfig.name}`);
89
+ } catch (err) {
90
+ failSpinner(spinner, 'Failed to load agent.json');
91
+ logger.error(errorColor((err as Error).message));
92
+ return;
93
+ }
94
+
95
+ // ----------------------------------------------------------------
96
+ // 2. Resolve profile
97
+ // ----------------------------------------------------------------
98
+ const profileName = flags.profile || getFirstProfile(agentConfig);
99
+
100
+ if (!profileName) {
101
+ logger.error('No deploy profiles found in agent.json');
102
+ logger.log('Add a deploy profile under the "deploy" field in agent.json');
103
+ return;
104
+ }
105
+
106
+ const profile = getProfile(agentConfig, profileName);
107
+ if (!profile) {
108
+ logger.error(`Profile "${profileName}" not found in agent.json`);
109
+ return;
110
+ }
111
+
112
+ // Validate it's an Akash profile
113
+ if (profile.target !== 'akash') {
114
+ logger.error(`Profile "${profileName}" is not an Akash profile (target: ${profile.target})`);
115
+ return;
116
+ }
117
+
118
+ // TypeScript type narrowing: profile is now AkashProfile
119
+ type AkashProfileType = typeof profile & { target: 'akash' };
120
+ const akashProfile = profile as AkashProfileType;
121
+
122
+ const network = akashProfile.network || (flags.network as 'mainnet' | 'testnet') || 'testnet';
123
+ logger.log(successColor(`✓ Using profile: ${profileName} (${network})`));
124
+
125
+ // ----------------------------------------------------------------
126
+ // 2.5. SECRETS INJECTION
127
+ // If profile declares secrets, inject KADI_* env vars into all services
128
+ // Supports both legacy single-vault and multi-vault configurations.
129
+ // ----------------------------------------------------------------
130
+ const normalizedSecrets = normalizeSecrets(akashProfile.secrets as any);
131
+ const hasSecrets = hasAnySecrets(normalizedSecrets);
132
+
133
+ let deployNonce: string | undefined;
134
+ let brokerUrl: string | null = null;
135
+ let secretsVault: string | undefined;
136
+ let secretsSources: NormalizedVaultSource[] = [];
137
+
138
+ if (hasSecrets) {
139
+ const result = prepareSecretsForDeployment({
140
+ profile: akashProfile,
141
+ agentConfig,
142
+ projectRoot,
143
+ logger,
144
+ colors: { dim, success: successColor, error: errorColor },
145
+ });
146
+
147
+ if (!result) return;
148
+
149
+ deployNonce = result.deployNonce;
150
+ brokerUrl = result.brokerUrl;
151
+ secretsVault = result.secretsVault;
152
+ secretsSources = result.secretsSources;
153
+ }
154
+
155
+ // ----------------------------------------------------------------
156
+ // 2.6. START LISTENING FOR SECRET REQUESTS EARLY
157
+ // We start listening BEFORE deploying to avoid a race condition where
158
+ // the agent requests secrets before we're subscribed to the broker.
159
+ // ----------------------------------------------------------------
160
+ let secretsPromise: Promise<WaitForSecretsResult> | null = null;
161
+
162
+ if (hasSecrets && deployNonce && brokerUrl && !flags.dryRun) {
163
+ logger.log(dim(`\nConnecting to broker to listen for secret requests...`));
164
+
165
+ // Start listening NOW (don't await - let it run in background)
166
+ // This just captures the request silently - we'll prompt after deployment
167
+ secretsPromise = waitForSecretRequest({
168
+ brokerUrl,
169
+ expectedNonce: deployNonce,
170
+ timeout: flags.secretTimeout || 5 * 60 * 1000, // Default 5 minutes
171
+ logger: {
172
+ log: (msg: string) => logger.log(dim(msg)),
173
+ error: (msg: string) => logger.error(errorColor(msg)),
174
+ },
175
+ });
176
+
177
+ // Wait for broker subscription before starting deployment
178
+ await new Promise(resolve => setTimeout(resolve, 1000));
179
+ }
180
+
181
+ // ----------------------------------------------------------------
182
+ // 3. LOCAL IMAGE HANDLING
183
+ // If profile uses local images (e.g., "my-app" instead of "docker.io/nginx"),
184
+ // we need to make them accessible to Akash providers:
185
+ // - Start temporary registry (localhost:3000)
186
+ // - Push local images to it
187
+ // - Expose publicly via kadi/ngrok/serveo/localtunnel
188
+ // - Rewrite profile to use public URLs
189
+ // ----------------------------------------------------------------
190
+
191
+ // Validate tunnel service if provided
192
+ const validTunnelServices = ['kadi', 'ngrok', 'serveo', 'localtunnel'] as const;
193
+ let tunnelService: 'kadi' | 'ngrok' | 'serveo' | 'localtunnel' | undefined = undefined;
194
+
195
+ if (flags.tunnelService) {
196
+ if (!validTunnelServices.includes(flags.tunnelService as 'kadi' | 'ngrok' | 'serveo' | 'localtunnel')) {
197
+ logger.error(`Invalid tunnel service: ${flags.tunnelService}`);
198
+ logger.log(`Valid options: ${validTunnelServices.join(', ')}`);
199
+ return;
200
+ }
201
+ tunnelService = flags.tunnelService as 'kadi' | 'ngrok' | 'serveo' | 'localtunnel';
202
+ }
203
+
204
+ // Type assertion: kadi-deploy's AkashProfileType is runtime-compatible with deploy-ability's AkashDeploymentProfile
205
+ // Both have the same structure (services with image/env/expose), but TypeScript types differ slightly
206
+ const registryContext = await setupRegistryIfNeeded(akashProfile as any, logger, {
207
+ useRemoteRegistry: flags.useRemoteRegistry,
208
+ registryDuration: flags.registryDuration,
209
+ tunnelService,
210
+ containerEngine: (flags.engine || akashProfile.engine) as 'docker' | 'podman' | undefined
211
+ });
212
+
213
+ // Wrap transformed profile in LoadedProfile structure for deploy-ability
214
+ // Type assertion: Our Zod-validated profile is compatible with deploy-ability's expected type
215
+ // The runtime structure is correct even though TypeScript types differ slightly
216
+ const loadedProfile = {
217
+ name: profileName,
218
+ profile: registryContext.deployableProfile,
219
+ agent: agentConfig,
220
+ projectRoot
221
+ } as any; // TODO: Align types between kadi-deploy and deploy-ability
222
+
223
+ // ----------------------------------------------------------------
224
+ // 3.5. AUTONOMOUS MODE - Fully automated deployment path
225
+ // When --autonomous flag is set, bypass all interactive prompts and use
226
+ // deploy-ability's autonomous mode with secrets vault for wallet credentials.
227
+ // ----------------------------------------------------------------
228
+ if (flags.autonomous) {
229
+ await executeAutonomousDeployment(ctx, {
230
+ akashProfile,
231
+ profileName,
232
+ network,
233
+ loadedProfile,
234
+ registryContext,
235
+ hasSecrets,
236
+ deployNonce,
237
+ brokerUrl,
238
+ secretsVault,
239
+ secretsSources,
240
+ agentConfig,
241
+ });
242
+ return;
243
+ }
244
+
245
+ // ----------------------------------------------------------------
246
+ // 4. Dry run check
247
+ // ----------------------------------------------------------------
248
+ if (flags.dryRun) {
249
+ logger.log('\n' + warning('⚠️ DRY RUN MODE - No deployment will be created'));
250
+ logger.log(dim('Skipping wallet connection and certificate checks'));
251
+ logger.log('');
252
+ }
253
+
254
+ // ----------------------------------------------------------------
255
+ // Resource management: wallet, akash client, and registry cleanup
256
+ // All resources are cleaned up in the finally block, ensuring proper
257
+ // cleanup on success, error, and early returns
258
+ // ----------------------------------------------------------------
259
+ let walletResult;
260
+ let akashClient: AkashClient | undefined;
261
+
262
+ try {
263
+ // ----------------------------------------------------------------
264
+ // 5. Connect wallet (WalletConnect with QR code)
265
+ // ----------------------------------------------------------------
266
+
267
+ if (!flags.dryRun) {
268
+ spinner = startSpinner('Initializing WalletConnect...');
269
+
270
+ const projectId = akashProfile.walletConnectProjectId || 'ef0fd1c783f5ef70fbb102f5e5dd2c43'; // Default KADI project ID
271
+
272
+ try {
273
+ // deploy-ability provides URI via callback, we display QR code
274
+ walletResult = await connectWallet(
275
+ projectId,
276
+ network as 'mainnet' | 'testnet',
277
+ {
278
+ onUriGenerated: (uri: string) => {
279
+ succeedSpinner(spinner, 'QR Code generated');
280
+ logger.log('');
281
+ logger.log('📱 Scan this QR code with your Keplr mobile wallet:');
282
+ logger.log('');
283
+ QRCode.generate(uri, { small: true }); // kadi-deploy displays it
284
+ logger.log('');
285
+ spinner = startSpinner('Waiting for wallet approval...');
286
+ }
287
+ }
288
+ );
289
+
290
+ if (!walletResult.success) {
291
+ failSpinner(spinner, 'Wallet connection failed');
292
+ logger.error(errorColor(walletResult.error.message));
293
+ return;
294
+ }
295
+
296
+ succeedSpinner(spinner, `Wallet connected: ${walletResult.data.address}`);
297
+ } catch (err) {
298
+ failSpinner(spinner, 'Wallet connection failed');
299
+ logger.error(errorColor((err as Error).message));
300
+ return;
301
+ }
302
+ }
303
+
304
+ // ----------------------------------------------------------------
305
+ // 6. Create Akash blockchain client
306
+ // ----------------------------------------------------------------
307
+ if (!flags.dryRun && walletResult) {
308
+ spinner = startSpinner('Creating Akash client...');
309
+
310
+ try {
311
+ // Create AkashClient with signer from wallet
312
+ akashClient = new AkashClient({
313
+ network: network as 'mainnet' | 'testnet',
314
+ signer: walletResult.data.signer
315
+ });
316
+
317
+ succeedSpinner(spinner, 'Akash client ready');
318
+ } catch (err) {
319
+ failSpinner(spinner, 'Failed to create Akash client');
320
+ logger.error(errorColor((err as Error).message));
321
+ return;
322
+ }
323
+ }
324
+
325
+ // ----------------------------------------------------------------
326
+ // 7. Load existing certificate if specified in profile
327
+ // ----------------------------------------------------------------
328
+ let existingCert;
329
+
330
+ if (!flags.dryRun && akashProfile.cert) {
331
+ const certPath = akashProfile.cert;
332
+
333
+ try {
334
+ const certData = await fs.readFile(certPath, 'utf-8');
335
+ existingCert = JSON.parse(certData);
336
+ logger.log(dim(`Loaded certificate from: ${certPath}`));
337
+ } catch (err) {
338
+ logger.error(errorColor(`Failed to load certificate from ${certPath}`));
339
+ logger.error(errorColor((err as Error).message));
340
+ return;
341
+ }
342
+ }
343
+
344
+ // ----------------------------------------------------------------
345
+ // 8. Ensure certificate exists
346
+ // ----------------------------------------------------------------
347
+ let certResult;
348
+ const defaultCertPath = path.join(os.homedir(), '.kadi', 'certificate.json');
349
+
350
+ if (!flags.dryRun && walletResult && akashClient) {
351
+ spinner = startSpinner('Checking certificate...');
352
+
353
+ try {
354
+ // Get certificate manager from AkashClient
355
+ const certManager = akashClient.getCertificateManager();
356
+
357
+ // Check if certificate exists on-chain (before calling getOrCreate)
358
+ const onChainCert = await certManager.query(walletResult.data.address);
359
+
360
+ // If cert exists on-chain but we don't have it locally, offer interactive options
361
+ if (onChainCert.success && onChainCert.data?.exists && !existingCert) {
362
+ succeedSpinner(spinner, 'Certificate check complete');
363
+
364
+ // Check if default cert file exists
365
+ let defaultCertExists = false;
366
+ try {
367
+ await fs.access(defaultCertPath);
368
+ defaultCertExists = true;
369
+ } catch {
370
+ defaultCertExists = false;
371
+ }
372
+
373
+ logger.log('');
374
+
375
+ if (defaultCertExists) {
376
+ // Scenario A: Cert file found at default location
377
+ logger.log(bold('This wallet has an active certificate on Akash.'));
378
+ logger.log(successColor(`Found certificate file at ${defaultCertPath}`));
379
+ logger.log('');
380
+
381
+ const useExisting = await confirmPrompt(
382
+ 'Use this certificate?',
383
+ true
384
+ );
385
+
386
+ if (useExisting) {
387
+ // Load the certificate
388
+ try {
389
+ const certData = await fs.readFile(defaultCertPath, 'utf-8');
390
+ existingCert = JSON.parse(certData);
391
+ logger.log(dim(`Loaded certificate from: ${defaultCertPath}`));
392
+ } catch (err) {
393
+ logger.error(errorColor(`Failed to load certificate: ${(err as Error).message}`));
394
+ exitCode = 1;
395
+ return;
396
+ }
397
+ } else {
398
+ // User said no, show the 3-option menu
399
+ const choice = await selectPrompt(
400
+ 'How would you like to proceed?',
401
+ [
402
+ 'Revoke existing and create new certificate (Recommended)',
403
+ 'Enter path to certificate file',
404
+ 'Cancel deployment'
405
+ ],
406
+ 0
407
+ );
408
+
409
+ if (choice.includes('Revoke')) {
410
+ // Revoke and create new
411
+ spinner = startSpinner('Revoking existing certificate(s)...');
412
+ const revokeResult = await certManager.revoke(walletResult.data.address);
413
+
414
+ if (!revokeResult.success) {
415
+ failSpinner(spinner, 'Failed to revoke certificate');
416
+ logger.error(errorColor(revokeResult.error.message));
417
+ exitCode = 1;
418
+ return;
419
+ }
420
+
421
+ succeedSpinner(spinner, `Revoked ${revokeResult.data.revokedCount} certificate(s)`);
422
+ // existingCert stays undefined, getOrCreate will make a new one
423
+
424
+ } else if (choice.includes('Enter path')) {
425
+ // Prompt for custom path
426
+ const customPath = await textPrompt('Enter certificate file path:');
427
+ try {
428
+ const certData = await fs.readFile(customPath, 'utf-8');
429
+ existingCert = JSON.parse(certData);
430
+ logger.log(dim(`Loaded certificate from: ${customPath}`));
431
+ } catch (err) {
432
+ logger.error(errorColor(`Failed to load certificate: ${(err as Error).message}`));
433
+ exitCode = 1;
434
+ return;
435
+ }
436
+
437
+ } else {
438
+ // Cancel
439
+ logger.log('Deployment cancelled');
440
+ exitCode = 0;
441
+ return;
442
+ }
443
+ }
444
+ } else {
445
+ // Scenario B: Cert file NOT found - show 3-option menu directly
446
+ logger.log(warning('This wallet has an active certificate on Akash, but we couldn\'t find'));
447
+ logger.log(warning('the certificate file locally.'));
448
+ logger.log('');
449
+ logger.log(dim(`Checked ${defaultCertPath}...`), dim('not found'));
450
+ logger.log('');
451
+
452
+ const choice = await selectPrompt(
453
+ 'How would you like to proceed?',
454
+ [
455
+ 'Revoke existing and create new certificate (Recommended)',
456
+ 'Enter path to certificate file',
457
+ 'Cancel deployment'
458
+ ],
459
+ 0
460
+ );
461
+
462
+ if (choice.includes('Revoke')) {
463
+ // Revoke and create new
464
+ spinner = startSpinner('Revoking existing certificate(s)...');
465
+ const revokeResult = await certManager.revoke(walletResult.data.address);
466
+
467
+ if (!revokeResult.success) {
468
+ failSpinner(spinner, 'Failed to revoke certificate');
469
+ logger.error(errorColor(revokeResult.error.message));
470
+ exitCode = 1;
471
+ return;
472
+ }
473
+
474
+ succeedSpinner(spinner, `Revoked ${revokeResult.data.revokedCount} certificate(s)`);
475
+ // existingCert stays undefined, getOrCreate will make a new one
476
+
477
+ } else if (choice.includes('Enter path')) {
478
+ // Prompt for custom path
479
+ const customPath = await textPrompt('Enter certificate file path:');
480
+ try {
481
+ const certData = await fs.readFile(customPath, 'utf-8');
482
+ existingCert = JSON.parse(certData);
483
+ logger.log(dim(`Loaded certificate from: ${customPath}`));
484
+ } catch (err) {
485
+ logger.error(errorColor(`Failed to load certificate: ${(err as Error).message}`));
486
+ exitCode = 1;
487
+ return;
488
+ }
489
+
490
+ } else {
491
+ // Cancel
492
+ logger.log('Deployment cancelled');
493
+ exitCode = 0;
494
+ return;
495
+ }
496
+ }
497
+
498
+ // Restart spinner for the next step
499
+ spinner = startSpinner('Checking certificate...');
500
+ }
501
+
502
+ // Now proceed with getOrCreate (will create new cert if needed, or validate existing)
503
+ certResult = await certManager.getOrCreate(
504
+ walletResult.data.address,
505
+ existingCert ? { existingCertificate: existingCert } : undefined
506
+ );
507
+
508
+ if (!certResult.success) {
509
+ failSpinner(spinner, 'Certificate check failed');
510
+ // Extract error message properly (avoid [object Object])
511
+ // TODO: The error message may still show "[object Object]" when wallet rejects
512
+ // a transaction. This needs to be fixed in deploy-ability's broadcast() method
513
+ // where `${error}` should be `${error?.message || String(error)}` instead.
514
+ const errMsg = certResult.error?.message || String(certResult.error) || 'Unknown error';
515
+ logger.error(errorColor(errMsg));
516
+ exitCode = 1;
517
+ return;
518
+ }
519
+
520
+ succeedSpinner(spinner, 'Certificate ready');
521
+
522
+ // Save certificate for future use if newly generated
523
+ if (!existingCert && !akashProfile.cert) {
524
+ try {
525
+ await fs.mkdir(path.dirname(defaultCertPath), { recursive: true });
526
+ await fs.writeFile(defaultCertPath, JSON.stringify(certResult.data, null, 2), 'utf-8');
527
+ logger.log(successColor(`\nCertificate saved to: ${defaultCertPath}`));
528
+ logger.log(dim(`Tip: Add ${highlight('"cert": "~/.kadi/certificate.json"')} to your profile to skip this step`));
529
+ } catch (saveErr) {
530
+ logger.log(warning('Could not save certificate to disk'));
531
+ }
532
+ }
533
+ } catch (err) {
534
+ failSpinner(spinner, 'Certificate check failed');
535
+ logger.error(errorColor((err as Error).message));
536
+ exitCode = 1;
537
+ return;
538
+ }
539
+ }
540
+
541
+ // ----------------------------------------------------------------
542
+ // 8. Confirm deployment
543
+ // ----------------------------------------------------------------
544
+ if (!flags.dryRun && !flags.yes) {
545
+ const proceed = await confirmPrompt(
546
+ `Deploy to ${network}?`,
547
+ true
548
+ );
549
+
550
+ if (!proceed) {
551
+ logger.log('Deployment cancelled');
552
+ // Finally block will handle cleanup
553
+ return;
554
+ }
555
+ }
556
+
557
+ // ----------------------------------------------------------------
558
+ // 9. Deploy using deploy-ability
559
+ // ----------------------------------------------------------------
560
+ spinner = startSpinner('Deploying to Akash Network...');
561
+
562
+ try {
563
+ // For non-dry-run, wallet and certificate are required
564
+ if (!flags.dryRun && (!walletResult || !certResult)) {
565
+ failSpinner(spinner, 'Missing wallet or certificate');
566
+ logger.error('Wallet and certificate are required for deployment');
567
+ // Finally block will handle cleanup
568
+ return;
569
+ }
570
+
571
+ const result = await deployToAkash({
572
+ projectRoot,
573
+ profile: profileName,
574
+ loadedProfile, // Pass transformed profile with registry URLs
575
+ network: network as 'mainnet' | 'testnet',
576
+ dryRun: flags.dryRun,
577
+ verbose: flags.verbose,
578
+ blacklist: akashProfile.blacklist as readonly string[] | undefined, // Provider blacklist from profile
579
+
580
+ // Wait for containers to be ready before returning
581
+ // This ensures providers have time to pull images from temporary registry
582
+ containerTimeout: 600000, // 10 minutes
583
+
584
+ // Wallet and certificate optional for dry-run, required otherwise
585
+ wallet: walletResult?.data,
586
+ certificate: certResult?.data,
587
+
588
+ // Interactive bid selector - prompts user to choose provider
589
+ bidSelector: async (bids: EnhancedBid[]) => {
590
+ // Sort bids by quality (audited + high uptime first)
591
+ const sortedBids = sortBidsByQuality(bids);
592
+
593
+ // Stop spinner to allow enquirer to take over terminal
594
+ spinner.stop();
595
+
596
+ // Fetch AKT price for USD conversion
597
+ // Fails gracefully - if unavailable, we just show AKT pricing
598
+ const aktPriceUsd = await fetchAktPriceUSD();
599
+
600
+ // Show interactive selection prompt with USD pricing if available
601
+ const selected = await selectBidInteractively(sortedBids, aktPriceUsd);
602
+
603
+ // Restart spinner after selection
604
+ if (selected) {
605
+ spinner = startSpinner('Creating lease with selected provider...');
606
+ }
607
+
608
+ return selected;
609
+ },
610
+
611
+ // Custom logger that updates spinner text with progress
612
+ logger: {
613
+ log: (msg: string) => {
614
+ // Update spinner text to show current progress
615
+ spinner.text = msg;
616
+ },
617
+ error: (msg: string) => logger.error(msg),
618
+ warn: (msg: string) => {
619
+ spinner.text = msg;
620
+ },
621
+ debug: flags.verbose ? (msg: string) => {
622
+ spinner.text = msg;
623
+ } : () => {}
624
+ }
625
+ });
626
+
627
+ if (!result.success) {
628
+ failSpinner(spinner, 'Deployment failed');
629
+ logger.error(errorColor(result.error.message));
630
+
631
+ if (flags.verbose && result.error.cause) {
632
+ logger.error('\nCause:', result.error.cause);
633
+ }
634
+
635
+ // Set exit code and return (finally block will handle cleanup and exit)
636
+ exitCode = 1;
637
+ return;
638
+ }
639
+
640
+ succeedSpinner(spinner, 'Deployment complete!');
641
+
642
+ // ----------------------------------------------------------------
643
+ // 9. Display results
644
+ // ----------------------------------------------------------------
645
+ const data = result.data;
646
+
647
+ if ('dryRun' in data && data.dryRun) {
648
+ // Dry run result
649
+ displayDryRunInfo(profileName, data.sdl);
650
+
651
+ // Set exit code (finally block will handle cleanup and exit)
652
+ exitCode = 0;
653
+ } else {
654
+ // Actual deployment result
655
+ const deploymentData = data as AkashDeploymentData;
656
+
657
+ // Fetch AKT price for USD display (fails gracefully)
658
+ const aktPriceUsd = await fetchAktPriceUSD();
659
+
660
+ if (flags.verbose) {
661
+ displayDeploymentInfo(deploymentData, aktPriceUsd);
662
+ } else {
663
+ displayDeploymentSummary(deploymentData, aktPriceUsd);
664
+ }
665
+
666
+ // Show endpoints if available
667
+ if (deploymentData.endpoints && Object.keys(deploymentData.endpoints).length > 0) {
668
+ logger.log(successColor('\nService Endpoints:'));
669
+ for (const [serviceName, endpoint] of Object.entries(deploymentData.endpoints)) {
670
+ logger.log(` ${serviceName}: ${endpoint}`);
671
+ }
672
+ logger.log('');
673
+ }
674
+
675
+ // ----------------------------------------------------------------
676
+ // 9.5. Write deployment lock file for `kadi deploy down`
677
+ // ----------------------------------------------------------------
678
+ try {
679
+ const lock = buildAkashLock(profileName, deploymentData, flags.label);
680
+ const instanceId = await writeLockFile(projectRoot, lock);
681
+ logger.log(dim(` Instance: ${instanceId} (profile: ${profileName}${flags.label ? `, label: ${flags.label}` : ''})`));
682
+ logger.log(dim(` Use \`kadi deploy down --instance ${instanceId}\` to tear down.`));
683
+ if (flags.verbose) {
684
+ logger.log(dim('Lock file written: .kadi-deploy.lock'));
685
+ }
686
+ } catch (lockErr) {
687
+ // Non-fatal: deployment succeeded even if lock write fails
688
+ logger.log(warning(`Could not write lock file: ${(lockErr as Error).message}`));
689
+ }
690
+
691
+ // ----------------------------------------------------------------
692
+ // 10. Wait for secret request result (we started listening earlier)
693
+ // ----------------------------------------------------------------
694
+ if (secretsPromise && brokerUrl && secretsVault) {
695
+ logger.log(dim('\n─────────────────────────────────────────────────────────────────'));
696
+ logger.log(bold('Waiting for agent to request secrets...'));
697
+ logger.log(dim(`Broker: ${brokerUrl}`));
698
+ logger.log(dim('Press Ctrl+C to skip (agent will not receive secrets)'));
699
+ logger.log(dim('─────────────────────────────────────────────────────────────────\n'));
700
+
701
+ // Handle Ctrl+C during secret wait - force exit cleanly
702
+ const sigintHandler = () => {
703
+ logger.log(warning('\n\nSkipping secret sharing (Ctrl+C)'));
704
+ exitCode = 0;
705
+ process.exit(0);
706
+ };
707
+ process.on('SIGINT', sigintHandler);
708
+
709
+ const waitResult = await secretsPromise;
710
+
711
+ // Remove handler after secrets are done
712
+ process.off('SIGINT', sigintHandler);
713
+
714
+ if (waitResult.success && waitResult.request) {
715
+ const request = waitResult.request;
716
+
717
+ // Display request info
718
+ logger.log('');
719
+ logger.log(successColor('Agent connected and requesting secrets!'));
720
+ logger.log('');
721
+ logger.log(` Agent ID: ${request.agentId}`);
722
+ logger.log(` Required: ${request.required.join(', ')}`);
723
+ if (request.optional?.length) {
724
+ logger.log(` Optional: ${request.optional.join(', ')}`);
725
+ }
726
+ logger.log('');
727
+
728
+ // Prompt for approval (auto-approve with --yes or --auto-approve-secrets flag)
729
+ const approved = flags.yes || flags.autoApproveSecrets || await confirmPrompt('Share these secrets with the agent?', true);
730
+
731
+ if (!approved) {
732
+ logger.log(warning('\nSecret sharing declined.'));
733
+ // Notify agent so it can fail immediately instead of timing out
734
+ await sendRejection({
735
+ brokerUrl,
736
+ agentId: request.agentId,
737
+ reason: 'User declined to share secrets',
738
+ logger: {
739
+ log: (msg: string) => logger.log(dim(msg)),
740
+ error: (msg: string) => logger.error(errorColor(msg)),
741
+ },
742
+ });
743
+ } else {
744
+ const allRequestedKeys = [
745
+ ...request.required,
746
+ ...(request.optional || []),
747
+ ];
748
+
749
+ // Multi-vault: read from each vault source
750
+ const secrets: Record<string, string> = {};
751
+ for (const source of secretsSources) {
752
+ const sourceKeys = [...source.required, ...source.optional]
753
+ .filter(k => allRequestedKeys.includes(k));
754
+ if (sourceKeys.length === 0) continue;
755
+
756
+ logger.log(dim(`\nReading secrets from vault '${source.vault}'...`));
757
+ const vaultSecrets = readSecretsFromCli({
758
+ keys: sourceKeys,
759
+ vault: source.vault,
760
+ cwd: projectRoot,
761
+ onError: (key, error) => {
762
+ logger.log(dim(` Failed to read '${key}' from vault '${source.vault}': ${error}`));
763
+ },
764
+ });
765
+ Object.assign(secrets, vaultSecrets);
766
+ }
767
+ const foundKeys = Object.keys(secrets);
768
+
769
+ const missingRequired = request.required.filter(
770
+ (key) => !secrets[key]
771
+ );
772
+
773
+ if (missingRequired.length > 0) {
774
+ const vaultNames = secretsSources.map(s => s.vault).join(', ');
775
+ logger.log(warning(`\nMissing required secrets in vault(s) '${vaultNames}': ${missingRequired.join(', ')}`));
776
+ logger.log(dim(`Use "kadi secret set <key> <value> -v <vault>" to add them.`));
777
+ } else if (foundKeys.length === 0) {
778
+ const vaultNames = secretsSources.map(s => s.vault).join(', ');
779
+ logger.log(warning(`\nNo secrets found in vault(s) '${vaultNames}'.`));
780
+ } else if (!request.publicKey) {
781
+ logger.log(warning('\nAgent did not provide public key for encryption.'));
782
+ } else {
783
+ logger.log(successColor(`\nSharing ${foundKeys.length} secret(s) with agent (encrypted)...`));
784
+
785
+ try {
786
+ await shareSecrets({
787
+ brokerUrl,
788
+ agentId: request.agentId,
789
+ agentPublicKey: request.publicKey,
790
+ secrets,
791
+ logger: {
792
+ log: (msg: string) => logger.log(dim(msg)),
793
+ error: (msg: string) => logger.error(errorColor(msg)),
794
+ },
795
+ });
796
+
797
+ logger.log(successColor('Secrets shared successfully!'));
798
+ logger.log(dim(` Shared: ${foundKeys.join(', ')}`));
799
+ } catch (shareErr) {
800
+ logger.log(warning(`\nFailed to share secrets: ${(shareErr as Error).message}`));
801
+ }
802
+ }
803
+ }
804
+ } else if (waitResult.timedOut) {
805
+ logger.log(warning('\nTimeout waiting for agent to request secrets.'));
806
+ } else if (waitResult.error) {
807
+ logger.log(warning(`\nSecret handshake failed: ${waitResult.error}`));
808
+ }
809
+
810
+ logger.log('');
811
+ }
812
+
813
+ // Set exit code (finally block will handle cleanup and exit)
814
+ exitCode = 0;
815
+ }
816
+
817
+ } catch (err) {
818
+ failSpinner(spinner, 'Deployment failed');
819
+ logger.error(errorColor((err as Error).message));
820
+
821
+ if (flags.verbose) {
822
+ console.error(err);
823
+ }
824
+
825
+ // Set exit code (finally block will handle cleanup and exit)
826
+ exitCode = 1;
827
+ }
828
+ } finally {
829
+ // ----------------------------------------------------------------
830
+ // Guaranteed cleanup: wallet, akash client, and registry
831
+ // ----------------------------------------------------------------
832
+ // This block runs on ALL exit paths: success, error, and early returns
833
+ // Ensures Node.js event loop can exit by closing all open connections
834
+
835
+ // 1. Disconnect Akash client (closes RPC connection and SDK resources)
836
+ if (akashClient) {
837
+ try {
838
+ await akashClient.disconnect();
839
+ } catch (err) {
840
+ // Silently ignore cleanup errors
841
+ }
842
+ }
843
+
844
+ // 2. Disconnect wallet (closes WalletConnect session)
845
+ if (walletResult?.success) {
846
+ try {
847
+ await disconnectWallet(walletResult.data);
848
+ } catch (err) {
849
+ // Silently ignore cleanup errors
850
+ }
851
+ }
852
+
853
+ // 3. Cleanup registry (if it was started)
854
+ // Note: This is idempotent, safe to call multiple times
855
+ try {
856
+ await registryContext.cleanup();
857
+ } catch (err) {
858
+ // Silently ignore cleanup errors
859
+ }
860
+
861
+ // Force process exit after cleanup completes
862
+ // This is necessary because phantom handles in Node.js keep the event loop alive
863
+ // Even after all real resources are cleaned up, stale handle references remain
864
+ if (exitCode !== null) {
865
+ process.exit(exitCode);
866
+ }
867
+ }
868
+ }
869
+
870
+ // =============================================================================
871
+ // Autonomous Deployment
872
+ // =============================================================================
873
+
874
+ /**
875
+ * Options passed to the autonomous deployment function from the main flow
876
+ */
877
+ interface AutonomousDeploymentParams {
878
+ akashProfile: any;
879
+ profileName: string;
880
+ network: string;
881
+ loadedProfile: any;
882
+ registryContext: any;
883
+ hasSecrets: boolean;
884
+ deployNonce?: string;
885
+ brokerUrl: string | null;
886
+ secretsVault?: string;
887
+ secretsSources: NormalizedVaultSource[];
888
+ agentConfig: any;
889
+ }
890
+
891
+ /**
892
+ * Execute a fully autonomous Akash deployment with no human interaction.
893
+ *
894
+ * This function:
895
+ * 1. Creates a SecretsProvider from the local vault (or uses a simple mnemonic provider)
896
+ * 2. Calls deployToAkash() in autonomous mode (wallet from vault, auto-cert, algorithmic bid selection)
897
+ * 3. Auto-approves and performs secret handshake if profile declares secrets
898
+ * 4. Displays results and exits
899
+ *
900
+ * No prompts, no QR codes, no confirmation dialogs. Pure automation.
901
+ *
902
+ * @param ctx - Deployment context from CLI
903
+ * @param params - Pre-resolved profile and config from main flow
904
+ */
905
+ async function executeAutonomousDeployment(
906
+ ctx: DeploymentContext,
907
+ params: AutonomousDeploymentParams
908
+ ): Promise<void> {
909
+ const { logger, projectRoot, flags } = ctx;
910
+ const {
911
+ akashProfile,
912
+ profileName,
913
+ network,
914
+ loadedProfile,
915
+ registryContext,
916
+ hasSecrets,
917
+ deployNonce,
918
+ brokerUrl,
919
+ secretsVault,
920
+ secretsSources,
921
+ agentConfig,
922
+ } = params;
923
+
924
+ let exitCode: number | null = null;
925
+
926
+ logger.log('');
927
+ logger.log(bold('🤖 AUTONOMOUS DEPLOYMENT MODE'));
928
+ logger.log(dim(' No human interaction required — fully automated'));
929
+ logger.log('');
930
+
931
+ // ----------------------------------------------------------------
932
+ // 1. Build SecretsProvider for wallet credentials
933
+ // Uses deploy-ability's createSecretsProvider with secret-ability vault
934
+ // ----------------------------------------------------------------
935
+ const bidStrategy = (flags.bidStrategy as 'cheapest' | 'most-reliable' | 'balanced') || 'cheapest';
936
+
937
+ logger.log(dim(`Bid strategy: ${bidStrategy}`));
938
+ if (flags.bidMaxPrice) {
939
+ logger.log(dim(`Max bid price: ${flags.bidMaxPrice} uAKT/block`));
940
+ }
941
+ if (flags.requireAudited) {
942
+ logger.log(dim(`Requiring audited providers only`));
943
+ }
944
+ logger.log('');
945
+
946
+ // Build bid filter from CLI flags
947
+ const bidFilter: Record<string, unknown> = {};
948
+ if (flags.bidMaxPrice) {
949
+ bidFilter.maxPricePerBlock = flags.bidMaxPrice;
950
+ }
951
+ if (flags.requireAudited) {
952
+ bidFilter.requireAudited = true;
953
+ }
954
+
955
+ // ----------------------------------------------------------------
956
+ // 2. Start listening for secret requests early (same as interactive mode)
957
+ // ----------------------------------------------------------------
958
+ let secretsPromise: Promise<WaitForSecretsResult> | null = null;
959
+
960
+ if (hasSecrets && deployNonce && brokerUrl && !flags.dryRun) {
961
+ logger.log(dim(`Connecting to broker for automated secret sharing...`));
962
+
963
+ secretsPromise = waitForSecretRequest({
964
+ brokerUrl,
965
+ expectedNonce: deployNonce,
966
+ timeout: flags.secretTimeout || 5 * 60 * 1000,
967
+ logger: {
968
+ log: (msg: string) => logger.log(dim(msg)),
969
+ error: (msg: string) => logger.error(errorColor(msg)),
970
+ },
971
+ });
972
+
973
+ // Wait for broker subscription before starting deployment
974
+ await new Promise(resolve => setTimeout(resolve, 1000));
975
+ }
976
+
977
+ // ----------------------------------------------------------------
978
+ // 3. Deploy using deploy-ability in autonomous mode
979
+ // ----------------------------------------------------------------
980
+ let spinner = startSpinner('Starting autonomous deployment...');
981
+
982
+ try {
983
+ // Dry run path
984
+ if (flags.dryRun) {
985
+ spinner.text = 'Generating deployment preview...';
986
+
987
+ const result = await deployToAkash({
988
+ projectRoot,
989
+ profile: profileName,
990
+ loadedProfile,
991
+ network: network as 'mainnet' | 'testnet',
992
+ dryRun: true,
993
+ verbose: flags.verbose,
994
+ logger: {
995
+ log: (msg: string) => { spinner.text = msg; },
996
+ error: (msg: string) => logger.error(msg),
997
+ warn: (msg: string) => { spinner.text = msg; },
998
+ debug: flags.verbose ? (msg: string) => { spinner.text = msg; } : () => {},
999
+ },
1000
+ });
1001
+
1002
+ if (!result.success) {
1003
+ failSpinner(spinner, 'Dry run failed');
1004
+ logger.error(errorColor(result.error.message));
1005
+ exitCode = 1;
1006
+ return;
1007
+ }
1008
+
1009
+ succeedSpinner(spinner, 'Dry run complete');
1010
+ const data = result.data as AkashDryRunData;
1011
+ displayDryRunInfo(profileName, data.sdl);
1012
+ exitCode = 0;
1013
+ return;
1014
+ }
1015
+
1016
+ // Build the SecretsProvider for wallet credentials (mnemonic + certificate)
1017
+ // The wallet vault defaults to 'global' — it's separate from the deployment
1018
+ // secrets vault (profile.secrets.vault) which holds app secrets like DB_PASSWORD.
1019
+ // --secrets-vault overrides ONLY the wallet vault, not deployment secrets.
1020
+ spinner.text = 'Setting up wallet from secrets vault...';
1021
+
1022
+ const walletVault = flags.secretsVault || 'global';
1023
+ const secretsProvider = createCliSecretsProvider(walletVault, projectRoot);
1024
+
1025
+ // Full autonomous deployment
1026
+ const autonomousConfig: AutonomousDeploymentConfig = {
1027
+ secrets: secretsProvider,
1028
+ bidStrategy,
1029
+ bidFilter: Object.keys(bidFilter).length > 0 ? bidFilter as BidFilterCriteria : undefined,
1030
+ };
1031
+
1032
+ const result = await deployToAkash({
1033
+ projectRoot,
1034
+ profile: profileName,
1035
+ loadedProfile,
1036
+ network: network as 'mainnet' | 'testnet',
1037
+ dryRun: false,
1038
+ verbose: flags.verbose,
1039
+ blacklist: akashProfile.blacklist as readonly string[] | undefined,
1040
+ containerTimeout: 600000, // 10 minutes
1041
+
1042
+ // Autonomous mode config — deploy-ability handles everything
1043
+ autonomous: autonomousConfig,
1044
+
1045
+ // Logger that updates spinner
1046
+ // warn/debug persist-print so diagnostic info isn't lost
1047
+ logger: {
1048
+ log: (msg: string) => { spinner.text = msg; },
1049
+ error: (msg: string) => logger.error(msg),
1050
+ warn: (msg: string) => {
1051
+ const saved = spinner.text;
1052
+ spinner.stop();
1053
+ logger.log(warning(msg));
1054
+ spinner.start(saved);
1055
+ },
1056
+ debug: flags.verbose ? (msg: string) => {
1057
+ const saved = spinner.text;
1058
+ spinner.stop();
1059
+ logger.log(dim(msg));
1060
+ spinner.start(saved);
1061
+ } : () => {},
1062
+ },
1063
+ });
1064
+
1065
+ if (!result.success) {
1066
+ failSpinner(spinner, 'Autonomous deployment failed');
1067
+ logger.error(errorColor(result.error.message));
1068
+
1069
+ if (flags.verbose && result.error.cause) {
1070
+ logger.error('\nCause:', result.error.cause);
1071
+ }
1072
+
1073
+ // Provide helpful hints for common autonomous errors
1074
+ const errorCode = (result.error as any).code;
1075
+ if (errorCode === 'SECRETS_ERROR') {
1076
+ logger.log('');
1077
+ logger.log(warning('Hint: Make sure your wallet mnemonic is stored in the vault:'));
1078
+ logger.log(dim(` kadi secrets set --vault ${walletVault} --key AKASH_WALLET --value "your mnemonic"`));
1079
+ }
1080
+
1081
+ exitCode = 1;
1082
+ return;
1083
+ }
1084
+
1085
+ succeedSpinner(spinner, 'Autonomous deployment complete!');
1086
+
1087
+ // ----------------------------------------------------------------
1088
+ // 4. Display results
1089
+ // ----------------------------------------------------------------
1090
+ const data = result.data;
1091
+
1092
+ if ('dryRun' in data && data.dryRun) {
1093
+ displayDryRunInfo(profileName, (data as AkashDryRunData).sdl);
1094
+ exitCode = 0;
1095
+ } else {
1096
+ const deploymentData = data as AkashDeploymentData;
1097
+
1098
+ // Fetch AKT price for USD display
1099
+ const aktPriceUsd = await fetchAktPriceUSD();
1100
+
1101
+ if (flags.verbose) {
1102
+ displayDeploymentInfo(deploymentData, aktPriceUsd);
1103
+ } else {
1104
+ displayDeploymentSummary(deploymentData, aktPriceUsd);
1105
+ }
1106
+
1107
+ // Show endpoints
1108
+ if (deploymentData.endpoints && Object.keys(deploymentData.endpoints).length > 0) {
1109
+ logger.log(successColor('\nService Endpoints:'));
1110
+ for (const [serviceName, endpoint] of Object.entries(deploymentData.endpoints)) {
1111
+ logger.log(` ${serviceName}: ${endpoint}`);
1112
+ }
1113
+ logger.log('');
1114
+ }
1115
+
1116
+ // ----------------------------------------------------------------
1117
+ // 4.5. Write deployment lock file for `kadi deploy down`
1118
+ // ----------------------------------------------------------------
1119
+ try {
1120
+ const lock = buildAkashLock(profileName, deploymentData, flags.label);
1121
+ const instanceId = await writeLockFile(projectRoot, lock);
1122
+ logger.log(dim(` Instance: ${instanceId} (profile: ${profileName}${flags.label ? `, label: ${flags.label}` : ''})`));
1123
+ logger.log(dim(` Use \`kadi deploy down --instance ${instanceId}\` to tear down.`));
1124
+ if (flags.verbose) {
1125
+ logger.log(dim('Lock file written: .kadi-deploy.lock'));
1126
+ }
1127
+ } catch (lockErr) {
1128
+ // Non-fatal: deployment succeeded even if lock write fails
1129
+ logger.log(warning(`Could not write lock file: ${(lockErr as Error).message}`));
1130
+ }
1131
+
1132
+ // ----------------------------------------------------------------
1133
+ // 5. Automated secret sharing (no prompts)
1134
+ // ----------------------------------------------------------------
1135
+ if (secretsPromise && brokerUrl && secretsVault) {
1136
+ logger.log(dim('\n─────────────────────────────────────────────────────────────────'));
1137
+ logger.log(bold('Automated secret sharing in progress...'));
1138
+ logger.log(dim(`Broker: ${brokerUrl}`));
1139
+ logger.log(dim('─────────────────────────────────────────────────────────────────\n'));
1140
+
1141
+ const waitResult = await secretsPromise;
1142
+
1143
+ if (waitResult.success && waitResult.request) {
1144
+ const request = waitResult.request;
1145
+
1146
+ logger.log(successColor('Agent connected and requesting secrets.'));
1147
+ logger.log(dim(` Agent ID: ${request.agentId}`));
1148
+ logger.log(dim(` Required: ${request.required.join(', ')}`));
1149
+ if (request.optional?.length) {
1150
+ logger.log(dim(` Optional: ${request.optional.join(', ')}`));
1151
+ }
1152
+
1153
+ // Auto-approve: no prompting in autonomous mode
1154
+ logger.log(successColor('\nAuto-approving secret sharing (autonomous mode)...'));
1155
+
1156
+ const allRequestedKeys = [
1157
+ ...request.required,
1158
+ ...(request.optional || []),
1159
+ ];
1160
+
1161
+ // Multi-vault: read from each vault source
1162
+ const secrets: Record<string, string> = {};
1163
+ for (const source of secretsSources) {
1164
+ const sourceKeys = [...source.required, ...source.optional]
1165
+ .filter(k => allRequestedKeys.includes(k));
1166
+ if (sourceKeys.length === 0) continue;
1167
+
1168
+ logger.log(dim(`Reading secrets from vault '${source.vault}'...`));
1169
+ const vaultSecrets = readSecretsFromCli({
1170
+ keys: sourceKeys,
1171
+ vault: source.vault,
1172
+ cwd: projectRoot,
1173
+ onError: (key, error) => {
1174
+ logger.log(dim(` Failed to read '${key}' from vault '${source.vault}': ${error}`));
1175
+ },
1176
+ });
1177
+ Object.assign(secrets, vaultSecrets);
1178
+ }
1179
+ const foundKeys = Object.keys(secrets);
1180
+
1181
+ const missingRequired = request.required.filter(
1182
+ (key) => !secrets[key]
1183
+ );
1184
+
1185
+ if (missingRequired.length > 0) {
1186
+ const vaultNames = secretsSources.map(s => s.vault).join(', ');
1187
+ logger.log(warning(`Missing required secrets in vault(s) '${vaultNames}': ${missingRequired.join(', ')}`));
1188
+ logger.log(dim(`Use "kadi secret set <key> <value> -v <vault>" to add them.`));
1189
+ } else if (foundKeys.length === 0) {
1190
+ const vaultNames = secretsSources.map(s => s.vault).join(', ');
1191
+ logger.log(warning(`No secrets found in vault(s) '${vaultNames}'.`));
1192
+ } else if (!request.publicKey) {
1193
+ logger.log(warning('Agent did not provide public key for encryption.'));
1194
+ } else {
1195
+ logger.log(successColor(`Sharing ${foundKeys.length} secret(s) with agent (encrypted)...`));
1196
+
1197
+ try {
1198
+ await shareSecrets({
1199
+ brokerUrl,
1200
+ agentId: request.agentId,
1201
+ agentPublicKey: request.publicKey,
1202
+ secrets,
1203
+ logger: {
1204
+ log: (msg: string) => logger.log(dim(msg)),
1205
+ error: (msg: string) => logger.error(errorColor(msg)),
1206
+ },
1207
+ });
1208
+
1209
+ logger.log(successColor('✓ Secrets shared successfully!'));
1210
+ logger.log(dim(` Shared: ${foundKeys.join(', ')}`));
1211
+ } catch (shareErr) {
1212
+ logger.log(warning(`Failed to share secrets: ${(shareErr as Error).message}`));
1213
+ }
1214
+ }
1215
+ } else if (waitResult.timedOut) {
1216
+ logger.log(warning('Timeout waiting for agent to request secrets.'));
1217
+ logger.log(dim('The agent may not have started yet, or may not require secrets.'));
1218
+ } else if (waitResult.error) {
1219
+ logger.log(warning(`Secret handshake failed: ${waitResult.error}`));
1220
+ }
1221
+
1222
+ logger.log('');
1223
+ }
1224
+
1225
+ exitCode = 0;
1226
+ }
1227
+
1228
+ } catch (err) {
1229
+ failSpinner(spinner, 'Autonomous deployment failed');
1230
+ logger.error(errorColor((err as Error).message));
1231
+
1232
+ if (flags.verbose) {
1233
+ console.error(err);
1234
+ }
1235
+
1236
+ exitCode = 1;
1237
+ } finally {
1238
+ // Cleanup registry (if it was started)
1239
+ try {
1240
+ await registryContext.cleanup();
1241
+ } catch (err) {
1242
+ // Silently ignore cleanup errors
1243
+ }
1244
+
1245
+ if (exitCode !== null) {
1246
+ process.exit(exitCode);
1247
+ }
1248
+ }
1249
+ }
1250
+
1251
+ // =============================================================================
1252
+ // CLI Secrets Provider Shim
1253
+ // =============================================================================
1254
+
1255
+ /**
1256
+ * Create a SecretsProvider that reads from the local kadi-secret CLI vault.
1257
+ *
1258
+ * This bridges deploy-ability's SecretsProvider interface with kadi-deploy's
1259
+ * existing CLI-based secret vault access. It reads the wallet mnemonic from
1260
+ * the specified vault using `kadi secret get` commands.
1261
+ *
1262
+ * For certificate caching, it stores certificates in the vault alongside the mnemonic.
1263
+ *
1264
+ * @param vaultName - Name of the vault to read from (e.g., 'global')
1265
+ * @param cwd - Working directory for CLI execution
1266
+ * @returns SecretsProvider compatible with deploy-ability's autonomous mode
1267
+ */
1268
+ function createCliSecretsProvider(vaultName: string, cwd: string): SecretsProvider {
1269
+ return {
1270
+ async getMnemonic(): Promise<string> {
1271
+ // Read the wallet mnemonic from the local kadi-secret vault
1272
+ const mnemonic = readSecretFromCli({
1273
+ key: 'AKASH_WALLET',
1274
+ vault: vaultName,
1275
+ cwd,
1276
+ });
1277
+
1278
+ if (!mnemonic) {
1279
+ throw new Error(
1280
+ `Wallet mnemonic not found in vault '${vaultName}' (key: AKASH_WALLET).\n\n` +
1281
+ `Store your mnemonic with:\n` +
1282
+ ` kadi secret set AKASH_WALLET "your 12 or 24 word mnemonic" -v ${vaultName}\n\n` +
1283
+ `Or create the vault first:\n` +
1284
+ ` kadi secret create ${vaultName}`
1285
+ );
1286
+ }
1287
+
1288
+ return mnemonic;
1289
+ },
1290
+
1291
+ async getCertificate(): Promise<any | null> {
1292
+ // 1. Try the default filesystem path (where interactive mode saves it)
1293
+ const defaultCertPath = path.join(os.homedir(), '.kadi', 'certificate.json');
1294
+ try {
1295
+ const certData = readFileSync(defaultCertPath, 'utf-8');
1296
+ const cert = JSON.parse(certData);
1297
+ if (cert && cert.cert && cert.privateKey && cert.publicKey) {
1298
+ return cert;
1299
+ }
1300
+ } catch {
1301
+ // File doesn't exist or isn't valid JSON — fall through
1302
+ }
1303
+
1304
+ // 2. Try to read cached certificate from secrets vault
1305
+ try {
1306
+ const certJson = readSecretFromCli({
1307
+ key: 'akash-tls-certificate',
1308
+ vault: vaultName,
1309
+ cwd,
1310
+ });
1311
+ if (certJson) {
1312
+ const cert = JSON.parse(certJson);
1313
+ if (cert) {
1314
+ return cert;
1315
+ }
1316
+ }
1317
+ } catch {
1318
+ // Vault read failed — fall through
1319
+ }
1320
+
1321
+ return null;
1322
+ },
1323
+
1324
+ async storeCertificate(cert: any): Promise<void> {
1325
+ // Store certificate in vault for future use
1326
+ // Use execSync to call kadi secret set
1327
+ try {
1328
+ const { execSync } = await import('node:child_process');
1329
+ execSync(
1330
+ `kadi secret set akash-tls-certificate '${JSON.stringify(cert).replace(/'/g, "\\'")}' -v "${vaultName}"`,
1331
+ {
1332
+ encoding: 'utf-8',
1333
+ cwd,
1334
+ stdio: ['pipe', 'pipe', 'pipe'],
1335
+ }
1336
+ );
1337
+ } catch {
1338
+ // Non-fatal: certificate is already on blockchain
1339
+ }
1340
+ },
1341
+ };
1342
+ }