@quatrain/cli 1.1.8

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.
@@ -0,0 +1,829 @@
1
+ import inquirer from 'inquirer'
2
+ import fs from 'node:fs'
3
+ import path from 'node:path'
4
+ import { execSync } from 'node:child_process'
5
+
6
+ interface AppMetadata {
7
+ appName: string
8
+ namespace: string
9
+ env: 'dev' | 'prod'
10
+ domain: string
11
+ imageRef: string
12
+ authUser: string
13
+ authPass: string
14
+ resources: {
15
+ cpuRequests: string
16
+ cpuLimits: string
17
+ memRequests: string
18
+ memLimits: string
19
+ dataPvcSize: string
20
+ storagePvcSize: string
21
+ }
22
+ createdAt: string
23
+ updatedAt?: string
24
+ }
25
+
26
+ /**
27
+ * Discovers the absolute path to the CoreDeploy repository.
28
+ */
29
+ function discoverCoreDeployPath(): string {
30
+ const cwd = process.cwd()
31
+
32
+ // 1. Check if we are inside CoreDeploy already
33
+ if (fs.existsSync(path.join(cwd, 'k8s/templates/namespace.yaml'))) {
34
+ return cwd
35
+ }
36
+
37
+ // 2. Check sibling directories
38
+ const siblingCandidates = [
39
+ path.resolve(cwd, '../CoreDeploy'),
40
+ path.resolve(cwd, '../../CoreDeploy'),
41
+ path.resolve(cwd, 'CoreDeploy'),
42
+ '/Users/crapougnax/CODE/QUATRAIN/CoreDeploy'
43
+ ]
44
+
45
+ for (const candidate of siblingCandidates) {
46
+ if (fs.existsSync(path.join(candidate, 'k8s/templates/namespace.yaml'))) {
47
+ return candidate
48
+ }
49
+ }
50
+
51
+ return ''
52
+ }
53
+
54
+ /**
55
+ * Generates a high-entropy 24-character password.
56
+ */
57
+ function generateSecurePassword(): string {
58
+ const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#%^*()-_=+[]{}'
59
+ let pass = ''
60
+ for (let i = 0; i < 24; i++) {
61
+ const idx = Math.floor(Math.random() * chars.length)
62
+ pass += chars[idx]
63
+ }
64
+ return pass
65
+ }
66
+
67
+ /**
68
+ * Attempts to deduce the latest stable studio-image tag from CoreApps workspace.
69
+ */
70
+ function detectLatestStableTag(coreDeployPath: string): string {
71
+ const fallback = '1.1.49'
72
+ try {
73
+ const packageJsonPath = path.resolve(coreDeployPath, '../CoreApps/containers/studio-image/package.json')
74
+ if (fs.existsSync(packageJsonPath)) {
75
+ const content = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'))
76
+ if (content && content.version) {
77
+ return content.version
78
+ }
79
+ }
80
+ } catch {
81
+ // Ignore and use fallback
82
+ }
83
+ return fallback
84
+ }
85
+
86
+ /**
87
+ * Parses existing YAML files to reconstruct metadata if metadata.json is missing.
88
+ */
89
+ function parseMetadataFromYaml(appDir: string, namespaceName: string): AppMetadata | null {
90
+ try {
91
+ const nsPath = path.join(appDir, 'namespace.yaml')
92
+ const ingPath = path.join(appDir, 'ingressroute.yaml')
93
+ const depPath = path.join(appDir, 'deployment.yaml')
94
+ const secPath = path.join(appDir, 'secret.yaml')
95
+ const pvcPath = path.join(appDir, 'pvc.yaml')
96
+
97
+ if (!fs.existsSync(nsPath) || !fs.existsSync(ingPath) || !fs.existsSync(depPath) || !fs.existsSync(secPath)) {
98
+ return null
99
+ }
100
+
101
+ const nsContent = fs.readFileSync(nsPath, 'utf8')
102
+ const ingContent = fs.readFileSync(ingPath, 'utf8')
103
+ const depContent = fs.readFileSync(depPath, 'utf8')
104
+ const secContent = fs.readFileSync(secPath, 'utf8')
105
+
106
+ // Deducing env and appName
107
+ const env: 'dev' | 'prod' = namespaceName.endsWith('-dev') ? 'dev' : 'prod'
108
+
109
+ // Extract appName from namespace.yaml labels: app.kubernetes.io/instance: name
110
+ let appName = namespaceName.replace(/-[a-z0-9]{5}(-dev)?$/, '')
111
+ const instanceMatch = nsContent.match(/app\.kubernetes\.io\/instance:\s*([^\s\n]+)/)
112
+ if (instanceMatch) {
113
+ appName = instanceMatch[1]
114
+ }
115
+
116
+ // Extract domain from ingressroute.yaml Host(`domain`)
117
+ let domain = ''
118
+ const hostMatch = ingContent.match(/Host\(`([^`]+)`\)/)
119
+ if (hostMatch) {
120
+ domain = hostMatch[1]
121
+ }
122
+
123
+ // Extract image reference
124
+ let imageRef = ''
125
+ const imageMatch = depContent.match(/image:\s*(ghcr\.io\/quatrain\/studio-image:[^\s\n]+)/)
126
+ if (imageMatch) {
127
+ imageRef = imageMatch[1]
128
+ } else {
129
+ const generalImageMatch = depContent.match(/image:\s*([^\s\n]+)/)
130
+ if (generalImageMatch) {
131
+ imageRef = generalImageMatch[1]
132
+ }
133
+ }
134
+
135
+ // Extract Auth User/Pass from secret
136
+ let authUser = 'admin'
137
+ let authPass = ''
138
+ const userMatch = secContent.match(/STUDIO_AUTH_USER:\s*([^\s\n]+)/)
139
+ const passMatch = secContent.match(/STUDIO_AUTH_PASS:\s*([^\s\n]+)/)
140
+ if (userMatch) {
141
+ authUser = Buffer.from(userMatch[1].trim(), 'base64').toString('utf8')
142
+ }
143
+ if (passMatch) {
144
+ authPass = Buffer.from(passMatch[1].trim(), 'base64').toString('utf8')
145
+ }
146
+
147
+ // Extract resources
148
+ let cpuRequests = '100m'
149
+ let cpuLimits = '500m'
150
+ let memRequests = '256Mi'
151
+ let memLimits = '512Mi'
152
+
153
+ // We focus on the core-studio container resources
154
+ const containerSplit = depContent.split('containers:')
155
+ if (containerSplit.length > 1) {
156
+ const resourcesPart = containerSplit[1]
157
+ const reqCpuMatch = resourcesPart.match(/requests:[\s\S]*?cpu:\s*"?([^\s\n"]+)"?/)
158
+ const reqMemMatch = resourcesPart.match(/requests:[\s\S]*?memory:\s*"?([^\s\n"]+)"?/)
159
+ const limCpuMatch = resourcesPart.match(/limits:[\s\S]*?cpu:\s*"?([^\s\n"]+)"?/)
160
+ const limMemMatch = resourcesPart.match(/limits:[\s\S]*?memory:\s*"?([^\s\n"]+)"?/)
161
+
162
+ if (reqCpuMatch) cpuRequests = reqCpuMatch[1]
163
+ if (reqMemMatch) memRequests = reqMemMatch[1]
164
+ if (limCpuMatch) cpuLimits = limCpuMatch[1]
165
+ if (limMemMatch) memLimits = limMemMatch[1]
166
+ }
167
+
168
+ // Extract PVC sizes
169
+ let dataPvcSize = '1Gi'
170
+ let storagePvcSize = '10Gi'
171
+ if (fs.existsSync(pvcPath)) {
172
+ const pvcContent = fs.readFileSync(pvcPath, 'utf8')
173
+ // We split by standard YAML document separator
174
+ const docs = pvcContent.split('---')
175
+ docs.forEach(doc => {
176
+ const nameMatch = doc.match(/name:\s*([^\s\n]+)/)
177
+ const storageMatch = doc.match(/storage:\s*([^\s\n]+)/)
178
+ if (nameMatch && storageMatch) {
179
+ if (nameMatch[1].includes('data-pvc')) {
180
+ dataPvcSize = storageMatch[1]
181
+ } else if (nameMatch[1].includes('storage-pvc')) {
182
+ storagePvcSize = storageMatch[1]
183
+ }
184
+ }
185
+ })
186
+ }
187
+
188
+ const meta: AppMetadata = {
189
+ appName,
190
+ namespace: namespaceName,
191
+ env,
192
+ domain,
193
+ imageRef,
194
+ authUser,
195
+ authPass,
196
+ resources: {
197
+ cpuRequests,
198
+ cpuLimits,
199
+ memRequests,
200
+ memLimits,
201
+ dataPvcSize,
202
+ storagePvcSize
203
+ },
204
+ createdAt: new Date().toISOString()
205
+ }
206
+
207
+ // Write the file so we don't have to parse it next time
208
+ fs.writeFileSync(path.join(appDir, 'metadata.json'), JSON.stringify(meta, null, 3), 'utf8')
209
+ return meta
210
+ } catch (err) {
211
+ console.error(`Failed to parse YAML configuration for ${namespaceName}:`, err)
212
+ return null
213
+ }
214
+ }
215
+
216
+ /**
217
+ * Loads all app metadata from the CoreDeploy k8s/apps directory.
218
+ */
219
+ function loadAllDeployments(coreDeployPath: string): AppMetadata[] {
220
+ const appsDir = path.join(coreDeployPath, 'k8s/apps')
221
+ if (!fs.existsSync(appsDir)) {
222
+ return []
223
+ }
224
+
225
+ const directories = fs.readdirSync(appsDir).filter(name => {
226
+ return fs.statSync(path.join(appsDir, name)).isDirectory()
227
+ })
228
+
229
+ const deployments: AppMetadata[] = []
230
+ for (const dir of directories) {
231
+ const appDir = path.join(appsDir, dir)
232
+ const metaPath = path.join(appDir, 'metadata.json')
233
+
234
+ if (fs.existsSync(metaPath)) {
235
+ try {
236
+ const data = JSON.parse(fs.readFileSync(metaPath, 'utf8'))
237
+ deployments.push(data)
238
+ } catch {
239
+ // Attempt fallback parsing from YAML
240
+ const meta = parseMetadataFromYaml(appDir, dir)
241
+ if (meta) deployments.push(meta)
242
+ }
243
+ } else {
244
+ // Attempt fallback parsing from YAML
245
+ const meta = parseMetadataFromYaml(appDir, dir)
246
+ if (meta) deployments.push(meta)
247
+ }
248
+ }
249
+
250
+ return deployments
251
+ }
252
+
253
+ /**
254
+ * Scaffolds manifest files from templates with variable substitution.
255
+ */
256
+ function scaffoldManifests(
257
+ coreDeployPath: string,
258
+ meta: AppMetadata
259
+ ) {
260
+ const templatesDir = path.join(coreDeployPath, 'k8s/templates')
261
+ const targetDir = path.join(coreDeployPath, 'k8s/apps', meta.namespace)
262
+
263
+ if (!fs.existsSync(targetDir)) {
264
+ fs.mkdirSync(targetDir, { recursive: true })
265
+ }
266
+
267
+ const templates = [
268
+ 'namespace.yaml',
269
+ 'pvc.yaml',
270
+ 'configmap.yaml',
271
+ 'secret.yaml',
272
+ 'deployment.yaml',
273
+ 'service.yaml',
274
+ 'ingressroute.yaml'
275
+ ]
276
+
277
+ const authUserB64 = Buffer.from(meta.authUser).toString('base64')
278
+ const authPassB64 = Buffer.from(meta.authPass).toString('base64')
279
+
280
+ for (const file of templates) {
281
+ const srcFile = path.join(templatesDir, file)
282
+ const destFile = path.join(targetDir, file)
283
+
284
+ if (!fs.existsSync(srcFile)) {
285
+ throw new Error(`Template missing: ${srcFile}`)
286
+ }
287
+
288
+ let content = fs.readFileSync(srcFile, 'utf8')
289
+
290
+ // Replace placeholders
291
+ content = content.replace(/{{NAMESPACE}}/g, meta.namespace)
292
+ content = content.replace(/{{APP_NAME}}/g, meta.appName)
293
+ content = content.replace(/{{IMAGE_REF}}/g, meta.imageRef)
294
+ content = content.replace(/{{AUTH_USER_B64}}/g, authUserB64)
295
+ content = content.replace(/{{AUTH_PASS_B64}}/g, authPassB64)
296
+ content = content.replace(/{{DOMAIN}}/g, meta.domain)
297
+ content = content.replace(/{{CPU_REQUESTS}}/g, meta.resources.cpuRequests)
298
+ content = content.replace(/{{CPU_LIMITS}}/g, meta.resources.cpuLimits)
299
+ content = content.replace(/{{MEM_REQUESTS}}/g, meta.resources.memRequests)
300
+ content = content.replace(/{{MEM_LIMITS}}/g, meta.resources.memLimits)
301
+ content = content.replace(/{{DATA_PVC_SIZE}}/g, meta.resources.dataPvcSize)
302
+ content = content.replace(/{{STORAGE_PVC_SIZE}}/g, meta.resources.storagePvcSize)
303
+
304
+ fs.writeFileSync(destFile, content, 'utf8')
305
+ }
306
+
307
+ // Write metadata.json
308
+ fs.writeFileSync(path.join(targetDir, 'metadata.json'), JSON.stringify(meta, null, 3), 'utf8')
309
+ }
310
+
311
+ /**
312
+ * Applies the scaffolding manifests in the correct order to ensure the namespace exists first
313
+ * and non-k8s files (like metadata.json) are ignored.
314
+ */
315
+ function applyManifests(coreDeployPath: string, namespace: string): string {
316
+ const targetDir = path.join(coreDeployPath, 'k8s/apps', namespace)
317
+ const manifestFiles = [
318
+ 'namespace.yaml',
319
+ 'pvc.yaml',
320
+ 'configmap.yaml',
321
+ 'secret.yaml',
322
+ 'deployment.yaml',
323
+ 'service.yaml',
324
+ 'ingressroute.yaml'
325
+ ]
326
+ const filesArgs = manifestFiles
327
+ .map(f => `-f "${path.join(targetDir, f)}"`)
328
+ .join(' ')
329
+ const cmd = `kubectl apply ${filesArgs}`
330
+ console.log(`Executing: ${cmd}`)
331
+ return execSync(cmd, { encoding: 'utf8' })
332
+ }
333
+
334
+ /**
335
+ * Command Entrypoint for `core deploy`
336
+ */
337
+ export async function deployCommand() {
338
+ console.log('\n==============================================')
339
+ console.log(' Quatrain Core Studio - Deployment Manager ')
340
+ console.log('==============================================\n')
341
+
342
+ // 1. Auto-discover CoreDeploy Repository Path
343
+ let coreDeployPath = discoverCoreDeployPath()
344
+ if (coreDeployPath) {
345
+ console.log(`📡 Auto-detected CoreDeploy at: \x1b[33m${coreDeployPath}\x1b[0m`)
346
+ } else {
347
+ console.log('⚠️ Could not auto-detect the CoreDeploy repository directory.')
348
+ const pathAnswer = await inquirer.prompt([
349
+ {
350
+ type: 'input',
351
+ name: 'path',
352
+ message: 'Please provide the absolute path to CoreDeploy:',
353
+ validate: (input) => {
354
+ if (!input || !fs.existsSync(path.join(input, 'k8s/templates/namespace.yaml'))) {
355
+ return 'Invalid directory. Ensure it is the root of CoreDeploy containing k8s/templates/namespace.yaml.'
356
+ }
357
+ return true
358
+ }
359
+ }
360
+ ])
361
+ coreDeployPath = path.resolve(pathAnswer.path)
362
+ }
363
+
364
+ // Main loop
365
+ let exitCli = false
366
+ while (!exitCli) {
367
+ const { action } = await inquirer.prompt([
368
+ {
369
+ type: 'list',
370
+ name: 'action',
371
+ message: 'What deployment action would you like to perform?',
372
+ choices: [
373
+ { name: '📋 List deployments', value: 'list' },
374
+ { name: '✨ Create new deployment', value: 'create' },
375
+ { name: '⚙️ Modify existing deployment', value: 'modify' },
376
+ { name: '🚀 Promote deployment (Dev -> Prod)', value: 'promote' },
377
+ { name: '❌ Delete deployment', value: 'delete' },
378
+ { name: '🚪 Exit', value: 'exit' }
379
+ ]
380
+ }
381
+ ])
382
+
383
+ try {
384
+ switch (action) {
385
+ case 'list': {
386
+ const deployments = loadAllDeployments(coreDeployPath)
387
+ if (deployments.length === 0) {
388
+ console.log('\nℹ️ No deployments found.\n')
389
+ break
390
+ }
391
+
392
+ console.log('\nActive Studio Deployments:')
393
+ console.log('------------------------------------------------------------------------------------------------------------------------')
394
+ console.log(
395
+ `${'App Name'.padEnd(15)} | ${'Namespace'.padEnd(23)} | ${'Env'.padEnd(4)} | ${'FQDN Access Domain'.padEnd(35)} | ${'Image Reference'.padEnd(35)}`
396
+ )
397
+ console.log('------------------------------------------------------------------------------------------------------------------------')
398
+ deployments.forEach(d => {
399
+ console.log(
400
+ `${d.appName.padEnd(15)} | ${d.namespace.padEnd(23)} | ${d.env.padEnd(4)} | ${d.domain.padEnd(35)} | ${d.imageRef.padEnd(35)}`
401
+ )
402
+ })
403
+ console.log('------------------------------------------------------------------------------------------------------------------------\n')
404
+ break
405
+ }
406
+
407
+ case 'create': {
408
+ const answers = await inquirer.prompt([
409
+ {
410
+ type: 'input',
411
+ name: 'appName',
412
+ message: 'Nom de l\'application (alphanumeric/dashes only):',
413
+ validate: (input) => {
414
+ if (!input || !/^[a-zA-Z0-9\-]+$/.test(input)) {
415
+ return 'Application name is required and can only contain letters, numbers, and dashes.'
416
+ }
417
+ return true
418
+ }
419
+ },
420
+ {
421
+ type: 'list',
422
+ name: 'env',
423
+ message: 'Environnement (dev / prod) :',
424
+ choices: [
425
+ { name: 'Development (suffixed with -dev)', value: 'dev' },
426
+ { name: 'Production', value: 'prod' }
427
+ ]
428
+ }
429
+ ])
430
+
431
+ const appNameClean = answers.appName.toLowerCase()
432
+ const suffix = Math.random().toString(36).substring(2, 7) // 5 character alphanumeric string
433
+
434
+ let defaultNamespace = `${appNameClean}-${suffix}`
435
+ let defaultDomain = `${appNameClean}-${suffix}.quatrain.app`
436
+ if (answers.env === 'dev') {
437
+ defaultNamespace += '-dev'
438
+ defaultDomain = `${appNameClean}-${suffix}.quatrain.dev`
439
+ }
440
+
441
+ const flowAnswers = await inquirer.prompt([
442
+ {
443
+ type: 'input',
444
+ name: 'domain',
445
+ message: `Subdomain Access FQDN (Par défaut: ${defaultDomain}) :`,
446
+ default: defaultDomain
447
+ },
448
+ {
449
+ type: 'input',
450
+ name: 'imageRef',
451
+ message: `Référence de l'image (Par défaut: studio-image:${detectLatestStableTag(coreDeployPath)}) :`,
452
+ default: `ghcr.io/quatrain/studio-image:${detectLatestStableTag(coreDeployPath)}`
453
+ },
454
+ {
455
+ type: 'input',
456
+ name: 'authUser',
457
+ message: 'Auth Username (Par défaut: admin) :',
458
+ default: 'admin'
459
+ },
460
+ {
461
+ type: 'input',
462
+ name: 'authPass',
463
+ message: `Auth Password (Par défaut (généré sécurisé) :`,
464
+ default: () => generateSecurePassword()
465
+ },
466
+ {
467
+ type: 'list',
468
+ name: 'resourceChoice',
469
+ message: 'Configure Resources / Physical Storage:',
470
+ choices: [
471
+ { name: 'Use Default Limits (CPU req: 100m, limit: 500m | Mem req: 256Mi, limit: 512Mi | Data: 1Gi, Storage: 10Gi)', value: 'default' },
472
+ { name: 'Customize Resources & Storage', value: 'custom' }
473
+ ]
474
+ }
475
+ ])
476
+
477
+ let resources = {
478
+ cpuRequests: '100m',
479
+ cpuLimits: '500m',
480
+ memRequests: '256Mi',
481
+ memLimits: '512Mi',
482
+ dataPvcSize: '1Gi',
483
+ storagePvcSize: '10Gi'
484
+ }
485
+
486
+ if (flowAnswers.resourceChoice === 'custom') {
487
+ const customRes = await inquirer.prompt([
488
+ {
489
+ type: 'input',
490
+ name: 'cpuRequests',
491
+ message: 'CPU Requests (e.g. 100m, 50m) :',
492
+ default: '100m'
493
+ },
494
+ {
495
+ type: 'input',
496
+ name: 'cpuLimits',
497
+ message: 'CPU Limits (e.g. 500m, 200m) :',
498
+ default: '500m'
499
+ },
500
+ {
501
+ type: 'input',
502
+ name: 'memRequests',
503
+ message: 'Memory Requests (e.g. 256Mi, 128Mi) :',
504
+ default: '256Mi'
505
+ },
506
+ {
507
+ type: 'input',
508
+ name: 'memLimits',
509
+ message: 'Memory Limits (e.g. 512Mi, 256Mi) :',
510
+ default: '512Mi'
511
+ },
512
+ {
513
+ type: 'input',
514
+ name: 'dataPvcSize',
515
+ message: 'Data PVC Storage Capacity (e.g. 1Gi, 2Gi) :',
516
+ default: '1Gi'
517
+ },
518
+ {
519
+ type: 'input',
520
+ name: 'storagePvcSize',
521
+ message: 'Storage PVC Storage Capacity (e.g. 10Gi, 5Gi) :',
522
+ default: '10Gi'
523
+ }
524
+ ])
525
+ resources = customRes
526
+ }
527
+
528
+ const meta: AppMetadata = {
529
+ appName: appNameClean,
530
+ namespace: defaultNamespace,
531
+ env: answers.env,
532
+ domain: flowAnswers.domain,
533
+ imageRef: flowAnswers.imageRef,
534
+ authUser: flowAnswers.authUser,
535
+ authPass: flowAnswers.authPass,
536
+ resources,
537
+ createdAt: new Date().toISOString()
538
+ }
539
+
540
+ scaffoldManifests(coreDeployPath, meta)
541
+
542
+ console.log(`\n✅ Manifests and metadata successfully scaffolded under: k8s/apps/${meta.namespace}/`)
543
+ console.log(`🌐 FQDN access URL: https://${meta.domain}`)
544
+ console.log(`🔑 Credentials : ${meta.authUser} / ${meta.authPass}\n`)
545
+
546
+ const { applyNow } = await inquirer.prompt([
547
+ {
548
+ type: 'confirm',
549
+ name: 'applyNow',
550
+ message: `Do you want to deploy ${meta.namespace} immediately to the Kubernetes cluster?`,
551
+ default: true
552
+ }
553
+ ])
554
+
555
+ if (applyNow) {
556
+ const output = applyManifests(coreDeployPath, meta.namespace)
557
+ console.log(`\x1b[32m${output}\x1b[0m`)
558
+ }
559
+ break
560
+ }
561
+
562
+ case 'modify': {
563
+ const deployments = loadAllDeployments(coreDeployPath)
564
+ if (deployments.length === 0) {
565
+ console.log('\nℹ️ No deployments found to modify.\n')
566
+ break
567
+ }
568
+
569
+ const { targetNs } = await inquirer.prompt([
570
+ {
571
+ type: 'list',
572
+ name: 'targetNs',
573
+ message: 'Select the deployment to modify:',
574
+ choices: deployments.map(d => ({
575
+ name: `${d.namespace} (domain: ${d.domain})`,
576
+ value: d.namespace
577
+ }))
578
+ }
579
+ ])
580
+
581
+ const targetMeta = deployments.find(d => d.namespace === targetNs)!
582
+
583
+ const updates = await inquirer.prompt([
584
+ {
585
+ type: 'input',
586
+ name: 'domain',
587
+ message: 'Subdomain Access FQDN:',
588
+ default: targetMeta.domain
589
+ },
590
+ {
591
+ type: 'input',
592
+ name: 'imageRef',
593
+ message: 'Image Reference:',
594
+ default: targetMeta.imageRef
595
+ },
596
+ {
597
+ type: 'input',
598
+ name: 'authUser',
599
+ message: 'Auth Username:',
600
+ default: targetMeta.authUser
601
+ },
602
+ {
603
+ type: 'input',
604
+ name: 'authPass',
605
+ message: 'Auth Password:',
606
+ default: targetMeta.authPass
607
+ },
608
+ {
609
+ type: 'input',
610
+ name: 'cpuRequests',
611
+ message: 'CPU Requests:',
612
+ default: targetMeta.resources?.cpuRequests || '100m'
613
+ },
614
+ {
615
+ type: 'input',
616
+ name: 'cpuLimits',
617
+ message: 'CPU Limits:',
618
+ default: targetMeta.resources?.cpuLimits || '500m'
619
+ },
620
+ {
621
+ type: 'input',
622
+ name: 'memRequests',
623
+ message: 'Memory Requests:',
624
+ default: targetMeta.resources?.memRequests || '256Mi'
625
+ },
626
+ {
627
+ type: 'input',
628
+ name: 'memLimits',
629
+ message: 'Memory Limits:',
630
+ default: targetMeta.resources?.memLimits || '512Mi'
631
+ },
632
+ {
633
+ type: 'input',
634
+ name: 'dataPvcSize',
635
+ message: 'Data PVC storage size:',
636
+ default: targetMeta.resources?.dataPvcSize || '1Gi'
637
+ },
638
+ {
639
+ type: 'input',
640
+ name: 'storagePvcSize',
641
+ message: 'Storage PVC storage size:',
642
+ default: targetMeta.resources?.storagePvcSize || '10Gi'
643
+ }
644
+ ])
645
+
646
+ const updatedMeta: AppMetadata = {
647
+ ...targetMeta,
648
+ domain: updates.domain,
649
+ imageRef: updates.imageRef,
650
+ authUser: updates.authUser,
651
+ authPass: updates.authPass,
652
+ resources: {
653
+ cpuRequests: updates.cpuRequests,
654
+ cpuLimits: updates.cpuLimits,
655
+ memRequests: updates.memRequests,
656
+ memLimits: updates.memLimits,
657
+ dataPvcSize: updates.dataPvcSize,
658
+ storagePvcSize: updates.storagePvcSize
659
+ },
660
+ updatedAt: new Date().toISOString()
661
+ }
662
+
663
+ scaffoldManifests(coreDeployPath, updatedMeta)
664
+ console.log(`\n✅ Manifests and metadata updated for namespace ${targetNs}.`)
665
+
666
+ const { applyNow } = await inquirer.prompt([
667
+ {
668
+ type: 'confirm',
669
+ name: 'applyNow',
670
+ message: 'Do you want to apply these updates to the cluster now?',
671
+ default: true
672
+ }
673
+ ])
674
+
675
+ if (applyNow) {
676
+ const output = applyManifests(coreDeployPath, targetNs)
677
+ console.log(`\x1b[32m${output}\x1b[0m`)
678
+ }
679
+ break
680
+ }
681
+
682
+ case 'promote': {
683
+ const deployments = loadAllDeployments(coreDeployPath)
684
+ const devDeployments = deployments.filter(d => d.env === 'dev' || d.namespace.endsWith('-dev'))
685
+
686
+ if (devDeployments.length === 0) {
687
+ console.log('\nℹ️ No development deployments found to promote.\n')
688
+ break
689
+ }
690
+
691
+ const { targetNs } = await inquirer.prompt([
692
+ {
693
+ type: 'list',
694
+ name: 'targetNs',
695
+ message: 'Select the development app to promote to production:',
696
+ choices: devDeployments.map(d => ({
697
+ name: `${d.namespace} (domain: ${d.domain})`,
698
+ value: d.namespace
699
+ }))
700
+ }
701
+ ])
702
+
703
+ const devMeta = devDeployments.find(d => d.namespace === targetNs)!
704
+
705
+ // Deduce prod settings: strip -dev suffix
706
+ const prodNamespace = devMeta.namespace.replace(/-dev$/, '')
707
+ const prodDomain = devMeta.domain.replace(/\.dev$/, '.app')
708
+
709
+ console.log(`\nPromoting installation to production:`)
710
+ console.log(` Dev Namespace : ${devMeta.namespace} -> Prod Namespace : \x1b[32m${prodNamespace}\x1b[0m`)
711
+ console.log(` Dev Domain : ${devMeta.domain} -> Prod Domain : \x1b[32m${prodDomain}\x1b[0m\n`)
712
+
713
+ const confirmPromote = await inquirer.prompt([
714
+ {
715
+ type: 'confirm',
716
+ name: 'confirm',
717
+ message: 'Do you want to proceed with this promotion?',
718
+ default: true
719
+ }
720
+ ])
721
+
722
+ if (!confirmPromote.confirm) {
723
+ console.log('Promotion cancelled.\n')
724
+ break
725
+ }
726
+
727
+ const prodMeta: AppMetadata = {
728
+ appName: devMeta.appName,
729
+ namespace: prodNamespace,
730
+ env: 'prod',
731
+ domain: prodDomain,
732
+ imageRef: devMeta.imageRef,
733
+ authUser: devMeta.authUser,
734
+ authPass: devMeta.authPass,
735
+ resources: { ...devMeta.resources },
736
+ createdAt: new Date().toISOString()
737
+ }
738
+
739
+ scaffoldManifests(coreDeployPath, prodMeta)
740
+ console.log(`\n✅ Production manifests scaffolded under: k8s/apps/${prodNamespace}/`)
741
+
742
+ const { applyNow } = await inquirer.prompt([
743
+ {
744
+ type: 'confirm',
745
+ name: 'applyNow',
746
+ message: `Do you want to deploy ${prodNamespace} immediately to production?`,
747
+ default: true
748
+ }
749
+ ])
750
+
751
+ if (applyNow) {
752
+ const output = applyManifests(coreDeployPath, prodNamespace)
753
+ console.log(`\x1b[32m${output}\x1b[0m`)
754
+ }
755
+ break
756
+ }
757
+
758
+ case 'delete': {
759
+ const deployments = loadAllDeployments(coreDeployPath)
760
+ if (deployments.length === 0) {
761
+ console.log('\nℹ️ No deployments found to delete.\n')
762
+ break
763
+ }
764
+
765
+ const { targetNs } = await inquirer.prompt([
766
+ {
767
+ type: 'list',
768
+ name: 'targetNs',
769
+ message: 'Select the deployment to delete:',
770
+ choices: deployments.map(d => ({
771
+ name: `${d.namespace} (domain: ${d.domain})`,
772
+ value: d.namespace
773
+ }))
774
+ }
775
+ ])
776
+
777
+ const { confirmDelete } = await inquirer.prompt([
778
+ {
779
+ type: 'confirm',
780
+ name: 'confirmDelete',
781
+ message: `Are you sure you want to delete local manifests & config for ${targetNs}? (This action is irreversible!)`,
782
+ default: false
783
+ }
784
+ ])
785
+
786
+ if (!confirmDelete) {
787
+ console.log('Deletion cancelled.\n')
788
+ break
789
+ }
790
+
791
+ const { deleteK8s } = await inquirer.prompt([
792
+ {
793
+ type: 'confirm',
794
+ name: 'deleteK8s',
795
+ message: `Do you also want to delete the namespace "${targetNs}" from the active Kubernetes cluster?`,
796
+ default: true
797
+ }
798
+ ])
799
+
800
+ if (deleteK8s) {
801
+ console.log(`Executing: kubectl delete namespace ${targetNs}`)
802
+ try {
803
+ const output = execSync(`kubectl delete namespace "${targetNs}"`, { encoding: 'utf8' })
804
+ console.log(`\x1b[32m${output}\x1b[0m`)
805
+ } catch (err: any) {
806
+ console.error(`Failed to delete namespace ${targetNs} on the cluster:`, err.message)
807
+ }
808
+ }
809
+
810
+ // Remove local files
811
+ const appDir = path.join(coreDeployPath, 'k8s/apps', targetNs)
812
+ if (fs.existsSync(appDir)) {
813
+ fs.rmSync(appDir, { recursive: true, force: true })
814
+ console.log(`✅ Deleted local directory: k8s/apps/${targetNs}\n`)
815
+ }
816
+ break
817
+ }
818
+
819
+ case 'exit':
820
+ exitCli = true
821
+ break
822
+ }
823
+ } catch (err: any) {
824
+ console.error(`\n❌ Error performing action: ${err.message}\n`)
825
+ }
826
+ }
827
+
828
+ console.log('🚪 Exiting Deployment Manager.')
829
+ }