@aifabrix/builder 2.37.9 → 2.38.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 (45) hide show
  1. package/README.md +19 -0
  2. package/integration/hubspot/hubspot-deploy.json +1 -2
  3. package/lib/api/applications.api.js +23 -1
  4. package/lib/api/credentials.api.js +34 -0
  5. package/lib/api/deployments.api.js +27 -0
  6. package/lib/api/types/applications.types.js +1 -1
  7. package/lib/api/types/deployments.types.js +1 -1
  8. package/lib/api/types/pipeline.types.js +1 -1
  9. package/lib/api/wizard.api.js +21 -1
  10. package/lib/app/run-helpers.js +30 -2
  11. package/lib/cli/index.js +2 -0
  12. package/lib/cli/setup-app.js +32 -0
  13. package/lib/cli/setup-credential-deployment.js +72 -0
  14. package/lib/cli/setup-utility.js +1 -25
  15. package/lib/commands/app-down.js +80 -0
  16. package/lib/commands/app-logs.js +146 -0
  17. package/lib/commands/app.js +22 -0
  18. package/lib/commands/credential-list.js +104 -0
  19. package/lib/commands/deployment-list.js +184 -0
  20. package/lib/core/templates.js +2 -1
  21. package/lib/generator/builders.js +8 -3
  22. package/lib/generator/external-controller-manifest.js +5 -4
  23. package/lib/generator/index.js +16 -14
  24. package/lib/generator/split.js +1 -0
  25. package/lib/generator/wizard.js +4 -1
  26. package/lib/schema/application-schema.json +6 -2
  27. package/lib/schema/deployment-rules.yaml +121 -0
  28. package/lib/utils/app-run-containers.js +2 -1
  29. package/lib/utils/compose-generator.js +2 -1
  30. package/lib/utils/help-builder.js +0 -1
  31. package/lib/utils/image-version.js +209 -0
  32. package/lib/utils/schema-loader.js +1 -1
  33. package/lib/utils/variable-transformer.js +1 -19
  34. package/lib/validation/external-manifest-validator.js +1 -1
  35. package/package.json +1 -1
  36. package/templates/applications/README.md.hbs +1 -3
  37. package/templates/applications/dataplane/Dockerfile +2 -2
  38. package/templates/applications/dataplane/README.md +1 -3
  39. package/templates/applications/dataplane/variables.yaml +5 -3
  40. package/templates/applications/keycloak/Dockerfile +3 -3
  41. package/templates/applications/keycloak/README.md +14 -4
  42. package/templates/applications/keycloak/env.template +14 -2
  43. package/templates/applications/keycloak/variables.yaml +1 -1
  44. package/templates/applications/miso-controller/README.md +1 -3
  45. package/templates/applications/miso-controller/env.template +64 -11
package/README.md CHANGED
@@ -9,6 +9,25 @@ Install the AI Fabrix platform and test it locally. Then add external integratio
9
9
 
10
10
  ---
11
11
 
12
+ ## Why AI Fabrix Builder?
13
+
14
+ - **Build perspective:** Everything is driven by declarative config and JSON schemas—no hidden logic, AI assistant–friendly.
15
+ - **Industry standards and security:** Follow industry standards and high security (e.g. ISO 27k); no secrets in version control.
16
+ - **Full lifecycle in your version control:** Configuration, apps, and integrations live in your own VCS (GitHub, GitLab, Azure DevOps).
17
+ - **One tool from day one:** Single CLI for local infra, app and integration creation, build, run, and deploy—same workflow for apps and integrations.
18
+ - **Consistency and production readiness:** Schema-driven; deploy apps and integrations to the same controller/dataplane; production-ready secrets with `kv://` and Azure Key Vault.
19
+ - **Application development:** Use **[miso-client](https://github.com/esystemsdev/aifabrix-miso-client)** for TypeScript and Python to talk to the dataplane and controller (see [templates/applications/dataplane/README.md](templates/applications/dataplane/README.md) and the repo for usage).
20
+
21
+ ---
22
+
23
+ ## Prerequisites
24
+
25
+ - **Node.js 18+** – Recommended for running the CLI.
26
+ - **AI Fabrix Azure / platform:** Install from **Azure Marketplace** or run via **Docker** (e.g. `aifabrix up-platform`). You need **full access to Docker** (docker commands) where applicable.
27
+ - **Secrets before platform:** Add secrets (e.g. OpenAI or Azure OpenAI) **before** running `aifabrix up-platform`; the platform reads them from the place you configure. See [Infrastructure](docs/infrastructure.md) and secrets configuration.
28
+
29
+ ---
30
+
12
31
  ## Install
13
32
 
14
33
  ```bash
@@ -839,6 +839,5 @@
839
839
  ],
840
840
  "requiresDatabase": false,
841
841
  "requiresRedis": false,
842
- "requiresStorage": false,
843
- "deploymentKey": "9b5209bed2ef1eccb04fa83be73a74cf3a9c84d5d2580a95028d34fdb8db78a8"
842
+ "requiresStorage": false
844
843
  }
@@ -153,6 +153,27 @@ async function rotateApplicationSecret(controllerUrl, envKey, appKey, authConfig
153
153
  return await client.post(`/api/v1/environments/${envKey}/applications/${appKey}/rotate-secret`);
154
154
  }
155
155
 
156
+ /**
157
+ * Get application status (metadata only, no configuration)
158
+ * GET /api/v1/environments/{envKey}/applications/{appKey}/status
159
+ * Auth: bearer token or pipeline client credentials for that application.
160
+ *
161
+ * @async
162
+ * @function getApplicationStatus
163
+ * @param {string} controllerUrl - Controller base URL
164
+ * @param {string} envKey - Environment key
165
+ * @param {string} appKey - Application key
166
+ * @param {Object} authConfig - Authentication configuration
167
+ * @returns {Promise<Object>} Response with data: { id, key, displayName, url, internalUrl, port, status, runtimeStatus, environmentId, createdAt, updatedAt, image, description }
168
+ * @throws {Error} If request fails
169
+ */
170
+ async function getApplicationStatus(controllerUrl, envKey, appKey, authConfig) {
171
+ const client = new ApiClient(controllerUrl, authConfig);
172
+ return await client.get(
173
+ `/api/v1/environments/${envKey}/applications/${appKey}/status`
174
+ );
175
+ }
176
+
156
177
  module.exports = {
157
178
  listApplications,
158
179
  createApplication,
@@ -160,6 +181,7 @@ module.exports = {
160
181
  updateApplication,
161
182
  deleteApplication,
162
183
  registerApplication,
163
- rotateApplicationSecret
184
+ rotateApplicationSecret,
185
+ getApplicationStatus
164
186
  };
165
187
 
@@ -0,0 +1,34 @@
1
+ /**
2
+ * @fileoverview Credentials API functions (controller/dataplane credential list)
3
+ * @author AI Fabrix Team
4
+ * @version 2.0.0
5
+ */
6
+
7
+ const { ApiClient } = require('./index');
8
+
9
+ /**
10
+ * List credentials from controller or dataplane
11
+ * GET /api/v1/credential
12
+ * Used by `aifabrix credential list`. Call with controller or dataplane base URL per deployment.
13
+ *
14
+ * @async
15
+ * @function listCredentials
16
+ * @param {string} baseUrl - Controller or dataplane base URL
17
+ * @param {Object} authConfig - Authentication configuration
18
+ * @param {Object} [options] - List options
19
+ * @param {boolean} [options.activeOnly] - If true, return only active credentials
20
+ * @param {number} [options.page] - Page number
21
+ * @param {number} [options.pageSize] - Items per page
22
+ * @returns {Promise<Object>} Response with credentials (e.g. data.credentials or data.items)
23
+ * @throws {Error} If request fails
24
+ */
25
+ async function listCredentials(baseUrl, authConfig, options = {}) {
26
+ const client = new ApiClient(baseUrl, authConfig);
27
+ return await client.get('/api/v1/credential', {
28
+ params: options
29
+ });
30
+ }
31
+
32
+ module.exports = {
33
+ listCredentials
34
+ };
@@ -116,10 +116,37 @@ async function getDeploymentLogs(controllerUrl, envKey, deploymentId, authConfig
116
116
  });
117
117
  }
118
118
 
119
+ /**
120
+ * List deployments for a single application in an environment
121
+ * GET /api/v1/environments/{envKey}/applications/{appKey}/deployments
122
+ *
123
+ * @async
124
+ * @function listApplicationDeployments
125
+ * @param {string} controllerUrl - Controller base URL
126
+ * @param {string} envKey - Environment key
127
+ * @param {string} appKey - Application key
128
+ * @param {Object} authConfig - Authentication configuration
129
+ * @param {Object} [options] - List options
130
+ * @param {number} [options.page] - Page number
131
+ * @param {number} [options.pageSize] - Items per page (default 50)
132
+ * @param {string} [options.sort] - Sort parameter
133
+ * @param {string} [options.filter] - Filter parameter
134
+ * @returns {Promise<Object>} Paginated list of deployments for the application
135
+ * @throws {Error} If request fails
136
+ */
137
+ async function listApplicationDeployments(controllerUrl, envKey, appKey, authConfig, options = {}) {
138
+ const client = new ApiClient(controllerUrl, authConfig);
139
+ return await client.get(
140
+ `/api/v1/environments/${envKey}/applications/${appKey}/deployments`,
141
+ { params: options }
142
+ );
143
+ }
144
+
119
145
  module.exports = {
120
146
  deployApplication,
121
147
  deployEnvironment,
122
148
  listDeployments,
149
+ listApplicationDeployments,
123
150
  getDeployment,
124
151
  getDeploymentLogs
125
152
  };
@@ -30,7 +30,7 @@
30
30
  * @property {string} displayName - Human-readable application name
31
31
  * @property {string} description - Application description
32
32
  * @property {string} type - Azure application type ('webapp' | 'functionapp' | 'api' | 'service' | 'external')
33
- * @property {string} deploymentKey - SHA256 hash of deployment manifest
33
+ * @property {string} [deploymentKey] - SHA256 hash of deployment manifest (Controller adds internally)
34
34
  * @property {string} [image] - Container image reference
35
35
  * @property {string} [registryMode] - Registry mode ('acr' | 'external' | 'public')
36
36
  * @property {number} [port] - Application port number
@@ -30,7 +30,7 @@
30
30
  * @property {string} displayName - Human-readable application name
31
31
  * @property {string} description - Application description
32
32
  * @property {string} type - Azure application type
33
- * @property {string} deploymentKey - SHA256 hash of deployment manifest
33
+ * @property {string} [deploymentKey] - SHA256 hash of deployment manifest (Controller adds internally)
34
34
  * @property {*} [additionalProperties] - Additional configuration properties
35
35
  */
36
36
 
@@ -11,7 +11,7 @@
11
11
  * @property {string} displayName - Human-readable application name
12
12
  * @property {string} description - Application description
13
13
  * @property {string} type - Azure application type
14
- * @property {string} deploymentKey - SHA256 hash of deployment manifest
14
+ * @property {string} [deploymentKey] - SHA256 hash of deployment manifest (Controller adds internally)
15
15
  * @property {*} [additionalProperties] - Additional configuration properties
16
16
  */
17
17
 
@@ -370,6 +370,25 @@ async function getWizardPlatforms(dataplaneUrl, authConfig) {
370
370
  }
371
371
  }
372
372
 
373
+ /**
374
+ * List credentials for wizard selection (Step 3)
375
+ * GET /api/v1/wizard/credentials
376
+ * @async
377
+ * @function listWizardCredentials
378
+ * @param {string} dataplaneUrl - Dataplane base URL
379
+ * @param {Object} authConfig - Authentication configuration
380
+ * @param {Object} [options] - Query options
381
+ * @param {boolean} [options.activeOnly] - If true, return only active credentials
382
+ * @returns {Promise<Object>} Response with credentials list (e.g. data.credentials or data.items)
383
+ * @throws {Error} If request fails
384
+ */
385
+ async function listWizardCredentials(dataplaneUrl, authConfig, options = {}) {
386
+ const client = new ApiClient(dataplaneUrl, authConfig);
387
+ return await client.get('/api/v1/wizard/credentials', {
388
+ params: options
389
+ });
390
+ }
391
+
373
392
  module.exports = {
374
393
  createWizardSession,
375
394
  getWizardSession,
@@ -388,5 +407,6 @@ module.exports = {
388
407
  testMcpConnection,
389
408
  getDeploymentDocs,
390
409
  postDeploymentDocs,
391
- getWizardPlatforms
410
+ getWizardPlatforms,
411
+ listWizardCredentials
392
412
  };
@@ -27,6 +27,7 @@ const composeGenerator = require('../utils/compose-generator');
27
27
  const dockerUtils = require('../utils/docker');
28
28
  const containerHelpers = require('../utils/app-run-containers');
29
29
  const pathsUtil = require('../utils/paths');
30
+ const { resolveVersionForApp } = require('../utils/image-version');
30
31
 
31
32
  const execAsync = promisify(exec);
32
33
 
@@ -138,6 +139,23 @@ async function validateAppConfiguration(appName) {
138
139
  return appConfig;
139
140
  }
140
141
 
142
+ /**
143
+ * Resolves version from image and updates builder/variables.yaml when running
144
+ * @async
145
+ * @param {string} appName - Application name
146
+ * @param {Object} appConfig - Application configuration
147
+ * @param {boolean} debug - Enable debug logging
148
+ */
149
+ async function resolveAndUpdateVersion(appName, appConfig, debug) {
150
+ const resolved = await resolveVersionForApp(appName, appConfig, {
151
+ updateBuilder: true,
152
+ builderPath: pathsUtil.getBuilderPath(appName)
153
+ });
154
+ if (resolved.fromImage && resolved.updated && debug) {
155
+ logger.log(chalk.gray(`[DEBUG] Updated app.version to ${resolved.version} from image`));
156
+ }
157
+ }
158
+
141
159
  /**
142
160
  * Checks prerequisites: Docker image and (optionally) infrastructure
143
161
  * @async
@@ -163,10 +181,20 @@ async function checkPrerequisites(appName, appConfig, debug = false, skipInfraCh
163
181
  }
164
182
  logger.log(chalk.green(`✓ Image ${fullImageName} found`));
165
183
 
166
- if (skipInfraCheck) {
167
- return;
184
+ await resolveAndUpdateVersion(appName, appConfig, debug);
185
+
186
+ if (!skipInfraCheck) {
187
+ await checkInfraHealthOrThrow(debug);
168
188
  }
189
+ }
169
190
 
191
+ /**
192
+ * Checks infrastructure health and throws if unhealthy
193
+ * @async
194
+ * @param {boolean} debug - Enable debug logging
195
+ * @throws {Error} If infrastructure is not healthy
196
+ */
197
+ async function checkInfraHealthOrThrow(debug) {
170
198
  logger.log(chalk.blue('Checking infrastructure health...'));
171
199
  const infraHealth = await infra.checkInfraHealth();
172
200
  if (debug) {
package/lib/cli/index.js CHANGED
@@ -20,6 +20,7 @@ const { setupSecretsCommands } = require('./setup-secrets');
20
20
  const { setupExternalSystemCommands } = require('./setup-external-system');
21
21
  const { setupAppCommands: setupAppManagementCommands } = require('../commands/app');
22
22
  const { setupDatasourceCommands } = require('../commands/datasource');
23
+ const { setupCredentialDeploymentCommands } = require('./setup-credential-deployment');
23
24
 
24
25
  /**
25
26
  * Sets up all CLI commands on the Commander program instance
@@ -33,6 +34,7 @@ function setupCommands(program) {
33
34
  setupAppManagementCommands(program);
34
35
  setupDatasourceCommands(program);
35
36
  setupUtilityCommands(program);
37
+ setupCredentialDeploymentCommands(program);
36
38
  setupExternalSystemCommands(program);
37
39
  setupDevCommands(program);
38
40
  setupSecretsCommands(program);
@@ -173,6 +173,7 @@ See integration/hubspot/wizard-hubspot-e2e.yaml for an example.`)
173
173
  .description('Run application locally')
174
174
  .option('-p, --port <port>', 'Override local port')
175
175
  .option('-d, --debug', 'Enable debug output with detailed container information')
176
+ .option('-t, --tag <tag>', 'Image tag to run (e.g. v1.0.0); overrides variables.yaml image.tag')
176
177
  .action(async(appName, options) => {
177
178
  try {
178
179
  await app.runApp(appName, options);
@@ -182,6 +183,37 @@ See integration/hubspot/wizard-hubspot-e2e.yaml for an example.`)
182
183
  }
183
184
  });
184
185
 
186
+ program.command('logs <app>')
187
+ .description('Show application container logs (and optional env summary with secrets masked)')
188
+ .option('-f', 'Follow log stream')
189
+ .option('-t, --tail <lines>', 'Number of lines (default: 100); 0 = full list', '100')
190
+ .action(async(appName, options) => {
191
+ try {
192
+ const { runAppLogs } = require('../commands/app-logs');
193
+ const tailNum = parseInt(options.tail, 10);
194
+ await runAppLogs(appName, {
195
+ follow: options.f,
196
+ tail: Number.isNaN(tailNum) ? 100 : tailNum
197
+ });
198
+ } catch (error) {
199
+ handleCommandError(error, 'logs');
200
+ process.exit(1);
201
+ }
202
+ });
203
+
204
+ program.command('down-app <app>')
205
+ .description('Stop and remove application container; optionally remove volume and image')
206
+ .option('--volumes', 'Remove application Docker volume')
207
+ .action(async(appName, options) => {
208
+ try {
209
+ const { runDownAppWithImageRemoval } = require('../commands/app-down');
210
+ await runDownAppWithImageRemoval(appName, { volumes: options.volumes });
211
+ } catch (error) {
212
+ handleCommandError(error, 'down-app');
213
+ process.exit(1);
214
+ }
215
+ });
216
+
185
217
  program.command('push <app>')
186
218
  .description('Push image to Azure Container Registry')
187
219
  .option('-r, --registry <registry>', 'ACR registry URL (overrides variables.yaml)')
@@ -0,0 +1,72 @@
1
+ /**
2
+ * CLI credential and deployment list command setup.
3
+ * Commands: credential list, deployment list.
4
+ *
5
+ * @fileoverview Credential and deployment list CLI definitions
6
+ * @author AI Fabrix Team
7
+ * @version 2.0.0
8
+ */
9
+
10
+ const chalk = require('chalk');
11
+ const logger = require('../utils/logger');
12
+ const { handleCommandError } = require('../utils/cli-utils');
13
+ const { runCredentialList } = require('../commands/credential-list');
14
+ const { runDeploymentList } = require('../commands/deployment-list');
15
+
16
+ /**
17
+ * Sets up credential and deployment list commands
18
+ * @param {Command} program - Commander program instance
19
+ */
20
+ function setupCredentialDeploymentCommands(program) {
21
+ const credential = program
22
+ .command('credential')
23
+ .description('Manage credentials');
24
+
25
+ credential
26
+ .command('list')
27
+ .description('List credentials from controller/dataplane (GET /api/v1/credential)')
28
+ .option('--controller <url>', 'Controller URL (default: from config)')
29
+ .option('--active-only', 'List only active credentials')
30
+ .option('--page-size <n>', 'Items per page', '50')
31
+ .action(async(options) => {
32
+ try {
33
+ const opts = {
34
+ controller: options.controller,
35
+ activeOnly: options.activeOnly,
36
+ pageSize: parseInt(options.pageSize, 10) || 50
37
+ };
38
+ await runCredentialList(opts);
39
+ } catch (error) {
40
+ logger.error(chalk.red(`Error: ${error.message}`));
41
+ handleCommandError(error, 'credential list');
42
+ process.exit(1);
43
+ }
44
+ });
45
+
46
+ const deployment = program
47
+ .command('deployment')
48
+ .description('List deployments');
49
+
50
+ deployment
51
+ .command('list')
52
+ .description('List last N deployments for current environment (default pageSize=50)')
53
+ .option('--controller <url>', 'Controller URL (default: from config)')
54
+ .option('--environment <env>', 'Environment key (default: from config)')
55
+ .option('--page-size <n>', 'Items per page', '50')
56
+ .action(async(options) => {
57
+ try {
58
+ const opts = {
59
+ controller: options.controller,
60
+ environment: options.environment,
61
+ pageSize: parseInt(options.pageSize, 10) || 50
62
+ };
63
+ await runDeploymentList(opts);
64
+ } catch (error) {
65
+ logger.error(chalk.red(`Error: ${error.message}`));
66
+ handleCommandError(error, 'deployment list');
67
+ process.exit(1);
68
+ }
69
+ });
70
+ }
71
+
72
+ module.exports = { setupCredentialDeploymentCommands };
@@ -1,5 +1,5 @@
1
1
  /**
2
- * CLI utility command setup (resolve, json, split-json, genkey, show, validate, diff).
2
+ * CLI utility command setup (resolve, json, split-json, show, validate, diff).
3
3
  *
4
4
  * @fileoverview Utility command definitions for AI Fabrix Builder CLI
5
5
  * @author AI Fabrix Team
@@ -127,30 +127,6 @@ function setupUtilityCommands(program) {
127
127
  }
128
128
  });
129
129
 
130
- program.command('genkey <app>')
131
- .description('Generate deployment key')
132
- .action(async(appName) => {
133
- try {
134
- const jsonPath = await generator.generateDeployJson(appName);
135
-
136
- const jsonContent = fs.readFileSync(jsonPath, 'utf8');
137
- const deployment = JSON.parse(jsonContent);
138
-
139
- const key = deployment.deploymentKey;
140
-
141
- if (!key) {
142
- throw new Error('deploymentKey not found in generated JSON');
143
- }
144
-
145
- logger.log(`\nDeployment key for ${appName}:`);
146
- logger.log(key);
147
- logger.log(chalk.gray(`\nGenerated from: ${jsonPath}`));
148
- } catch (error) {
149
- handleCommandError(error, 'genkey');
150
- process.exit(1);
151
- }
152
- });
153
-
154
130
  program.command('show <appKey>')
155
131
  .description('Show application info from local builder/ or integration/ (offline) or from controller (--online)')
156
132
  .option('--online', 'Fetch application data from the controller')
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Down-app command – stop container, optionally volumes, then remove image if unused
3
+ *
4
+ * @fileoverview Down-app command implementation
5
+ * @author AI Fabrix Team
6
+ * @version 2.0.0
7
+ */
8
+
9
+ const chalk = require('chalk');
10
+ const { exec } = require('child_process');
11
+ const { promisify } = require('util');
12
+ const logger = require('../utils/logger');
13
+ const config = require('../core/config');
14
+ const containerHelpers = require('../utils/app-run-containers');
15
+ const { downApp } = require('../app/down');
16
+
17
+ const execAsync = promisify(exec);
18
+
19
+ /**
20
+ * Get image ID of a running container (sha or name:tag)
21
+ * @async
22
+ * @param {string} containerName - Docker container name
23
+ * @returns {Promise<string|null>} Image ID or null if container not found / not running
24
+ */
25
+ async function getContainerImageId(containerName) {
26
+ try {
27
+ const { stdout } = await execAsync(
28
+ `docker inspect --format='{{.Image}}' ${containerName}`,
29
+ { encoding: 'utf8' }
30
+ );
31
+ const id = (stdout && stdout.trim()) || null;
32
+ return id || null;
33
+ } catch {
34
+ return null;
35
+ }
36
+ }
37
+
38
+ /**
39
+ * Remove Docker image by ID; ignore "in use" or "no such image"
40
+ * @async
41
+ * @param {string} imageId - Image ID (sha or name:tag)
42
+ */
43
+ async function removeImageIfUnused(imageId) {
44
+ if (!imageId) return;
45
+ try {
46
+ await execAsync(`docker rmi ${imageId}`);
47
+ logger.log(chalk.green(`✓ Image ${imageId} removed`));
48
+ } catch (err) {
49
+ const msg = (err && err.message) || '';
50
+ if (msg.includes('in use') || msg.includes('is being used')) {
51
+ logger.log(chalk.gray(`Image ${imageId} still in use by another container; not removed`));
52
+ } else if (msg.includes('No such image')) {
53
+ logger.log(chalk.gray(`Image ${imageId} not found (already removed)`));
54
+ } else {
55
+ logger.log(chalk.yellow(`Could not remove image: ${msg}`));
56
+ }
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Run down-app: get image from container, stop/remove container (and optionally volume), then remove image if unused
62
+ * @async
63
+ * @param {string} appName - Application name
64
+ * @param {Object} options - { volumes: boolean }
65
+ * @returns {Promise<void>}
66
+ */
67
+ async function runDownAppWithImageRemoval(appName, options = {}) {
68
+ const developerId = await config.getDeveloperId();
69
+ const containerName = containerHelpers.getContainerName(appName, developerId);
70
+ const imageId = await getContainerImageId(containerName);
71
+
72
+ await downApp(appName, options);
73
+ await removeImageIfUnused(imageId);
74
+ }
75
+
76
+ module.exports = {
77
+ runDownAppWithImageRemoval,
78
+ getContainerImageId,
79
+ removeImageIfUnused
80
+ };
@@ -0,0 +1,146 @@
1
+ /**
2
+ * App logs command – show container env (masked) and docker logs for an app
3
+ *
4
+ * @fileoverview App logs command implementation
5
+ * @author AI Fabrix Team
6
+ * @version 2.0.0
7
+ */
8
+
9
+ const chalk = require('chalk');
10
+ const { exec, spawn } = require('child_process');
11
+ const { promisify } = require('util');
12
+ const logger = require('../utils/logger');
13
+ const config = require('../core/config');
14
+ const containerHelpers = require('../utils/app-run-containers');
15
+ const { validateAppName } = require('../app/push');
16
+
17
+ const execAsync = promisify(exec);
18
+
19
+ /** Default number of log lines */
20
+ const DEFAULT_TAIL_LINES = 100;
21
+
22
+ /** Env key patterns that indicate a secret (mask value) */
23
+ const SECRET_KEY_PATTERN = /password|secret|token|credential|api[_-]?key/i;
24
+
25
+ /** Prefixes to strip before checking key (avoids masking KEYCLOAK_SERVER_URL etc.) */
26
+ const KEY_PREFIXES_TO_STRIP = /^KEYCLOAK_|^KEY_VAULT_/;
27
+
28
+ /** URL with embedded credentials: scheme://user:password@host → scheme://user:***@host */
29
+ const URL_CREDENTIAL_PATTERN = /(\w+:\/\/)([^:@]*):([^@]+)@/g;
30
+
31
+ /**
32
+ * Masks a single env line if the key looks like a secret or value contains URL credentials
33
+ * @param {string} line - Line in form KEY=value
34
+ * @returns {string} Same line or KEY=*** or value with masked URL credentials
35
+ */
36
+ function maskEnvLine(line) {
37
+ const eq = line.indexOf('=');
38
+ if (eq <= 0) return line;
39
+ const key = line.slice(0, eq);
40
+ const value = line.slice(eq + 1);
41
+
42
+ const keyForCheck = key.replace(KEY_PREFIXES_TO_STRIP, '');
43
+ const isSecretKey = SECRET_KEY_PATTERN.test(keyForCheck);
44
+
45
+ const maskedValue = value.replace(URL_CREDENTIAL_PATTERN, '$1$2:***@');
46
+ const hasUrlCredentials = maskedValue !== value;
47
+
48
+ if (isSecretKey) return `${key}=***`;
49
+ if (hasUrlCredentials) return `${key}=${maskedValue}`;
50
+ return line;
51
+ }
52
+
53
+ /**
54
+ * Dump container env (masked) and print to logger
55
+ * @async
56
+ * @param {string} containerName - Docker container name
57
+ * @returns {Promise<void>}
58
+ */
59
+ async function dumpMaskedEnv(containerName) {
60
+ try {
61
+ const { stdout } = await execAsync(`docker exec ${containerName} env`, { encoding: 'utf8', timeout: 5000 });
62
+ const lines = stdout.split('\n').filter((l) => l.trim());
63
+ if (lines.length === 0) return;
64
+ logger.log(chalk.bold('\n--- Environment (sensitive values masked) ---\n'));
65
+ lines.sort((a, b) => {
66
+ const keyA = a.indexOf('=') > 0 ? a.slice(0, a.indexOf('=')) : a;
67
+ const keyB = b.indexOf('=') > 0 ? b.slice(0, b.indexOf('=')) : b;
68
+ return keyA.localeCompare(keyB);
69
+ });
70
+ lines.forEach((line) => logger.log(maskEnvLine(line)));
71
+ logger.log(chalk.gray('\n--- Logs ---\n'));
72
+ } catch (err) {
73
+ logger.log(chalk.gray('(Could not read container env; container may be stopped)\n'));
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Run docker logs (non-follow): tail N lines or full (tail 0)
79
+ * @async
80
+ * @param {string} containerName - Docker container name
81
+ * @param {Object} options - { tail: number } (0 = full, no limit)
82
+ * @returns {Promise<void>}
83
+ */
84
+ async function runDockerLogs(containerName, options) {
85
+ const args = options.tail === 0 ? ['logs', containerName] : ['logs', '--tail', String(options.tail), containerName];
86
+ return new Promise((resolve, reject) => {
87
+ const proc = spawn('docker', args, { stdio: 'inherit' });
88
+ proc.on('error', reject);
89
+ proc.on('close', (code) => (code === 0 ? resolve() : reject(new Error(`docker logs exited with ${code}`))));
90
+ });
91
+ }
92
+
93
+ /**
94
+ * Run docker logs --follow (stream), optionally with tail
95
+ * @param {string} containerName - Docker container name
96
+ * @param {number} [tail] - Lines to show (0 = full, omit --tail)
97
+ */
98
+ function runDockerLogsFollow(containerName, tail) {
99
+ const args = tail === 0 ? ['logs', '-f', containerName] : ['logs', '-f', '--tail', String(tail), containerName];
100
+ const proc = spawn('docker', args, { stdio: 'inherit' });
101
+ proc.on('error', (err) => {
102
+ logger.log(chalk.red(`Error: ${err.message}`));
103
+ process.exit(1);
104
+ });
105
+ proc.on('close', (code) => {
106
+ if (code !== 0 && code !== null) process.exit(code);
107
+ });
108
+ }
109
+
110
+ /**
111
+ * Run app logs command: optional env dump (masked), then docker logs
112
+ * @async
113
+ * @param {string} appKey - Application key (app name)
114
+ * @param {Object} options - CLI options
115
+ * @param {boolean} [options.follow] - Follow log stream (-f)
116
+ * @param {number} [options.tail] - Number of lines (default 100; 0 = full list)
117
+ * @returns {Promise<void>}
118
+ */
119
+ async function runAppLogs(appKey, options = {}) {
120
+ validateAppName(appKey);
121
+ const developerId = await config.getDeveloperId();
122
+ const containerName = containerHelpers.getContainerName(appKey, developerId);
123
+
124
+ const follow = !!options.follow;
125
+ const tail = typeof options.tail === 'number' ? options.tail : DEFAULT_TAIL_LINES;
126
+
127
+ logger.log(chalk.blue(`Container: ${containerName}\n`));
128
+
129
+ if (!follow) {
130
+ await dumpMaskedEnv(containerName);
131
+ }
132
+
133
+ if (follow) {
134
+ runDockerLogsFollow(containerName, tail);
135
+ return;
136
+ }
137
+
138
+ try {
139
+ await runDockerLogs(containerName, { tail });
140
+ } catch (err) {
141
+ logger.log(chalk.red(`Error: ${err.message}`));
142
+ throw new Error(`Failed to show logs: ${err.message}`);
143
+ }
144
+ }
145
+
146
+ module.exports = { runAppLogs, maskEnvLine };