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,1143 @@
1
+ /**
2
+ * Deploy Down Command Tests
3
+ *
4
+ * These tests actually exercise the real functions with mocked externals:
5
+ *
6
+ * - executeDown(): verifies lock reading, routing, confirmation prompt,
7
+ * and delegation to the correct teardown path
8
+ * - Local teardown: verifies execSync is called with the right compose command,
9
+ * options, and that the lock file is cleaned up
10
+ * - Akash interactive: verifies connectWallet, closeDeployment, disconnectWallet
11
+ * are called with the correct arguments from the lock file
12
+ * - Akash autonomous: verifies readSecretFromCli, createWalletFromMnemonic,
13
+ * closeDeployment are called correctly
14
+ * - CLI registration: uses real Commander to verify the "down" subcommand is
15
+ * wired up and actually invokes executeDown when parsed
16
+ * - Error paths: missing lock, missing mnemonic, failed wallet, failed close
17
+ */
18
+
19
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
20
+ import fs from 'node:fs/promises';
21
+ import path from 'node:path';
22
+ import os from 'node:os';
23
+
24
+ import {
25
+ writeLockFile,
26
+ readLockFile,
27
+ buildLocalLock,
28
+ buildAkashLock,
29
+ type DeploymentLock,
30
+ } from '../src/commands/lock.js';
31
+
32
+ // ─────────────────────────────────────────────────────────
33
+ // Mock external side-effects BEFORE importing down.ts
34
+ // ─────────────────────────────────────────────────────────
35
+
36
+ // Mock child_process.execSync — local teardown shells out
37
+ const mockExecSync = vi.fn();
38
+ vi.mock('node:child_process', () => ({
39
+ execSync: (...args: unknown[]) => mockExecSync(...args),
40
+ }));
41
+
42
+ // Mock spinners (ora) — avoid real terminal output
43
+ vi.mock('../src/cli/spinners.js', () => ({
44
+ startSpinner: vi.fn(() => ({ text: '', succeed: vi.fn(), fail: vi.fn() })),
45
+ succeedSpinner: vi.fn(),
46
+ failSpinner: vi.fn(),
47
+ }));
48
+
49
+ // Mock prompts — no interactive input in tests
50
+ const mockConfirmPrompt = vi.fn();
51
+ const mockSelectPrompt = vi.fn();
52
+ vi.mock('../src/cli/prompts.js', () => ({
53
+ confirmPrompt: (...args: unknown[]) => mockConfirmPrompt(...args),
54
+ selectPrompt: (...args: unknown[]) => mockSelectPrompt(...args),
55
+ }));
56
+
57
+ // Mock colors — pass-through so we can assert on message content
58
+ vi.mock('../src/cli/colors.js', () => ({
59
+ error: (s: string) => s,
60
+ warning: (s: string) => s,
61
+ success: (s: string) => s,
62
+ dim: (s: string) => s,
63
+ bold: (s: string) => s,
64
+ highlight: (s: string) => s,
65
+ formatKeyValue: (k: string, v: string) => `${k}: ${v}`,
66
+ }));
67
+
68
+ // Mock Akash SDK — avoid real blockchain calls
69
+ const mockCloseDeployment = vi.fn();
70
+ const mockDisconnect = vi.fn();
71
+ const mockConnectWallet = vi.fn();
72
+ const mockDisconnectWallet = vi.fn();
73
+ const mockCreateWalletFromMnemonic = vi.fn();
74
+
75
+ vi.mock('@kadi.build/deploy-ability/akash', () => ({
76
+ AkashClient: vi.fn().mockImplementation(() => ({
77
+ closeDeployment: mockCloseDeployment,
78
+ disconnect: mockDisconnect,
79
+ })),
80
+ connectWallet: (...args: unknown[]) => mockConnectWallet(...args),
81
+ disconnectWallet: (...args: unknown[]) => mockDisconnectWallet(...args),
82
+ createWalletFromMnemonic: (...args: unknown[]) => mockCreateWalletFromMnemonic(...args),
83
+ }));
84
+
85
+ // Mock secrets vault — avoid shelling out to `kadi secret get`
86
+ const mockReadSecretFromCli = vi.fn();
87
+ vi.mock('../src/secrets/index.js', () => ({
88
+ readSecretFromCli: (...args: unknown[]) => mockReadSecretFromCli(...args),
89
+ validateSecretsOrFail: vi.fn(),
90
+ }));
91
+
92
+ // Mock node-localstorage and qrcode-terminal (side-effect imports in down.ts)
93
+ vi.mock('node-localstorage', () => ({
94
+ LocalStorage: vi.fn().mockImplementation(() => ({})),
95
+ }));
96
+ vi.mock('qrcode-terminal', () => ({
97
+ default: { generate: vi.fn() },
98
+ generate: vi.fn(),
99
+ }));
100
+
101
+ // Import the mocked AkashClient constructor so we can re-set it after resetAllMocks
102
+ import { AkashClient as MockedAkashClient } from '@kadi.build/deploy-ability/akash';
103
+
104
+ // Now import the real function under test
105
+ import { executeDown } from '../src/commands/down.js';
106
+
107
+ // ─────────────────────────────────────────────────────────
108
+ // Fixtures
109
+ // ─────────────────────────────────────────────────────────
110
+
111
+ function makeLocalLock(overrides?: Partial<DeploymentLock>): DeploymentLock {
112
+ return {
113
+ instanceId: 'a1b2',
114
+ target: 'local',
115
+ profile: 'dev',
116
+ deployedAt: '2026-02-25T12:00:00.000Z',
117
+ local: {
118
+ composePath: '/tmp/project/docker-compose.yml',
119
+ engine: 'docker',
120
+ network: 'kadi-net',
121
+ services: ['web', 'api'],
122
+ containers: { web: 'abc123', api: 'def456' },
123
+ },
124
+ ...overrides,
125
+ };
126
+ }
127
+
128
+ function makeAkashLock(overrides?: Partial<DeploymentLock>): DeploymentLock {
129
+ return {
130
+ instanceId: 'c3d4',
131
+ target: 'akash',
132
+ profile: 'production',
133
+ deployedAt: '2026-02-25T12:00:00.000Z',
134
+ akash: {
135
+ dseq: 12345678,
136
+ owner: 'akash1abc123',
137
+ provider: 'akash1prov456',
138
+ providerUri: 'https://provider.example.com',
139
+ network: 'mainnet',
140
+ gseq: 1,
141
+ oseq: 1,
142
+ },
143
+ ...overrides,
144
+ };
145
+ }
146
+
147
+ function createMockLogger() {
148
+ return {
149
+ log: vi.fn(),
150
+ error: vi.fn(),
151
+ warn: vi.fn(),
152
+ info: vi.fn(),
153
+ debug: vi.fn(),
154
+ verbose: vi.fn(),
155
+ };
156
+ }
157
+
158
+ function createMockCtx() {
159
+ return {
160
+ commander: {} as any,
161
+ logger: createMockLogger(),
162
+ core: {} as any,
163
+ };
164
+ }
165
+
166
+ // ─────────────────────────────────────────────────────────
167
+ // Setup / teardown
168
+ // ─────────────────────────────────────────────────────────
169
+
170
+ let tmpDir: string;
171
+
172
+ beforeEach(async () => {
173
+ tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'kadi-down-test-'));
174
+ // resetAllMocks clears call history AND resets mockImplementation
175
+ // (clearAllMocks only clears history, leaving stale throw-implementations)
176
+ vi.resetAllMocks();
177
+ // Re-configure default mocks after reset
178
+ mockConfirmPrompt.mockResolvedValue(true);
179
+ mockExecSync.mockReturnValue(undefined);
180
+ // Re-establish AkashClient constructor mock (resetAllMocks clears it)
181
+ (MockedAkashClient as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => ({
182
+ closeDeployment: mockCloseDeployment,
183
+ disconnect: mockDisconnect,
184
+ }));
185
+ });
186
+
187
+ afterEach(async () => {
188
+ try {
189
+ await fs.rm(tmpDir, { recursive: true, force: true });
190
+ } catch {
191
+ // ignore
192
+ }
193
+ });
194
+
195
+ // ==========================================================
196
+ // 1. executeDown — no lock file
197
+ // ==========================================================
198
+
199
+ describe('executeDown — missing lock file', () => {
200
+ it('reports "No active deployment found" and does nothing', async () => {
201
+ const ctx = createMockCtx();
202
+
203
+ await executeDown(ctx, { project: tmpDir, yes: true });
204
+
205
+ expect(ctx.logger.error).toHaveBeenCalledWith(
206
+ expect.stringContaining('No active deployment found')
207
+ );
208
+ expect(mockExecSync).not.toHaveBeenCalled();
209
+ expect(mockCloseDeployment).not.toHaveBeenCalled();
210
+ expect(mockConnectWallet).not.toHaveBeenCalled();
211
+ });
212
+
213
+ it('tells the user which directory was searched', async () => {
214
+ const ctx = createMockCtx();
215
+
216
+ await executeDown(ctx, { project: tmpDir, yes: true });
217
+
218
+ const allLogs = ctx.logger.log.mock.calls.flat().join('\n');
219
+ expect(allLogs).toContain(tmpDir);
220
+ });
221
+ });
222
+
223
+ // ==========================================================
224
+ // 2. executeDown — confirmation prompt behavior
225
+ // ==========================================================
226
+
227
+ describe('executeDown — confirmation prompt', () => {
228
+ it('asks for confirmation when --yes is not set', async () => {
229
+ const ctx = createMockCtx();
230
+ await writeLockFile(tmpDir, makeLocalLock());
231
+ mockConfirmPrompt.mockResolvedValue(false);
232
+
233
+ await executeDown(ctx, { project: tmpDir });
234
+
235
+ expect(mockConfirmPrompt).toHaveBeenCalledWith(
236
+ expect.stringContaining('Tear down'),
237
+ false
238
+ );
239
+ // Should NOT have proceeded
240
+ expect(mockExecSync).not.toHaveBeenCalled();
241
+ });
242
+
243
+ it('skips confirmation when --yes is set', async () => {
244
+ const ctx = createMockCtx();
245
+ await writeLockFile(tmpDir, makeLocalLock());
246
+
247
+ await executeDown(ctx, { project: tmpDir, yes: true });
248
+
249
+ expect(mockConfirmPrompt).not.toHaveBeenCalled();
250
+ expect(mockExecSync).toHaveBeenCalled();
251
+ });
252
+
253
+ it('aborts without deleting lock when user declines', async () => {
254
+ const ctx = createMockCtx();
255
+ await writeLockFile(tmpDir, makeLocalLock());
256
+ mockConfirmPrompt.mockResolvedValue(false);
257
+
258
+ await executeDown(ctx, { project: tmpDir });
259
+
260
+ expect(ctx.logger.log).toHaveBeenCalledWith('Cancelled.');
261
+ expect(mockExecSync).not.toHaveBeenCalled();
262
+ expect(await readLockFile(tmpDir)).not.toBeNull();
263
+ });
264
+
265
+ it('skips confirmation when --autonomous is set (no --yes needed)', async () => {
266
+ const ctx = createMockCtx();
267
+ await writeLockFile(tmpDir, makeLocalLock());
268
+
269
+ await executeDown(ctx, { project: tmpDir, autonomous: true });
270
+
271
+ expect(mockConfirmPrompt).not.toHaveBeenCalled();
272
+ expect(mockExecSync).toHaveBeenCalled();
273
+ });
274
+ });
275
+
276
+ // ==========================================================
277
+ // 2b. executeDown — profile selection
278
+ // ==========================================================
279
+
280
+ describe('executeDown — profile selection', () => {
281
+ it('auto-selects the only active deployment', async () => {
282
+ const ctx = createMockCtx();
283
+ await writeLockFile(tmpDir, makeLocalLock());
284
+
285
+ await executeDown(ctx, { project: tmpDir, yes: true });
286
+
287
+ // Should not prompt for selection
288
+ expect(mockSelectPrompt).not.toHaveBeenCalled();
289
+ expect(mockExecSync).toHaveBeenCalled();
290
+ });
291
+
292
+ it('uses --profile flag to select a specific deployment', async () => {
293
+ const ctx = createMockCtx();
294
+ await writeLockFile(tmpDir, makeLocalLock());
295
+ await writeLockFile(tmpDir, makeAkashLock());
296
+
297
+ await executeDown(ctx, { project: tmpDir, yes: true, profile: 'dev' });
298
+
299
+ // Should not prompt, should use local teardown
300
+ expect(mockSelectPrompt).not.toHaveBeenCalled();
301
+ expect(mockExecSync).toHaveBeenCalled();
302
+ });
303
+
304
+ it('errors when --profile specifies non-existent profile', async () => {
305
+ const ctx = createMockCtx();
306
+ await writeLockFile(tmpDir, makeLocalLock());
307
+
308
+ await executeDown(ctx, { project: tmpDir, yes: true, profile: 'nonexistent' });
309
+
310
+ expect(ctx.logger.error).toHaveBeenCalledWith(
311
+ expect.stringContaining('No active deployment found for profile "nonexistent"')
312
+ );
313
+ expect(mockExecSync).not.toHaveBeenCalled();
314
+ });
315
+
316
+ it('prompts user to select when multiple deployments and no --profile', async () => {
317
+ const ctx = createMockCtx();
318
+ await writeLockFile(tmpDir, makeLocalLock());
319
+ await writeLockFile(tmpDir, makeAkashLock());
320
+
321
+ // The selection prompt now shows instanceId-based choices
322
+ mockSelectPrompt.mockResolvedValue('a1b2 (dev, local)');
323
+
324
+ await executeDown(ctx, { project: tmpDir, yes: true });
325
+
326
+ expect(mockSelectPrompt).toHaveBeenCalledWith(
327
+ expect.stringContaining('Which deployment'),
328
+ expect.arrayContaining([
329
+ expect.stringContaining('a1b2'),
330
+ expect.stringContaining('c3d4'),
331
+ ])
332
+ );
333
+ expect(mockExecSync).toHaveBeenCalled();
334
+ });
335
+
336
+ it('only removes the selected profile, keeps others', async () => {
337
+ const ctx = createMockCtx();
338
+ await writeLockFile(tmpDir, makeLocalLock());
339
+ await writeLockFile(tmpDir, makeAkashLock());
340
+
341
+ // Tear down just local
342
+ await executeDown(ctx, { project: tmpDir, yes: true, profile: 'dev' });
343
+
344
+ // Akash entry should still exist
345
+ const lockFile = await readLockFile(tmpDir);
346
+ expect(lockFile).not.toBeNull();
347
+ const remaining = Object.values(lockFile!.deployments);
348
+ expect(remaining).toHaveLength(1);
349
+ expect(remaining[0].profile).toBe('production');
350
+ });
351
+
352
+ it('errors in autonomous mode with multiple deployments and no --profile', async () => {
353
+ const ctx = createMockCtx();
354
+ await writeLockFile(tmpDir, makeLocalLock());
355
+ await writeLockFile(tmpDir, makeAkashLock());
356
+
357
+ await executeDown(ctx, { project: tmpDir, autonomous: true });
358
+
359
+ expect(mockSelectPrompt).not.toHaveBeenCalled();
360
+ expect(ctx.logger.error).toHaveBeenCalledWith(
361
+ expect.stringContaining('specify which one with --profile')
362
+ );
363
+ expect(mockExecSync).not.toHaveBeenCalled();
364
+ expect(mockCloseDeployment).not.toHaveBeenCalled();
365
+ });
366
+ });
367
+
368
+ // ==========================================================
369
+ // 3. executeDown — local teardown (the real function path)
370
+ // ==========================================================
371
+
372
+ describe('executeDown — local teardown', () => {
373
+ it('calls execSync with correct compose command from lock data', async () => {
374
+ const ctx = createMockCtx();
375
+ await writeLockFile(tmpDir, makeLocalLock());
376
+
377
+ await executeDown(ctx, { project: tmpDir, yes: true });
378
+
379
+ expect(mockExecSync).toHaveBeenCalledTimes(1);
380
+ const [cmd, opts] = mockExecSync.mock.calls[0];
381
+ expect(cmd).toBe(
382
+ 'docker compose -f "/tmp/project/docker-compose.yml" down --remove-orphans'
383
+ );
384
+ expect(opts.cwd).toBe(tmpDir);
385
+ expect(opts.timeout).toBe(60_000);
386
+ });
387
+
388
+ it('uses engine from the lock (podman)', async () => {
389
+ const ctx = createMockCtx();
390
+ await writeLockFile(tmpDir, makeLocalLock({
391
+ local: {
392
+ composePath: '/tmp/project/docker-compose.yml',
393
+ engine: 'podman',
394
+ network: 'kadi-net',
395
+ services: ['web'],
396
+ containers: { web: 'abc' },
397
+ },
398
+ }));
399
+
400
+ await executeDown(ctx, { project: tmpDir, yes: true });
401
+
402
+ const cmd = mockExecSync.mock.calls[0][0] as string;
403
+ expect(cmd).toMatch(/^podman compose/);
404
+ });
405
+
406
+ it('--engine flag overrides the lock file engine', async () => {
407
+ const ctx = createMockCtx();
408
+ await writeLockFile(tmpDir, makeLocalLock()); // lock says docker
409
+
410
+ await executeDown(ctx, { project: tmpDir, yes: true, engine: 'podman' });
411
+
412
+ const cmd = mockExecSync.mock.calls[0][0] as string;
413
+ expect(cmd).toMatch(/^podman compose/);
414
+ expect(cmd).not.toMatch(/^docker compose/);
415
+ });
416
+
417
+ it('uses the compose file path from the lock', async () => {
418
+ const ctx = createMockCtx();
419
+ const customPath = '/my/custom/compose.yaml';
420
+ await writeLockFile(tmpDir, makeLocalLock({
421
+ local: {
422
+ composePath: customPath,
423
+ engine: 'docker',
424
+ network: 'net',
425
+ services: ['app'],
426
+ containers: { app: 'xyz' },
427
+ },
428
+ }));
429
+
430
+ await executeDown(ctx, { project: tmpDir, yes: true });
431
+
432
+ const cmd = mockExecSync.mock.calls[0][0] as string;
433
+ expect(cmd).toContain(`-f "${customPath}"`);
434
+ });
435
+
436
+ it('deletes the lock file after successful teardown', async () => {
437
+ const ctx = createMockCtx();
438
+ await writeLockFile(tmpDir, makeLocalLock());
439
+ expect(await readLockFile(tmpDir)).not.toBeNull();
440
+
441
+ await executeDown(ctx, { project: tmpDir, yes: true });
442
+
443
+ expect(await readLockFile(tmpDir)).toBeNull();
444
+ });
445
+
446
+ it('logs success message', async () => {
447
+ const ctx = createMockCtx();
448
+ await writeLockFile(tmpDir, makeLocalLock());
449
+
450
+ await executeDown(ctx, { project: tmpDir, yes: true });
451
+
452
+ const allLogs = ctx.logger.log.mock.calls.flat().join('\n');
453
+ expect(allLogs).toContain('Local deployment torn down successfully');
454
+ });
455
+
456
+ it('passes stdio=inherit when --verbose', async () => {
457
+ const ctx = createMockCtx();
458
+ await writeLockFile(tmpDir, makeLocalLock());
459
+
460
+ await executeDown(ctx, { project: tmpDir, yes: true, verbose: true });
461
+
462
+ expect(mockExecSync.mock.calls[0][1].stdio).toBe('inherit');
463
+ });
464
+
465
+ it('passes stdio=pipe when not verbose', async () => {
466
+ const ctx = createMockCtx();
467
+ await writeLockFile(tmpDir, makeLocalLock());
468
+
469
+ await executeDown(ctx, { project: tmpDir, yes: true });
470
+
471
+ expect(mockExecSync.mock.calls[0][1].stdio).toBe('pipe');
472
+ });
473
+
474
+ it('keeps lock file when execSync throws (containers already stopped)', async () => {
475
+ const ctx = createMockCtx();
476
+ await writeLockFile(tmpDir, makeLocalLock());
477
+ mockExecSync.mockImplementation(() => {
478
+ throw new Error('exit code 1');
479
+ });
480
+
481
+ await executeDown(ctx, { project: tmpDir, yes: true });
482
+
483
+ expect(ctx.logger.error).toHaveBeenCalledWith(
484
+ expect.stringContaining('exit code 1')
485
+ );
486
+ // Lock should NOT be deleted on failure
487
+ expect(await readLockFile(tmpDir)).not.toBeNull();
488
+ });
489
+ });
490
+
491
+ // ==========================================================
492
+ // 4. executeDown — displays deployment info
493
+ // ==========================================================
494
+
495
+ describe('executeDown — deployment info display', () => {
496
+ it('displays local deployment details before teardown', async () => {
497
+ const ctx = createMockCtx();
498
+ await writeLockFile(tmpDir, makeLocalLock());
499
+
500
+ await executeDown(ctx, { project: tmpDir, yes: true });
501
+
502
+ const allLogs = ctx.logger.log.mock.calls.flat().join('\n');
503
+ expect(allLogs).toContain('local');
504
+ expect(allLogs).toContain('dev');
505
+ expect(allLogs).toContain('docker');
506
+ expect(allLogs).toContain('web, api');
507
+ });
508
+
509
+ it('displays Akash deployment details before teardown', async () => {
510
+ const ctx = createMockCtx();
511
+ await writeLockFile(tmpDir, makeAkashLock());
512
+
513
+ // Set up interactive mocks
514
+ mockConnectWallet.mockResolvedValue({
515
+ success: true,
516
+ data: { address: 'akash1test', signer: {} },
517
+ });
518
+ mockCloseDeployment.mockResolvedValue({
519
+ success: true,
520
+ data: { dseq: '12345678', transactionHash: 'ABC', height: 99, closedAt: new Date() },
521
+ });
522
+ const mockExit = vi.spyOn(process, 'exit').mockImplementation((() => {}) as any);
523
+
524
+ await executeDown(ctx, { project: tmpDir, yes: true });
525
+
526
+ const allLogs = ctx.logger.log.mock.calls.flat().join('\n');
527
+ expect(allLogs).toContain('12345678');
528
+ expect(allLogs).toContain('akash1abc123');
529
+ expect(allLogs).toContain('mainnet');
530
+
531
+ mockExit.mockRestore();
532
+ });
533
+ });
534
+
535
+ // ==========================================================
536
+ // 5. executeDown — target routing
537
+ // ==========================================================
538
+
539
+ describe('executeDown — target routing', () => {
540
+ it('routes local → execSync, not Akash SDK', async () => {
541
+ const ctx = createMockCtx();
542
+ await writeLockFile(tmpDir, makeLocalLock());
543
+
544
+ await executeDown(ctx, { project: tmpDir, yes: true });
545
+
546
+ expect(mockExecSync).toHaveBeenCalled();
547
+ expect(mockConnectWallet).not.toHaveBeenCalled();
548
+ expect(mockCreateWalletFromMnemonic).not.toHaveBeenCalled();
549
+ });
550
+
551
+ it('routes akash → connectWallet, not execSync', async () => {
552
+ const ctx = createMockCtx();
553
+ await writeLockFile(tmpDir, makeAkashLock());
554
+
555
+ mockConnectWallet.mockResolvedValue({
556
+ success: true,
557
+ data: { address: 'akash1test', signer: {} },
558
+ });
559
+ mockCloseDeployment.mockResolvedValue({
560
+ success: true,
561
+ data: { dseq: '12345678', transactionHash: 'TX', height: 1, closedAt: new Date() },
562
+ });
563
+ const mockExit = vi.spyOn(process, 'exit').mockImplementation((() => {}) as any);
564
+
565
+ await executeDown(ctx, { project: tmpDir, yes: true });
566
+
567
+ expect(mockExecSync).not.toHaveBeenCalled();
568
+ expect(mockConnectWallet).toHaveBeenCalled();
569
+
570
+ mockExit.mockRestore();
571
+ });
572
+
573
+ it('routes akash + --autonomous → createWalletFromMnemonic, not connectWallet', async () => {
574
+ const ctx = createMockCtx();
575
+ await writeLockFile(tmpDir, makeAkashLock());
576
+
577
+ mockReadSecretFromCli.mockReturnValue('mnemonic words');
578
+ mockCreateWalletFromMnemonic.mockResolvedValue({
579
+ success: true,
580
+ data: { address: 'akash1auto', signer: {} },
581
+ });
582
+ mockCloseDeployment.mockResolvedValue({
583
+ success: true,
584
+ data: { dseq: '12345678', transactionHash: 'AUTO', height: 5, closedAt: new Date() },
585
+ });
586
+ const mockExit = vi.spyOn(process, 'exit').mockImplementation((() => {}) as any);
587
+
588
+ await executeDown(ctx, { project: tmpDir, yes: true, autonomous: true });
589
+
590
+ expect(mockConnectWallet).not.toHaveBeenCalled();
591
+ expect(mockCreateWalletFromMnemonic).toHaveBeenCalled();
592
+
593
+ mockExit.mockRestore();
594
+ });
595
+
596
+ it('logs error for unknown deployment target', async () => {
597
+ const ctx = createMockCtx();
598
+ // Manually write a lock with a bogus target
599
+ await writeLockFile(tmpDir, {
600
+ instanceId: '',
601
+ target: 'gcp' as any,
602
+ profile: 'test',
603
+ deployedAt: new Date().toISOString(),
604
+ });
605
+
606
+ await executeDown(ctx, { project: tmpDir, yes: true });
607
+
608
+ expect(ctx.logger.error).toHaveBeenCalledWith(
609
+ expect.stringContaining('Unknown deployment target')
610
+ );
611
+ expect(mockExecSync).not.toHaveBeenCalled();
612
+ expect(mockConnectWallet).not.toHaveBeenCalled();
613
+ });
614
+ });
615
+
616
+ // ==========================================================
617
+ // 6. executeDown — Akash interactive teardown
618
+ // ==========================================================
619
+
620
+ describe('executeDown — Akash interactive teardown', () => {
621
+ let mockExit: ReturnType<typeof vi.spyOn>;
622
+
623
+ beforeEach(() => {
624
+ mockExit = vi.spyOn(process, 'exit').mockImplementation((() => {}) as any);
625
+ });
626
+
627
+ afterEach(() => {
628
+ mockExit.mockRestore();
629
+ });
630
+
631
+ it('passes the lock network to connectWallet', async () => {
632
+ const ctx = createMockCtx();
633
+ await writeLockFile(tmpDir, makeAkashLock());
634
+
635
+ mockConnectWallet.mockResolvedValue({
636
+ success: true,
637
+ data: { address: 'akash1test', signer: {} },
638
+ });
639
+ mockCloseDeployment.mockResolvedValue({
640
+ success: true,
641
+ data: { dseq: '12345678', transactionHash: 'TX', height: 1, closedAt: new Date() },
642
+ });
643
+
644
+ await executeDown(ctx, { project: tmpDir, yes: true });
645
+
646
+ expect(mockConnectWallet).toHaveBeenCalledWith(
647
+ expect.any(String),
648
+ 'mainnet',
649
+ expect.any(Object),
650
+ );
651
+ });
652
+
653
+ it('--network flag overrides the lock network', async () => {
654
+ const ctx = createMockCtx();
655
+ await writeLockFile(tmpDir, makeAkashLock());
656
+
657
+ mockConnectWallet.mockResolvedValue({
658
+ success: true,
659
+ data: { address: 'akash1test', signer: {} },
660
+ });
661
+ mockCloseDeployment.mockResolvedValue({
662
+ success: true,
663
+ data: { dseq: '12345678', transactionHash: 'TX', height: 1, closedAt: new Date() },
664
+ });
665
+
666
+ await executeDown(ctx, { project: tmpDir, yes: true, network: 'testnet' });
667
+
668
+ expect(mockConnectWallet).toHaveBeenCalledWith(
669
+ expect.any(String),
670
+ 'testnet',
671
+ expect.any(Object),
672
+ );
673
+ });
674
+
675
+ it('calls closeDeployment with the dseq from the lock', async () => {
676
+ const ctx = createMockCtx();
677
+ await writeLockFile(tmpDir, makeAkashLock());
678
+
679
+ mockConnectWallet.mockResolvedValue({
680
+ success: true,
681
+ data: { address: 'akash1test', signer: {} },
682
+ });
683
+ mockCloseDeployment.mockResolvedValue({
684
+ success: true,
685
+ data: { dseq: '12345678', transactionHash: 'TX', height: 1, closedAt: new Date() },
686
+ });
687
+
688
+ await executeDown(ctx, { project: tmpDir, yes: true });
689
+
690
+ expect(mockCloseDeployment).toHaveBeenCalledWith(12345678);
691
+ });
692
+
693
+ it('deletes lock after successful Akash close', async () => {
694
+ const ctx = createMockCtx();
695
+ await writeLockFile(tmpDir, makeAkashLock());
696
+
697
+ mockConnectWallet.mockResolvedValue({
698
+ success: true,
699
+ data: { address: 'akash1test', signer: {} },
700
+ });
701
+ mockCloseDeployment.mockResolvedValue({
702
+ success: true,
703
+ data: { dseq: '12345678', transactionHash: 'TX', height: 1, closedAt: new Date() },
704
+ });
705
+
706
+ await executeDown(ctx, { project: tmpDir, yes: true });
707
+
708
+ expect(await readLockFile(tmpDir)).toBeNull();
709
+ });
710
+
711
+ it('does NOT delete lock and does NOT call closeDeployment when wallet fails', async () => {
712
+ const ctx = createMockCtx();
713
+ await writeLockFile(tmpDir, makeAkashLock());
714
+
715
+ mockConnectWallet.mockResolvedValue({
716
+ success: false,
717
+ error: { message: 'User rejected' },
718
+ });
719
+
720
+ await executeDown(ctx, { project: tmpDir, yes: true });
721
+
722
+ expect(ctx.logger.error).toHaveBeenCalledWith(
723
+ expect.stringContaining('User rejected')
724
+ );
725
+ expect(await readLockFile(tmpDir)).not.toBeNull();
726
+ expect(mockCloseDeployment).not.toHaveBeenCalled();
727
+ });
728
+
729
+ it('does NOT delete lock when closeDeployment fails', async () => {
730
+ const ctx = createMockCtx();
731
+ await writeLockFile(tmpDir, makeAkashLock());
732
+
733
+ mockConnectWallet.mockResolvedValue({
734
+ success: true,
735
+ data: { address: 'akash1test', signer: {} },
736
+ });
737
+ mockCloseDeployment.mockResolvedValue({
738
+ success: false,
739
+ error: { message: 'Insufficient funds' },
740
+ });
741
+
742
+ await executeDown(ctx, { project: tmpDir, yes: true });
743
+
744
+ expect(ctx.logger.error).toHaveBeenCalledWith(
745
+ expect.stringContaining('Insufficient funds')
746
+ );
747
+ expect(await readLockFile(tmpDir)).not.toBeNull();
748
+ });
749
+
750
+ it('calls disconnectWallet in the finally block', async () => {
751
+ const ctx = createMockCtx();
752
+ await writeLockFile(tmpDir, makeAkashLock());
753
+
754
+ const walletData = { address: 'akash1test', signer: {} };
755
+ mockConnectWallet.mockResolvedValue({ success: true, data: walletData });
756
+ mockCloseDeployment.mockResolvedValue({
757
+ success: true,
758
+ data: { dseq: '12345678', transactionHash: 'TX', height: 1, closedAt: new Date() },
759
+ });
760
+
761
+ await executeDown(ctx, { project: tmpDir, yes: true });
762
+
763
+ expect(mockDisconnectWallet).toHaveBeenCalledWith(walletData);
764
+ });
765
+ });
766
+
767
+ // ==========================================================
768
+ // 7. executeDown — Akash autonomous teardown
769
+ // ==========================================================
770
+
771
+ describe('executeDown — Akash autonomous teardown', () => {
772
+ let mockExit: ReturnType<typeof vi.spyOn>;
773
+
774
+ beforeEach(() => {
775
+ mockExit = vi.spyOn(process, 'exit').mockImplementation((() => {}) as any);
776
+ });
777
+
778
+ afterEach(() => {
779
+ mockExit.mockRestore();
780
+ });
781
+
782
+ it('reads AKASH_WALLET from the specified vault', async () => {
783
+ const ctx = createMockCtx();
784
+ await writeLockFile(tmpDir, makeAkashLock());
785
+
786
+ mockReadSecretFromCli.mockReturnValue('mnemonic');
787
+ mockCreateWalletFromMnemonic.mockResolvedValue({
788
+ success: true,
789
+ data: { address: 'akash1auto', signer: {} },
790
+ });
791
+ mockCloseDeployment.mockResolvedValue({
792
+ success: true,
793
+ data: { dseq: '12345678', transactionHash: 'TX', height: 1, closedAt: new Date() },
794
+ });
795
+
796
+ await executeDown(ctx, {
797
+ project: tmpDir,
798
+ yes: true,
799
+ autonomous: true,
800
+ secretsVault: 'my-vault',
801
+ });
802
+
803
+ expect(mockReadSecretFromCli).toHaveBeenCalledWith(
804
+ expect.objectContaining({
805
+ key: 'AKASH_WALLET',
806
+ vault: 'my-vault',
807
+ cwd: tmpDir,
808
+ })
809
+ );
810
+ });
811
+
812
+ it('defaults vault to "global"', async () => {
813
+ const ctx = createMockCtx();
814
+ await writeLockFile(tmpDir, makeAkashLock());
815
+
816
+ mockReadSecretFromCli.mockReturnValue('mnemonic');
817
+ mockCreateWalletFromMnemonic.mockResolvedValue({
818
+ success: true,
819
+ data: { address: 'akash1auto', signer: {} },
820
+ });
821
+ mockCloseDeployment.mockResolvedValue({
822
+ success: true,
823
+ data: { dseq: '12345678', transactionHash: 'TX', height: 1, closedAt: new Date() },
824
+ });
825
+
826
+ await executeDown(ctx, { project: tmpDir, yes: true, autonomous: true });
827
+
828
+ expect(mockReadSecretFromCli).toHaveBeenCalledWith(
829
+ expect.objectContaining({ vault: 'global' })
830
+ );
831
+ });
832
+
833
+ it('passes mnemonic and network to createWalletFromMnemonic', async () => {
834
+ const ctx = createMockCtx();
835
+ await writeLockFile(tmpDir, makeAkashLock());
836
+
837
+ const mnemonic = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
838
+ mockReadSecretFromCli.mockReturnValue(mnemonic);
839
+ mockCreateWalletFromMnemonic.mockResolvedValue({
840
+ success: true,
841
+ data: { address: 'akash1auto', signer: {} },
842
+ });
843
+ mockCloseDeployment.mockResolvedValue({
844
+ success: true,
845
+ data: { dseq: '12345678', transactionHash: 'TX', height: 1, closedAt: new Date() },
846
+ });
847
+
848
+ await executeDown(ctx, { project: tmpDir, yes: true, autonomous: true });
849
+
850
+ expect(mockCreateWalletFromMnemonic).toHaveBeenCalledWith(mnemonic, 'mainnet');
851
+ });
852
+
853
+ it('--network overrides lock network in autonomous mode', async () => {
854
+ const ctx = createMockCtx();
855
+ await writeLockFile(tmpDir, makeAkashLock());
856
+
857
+ mockReadSecretFromCli.mockReturnValue('mnemonic');
858
+ mockCreateWalletFromMnemonic.mockResolvedValue({
859
+ success: true,
860
+ data: { address: 'akash1auto', signer: {} },
861
+ });
862
+ mockCloseDeployment.mockResolvedValue({
863
+ success: true,
864
+ data: { dseq: '12345678', transactionHash: 'TX', height: 1, closedAt: new Date() },
865
+ });
866
+
867
+ await executeDown(ctx, { project: tmpDir, yes: true, autonomous: true, network: 'testnet' });
868
+
869
+ expect(mockCreateWalletFromMnemonic).toHaveBeenCalledWith(
870
+ expect.any(String),
871
+ 'testnet'
872
+ );
873
+ });
874
+
875
+ it('calls closeDeployment with dseq from lock', async () => {
876
+ const ctx = createMockCtx();
877
+ const lock = makeAkashLock();
878
+ lock.akash!.dseq = 99999;
879
+ await writeLockFile(tmpDir, lock);
880
+
881
+ mockReadSecretFromCli.mockReturnValue('mnemonic');
882
+ mockCreateWalletFromMnemonic.mockResolvedValue({
883
+ success: true,
884
+ data: { address: 'akash1auto', signer: {} },
885
+ });
886
+ mockCloseDeployment.mockResolvedValue({
887
+ success: true,
888
+ data: { dseq: '99999', transactionHash: 'TX', height: 1, closedAt: new Date() },
889
+ });
890
+
891
+ await executeDown(ctx, { project: tmpDir, yes: true, autonomous: true });
892
+
893
+ expect(mockCloseDeployment).toHaveBeenCalledWith(99999);
894
+ });
895
+
896
+ it('deletes lock after successful autonomous close', async () => {
897
+ const ctx = createMockCtx();
898
+ await writeLockFile(tmpDir, makeAkashLock());
899
+
900
+ mockReadSecretFromCli.mockReturnValue('mnemonic');
901
+ mockCreateWalletFromMnemonic.mockResolvedValue({
902
+ success: true,
903
+ data: { address: 'akash1auto', signer: {} },
904
+ });
905
+ mockCloseDeployment.mockResolvedValue({
906
+ success: true,
907
+ data: { dseq: '12345678', transactionHash: 'TX', height: 1, closedAt: new Date() },
908
+ });
909
+
910
+ await executeDown(ctx, { project: tmpDir, yes: true, autonomous: true });
911
+
912
+ expect(await readLockFile(tmpDir)).toBeNull();
913
+ });
914
+
915
+ it('errors when mnemonic is not found in vault', async () => {
916
+ const ctx = createMockCtx();
917
+ await writeLockFile(tmpDir, makeAkashLock());
918
+
919
+ mockReadSecretFromCli.mockReturnValue(null);
920
+
921
+ await executeDown(ctx, { project: tmpDir, yes: true, autonomous: true });
922
+
923
+ expect(ctx.logger.error).toHaveBeenCalledWith(
924
+ expect.stringContaining('AKASH_WALLET')
925
+ );
926
+ expect(mockCreateWalletFromMnemonic).not.toHaveBeenCalled();
927
+ expect(mockCloseDeployment).not.toHaveBeenCalled();
928
+ expect(await readLockFile(tmpDir)).not.toBeNull();
929
+ });
930
+
931
+ it('errors when createWalletFromMnemonic fails', async () => {
932
+ const ctx = createMockCtx();
933
+ await writeLockFile(tmpDir, makeAkashLock());
934
+
935
+ mockReadSecretFromCli.mockReturnValue('bad mnemonic');
936
+ mockCreateWalletFromMnemonic.mockResolvedValue({
937
+ success: false,
938
+ error: { message: 'Invalid mnemonic' },
939
+ });
940
+
941
+ await executeDown(ctx, { project: tmpDir, yes: true, autonomous: true });
942
+
943
+ expect(ctx.logger.error).toHaveBeenCalledWith(
944
+ expect.stringContaining('Invalid mnemonic')
945
+ );
946
+ expect(mockCloseDeployment).not.toHaveBeenCalled();
947
+ });
948
+
949
+ it('errors when closeDeployment fails in autonomous mode', async () => {
950
+ const ctx = createMockCtx();
951
+ await writeLockFile(tmpDir, makeAkashLock());
952
+
953
+ mockReadSecretFromCli.mockReturnValue('mnemonic');
954
+ mockCreateWalletFromMnemonic.mockResolvedValue({
955
+ success: true,
956
+ data: { address: 'akash1auto', signer: {} },
957
+ });
958
+ mockCloseDeployment.mockResolvedValue({
959
+ success: false,
960
+ error: { message: 'Deployment already closed' },
961
+ });
962
+
963
+ await executeDown(ctx, { project: tmpDir, yes: true, autonomous: true });
964
+
965
+ expect(ctx.logger.error).toHaveBeenCalledWith(
966
+ expect.stringContaining('Deployment already closed')
967
+ );
968
+ expect(await readLockFile(tmpDir)).not.toBeNull();
969
+ });
970
+ });
971
+
972
+ // ==========================================================
973
+ // 8. CLI registration — real Commander integration
974
+ // ==========================================================
975
+
976
+ describe('CLI registration — Commander wiring', () => {
977
+ it('registers a "deploy" parent command', async () => {
978
+ const { Command } = await import('commander');
979
+ const program = new Command();
980
+ const ctx = { commander: program, logger: createMockLogger(), core: {} as any };
981
+
982
+ const { default: registerDeploy } = await import('../src/index.js');
983
+ registerDeploy(ctx as any);
984
+
985
+ const deployCmd = program.commands.find((c: any) => c.name() === 'deploy');
986
+ expect(deployCmd).toBeDefined();
987
+ expect(deployCmd!.aliases()).toContain('d');
988
+ });
989
+
990
+ it('registers "up" as the default subcommand', async () => {
991
+ const { Command } = await import('commander');
992
+ const program = new Command();
993
+ const ctx = { commander: program, logger: createMockLogger(), core: {} as any };
994
+
995
+ const { default: registerDeploy } = await import('../src/index.js');
996
+ registerDeploy(ctx as any);
997
+
998
+ const deployCmd = program.commands.find((c: any) => c.name() === 'deploy');
999
+ const upCmd = deployCmd!.commands.find((c: any) => c.name() === 'up');
1000
+ expect(upCmd).toBeDefined();
1001
+ expect(upCmd!.description()).toContain('Deploy agents');
1002
+ });
1003
+
1004
+ it('registers "down" as a subcommand with correct description', async () => {
1005
+ const { Command } = await import('commander');
1006
+ const program = new Command();
1007
+ const ctx = { commander: program, logger: createMockLogger(), core: {} as any };
1008
+
1009
+ const { default: registerDeploy } = await import('../src/index.js');
1010
+ registerDeploy(ctx as any);
1011
+
1012
+ const deployCmd = program.commands.find((c: any) => c.name() === 'deploy');
1013
+ const downCmd = deployCmd!.commands.find((c: any) => c.name() === 'down');
1014
+ expect(downCmd).toBeDefined();
1015
+ expect(downCmd!.description()).toContain('Tear down');
1016
+ });
1017
+
1018
+ it('"down" subcommand has all required option flags', async () => {
1019
+ const { Command } = await import('commander');
1020
+ const program = new Command();
1021
+ const ctx = { commander: program, logger: createMockLogger(), core: {} as any };
1022
+
1023
+ const { default: registerDeploy } = await import('../src/index.js');
1024
+ registerDeploy(ctx as any);
1025
+
1026
+ const deployCmd = program.commands.find((c: any) => c.name() === 'deploy');
1027
+ const downCmd = deployCmd!.commands.find((c: any) => c.name() === 'down');
1028
+ const optionFlags = downCmd!.options.map((o: any) => o.long || o.short);
1029
+
1030
+ expect(optionFlags).toContain('--project');
1031
+ expect(optionFlags).toContain('--profile');
1032
+ expect(optionFlags).toContain('--instance');
1033
+ expect(optionFlags).toContain('--all');
1034
+ expect(optionFlags).toContain('--engine');
1035
+ expect(optionFlags).toContain('--yes');
1036
+ expect(optionFlags).toContain('--autonomous');
1037
+ expect(optionFlags).toContain('--secrets-vault');
1038
+ expect(optionFlags).toContain('--network');
1039
+ });
1040
+
1041
+ it('parsing "deploy down --project ... --yes" invokes executeDown and calls execSync', async () => {
1042
+ const { Command } = await import('commander');
1043
+ const program = new Command();
1044
+ program.exitOverride();
1045
+ const ctx = { commander: program, logger: createMockLogger(), core: {} as any };
1046
+
1047
+ const { default: registerDeploy } = await import('../src/index.js');
1048
+ registerDeploy(ctx as any);
1049
+
1050
+ // Write a lock so executeDown finds something
1051
+ await writeLockFile(tmpDir, makeLocalLock());
1052
+
1053
+ // Parse the CLI — this should trigger the action handler
1054
+ // Note: from:'user' means no argv[0]/argv[1] prefix needed
1055
+ await program.parseAsync(
1056
+ ['deploy', 'down', '--project', tmpDir, '--yes'],
1057
+ { from: 'user' }
1058
+ );
1059
+
1060
+ // If wired correctly, execSync was called with a compose down command
1061
+ expect(mockExecSync).toHaveBeenCalledTimes(1);
1062
+ const cmd = mockExecSync.mock.calls[0][0] as string;
1063
+ expect(cmd).toContain('compose');
1064
+ expect(cmd).toContain('down');
1065
+ expect(cmd).toContain('--remove-orphans');
1066
+ });
1067
+ });
1068
+
1069
+ // ==========================================================
1070
+ // 9. End-to-end: build lock → executeDown
1071
+ // ==========================================================
1072
+
1073
+ describe('end-to-end: lock lifecycle through executeDown', () => {
1074
+ it('buildLocalLock → writeLockFile → executeDown → lock deleted', async () => {
1075
+ const ctx = createMockCtx();
1076
+
1077
+ const lock = buildLocalLock('my-profile', {
1078
+ engine: 'docker',
1079
+ network: 'kadi-net',
1080
+ services: ['web', 'worker'],
1081
+ containers: { web: 'c1', worker: 'c2' },
1082
+ composePath: path.join(tmpDir, 'docker-compose.yml'),
1083
+ deployedAt: new Date(),
1084
+ });
1085
+ const instanceId = await writeLockFile(tmpDir, lock);
1086
+
1087
+ // Confirm lock is valid
1088
+ const lockFile = await readLockFile(tmpDir);
1089
+ const entry = Object.values(lockFile!.deployments).find(d => d.profile === 'my-profile');
1090
+ expect(entry).toBeDefined();
1091
+ expect(entry!.target).toBe('local');
1092
+ expect(entry!.profile).toBe('my-profile');
1093
+ expect(entry!.instanceId).toBe(instanceId);
1094
+ expect(entry!.local!.services).toEqual(['web', 'worker']);
1095
+
1096
+ // Tear down
1097
+ await executeDown(ctx, { project: tmpDir, yes: true });
1098
+
1099
+ // Verify the real execSync call
1100
+ expect(mockExecSync).toHaveBeenCalledTimes(1);
1101
+ const cmd = mockExecSync.mock.calls[0][0] as string;
1102
+ expect(cmd).toContain(path.join(tmpDir, 'docker-compose.yml'));
1103
+ expect(cmd).toContain('down --remove-orphans');
1104
+
1105
+ // Lock gone
1106
+ expect(await readLockFile(tmpDir)).toBeNull();
1107
+ });
1108
+
1109
+ it('buildAkashLock → writeLockFile → executeDown --autonomous → lock deleted', async () => {
1110
+ const ctx = createMockCtx();
1111
+ const mockExit = vi.spyOn(process, 'exit').mockImplementation((() => {}) as any);
1112
+
1113
+ const lock = buildAkashLock('prod', {
1114
+ dseq: 77777,
1115
+ owner: 'akash1deployer',
1116
+ provider: 'akash1provider',
1117
+ providerUri: 'https://p.example.com',
1118
+ network: 'testnet',
1119
+ gseq: 1,
1120
+ oseq: 1,
1121
+ deployedAt: new Date(),
1122
+ });
1123
+ await writeLockFile(tmpDir, lock);
1124
+
1125
+ mockReadSecretFromCli.mockReturnValue('test mnemonic');
1126
+ mockCreateWalletFromMnemonic.mockResolvedValue({
1127
+ success: true,
1128
+ data: { address: 'akash1auto', signer: {} },
1129
+ });
1130
+ mockCloseDeployment.mockResolvedValue({
1131
+ success: true,
1132
+ data: { dseq: '77777', transactionHash: 'HASH', height: 10, closedAt: new Date() },
1133
+ });
1134
+
1135
+ await executeDown(ctx, { project: tmpDir, yes: true, autonomous: true });
1136
+
1137
+ expect(mockCloseDeployment).toHaveBeenCalledWith(77777);
1138
+ expect(mockCreateWalletFromMnemonic).toHaveBeenCalledWith('test mnemonic', 'testnet');
1139
+ expect(await readLockFile(tmpDir)).toBeNull();
1140
+
1141
+ mockExit.mockRestore();
1142
+ });
1143
+ });