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,571 @@
1
+ /**
2
+ * Deployment Lock File Management (v3 — multi-instance)
3
+ *
4
+ * Handles reading, writing, and deleting the `.kadi-deploy.lock` file
5
+ * that tracks the state of active deployments. The lock file supports
6
+ * multiple simultaneous deployments keyed by `{profile}:{instanceId}`,
7
+ * enabling scenarios like deploying the same profile to multiple brokers,
8
+ * multiple Akash providers, or having both local and Akash deployments active.
9
+ *
10
+ * The lock file is updated after each successful deployment and cleaned
11
+ * up after a successful teardown. It is deleted entirely when the last
12
+ * deployment is removed.
13
+ *
14
+ * @module commands/lock
15
+ */
16
+
17
+ import crypto from 'node:crypto';
18
+ import fs from 'node:fs/promises';
19
+ import path from 'node:path';
20
+
21
+ // ─────────────────────────────────────────────────────────
22
+ // Types
23
+ // ─────────────────────────────────────────────────────────
24
+
25
+ /** Lock file schema version — bump when the shape changes */
26
+ export const LOCK_VERSION = 3;
27
+
28
+ /** Standard lock file name (lives in the project root next to agent.json) */
29
+ export const LOCK_FILENAME = '.kadi-deploy.lock';
30
+
31
+ /**
32
+ * Local deployment state persisted to disk
33
+ */
34
+ export interface LocalDeploymentLock {
35
+ composePath: string;
36
+ engine: 'docker' | 'podman';
37
+ network: string;
38
+ services: string[];
39
+ containers: Record<string, string>;
40
+ }
41
+
42
+ /**
43
+ * Akash deployment state persisted to disk
44
+ */
45
+ export interface AkashDeploymentLock {
46
+ dseq: number;
47
+ owner: string;
48
+ provider: string;
49
+ providerUri: string;
50
+ network: 'mainnet' | 'testnet';
51
+ gseq: number;
52
+ oseq: number;
53
+ }
54
+
55
+ /**
56
+ * A single deployment entry inside the lock file.
57
+ * Each entry is keyed by `{profile}:{instanceId}` in the `deployments` map.
58
+ */
59
+ export interface DeploymentLock {
60
+ /** Unique deployment instance ID (e.g. "a3f7") */
61
+ instanceId: string;
62
+ target: 'local' | 'akash';
63
+ profile: string;
64
+ deployedAt: string;
65
+ /** Optional human-readable label (e.g. "broker-us-east", "staging") */
66
+ label?: string;
67
+
68
+ /** Present when target === 'local' */
69
+ local?: LocalDeploymentLock;
70
+ /** Present when target === 'akash' */
71
+ akash?: AkashDeploymentLock;
72
+ }
73
+
74
+ /**
75
+ * The top-level lock file shape written to `.kadi-deploy.lock` (v3).
76
+ *
77
+ * ```json
78
+ * {
79
+ * "version": 3,
80
+ * "deployments": {
81
+ * "local:a3f7": { "instanceId": "a3f7", "target": "local", "profile": "local", ... },
82
+ * "akash:b9e2": { "instanceId": "b9e2", "target": "akash", "profile": "akash", ... }
83
+ * }
84
+ * }
85
+ * ```
86
+ */
87
+ export interface LockFile {
88
+ version: number;
89
+ deployments: Record<string, DeploymentLock>;
90
+ }
91
+
92
+ // ─────────────────────────────────────────────────────────
93
+ // v1 / v2 compat types (for migration)
94
+ // ─────────────────────────────────────────────────────────
95
+
96
+ /** Shape of the v1 lock file (single deployment, version on the entry) */
97
+ interface V1LockFile {
98
+ version: number;
99
+ target: 'local' | 'akash';
100
+ profile: string;
101
+ deployedAt: string;
102
+ local?: LocalDeploymentLock;
103
+ akash?: AkashDeploymentLock;
104
+ }
105
+
106
+ /** Shape of a v2 deployment entry (no instanceId) */
107
+ interface V2DeploymentEntry {
108
+ target: 'local' | 'akash';
109
+ profile: string;
110
+ deployedAt: string;
111
+ local?: LocalDeploymentLock;
112
+ akash?: AkashDeploymentLock;
113
+ }
114
+
115
+ /** Shape of the v2 lock file (keyed by profile name, no instanceId) */
116
+ interface V2LockFile {
117
+ version: number;
118
+ deployments: Record<string, V2DeploymentEntry>;
119
+ }
120
+
121
+ // ─────────────────────────────────────────────────────────
122
+ // Instance ID & key utilities
123
+ // ─────────────────────────────────────────────────────────
124
+
125
+ /**
126
+ * Generate a short random instance ID (4 hex characters).
127
+ * 65,536 possible values — more than enough for per-project tracking.
128
+ */
129
+ export function generateInstanceId(): string {
130
+ return crypto.randomBytes(2).toString('hex');
131
+ }
132
+
133
+ /**
134
+ * Build a composite deployment key: `{profile}:{instanceId}`.
135
+ */
136
+ export function deploymentKey(profile: string, instanceId: string): string {
137
+ return `${profile}:${instanceId}`;
138
+ }
139
+
140
+ /**
141
+ * Parse a composite deployment key back into its parts.
142
+ */
143
+ export function parseDeploymentKey(key: string): { profile: string; instanceId: string } {
144
+ const idx = key.lastIndexOf(':');
145
+ if (idx === -1) {
146
+ return { profile: key, instanceId: '' };
147
+ }
148
+ return { profile: key.slice(0, idx), instanceId: key.slice(idx + 1) };
149
+ }
150
+
151
+ // ─────────────────────────────────────────────────────────
152
+ // Lock file operations
153
+ // ─────────────────────────────────────────────────────────
154
+
155
+ /**
156
+ * Build the absolute path to the lock file for a given project root.
157
+ */
158
+ export function lockFilePath(projectRoot: string): string {
159
+ return path.join(projectRoot, LOCK_FILENAME);
160
+ }
161
+
162
+ /**
163
+ * Migrate a v1 lock file (single deployment) to the v3 format.
164
+ */
165
+ export function migrateV1toV3(v1: V1LockFile): LockFile {
166
+ const instanceId = generateInstanceId();
167
+ const entry: DeploymentLock = {
168
+ instanceId,
169
+ target: v1.target,
170
+ profile: v1.profile,
171
+ deployedAt: v1.deployedAt,
172
+ };
173
+
174
+ if (v1.local) entry.local = v1.local;
175
+ if (v1.akash) entry.akash = v1.akash;
176
+
177
+ return {
178
+ version: LOCK_VERSION,
179
+ deployments: {
180
+ [deploymentKey(v1.profile, instanceId)]: entry,
181
+ },
182
+ };
183
+ }
184
+
185
+ /**
186
+ * Migrate a v2 lock file (keyed by profile name, no instanceId) to v3 format.
187
+ */
188
+ export function migrateV2toV3(v2: V2LockFile): LockFile {
189
+ const v3: LockFile = { version: LOCK_VERSION, deployments: {} };
190
+
191
+ for (const [profileName, oldEntry] of Object.entries(v2.deployments)) {
192
+ const instanceId = generateInstanceId();
193
+ const key = deploymentKey(profileName, instanceId);
194
+ v3.deployments[key] = {
195
+ instanceId,
196
+ target: oldEntry.target,
197
+ profile: oldEntry.profile,
198
+ deployedAt: oldEntry.deployedAt,
199
+ ...(oldEntry.local ? { local: oldEntry.local } : {}),
200
+ ...(oldEntry.akash ? { akash: oldEntry.akash } : {}),
201
+ };
202
+ }
203
+
204
+ return v3;
205
+ }
206
+
207
+ /**
208
+ * @deprecated Use migrateV1toV3 instead. Kept for backward compatibility.
209
+ */
210
+ export function migrateV1toV2(v1: V1LockFile): LockFile {
211
+ return migrateV1toV3(v1);
212
+ }
213
+
214
+ /**
215
+ * Detect whether a lock file's entries have instanceId fields (v3) or not (v2).
216
+ */
217
+ function isV3Format(data: { deployments: Record<string, any> }): boolean {
218
+ const entries = Object.values(data.deployments);
219
+ if (entries.length === 0) return true; // empty is fine as v3
220
+ return entries.every((entry) => typeof entry.instanceId === 'string' && entry.instanceId.length > 0);
221
+ }
222
+
223
+ /**
224
+ * Validate that a deployment entry has the expected sections.
225
+ */
226
+ function validateEntry(entry: DeploymentLock, filePath: string): void {
227
+ if (!entry.target || !entry.profile) {
228
+ throw new Error(
229
+ `Invalid deployment entry in ${filePath}: missing required fields (target, profile)`
230
+ );
231
+ }
232
+
233
+ if (entry.target === 'local' && !entry.local) {
234
+ throw new Error(
235
+ `Invalid deployment entry "${entry.profile}" in ${filePath}: target is "local" but "local" section is missing`
236
+ );
237
+ }
238
+
239
+ if (entry.target === 'akash' && !entry.akash) {
240
+ throw new Error(
241
+ `Invalid deployment entry "${entry.profile}" in ${filePath}: target is "akash" but "akash" section is missing`
242
+ );
243
+ }
244
+ }
245
+
246
+ /**
247
+ * Read the deployment lock file from disk.
248
+ *
249
+ * Transparently migrates v1 and v2 lock files to v3 format on read
250
+ * (the file on disk is NOT rewritten — migration is in-memory only).
251
+ *
252
+ * @param projectRoot - Absolute path to the project directory
253
+ * @returns The parsed v3 lock data, or `null` if the file does not exist
254
+ * @throws If the file exists but cannot be parsed or is invalid
255
+ */
256
+ export async function readLockFile(
257
+ projectRoot: string
258
+ ): Promise<LockFile | null> {
259
+ const filePath = lockFilePath(projectRoot);
260
+
261
+ try {
262
+ const raw = await fs.readFile(filePath, 'utf-8');
263
+ const data = JSON.parse(raw);
264
+
265
+ // Detect v1 format: has `target` at the top level
266
+ if (data.version === 1 || (data.target && !data.deployments)) {
267
+ const v3 = migrateV1toV3(data as V1LockFile);
268
+ for (const entry of Object.values(v3.deployments)) {
269
+ validateEntry(entry, filePath);
270
+ }
271
+ return v3;
272
+ }
273
+
274
+ // Detect v2 format: has deployments but entries lack instanceId
275
+ if (data.version === 2 || (data.deployments && !isV3Format(data))) {
276
+ const v3 = migrateV2toV3(data as V2LockFile);
277
+ for (const entry of Object.values(v3.deployments)) {
278
+ validateEntry(entry, filePath);
279
+ }
280
+ return v3;
281
+ }
282
+
283
+ // v3 format
284
+ const lockFile = data as LockFile;
285
+
286
+ if (!lockFile.version || !lockFile.deployments) {
287
+ throw new Error(
288
+ `Invalid lock file at ${filePath}: missing required fields (version, deployments)`
289
+ );
290
+ }
291
+
292
+ for (const entry of Object.values(lockFile.deployments)) {
293
+ validateEntry(entry, filePath);
294
+ }
295
+
296
+ return lockFile;
297
+ } catch (err: unknown) {
298
+ if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
299
+ return null;
300
+ }
301
+ throw err;
302
+ }
303
+ }
304
+
305
+ /**
306
+ * Write or update the lock file on disk.
307
+ *
308
+ * This is merge-based: it reads any existing lock file, assigns a unique
309
+ * instanceId if not already set, inserts the entry keyed by
310
+ * `{profile}:{instanceId}`, and writes the whole thing back.
311
+ *
312
+ * @param projectRoot - Absolute path to the project directory
313
+ * @param lock - The deployment entry to add or update
314
+ * @returns The instanceId of the written deployment
315
+ */
316
+ export async function writeLockFile(
317
+ projectRoot: string,
318
+ lock: DeploymentLock
319
+ ): Promise<string> {
320
+ const filePath = lockFilePath(projectRoot);
321
+
322
+ // Read existing (may be null or v1/v2 — readLockFile handles both)
323
+ const existing = await readLockFile(projectRoot);
324
+
325
+ const lockFile: LockFile = existing ?? {
326
+ version: LOCK_VERSION,
327
+ deployments: {},
328
+ };
329
+
330
+ // Ensure version is current
331
+ lockFile.version = LOCK_VERSION;
332
+
333
+ // Generate a collision-free instance ID if not already set
334
+ let instanceId = lock.instanceId;
335
+ if (!instanceId) {
336
+ do {
337
+ instanceId = generateInstanceId();
338
+ } while (deploymentKey(lock.profile, instanceId) in lockFile.deployments);
339
+ lock.instanceId = instanceId;
340
+ }
341
+
342
+ // Add/update this deployment's entry using composite key
343
+ const key = deploymentKey(lock.profile, instanceId);
344
+ lockFile.deployments[key] = lock;
345
+
346
+ const json = JSON.stringify(lockFile, null, 2) + '\n';
347
+ await fs.writeFile(filePath, json, 'utf-8');
348
+
349
+ return instanceId;
350
+ }
351
+
352
+ /**
353
+ * Remove a single deployment entry from the lock file by profile and instanceId.
354
+ *
355
+ * If the last entry is removed, the file is deleted entirely.
356
+ *
357
+ * @returns `true` if the entry was found and removed, `false` otherwise
358
+ */
359
+ export async function removeDeployment(
360
+ projectRoot: string,
361
+ profile: string,
362
+ instanceId?: string
363
+ ): Promise<boolean> {
364
+ const lockFile = await readLockFile(projectRoot);
365
+ if (!lockFile) return false;
366
+
367
+ let keyToRemove: string | undefined;
368
+
369
+ if (instanceId) {
370
+ // Direct lookup with composite key
371
+ keyToRemove = deploymentKey(profile, instanceId);
372
+ } else {
373
+ // Backward compat: find the first (or only) entry matching this profile.
374
+ // This supports callers that only pass a profile name.
375
+ const matching = Object.entries(lockFile.deployments)
376
+ .filter(([_, entry]) => entry.profile === profile);
377
+
378
+ if (matching.length === 1) {
379
+ keyToRemove = matching[0][0];
380
+ } else if (matching.length === 0) {
381
+ return false;
382
+ } else {
383
+ // Multiple instances from same profile — caller must specify instanceId
384
+ // For backward compat, remove the first one found
385
+ keyToRemove = matching[0][0];
386
+ }
387
+ }
388
+
389
+ if (!keyToRemove || !(keyToRemove in lockFile.deployments)) return false;
390
+
391
+ delete lockFile.deployments[keyToRemove];
392
+
393
+ // If no deployments left, delete the entire file
394
+ if (Object.keys(lockFile.deployments).length === 0) {
395
+ return deleteLockFile(projectRoot);
396
+ }
397
+
398
+ // Write back the remaining deployments
399
+ const filePath = lockFilePath(projectRoot);
400
+ const json = JSON.stringify(lockFile, null, 2) + '\n';
401
+ await fs.writeFile(filePath, json, 'utf-8');
402
+ return true;
403
+ }
404
+
405
+ /**
406
+ * Get a single deployment entry by instance ID.
407
+ *
408
+ * @returns The deployment entry, or `null` if not found
409
+ */
410
+ export async function getDeploymentByInstance(
411
+ projectRoot: string,
412
+ instanceId: string
413
+ ): Promise<DeploymentLock | null> {
414
+ const lockFile = await readLockFile(projectRoot);
415
+ if (!lockFile) return null;
416
+ const entry = Object.values(lockFile.deployments)
417
+ .find((d) => d.instanceId === instanceId);
418
+ return entry ?? null;
419
+ }
420
+
421
+ /**
422
+ * Get a single deployment entry by profile name.
423
+ *
424
+ * If multiple instances exist for the same profile, returns the first one found.
425
+ *
426
+ * @returns The deployment entry, or `null` if not found
427
+ */
428
+ export async function getDeployment(
429
+ projectRoot: string,
430
+ profile: string
431
+ ): Promise<DeploymentLock | null> {
432
+ const lockFile = await readLockFile(projectRoot);
433
+ if (!lockFile) return null;
434
+
435
+ // Check composite key first (backward compat for tests passing full key)
436
+ if (lockFile.deployments[profile]) {
437
+ return lockFile.deployments[profile];
438
+ }
439
+
440
+ // Search by profile field
441
+ const entry = Object.values(lockFile.deployments)
442
+ .find((d) => d.profile === profile);
443
+ return entry ?? null;
444
+ }
445
+
446
+ /**
447
+ * Get all deployment entries for a given profile name.
448
+ *
449
+ * @returns Array of [compositeKey, entry] tuples
450
+ */
451
+ export async function getDeploymentsByProfile(
452
+ projectRoot: string,
453
+ profile: string
454
+ ): Promise<Array<[string, DeploymentLock]>> {
455
+ const lockFile = await readLockFile(projectRoot);
456
+ if (!lockFile) return [];
457
+ return Object.entries(lockFile.deployments)
458
+ .filter(([_, entry]) => entry.profile === profile);
459
+ }
460
+
461
+ /**
462
+ * List all active deployment entries.
463
+ *
464
+ * @returns Array of [compositeKey, entry] tuples, or empty if no lock file
465
+ */
466
+ export async function listDeployments(
467
+ projectRoot: string
468
+ ): Promise<Array<[string, DeploymentLock]>> {
469
+ const lockFile = await readLockFile(projectRoot);
470
+ if (!lockFile) return [];
471
+ return Object.entries(lockFile.deployments);
472
+ }
473
+
474
+ /**
475
+ * Delete the deployment lock file entirely.
476
+ *
477
+ * @param projectRoot - Absolute path to the project directory
478
+ * @returns `true` if the file was deleted, `false` if it did not exist
479
+ */
480
+ export async function deleteLockFile(projectRoot: string): Promise<boolean> {
481
+ const filePath = lockFilePath(projectRoot);
482
+
483
+ try {
484
+ await fs.unlink(filePath);
485
+ return true;
486
+ } catch (err: unknown) {
487
+ if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
488
+ return false;
489
+ }
490
+ throw err;
491
+ }
492
+ }
493
+
494
+ // ─────────────────────────────────────────────────────────
495
+ // Lock builders — convenience functions to create lock data
496
+ // from deployment results
497
+ // ─────────────────────────────────────────────────────────
498
+
499
+ /**
500
+ * Build a DeploymentLock from local deployment result data.
501
+ *
502
+ * @param profileName - The profile name used
503
+ * @param data - LocalDeploymentData from deploy-ability
504
+ * @param label - Optional human-readable label for this deployment instance
505
+ */
506
+ export function buildLocalLock(
507
+ profileName: string,
508
+ data: {
509
+ engine: string;
510
+ network: string;
511
+ services: readonly string[];
512
+ containers: Record<string, string>;
513
+ composePath: string;
514
+ deployedAt: Date;
515
+ },
516
+ label?: string
517
+ ): DeploymentLock {
518
+ return {
519
+ instanceId: '', // assigned by writeLockFile
520
+ target: 'local',
521
+ profile: profileName,
522
+ deployedAt: data.deployedAt.toISOString(),
523
+ ...(label ? { label } : {}),
524
+ local: {
525
+ composePath: data.composePath,
526
+ engine: data.engine as 'docker' | 'podman',
527
+ network: data.network,
528
+ services: [...data.services],
529
+ containers: data.containers,
530
+ },
531
+ };
532
+ }
533
+
534
+ /**
535
+ * Build a DeploymentLock from Akash deployment result data.
536
+ *
537
+ * @param profileName - The profile name used
538
+ * @param data - AkashDeploymentData from deploy-ability
539
+ * @param label - Optional human-readable label for this deployment instance
540
+ */
541
+ export function buildAkashLock(
542
+ profileName: string,
543
+ data: {
544
+ dseq: number;
545
+ owner: string;
546
+ provider: string;
547
+ providerUri: string;
548
+ network: string;
549
+ gseq: number;
550
+ oseq: number;
551
+ deployedAt: Date;
552
+ },
553
+ label?: string
554
+ ): DeploymentLock {
555
+ return {
556
+ instanceId: '', // assigned by writeLockFile
557
+ target: 'akash',
558
+ profile: profileName,
559
+ deployedAt: data.deployedAt.toISOString(),
560
+ ...(label ? { label } : {}),
561
+ akash: {
562
+ dseq: data.dseq,
563
+ owner: data.owner,
564
+ provider: data.provider,
565
+ providerUri: data.providerUri,
566
+ network: data.network as 'mainnet' | 'testnet',
567
+ gseq: data.gseq,
568
+ oseq: data.oseq,
569
+ },
570
+ };
571
+ }