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.
- package/.env.example +6 -0
- package/.prettierrc +6 -0
- package/README.md +589 -0
- package/agent.json +23 -0
- package/index.js +11 -0
- package/package.json +42 -0
- package/quick-command.txt +92 -0
- package/scripts/preflight.js +458 -0
- package/scripts/preflight.sh +300 -0
- package/src/cli/bid-selector.ts +222 -0
- package/src/cli/colors.ts +216 -0
- package/src/cli/index.ts +11 -0
- package/src/cli/prompts.ts +190 -0
- package/src/cli/spinners.ts +165 -0
- package/src/commands/deploy-local.ts +475 -0
- package/src/commands/deploy.ts +1342 -0
- package/src/commands/down.ts +679 -0
- package/src/commands/index.ts +10 -0
- package/src/commands/lock.ts +571 -0
- package/src/config/agent-loader.ts +177 -0
- package/src/config/index.ts +9 -0
- package/src/display/deployment-info.ts +220 -0
- package/src/display/pricing.ts +137 -0
- package/src/display/resources.ts +234 -0
- package/src/enhanced-registry-manager.ts +892 -0
- package/src/index.ts +307 -0
- package/src/infrastructure/registry.ts +269 -0
- package/src/schemas/profiles.ts +529 -0
- package/src/secrets/broker-urls.ts +109 -0
- package/src/secrets/handshake.ts +407 -0
- package/src/secrets/index.ts +69 -0
- package/src/secrets/inject-env.ts +171 -0
- package/src/secrets/nonce.ts +31 -0
- package/src/secrets/normalize.ts +204 -0
- package/src/secrets/prepare.ts +152 -0
- package/src/secrets/validate.ts +243 -0
- package/src/secrets/vault.ts +80 -0
- package/src/types/akash.ts +116 -0
- package/src/types/container-registry-ability.d.ts +158 -0
- package/src/types/external.ts +49 -0
- package/src/types.ts +211 -0
- package/src/utils/akt-price.ts +74 -0
- package/tests/agent-loader.test.ts +239 -0
- package/tests/autonomous.test.ts +244 -0
- package/tests/down.test.ts +1143 -0
- package/tests/lock.test.ts +1148 -0
- package/tests/nonce.test.ts +34 -0
- package/tests/normalize.test.ts +270 -0
- package/tests/secrets-schema.test.ts +301 -0
- package/tests/types.test.ts +198 -0
- package/tsconfig.json +18 -0
- 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
|
+
});
|