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,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
|
+
}
|