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,1148 @@
1
+ /**
2
+ * Deployment Lock File Tests (v3 — multi-instance)
3
+ *
4
+ * Tests for the `.kadi-deploy.lock` file management:
5
+ * - writeLockFile / readLockFile round-trip (v3 format)
6
+ * - Multi-instance per profile behavior
7
+ * - removeDeployment / getDeployment / getDeploymentByInstance / getDeploymentsByProfile / listDeployments
8
+ * - v1 → v3 migration
9
+ * - v2 → v3 migration
10
+ * - buildLocalLock / buildAkashLock convenience builders
11
+ * - instanceId generation and collision avoidance
12
+ * - Validation of malformed lock files
13
+ * - Missing file handling
14
+ */
15
+
16
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
17
+ import fs from 'node:fs/promises';
18
+ import path from 'node:path';
19
+ import os from 'node:os';
20
+
21
+ import {
22
+ LOCK_VERSION,
23
+ LOCK_FILENAME,
24
+ lockFilePath,
25
+ writeLockFile,
26
+ readLockFile,
27
+ deleteLockFile,
28
+ removeDeployment,
29
+ getDeployment,
30
+ getDeploymentByInstance,
31
+ getDeploymentsByProfile,
32
+ listDeployments,
33
+ migrateV1toV2,
34
+ migrateV1toV3,
35
+ migrateV2toV3,
36
+ generateInstanceId,
37
+ deploymentKey,
38
+ parseDeploymentKey,
39
+ buildLocalLock,
40
+ buildAkashLock,
41
+ type DeploymentLock,
42
+ type LockFile,
43
+ type LocalDeploymentLock,
44
+ type AkashDeploymentLock,
45
+ } from '../src/commands/lock.js';
46
+
47
+ // ─────────────────────────────────────────────────────────
48
+ // Fixtures
49
+ // ─────────────────────────────────────────────────────────
50
+
51
+ const LOCAL_ENTRY: DeploymentLock = {
52
+ instanceId: 'a1b2',
53
+ target: 'local',
54
+ profile: 'dev',
55
+ deployedAt: '2026-02-25T12:00:00.000Z',
56
+ local: {
57
+ composePath: '/tmp/test-project/docker-compose.yml',
58
+ engine: 'docker',
59
+ network: 'kadi-net',
60
+ services: ['web', 'api'],
61
+ containers: { web: 'abc123', api: 'def456' },
62
+ },
63
+ };
64
+
65
+ const AKASH_ENTRY: DeploymentLock = {
66
+ instanceId: 'c3d4',
67
+ target: 'akash',
68
+ profile: 'production',
69
+ deployedAt: '2026-02-25T12:00:00.000Z',
70
+ akash: {
71
+ dseq: 12345678,
72
+ owner: 'akash1abc123def456',
73
+ provider: 'akash1provider789',
74
+ providerUri: 'https://provider.example.com',
75
+ network: 'mainnet',
76
+ gseq: 1,
77
+ oseq: 1,
78
+ },
79
+ };
80
+
81
+ // ─────────────────────────────────────────────────────────
82
+ // Test helpers
83
+ // ─────────────────────────────────────────────────────────
84
+
85
+ let tmpDir: string;
86
+
87
+ beforeEach(async () => {
88
+ tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'kadi-deploy-lock-test-'));
89
+ });
90
+
91
+ afterEach(async () => {
92
+ try {
93
+ await fs.rm(tmpDir, { recursive: true, force: true });
94
+ } catch {
95
+ // ignore cleanup errors
96
+ }
97
+ });
98
+
99
+ // ─────────────────────────────────────────────────────────
100
+ // Constants
101
+ // ─────────────────────────────────────────────────────────
102
+
103
+ describe('constants', () => {
104
+ it('LOCK_VERSION is 3', () => {
105
+ expect(LOCK_VERSION).toBe(3);
106
+ });
107
+
108
+ it('LOCK_FILENAME is .kadi-deploy.lock', () => {
109
+ expect(LOCK_FILENAME).toBe('.kadi-deploy.lock');
110
+ });
111
+ });
112
+
113
+ // ─────────────────────────────────────────────────────────
114
+ // lockFilePath
115
+ // ─────────────────────────────────────────────────────────
116
+
117
+ describe('lockFilePath', () => {
118
+ it('returns the correct path', () => {
119
+ expect(lockFilePath('/my/project')).toBe(
120
+ path.join('/my/project', LOCK_FILENAME)
121
+ );
122
+ });
123
+ });
124
+
125
+ // ─────────────────────────────────────────────────────────
126
+ // writeLockFile + readLockFile round-trip (v2)
127
+ // ─────────────────────────────────────────────────────────
128
+
129
+ describe('writeLockFile + readLockFile', () => {
130
+ it('round-trips a local deployment entry', async () => {
131
+ await writeLockFile(tmpDir, LOCAL_ENTRY);
132
+ const lockFile = await readLockFile(tmpDir);
133
+
134
+ expect(lockFile).not.toBeNull();
135
+ expect(lockFile!.version).toBe(LOCK_VERSION);
136
+
137
+ const entries = Object.values(lockFile!.deployments);
138
+ expect(entries).toHaveLength(1);
139
+
140
+ const entry = entries[0];
141
+ expect(entry.target).toBe('local');
142
+ expect(entry.profile).toBe('dev');
143
+ expect(entry.instanceId).toBe('a1b2');
144
+ expect(entry.local).toBeDefined();
145
+ expect(entry.local!.engine).toBe('docker');
146
+ expect(entry.local!.services).toEqual(['web', 'api']);
147
+ expect(entry.local!.containers).toEqual({ web: 'abc123', api: 'def456' });
148
+ expect(entry.local!.composePath).toBe('/tmp/test-project/docker-compose.yml');
149
+ expect(entry.local!.network).toBe('kadi-net');
150
+ });
151
+
152
+ it('round-trips an Akash deployment entry', async () => {
153
+ await writeLockFile(tmpDir, AKASH_ENTRY);
154
+ const lockFile = await readLockFile(tmpDir);
155
+
156
+ expect(lockFile).not.toBeNull();
157
+ expect(lockFile!.version).toBe(LOCK_VERSION);
158
+
159
+ const entries = Object.values(lockFile!.deployments);
160
+ expect(entries).toHaveLength(1);
161
+
162
+ const entry = entries[0];
163
+ expect(entry.target).toBe('akash');
164
+ expect(entry.profile).toBe('production');
165
+ expect(entry.instanceId).toBe('c3d4');
166
+ expect(entry.akash).toBeDefined();
167
+ expect(entry.akash!.dseq).toBe(12345678);
168
+ expect(entry.akash!.owner).toBe('akash1abc123def456');
169
+ expect(entry.akash!.provider).toBe('akash1provider789');
170
+ expect(entry.akash!.providerUri).toBe('https://provider.example.com');
171
+ expect(entry.akash!.network).toBe('mainnet');
172
+ expect(entry.akash!.gseq).toBe(1);
173
+ expect(entry.akash!.oseq).toBe(1);
174
+ });
175
+
176
+ it('preserves deployedAt as ISO string', async () => {
177
+ await writeLockFile(tmpDir, AKASH_ENTRY);
178
+ const lockFile = await readLockFile(tmpDir);
179
+ const entry = Object.values(lockFile!.deployments)[0];
180
+ expect(entry.deployedAt).toBe('2026-02-25T12:00:00.000Z');
181
+ });
182
+
183
+ it('writes valid JSON with trailing newline', async () => {
184
+ await writeLockFile(tmpDir, LOCAL_ENTRY);
185
+ const raw = await fs.readFile(lockFilePath(tmpDir), 'utf-8');
186
+ expect(raw.endsWith('\n')).toBe(true);
187
+ expect(() => JSON.parse(raw)).not.toThrow();
188
+ });
189
+
190
+ it('writes v3 format with version and deployments map', async () => {
191
+ await writeLockFile(tmpDir, LOCAL_ENTRY);
192
+ const raw = await fs.readFile(lockFilePath(tmpDir), 'utf-8');
193
+ const data = JSON.parse(raw);
194
+ expect(data.version).toBe(3);
195
+ expect(data.deployments).toBeDefined();
196
+ expect(typeof data.deployments).toBe('object');
197
+ });
198
+
199
+ it('uses composite key {profile}:{instanceId}', async () => {
200
+ await writeLockFile(tmpDir, LOCAL_ENTRY);
201
+ const raw = await fs.readFile(lockFilePath(tmpDir), 'utf-8');
202
+ const data = JSON.parse(raw);
203
+ const keys = Object.keys(data.deployments);
204
+ expect(keys).toEqual(['dev:a1b2']);
205
+ });
206
+
207
+ it('returns instanceId from writeLockFile', async () => {
208
+ const instanceId = await writeLockFile(tmpDir, LOCAL_ENTRY);
209
+ expect(instanceId).toBe('a1b2');
210
+ });
211
+
212
+ it('generates instanceId when not provided', async () => {
213
+ const entry: DeploymentLock = {
214
+ instanceId: '', // empty — should be auto-generated
215
+ target: 'local',
216
+ profile: 'test',
217
+ deployedAt: '2026-02-25T12:00:00.000Z',
218
+ local: LOCAL_ENTRY.local,
219
+ };
220
+ const instanceId = await writeLockFile(tmpDir, entry);
221
+ expect(instanceId).toHaveLength(4);
222
+ expect(/^[0-9a-f]{4}$/.test(instanceId)).toBe(true);
223
+ });
224
+ });
225
+
226
+ // ─────────────────────────────────────────────────────────
227
+ // Multi-deployment merge behavior
228
+ // ─────────────────────────────────────────────────────────
229
+
230
+ describe('multi-deployment merge', () => {
231
+ it('merges multiple deployments into the same lock file', async () => {
232
+ await writeLockFile(tmpDir, LOCAL_ENTRY);
233
+ await writeLockFile(tmpDir, AKASH_ENTRY);
234
+
235
+ const lockFile = await readLockFile(tmpDir);
236
+ expect(lockFile).not.toBeNull();
237
+
238
+ const entries = Object.values(lockFile!.deployments);
239
+ expect(entries).toHaveLength(2);
240
+
241
+ const profiles = entries.map(e => e.profile).sort();
242
+ expect(profiles).toEqual(['dev', 'production']);
243
+ });
244
+
245
+ it('does NOT overwrite same profile — creates a new instance', async () => {
246
+ await writeLockFile(tmpDir, LOCAL_ENTRY);
247
+
248
+ const updated: DeploymentLock = {
249
+ instanceId: 'e5f6',
250
+ target: 'local',
251
+ profile: 'dev',
252
+ deployedAt: '2026-03-01T00:00:00.000Z',
253
+ local: LOCAL_ENTRY.local,
254
+ };
255
+ await writeLockFile(tmpDir, updated);
256
+
257
+ const lockFile = await readLockFile(tmpDir);
258
+ const entries = Object.values(lockFile!.deployments);
259
+ expect(entries).toHaveLength(2);
260
+
261
+ const instanceIds = entries.map(e => e.instanceId).sort();
262
+ expect(instanceIds).toEqual(['a1b2', 'e5f6']);
263
+ });
264
+
265
+ it('supports three simultaneous deployments', async () => {
266
+ await writeLockFile(tmpDir, LOCAL_ENTRY);
267
+ await writeLockFile(tmpDir, AKASH_ENTRY);
268
+
269
+ const staging: DeploymentLock = {
270
+ instanceId: 'f7f8',
271
+ target: 'akash',
272
+ profile: 'staging',
273
+ deployedAt: '2026-02-25T15:00:00.000Z',
274
+ akash: { ...AKASH_ENTRY.akash!, dseq: 99999 },
275
+ };
276
+ await writeLockFile(tmpDir, staging);
277
+
278
+ const lockFile = await readLockFile(tmpDir);
279
+ const entries = Object.values(lockFile!.deployments);
280
+ expect(entries).toHaveLength(3);
281
+ });
282
+
283
+ it('allows multiple instances of the same profile', async () => {
284
+ // Deploy "local" profile twice with different instanceIds
285
+ const instance1: DeploymentLock = {
286
+ instanceId: 'aa11',
287
+ target: 'local',
288
+ profile: 'local',
289
+ deployedAt: '2026-03-01T10:00:00.000Z',
290
+ local: LOCAL_ENTRY.local,
291
+ };
292
+ const instance2: DeploymentLock = {
293
+ instanceId: 'bb22',
294
+ target: 'local',
295
+ profile: 'local',
296
+ deployedAt: '2026-03-01T11:00:00.000Z',
297
+ local: { ...LOCAL_ENTRY.local!, network: 'kadi-net-2' },
298
+ };
299
+
300
+ await writeLockFile(tmpDir, instance1);
301
+ await writeLockFile(tmpDir, instance2);
302
+
303
+ const lockFile = await readLockFile(tmpDir);
304
+ expect(Object.keys(lockFile!.deployments).sort()).toEqual(['local:aa11', 'local:bb22']);
305
+
306
+ // Both entries should exist with different data
307
+ const entries = Object.values(lockFile!.deployments);
308
+ expect(entries).toHaveLength(2);
309
+ expect(entries[0].profile).toBe('local');
310
+ expect(entries[1].profile).toBe('local');
311
+ });
312
+ });
313
+
314
+ // ─────────────────────────────────────────────────────────
315
+ // readLockFile — edge cases
316
+ // ─────────────────────────────────────────────────────────
317
+
318
+ describe('readLockFile edge cases', () => {
319
+ it('returns null when file does not exist', async () => {
320
+ const result = await readLockFile(tmpDir);
321
+ expect(result).toBeNull();
322
+ });
323
+
324
+ it('throws on invalid JSON', async () => {
325
+ await fs.writeFile(lockFilePath(tmpDir), 'not json!', 'utf-8');
326
+ await expect(readLockFile(tmpDir)).rejects.toThrow();
327
+ });
328
+
329
+ it('throws when v3 is missing version', async () => {
330
+ await fs.writeFile(
331
+ lockFilePath(tmpDir),
332
+ JSON.stringify({ deployments: {} }),
333
+ 'utf-8'
334
+ );
335
+ await expect(readLockFile(tmpDir)).rejects.toThrow(/missing required fields/);
336
+ });
337
+
338
+ it('throws when v3 is missing deployments', async () => {
339
+ await fs.writeFile(
340
+ lockFilePath(tmpDir),
341
+ JSON.stringify({ version: 3 }),
342
+ 'utf-8'
343
+ );
344
+ await expect(readLockFile(tmpDir)).rejects.toThrow(/missing required fields/);
345
+ });
346
+
347
+ it('throws when entry target=local but local section missing', async () => {
348
+ const data: LockFile = {
349
+ version: 3,
350
+ deployments: {
351
+ 'dev:ab12': { instanceId: 'ab12', target: 'local', profile: 'dev', deployedAt: new Date().toISOString() },
352
+ },
353
+ };
354
+ await fs.writeFile(lockFilePath(tmpDir), JSON.stringify(data), 'utf-8');
355
+ await expect(readLockFile(tmpDir)).rejects.toThrow(/"local" section is missing/);
356
+ });
357
+
358
+ it('throws when entry target=akash but akash section missing', async () => {
359
+ const data: LockFile = {
360
+ version: 3,
361
+ deployments: {
362
+ 'prod:cd34': { instanceId: 'cd34', target: 'akash', profile: 'prod', deployedAt: new Date().toISOString() },
363
+ },
364
+ };
365
+ await fs.writeFile(lockFilePath(tmpDir), JSON.stringify(data), 'utf-8');
366
+ await expect(readLockFile(tmpDir)).rejects.toThrow(/"akash" section is missing/);
367
+ });
368
+ });
369
+
370
+ // ─────────────────────────────────────────────────────────
371
+ // v1 → v2 migration
372
+ // ─────────────────────────────────────────────────────────
373
+
374
+ describe('v1 → v3 migration', () => {
375
+ it('migrateV1toV3 converts a v1 local lock', () => {
376
+ const v1 = {
377
+ version: 1,
378
+ target: 'local' as const,
379
+ profile: 'dev',
380
+ deployedAt: '2026-01-01T00:00:00.000Z',
381
+ local: LOCAL_ENTRY.local,
382
+ };
383
+
384
+ const v3 = migrateV1toV3(v1);
385
+ expect(v3.version).toBe(LOCK_VERSION);
386
+
387
+ const entries = Object.values(v3.deployments);
388
+ expect(entries).toHaveLength(1);
389
+ expect(entries[0].target).toBe('local');
390
+ expect(entries[0].profile).toBe('dev');
391
+ expect(entries[0].instanceId).toHaveLength(4);
392
+ expect(entries[0].local).toEqual(LOCAL_ENTRY.local);
393
+ });
394
+
395
+ it('migrateV1toV3 converts a v1 Akash lock', () => {
396
+ const v1 = {
397
+ version: 1,
398
+ target: 'akash' as const,
399
+ profile: 'production',
400
+ deployedAt: '2026-01-01T00:00:00.000Z',
401
+ akash: AKASH_ENTRY.akash,
402
+ };
403
+
404
+ const v3 = migrateV1toV3(v1);
405
+ expect(v3.version).toBe(LOCK_VERSION);
406
+
407
+ const entries = Object.values(v3.deployments);
408
+ expect(entries).toHaveLength(1);
409
+ expect(entries[0].target).toBe('akash');
410
+ expect(entries[0].profile).toBe('production');
411
+ expect(entries[0].instanceId).toHaveLength(4);
412
+ expect(entries[0].akash).toEqual(AKASH_ENTRY.akash);
413
+ });
414
+
415
+ it('migrateV1toV2 is a backward-compat alias for migrateV1toV3', () => {
416
+ const v1 = {
417
+ version: 1,
418
+ target: 'local' as const,
419
+ profile: 'dev',
420
+ deployedAt: '2026-01-01T00:00:00.000Z',
421
+ local: LOCAL_ENTRY.local,
422
+ };
423
+
424
+ const result = migrateV1toV2(v1);
425
+ expect(result.version).toBe(LOCK_VERSION);
426
+ const entries = Object.values(result.deployments);
427
+ expect(entries).toHaveLength(1);
428
+ expect(entries[0].instanceId).toHaveLength(4);
429
+ });
430
+
431
+ it('readLockFile transparently reads v1 files as v3', async () => {
432
+ // Write a raw v1-format lock file
433
+ const v1 = {
434
+ version: 1,
435
+ target: 'local',
436
+ profile: 'dev',
437
+ deployedAt: '2026-01-15T08:00:00.000Z',
438
+ local: LOCAL_ENTRY.local,
439
+ };
440
+ await fs.writeFile(lockFilePath(tmpDir), JSON.stringify(v1, null, 2), 'utf-8');
441
+
442
+ const lockFile = await readLockFile(tmpDir);
443
+ expect(lockFile).not.toBeNull();
444
+ expect(lockFile!.version).toBe(LOCK_VERSION);
445
+
446
+ const entries = Object.values(lockFile!.deployments);
447
+ expect(entries).toHaveLength(1);
448
+ expect(entries[0].target).toBe('local');
449
+ expect(entries[0].profile).toBe('dev');
450
+ expect(entries[0].instanceId).toHaveLength(4);
451
+ });
452
+
453
+ it('readLockFile validates v1 entries during migration', async () => {
454
+ const v1 = {
455
+ version: 1,
456
+ target: 'local',
457
+ profile: 'dev',
458
+ deployedAt: '2026-01-15T08:00:00.000Z',
459
+ // missing local section
460
+ };
461
+ await fs.writeFile(lockFilePath(tmpDir), JSON.stringify(v1), 'utf-8');
462
+ await expect(readLockFile(tmpDir)).rejects.toThrow(/"local" section is missing/);
463
+ });
464
+ });
465
+
466
+ // ─────────────────────────────────────────────────────────
467
+ // v2 → v3 migration
468
+ // ─────────────────────────────────────────────────────────
469
+
470
+ describe('v2 → v3 migration', () => {
471
+ it('migrates v2 lock file entries and assigns instanceIds', async () => {
472
+ // Write a raw v2-format lock file (keyed by profile name, no instanceId)
473
+ const v2 = {
474
+ version: 2,
475
+ deployments: {
476
+ dev: {
477
+ target: 'local',
478
+ profile: 'dev',
479
+ deployedAt: '2026-02-25T12:00:00.000Z',
480
+ local: LOCAL_ENTRY.local,
481
+ },
482
+ production: {
483
+ target: 'akash',
484
+ profile: 'production',
485
+ deployedAt: '2026-02-25T12:00:00.000Z',
486
+ akash: AKASH_ENTRY.akash,
487
+ },
488
+ },
489
+ };
490
+ await fs.writeFile(lockFilePath(tmpDir), JSON.stringify(v2, null, 2), 'utf-8');
491
+
492
+ const lockFile = await readLockFile(tmpDir);
493
+ expect(lockFile).not.toBeNull();
494
+ expect(lockFile!.version).toBe(LOCK_VERSION);
495
+
496
+ const entries = Object.values(lockFile!.deployments);
497
+ expect(entries).toHaveLength(2);
498
+
499
+ // Each entry should have a generated instanceId
500
+ for (const entry of entries) {
501
+ expect(entry.instanceId).toHaveLength(4);
502
+ expect(/^[0-9a-f]{4}$/.test(entry.instanceId)).toBe(true);
503
+ }
504
+
505
+ // Keys should be composite
506
+ const keys = Object.keys(lockFile!.deployments);
507
+ for (const key of keys) {
508
+ expect(key).toContain(':');
509
+ }
510
+ });
511
+
512
+ it('preserves deployment data during v2 → v3 migration', async () => {
513
+ const v2 = {
514
+ version: 2,
515
+ deployments: {
516
+ production: {
517
+ target: 'akash',
518
+ profile: 'production',
519
+ deployedAt: '2026-02-25T12:00:00.000Z',
520
+ akash: AKASH_ENTRY.akash,
521
+ },
522
+ },
523
+ };
524
+ await fs.writeFile(lockFilePath(tmpDir), JSON.stringify(v2, null, 2), 'utf-8');
525
+
526
+ const lockFile = await readLockFile(tmpDir);
527
+ const entry = Object.values(lockFile!.deployments)[0];
528
+ expect(entry.akash!.dseq).toBe(12345678);
529
+ expect(entry.akash!.owner).toBe('akash1abc123def456');
530
+ expect(entry.profile).toBe('production');
531
+ });
532
+ });
533
+
534
+ // ─────────────────────────────────────────────────────────
535
+ // removeDeployment
536
+ // ─────────────────────────────────────────────────────────
537
+
538
+ describe('removeDeployment', () => {
539
+ it('removes a deployment by profile+instanceId and keeps others', async () => {
540
+ const id1 = await writeLockFile(tmpDir, LOCAL_ENTRY);
541
+ const id2 = await writeLockFile(tmpDir, AKASH_ENTRY);
542
+
543
+ const removed = await removeDeployment(tmpDir, 'dev', id1);
544
+ expect(removed).toBe(true);
545
+
546
+ const lockFile = await readLockFile(tmpDir);
547
+ expect(lockFile).not.toBeNull();
548
+ const remaining = Object.values(lockFile!.deployments);
549
+ expect(remaining).toHaveLength(1);
550
+ expect(remaining[0].profile).toBe('production');
551
+ expect(remaining[0].instanceId).toBe(id2);
552
+ });
553
+
554
+ it('removes by profile alone when only one instance exists', async () => {
555
+ await writeLockFile(tmpDir, LOCAL_ENTRY);
556
+ await writeLockFile(tmpDir, AKASH_ENTRY);
557
+
558
+ const removed = await removeDeployment(tmpDir, 'dev');
559
+ expect(removed).toBe(true);
560
+
561
+ const lockFile = await readLockFile(tmpDir);
562
+ expect(lockFile).not.toBeNull();
563
+ const remaining = Object.values(lockFile!.deployments);
564
+ expect(remaining).toHaveLength(1);
565
+ expect(remaining[0].profile).toBe('production');
566
+ });
567
+
568
+ it('deletes the lock file when last deployment is removed', async () => {
569
+ const id1 = await writeLockFile(tmpDir, LOCAL_ENTRY);
570
+
571
+ const removed = await removeDeployment(tmpDir, 'dev', id1);
572
+ expect(removed).toBe(true);
573
+
574
+ const lockFile = await readLockFile(tmpDir);
575
+ expect(lockFile).toBeNull();
576
+
577
+ // File should not exist on disk
578
+ const exists = await fs.stat(lockFilePath(tmpDir)).then(() => true).catch(() => false);
579
+ expect(exists).toBe(false);
580
+ });
581
+
582
+ it('returns false for a non-existent profile', async () => {
583
+ await writeLockFile(tmpDir, LOCAL_ENTRY);
584
+ const removed = await removeDeployment(tmpDir, 'nonexistent');
585
+ expect(removed).toBe(false);
586
+ });
587
+
588
+ it('returns false when no lock file exists', async () => {
589
+ const removed = await removeDeployment(tmpDir, 'anything');
590
+ expect(removed).toBe(false);
591
+ });
592
+
593
+ it('removes correct instance when multiple instances share a profile', async () => {
594
+ const id1 = await writeLockFile(tmpDir, { ...LOCAL_ENTRY, instanceId: '' });
595
+ const id2 = await writeLockFile(tmpDir, { ...LOCAL_ENTRY, instanceId: '' });
596
+ expect(id1).not.toBe(id2);
597
+
598
+ const removed = await removeDeployment(tmpDir, 'dev', id1);
599
+ expect(removed).toBe(true);
600
+
601
+ const lockFile = await readLockFile(tmpDir);
602
+ const remaining = Object.values(lockFile!.deployments);
603
+ expect(remaining).toHaveLength(1);
604
+ expect(remaining[0].instanceId).toBe(id2);
605
+ });
606
+ });
607
+
608
+ // ─────────────────────────────────────────────────────────
609
+ // getDeployment / getDeploymentByInstance / getDeploymentsByProfile
610
+ // ─────────────────────────────────────────────────────────
611
+
612
+ describe('getDeployment', () => {
613
+ it('returns a deployment by profile name', async () => {
614
+ await writeLockFile(tmpDir, LOCAL_ENTRY);
615
+ await writeLockFile(tmpDir, AKASH_ENTRY);
616
+
617
+ const entry = await getDeployment(tmpDir, 'dev');
618
+ expect(entry).not.toBeNull();
619
+ expect(entry!.target).toBe('local');
620
+ expect(entry!.profile).toBe('dev');
621
+ expect(entry!.instanceId).toHaveLength(4);
622
+ });
623
+
624
+ it('returns null for non-existent profile', async () => {
625
+ await writeLockFile(tmpDir, LOCAL_ENTRY);
626
+ const entry = await getDeployment(tmpDir, 'nonexistent');
627
+ expect(entry).toBeNull();
628
+ });
629
+
630
+ it('returns null when no lock file exists', async () => {
631
+ const entry = await getDeployment(tmpDir, 'dev');
632
+ expect(entry).toBeNull();
633
+ });
634
+ });
635
+
636
+ describe('getDeploymentByInstance', () => {
637
+ it('returns a deployment by instanceId', async () => {
638
+ const id = await writeLockFile(tmpDir, LOCAL_ENTRY);
639
+ const entry = await getDeploymentByInstance(tmpDir, id);
640
+ expect(entry).not.toBeNull();
641
+ expect(entry!.instanceId).toBe(id);
642
+ expect(entry!.profile).toBe('dev');
643
+ });
644
+
645
+ it('returns null for non-existent instanceId', async () => {
646
+ await writeLockFile(tmpDir, LOCAL_ENTRY);
647
+ const entry = await getDeploymentByInstance(tmpDir, 'ffff');
648
+ expect(entry).toBeNull();
649
+ });
650
+
651
+ it('returns null when no lock file exists', async () => {
652
+ const entry = await getDeploymentByInstance(tmpDir, 'abcd');
653
+ expect(entry).toBeNull();
654
+ });
655
+ });
656
+
657
+ describe('getDeploymentsByProfile', () => {
658
+ it('returns all instances for a given profile', async () => {
659
+ const id1 = await writeLockFile(tmpDir, { ...LOCAL_ENTRY, instanceId: '' });
660
+ const id2 = await writeLockFile(tmpDir, { ...LOCAL_ENTRY, instanceId: '' });
661
+ await writeLockFile(tmpDir, AKASH_ENTRY);
662
+
663
+ const results = await getDeploymentsByProfile(tmpDir, 'dev');
664
+ expect(results).toHaveLength(2);
665
+ const ids = results.map(([_, e]) => e.instanceId).sort();
666
+ expect(ids).toContain(id1);
667
+ expect(ids).toContain(id2);
668
+ });
669
+
670
+ it('returns empty array for non-existent profile', async () => {
671
+ await writeLockFile(tmpDir, LOCAL_ENTRY);
672
+ const results = await getDeploymentsByProfile(tmpDir, 'nonexistent');
673
+ expect(results).toEqual([]);
674
+ });
675
+
676
+ it('returns empty array when no lock file exists', async () => {
677
+ const results = await getDeploymentsByProfile(tmpDir, 'dev');
678
+ expect(results).toEqual([]);
679
+ });
680
+ });
681
+
682
+ // ─────────────────────────────────────────────────────────
683
+ // listDeployments
684
+ // ─────────────────────────────────────────────────────────
685
+
686
+ describe('listDeployments', () => {
687
+ it('lists all active deployments as tuples with composite keys', async () => {
688
+ const id1 = await writeLockFile(tmpDir, LOCAL_ENTRY);
689
+ const id2 = await writeLockFile(tmpDir, AKASH_ENTRY);
690
+
691
+ const list = await listDeployments(tmpDir);
692
+ expect(list).toHaveLength(2);
693
+
694
+ const keys = list.map(([key]) => key).sort();
695
+ expect(keys).toEqual([`dev:${id1}`, `production:${id2}`].sort());
696
+ });
697
+
698
+ it('returns empty array when no lock file exists', async () => {
699
+ const list = await listDeployments(tmpDir);
700
+ expect(list).toEqual([]);
701
+ });
702
+
703
+ it('returns single entry for one deployment', async () => {
704
+ const id = await writeLockFile(tmpDir, LOCAL_ENTRY);
705
+ const list = await listDeployments(tmpDir);
706
+ expect(list).toHaveLength(1);
707
+ expect(list[0][0]).toBe(`dev:${id}`);
708
+ expect(list[0][1].target).toBe('local');
709
+ expect(list[0][1].instanceId).toBe(id);
710
+ });
711
+
712
+ it('lists multiple instances of the same profile', async () => {
713
+ const id1 = await writeLockFile(tmpDir, { ...LOCAL_ENTRY, instanceId: '' });
714
+ const id2 = await writeLockFile(tmpDir, { ...LOCAL_ENTRY, instanceId: '' });
715
+
716
+ const list = await listDeployments(tmpDir);
717
+ expect(list).toHaveLength(2);
718
+ const ids = list.map(([_, e]) => e.instanceId).sort();
719
+ expect(ids).toContain(id1);
720
+ expect(ids).toContain(id2);
721
+ });
722
+ });
723
+
724
+ // ─────────────────────────────────────────────────────────
725
+ // deleteLockFile
726
+ // ─────────────────────────────────────────────────────────
727
+
728
+ describe('deleteLockFile', () => {
729
+ it('deletes existing lock file and returns true', async () => {
730
+ await writeLockFile(tmpDir, LOCAL_ENTRY);
731
+
732
+ const exists = await fs.stat(lockFilePath(tmpDir)).then(() => true).catch(() => false);
733
+ expect(exists).toBe(true);
734
+
735
+ const deleted = await deleteLockFile(tmpDir);
736
+ expect(deleted).toBe(true);
737
+
738
+ const existsAfter = await fs.stat(lockFilePath(tmpDir)).then(() => true).catch(() => false);
739
+ expect(existsAfter).toBe(false);
740
+ });
741
+
742
+ it('returns false when file does not exist', async () => {
743
+ const deleted = await deleteLockFile(tmpDir);
744
+ expect(deleted).toBe(false);
745
+ });
746
+
747
+ it('is idempotent', async () => {
748
+ await writeLockFile(tmpDir, LOCAL_ENTRY);
749
+ expect(await deleteLockFile(tmpDir)).toBe(true);
750
+ expect(await deleteLockFile(tmpDir)).toBe(false);
751
+ });
752
+
753
+ it('removes the file so readLockFile returns null', async () => {
754
+ await writeLockFile(tmpDir, AKASH_ENTRY);
755
+ expect(await readLockFile(tmpDir)).not.toBeNull();
756
+
757
+ await deleteLockFile(tmpDir);
758
+ expect(await readLockFile(tmpDir)).toBeNull();
759
+ });
760
+ });
761
+
762
+ // ─────────────────────────────────────────────────────────
763
+ // buildLocalLock
764
+ // ─────────────────────────────────────────────────────────
765
+
766
+ describe('buildLocalLock', () => {
767
+ it('builds a valid local lock from deployment data', () => {
768
+ const lock = buildLocalLock('dev', {
769
+ engine: 'docker',
770
+ network: 'kadi-net',
771
+ services: ['web', 'api'],
772
+ containers: { web: 'abc', api: 'def' },
773
+ composePath: '/project/docker-compose.yml',
774
+ deployedAt: new Date('2026-02-25T12:00:00Z'),
775
+ });
776
+
777
+ expect(lock.target).toBe('local');
778
+ expect(lock.profile).toBe('dev');
779
+ expect(lock.instanceId).toBe(''); // assigned by writeLockFile
780
+ expect(lock.deployedAt).toBe('2026-02-25T12:00:00.000Z');
781
+ expect(lock.local).toBeDefined();
782
+ expect(lock.local!.composePath).toBe('/project/docker-compose.yml');
783
+ expect(lock.local!.engine).toBe('docker');
784
+ expect(lock.local!.services).toEqual(['web', 'api']);
785
+ expect(lock.akash).toBeUndefined();
786
+ });
787
+
788
+ it('does not include a version field on the entry', () => {
789
+ const lock = buildLocalLock('dev', {
790
+ engine: 'docker',
791
+ network: 'kadi-net',
792
+ services: ['app'],
793
+ containers: { app: '123' },
794
+ composePath: '/project/compose.yml',
795
+ deployedAt: new Date(),
796
+ });
797
+
798
+ expect('version' in lock).toBe(false);
799
+ });
800
+
801
+ it('supports podman engine', () => {
802
+ const lock = buildLocalLock('test', {
803
+ engine: 'podman',
804
+ network: 'kadi-net',
805
+ services: ['app'],
806
+ containers: { app: '123' },
807
+ composePath: '/project/docker-compose.yml',
808
+ deployedAt: new Date(),
809
+ });
810
+
811
+ expect(lock.local!.engine).toBe('podman');
812
+ });
813
+
814
+ it('converts deployedAt Date to ISO string', () => {
815
+ const now = new Date();
816
+ const lock = buildLocalLock('test', {
817
+ engine: 'docker',
818
+ network: 'test-net',
819
+ services: ['app'],
820
+ containers: { app: '123' },
821
+ composePath: '/project/compose.yml',
822
+ deployedAt: now,
823
+ });
824
+
825
+ expect(lock.deployedAt).toBe(now.toISOString());
826
+ });
827
+
828
+ it('includes label when provided', () => {
829
+ const lock = buildLocalLock('dev', {
830
+ engine: 'docker',
831
+ network: 'kadi-net',
832
+ services: ['app'],
833
+ containers: { app: '123' },
834
+ composePath: '/project/compose.yml',
835
+ deployedAt: new Date(),
836
+ }, 'broker-east');
837
+
838
+ expect(lock.label).toBe('broker-east');
839
+ });
840
+
841
+ it('omits label when not provided', () => {
842
+ const lock = buildLocalLock('dev', {
843
+ engine: 'docker',
844
+ network: 'kadi-net',
845
+ services: ['app'],
846
+ containers: { app: '123' },
847
+ composePath: '/project/compose.yml',
848
+ deployedAt: new Date(),
849
+ });
850
+
851
+ expect(lock.label).toBeUndefined();
852
+ });
853
+
854
+ it('writeLockFile assigns instanceId to a buildLocalLock result', async () => {
855
+ const lock = buildLocalLock('dev', {
856
+ engine: 'docker',
857
+ network: 'kadi-net',
858
+ services: ['svc'],
859
+ containers: { svc: '123' },
860
+ composePath: '/project/compose.yml',
861
+ deployedAt: new Date(),
862
+ });
863
+
864
+ expect(lock.instanceId).toBe('');
865
+ const id = await writeLockFile(tmpDir, lock);
866
+ expect(id).toHaveLength(4);
867
+ expect(lock.instanceId).toBe(id);
868
+ });
869
+ });
870
+
871
+ // ─────────────────────────────────────────────────────────
872
+ // buildAkashLock
873
+ // ─────────────────────────────────────────────────────────
874
+
875
+ describe('buildAkashLock', () => {
876
+ it('builds a valid Akash lock from deployment data', () => {
877
+ const lock = buildAkashLock('production', {
878
+ dseq: 99999,
879
+ owner: 'akash1owner',
880
+ provider: 'akash1prov',
881
+ providerUri: 'https://prov.example.com',
882
+ network: 'mainnet',
883
+ gseq: 1,
884
+ oseq: 1,
885
+ deployedAt: new Date('2026-02-25T14:30:00Z'),
886
+ });
887
+
888
+ expect(lock.target).toBe('akash');
889
+ expect(lock.profile).toBe('production');
890
+ expect(lock.instanceId).toBe(''); // assigned by writeLockFile
891
+ expect(lock.deployedAt).toBe('2026-02-25T14:30:00.000Z');
892
+ expect(lock.akash).toBeDefined();
893
+ expect(lock.akash!.dseq).toBe(99999);
894
+ expect(lock.akash!.owner).toBe('akash1owner');
895
+ expect(lock.akash!.network).toBe('mainnet');
896
+ expect(lock.local).toBeUndefined();
897
+ });
898
+
899
+ it('does not include a version field on the entry', () => {
900
+ const lock = buildAkashLock('prod', {
901
+ dseq: 42,
902
+ owner: 'akash1owner',
903
+ provider: 'akash1prov',
904
+ providerUri: 'https://uri.com',
905
+ network: 'mainnet',
906
+ gseq: 1,
907
+ oseq: 1,
908
+ deployedAt: new Date(),
909
+ });
910
+
911
+ expect('version' in lock).toBe(false);
912
+ });
913
+
914
+ it('supports testnet network', () => {
915
+ const lock = buildAkashLock('staging', {
916
+ dseq: 11111,
917
+ owner: 'akash1test',
918
+ provider: 'akash1testprov',
919
+ providerUri: 'https://testprov.example.com',
920
+ network: 'testnet',
921
+ gseq: 1,
922
+ oseq: 1,
923
+ deployedAt: new Date(),
924
+ });
925
+
926
+ expect(lock.akash!.network).toBe('testnet');
927
+ });
928
+
929
+ it('preserves all deployment identifiers', () => {
930
+ const lock = buildAkashLock('prod', {
931
+ dseq: 42,
932
+ owner: 'akash1owner',
933
+ provider: 'akash1prov',
934
+ providerUri: 'https://uri.com',
935
+ network: 'mainnet',
936
+ gseq: 3,
937
+ oseq: 5,
938
+ deployedAt: new Date(),
939
+ });
940
+
941
+ expect(lock.akash!.gseq).toBe(3);
942
+ expect(lock.akash!.oseq).toBe(5);
943
+ expect(lock.akash!.providerUri).toBe('https://uri.com');
944
+ });
945
+
946
+ it('includes label when provided', () => {
947
+ const lock = buildAkashLock('production', {
948
+ dseq: 42,
949
+ owner: 'akash1owner',
950
+ provider: 'akash1prov',
951
+ providerUri: 'https://uri.com',
952
+ network: 'mainnet',
953
+ gseq: 1,
954
+ oseq: 1,
955
+ deployedAt: new Date(),
956
+ }, 'primary-gpu');
957
+
958
+ expect(lock.label).toBe('primary-gpu');
959
+ });
960
+
961
+ it('omits label when not provided', () => {
962
+ const lock = buildAkashLock('production', {
963
+ dseq: 42,
964
+ owner: 'akash1owner',
965
+ provider: 'akash1prov',
966
+ providerUri: 'https://uri.com',
967
+ network: 'mainnet',
968
+ gseq: 1,
969
+ oseq: 1,
970
+ deployedAt: new Date(),
971
+ });
972
+
973
+ expect(lock.label).toBeUndefined();
974
+ });
975
+
976
+ it('writeLockFile assigns instanceId to a buildAkashLock result', async () => {
977
+ const lock = buildAkashLock('production', {
978
+ dseq: 42,
979
+ owner: 'akash1owner',
980
+ provider: 'akash1prov',
981
+ providerUri: 'https://uri.com',
982
+ network: 'mainnet',
983
+ gseq: 1,
984
+ oseq: 1,
985
+ deployedAt: new Date(),
986
+ });
987
+
988
+ expect(lock.instanceId).toBe('');
989
+ const id = await writeLockFile(tmpDir, lock);
990
+ expect(id).toHaveLength(4);
991
+ expect(lock.instanceId).toBe(id);
992
+ });
993
+ });
994
+
995
+ // ─────────────────────────────────────────────────────────
996
+ // Full lifecycle: write → read → remove → delete
997
+ // ─────────────────────────────────────────────────────────
998
+
999
+ describe('full lock lifecycle', () => {
1000
+ it('write multiple → remove one → verify remainder → remove last → null', async () => {
1001
+ // Write two deployments
1002
+ const id1 = await writeLockFile(tmpDir, LOCAL_ENTRY);
1003
+ const id2 = await writeLockFile(tmpDir, AKASH_ENTRY);
1004
+
1005
+ let lockFile = await readLockFile(tmpDir);
1006
+ const profiles = Object.values(lockFile!.deployments).map(e => e.profile).sort();
1007
+ expect(profiles).toEqual(['dev', 'production']);
1008
+
1009
+ // Remove one by profile+instanceId
1010
+ await removeDeployment(tmpDir, 'dev', id1);
1011
+ lockFile = await readLockFile(tmpDir);
1012
+ const remaining = Object.values(lockFile!.deployments);
1013
+ expect(remaining).toHaveLength(1);
1014
+ expect(remaining[0].profile).toBe('production');
1015
+
1016
+ // Remove last — file should be deleted
1017
+ await removeDeployment(tmpDir, 'production', id2);
1018
+ lockFile = await readLockFile(tmpDir);
1019
+ expect(lockFile).toBeNull();
1020
+ });
1021
+
1022
+ it('buildLocalLock → writeLockFile → getDeployment round-trip', async () => {
1023
+ const lock = buildLocalLock('test-profile', {
1024
+ engine: 'podman',
1025
+ network: 'test-net',
1026
+ services: ['svc1', 'svc2', 'svc3'],
1027
+ containers: { svc1: 'c1', svc2: 'c2', svc3: 'c3' },
1028
+ composePath: path.join(tmpDir, 'docker-compose.yml'),
1029
+ deployedAt: new Date('2026-01-01T00:00:00Z'),
1030
+ });
1031
+
1032
+ const id = await writeLockFile(tmpDir, lock);
1033
+ const entry = await getDeployment(tmpDir, 'test-profile');
1034
+ expect(entry).not.toBeNull();
1035
+ expect(entry!.instanceId).toBe(id);
1036
+ expect(entry!.target).toBe('local');
1037
+ expect(entry!.profile).toBe('test-profile');
1038
+ expect(entry!.local!.engine).toBe('podman');
1039
+ });
1040
+
1041
+ it('buildAkashLock → writeLockFile → getDeployment round-trip', async () => {
1042
+ const lock = buildAkashLock('staging', {
1043
+ dseq: 777,
1044
+ owner: 'akash1abc',
1045
+ provider: 'akash1xyz',
1046
+ providerUri: 'https://example.com',
1047
+ network: 'testnet',
1048
+ gseq: 2,
1049
+ oseq: 3,
1050
+ deployedAt: new Date('2026-06-15T10:30:00Z'),
1051
+ });
1052
+
1053
+ const id = await writeLockFile(tmpDir, lock);
1054
+ const entry = await getDeployment(tmpDir, 'staging');
1055
+ expect(entry).not.toBeNull();
1056
+ expect(entry!.instanceId).toBe(id);
1057
+ expect(entry!.target).toBe('akash');
1058
+ expect(entry!.akash!.dseq).toBe(777);
1059
+ });
1060
+
1061
+ it('multiple instances of same profile coexist', async () => {
1062
+ const lock1 = buildLocalLock('dev', {
1063
+ engine: 'docker',
1064
+ network: 'net-1',
1065
+ services: ['svc'],
1066
+ containers: { svc: 'c1' },
1067
+ composePath: '/project/compose.yml',
1068
+ deployedAt: new Date(),
1069
+ });
1070
+ const lock2 = buildLocalLock('dev', {
1071
+ engine: 'docker',
1072
+ network: 'net-2',
1073
+ services: ['svc'],
1074
+ containers: { svc: 'c2' },
1075
+ composePath: '/project/compose.yml',
1076
+ deployedAt: new Date(),
1077
+ });
1078
+
1079
+ const id1 = await writeLockFile(tmpDir, lock1);
1080
+ const id2 = await writeLockFile(tmpDir, lock2);
1081
+ expect(id1).not.toBe(id2);
1082
+
1083
+ const all = await getDeploymentsByProfile(tmpDir, 'dev');
1084
+ expect(all).toHaveLength(2);
1085
+
1086
+ const list = await listDeployments(tmpDir);
1087
+ expect(list).toHaveLength(2);
1088
+
1089
+ // Each can be retrieved by instance
1090
+ const e1 = await getDeploymentByInstance(tmpDir, id1);
1091
+ const e2 = await getDeploymentByInstance(tmpDir, id2);
1092
+ expect(e1!.local!.network).toBe('net-1');
1093
+ expect(e2!.local!.network).toBe('net-2');
1094
+ });
1095
+
1096
+ it('labels are preserved through write → read cycle', async () => {
1097
+ const lock = buildLocalLock('dev', {
1098
+ engine: 'docker',
1099
+ network: 'kadi-net',
1100
+ services: ['svc'],
1101
+ containers: { svc: 'c1' },
1102
+ composePath: '/project/compose.yml',
1103
+ deployedAt: new Date(),
1104
+ }, 'broker-west');
1105
+
1106
+ const id = await writeLockFile(tmpDir, lock);
1107
+ const entry = await getDeploymentByInstance(tmpDir, id);
1108
+ expect(entry!.label).toBe('broker-west');
1109
+ });
1110
+ });
1111
+
1112
+ // ─────────────────────────────────────────────────────────
1113
+ // Utility functions: generateInstanceId, deploymentKey, parseDeploymentKey
1114
+ // ─────────────────────────────────────────────────────────
1115
+
1116
+ describe('generateInstanceId', () => {
1117
+ it('returns a 4-char hex string', () => {
1118
+ const id = generateInstanceId();
1119
+ expect(id).toHaveLength(4);
1120
+ expect(/^[0-9a-f]{4}$/.test(id)).toBe(true);
1121
+ });
1122
+
1123
+ it('generates unique IDs on successive calls', () => {
1124
+ const ids = new Set(Array.from({ length: 20 }, () => generateInstanceId()));
1125
+ // With 65536 possible values, 20 should almost certainly be unique
1126
+ expect(ids.size).toBe(20);
1127
+ });
1128
+ });
1129
+
1130
+ describe('deploymentKey', () => {
1131
+ it('creates a composite key', () => {
1132
+ expect(deploymentKey('dev', 'ab12')).toBe('dev:ab12');
1133
+ });
1134
+ });
1135
+
1136
+ describe('parseDeploymentKey', () => {
1137
+ it('parses a composite key into profile and instanceId', () => {
1138
+ const parsed = parseDeploymentKey('dev:ab12');
1139
+ expect(parsed.profile).toBe('dev');
1140
+ expect(parsed.instanceId).toBe('ab12');
1141
+ });
1142
+
1143
+ it('handles profiles with colons', () => {
1144
+ const parsed = parseDeploymentKey('my:complex:profile:ab12');
1145
+ expect(parsed.profile).toBe('my:complex:profile');
1146
+ expect(parsed.instanceId).toBe('ab12');
1147
+ });
1148
+ });