@aifabrix/builder 2.42.0 → 2.43.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 (133) hide show
  1. package/README.md +1 -1
  2. package/bin/aifabrix.js +1 -1
  3. package/integration/hubspot-test/README.md +126 -0
  4. package/integration/{hubspot → hubspot-test}/application.json +6 -6
  5. package/integration/{hubspot → hubspot-test}/create-hubspot.js +5 -5
  6. package/integration/hubspot-test/env.template +4 -0
  7. package/integration/{hubspot/hubspot-datasource-company.json → hubspot-test/hubspot-test-datasource-company.json} +3 -2
  8. package/integration/{hubspot/hubspot-datasource-contact.json → hubspot-test/hubspot-test-datasource-contact.json} +3 -2
  9. package/integration/{hubspot/hubspot-datasource-deal.json → hubspot-test/hubspot-test-datasource-deal.json} +3 -2
  10. package/integration/{hubspot/hubspot-datasource-users.json → hubspot-test/hubspot-test-datasource-users.json} +3 -2
  11. package/integration/{hubspot/hubspot-deploy.json → hubspot-test/hubspot-test-deploy.json} +198 -21
  12. package/integration/{hubspot/hubspot-system.json → hubspot-test/hubspot-test-system.json} +8 -7
  13. package/integration/hubspot-test/rbac.json +166 -0
  14. package/integration/{hubspot → hubspot-test}/test-artifacts/wizard-hubspot-credential-real.yaml +3 -3
  15. package/integration/{hubspot → hubspot-test}/test-artifacts/wizard-hubspot-env-vars.yaml +2 -2
  16. package/integration/{hubspot → hubspot-test}/test-artifacts/wizard-invalid-add-datasource.yaml +1 -1
  17. package/integration/{hubspot → hubspot-test}/test-artifacts/wizard-invalid-credential-create.yaml +1 -1
  18. package/integration/{hubspot → hubspot-test}/test-artifacts/wizard-invalid-credential-select.yaml +1 -1
  19. package/integration/{hubspot → hubspot-test}/test-artifacts/wizard-invalid-known-platform.yaml +1 -1
  20. package/integration/hubspot-test/test-artifacts/wizard-invalid-missing-source.yaml +2 -0
  21. package/integration/{hubspot → hubspot-test}/test-artifacts/wizard-invalid-mode.yaml +1 -1
  22. package/integration/{hubspot → hubspot-test}/test-artifacts/wizard-invalid-openapi-file.yaml +1 -1
  23. package/integration/{hubspot → hubspot-test}/test-artifacts/wizard-invalid-openapi-url.yaml +1 -1
  24. package/integration/{hubspot → hubspot-test}/test-artifacts/wizard-invalid-source.yaml +1 -1
  25. package/integration/{hubspot → hubspot-test}/test-artifacts/wizard-valid-for-dimension-array-test.yaml +1 -1
  26. package/integration/hubspot-test/test-artifacts/wizard-valid-for-dimension-key-test.yaml +5 -0
  27. package/integration/hubspot-test/test-artifacts/wizard-valid-for-dimension-path-test.yaml +5 -0
  28. package/integration/{hubspot → hubspot-test}/test-artifacts/wizard-valid-for-dimension-test.yaml +1 -1
  29. package/integration/{hubspot → hubspot-test}/test-artifacts/wizard-valid-for-rbac-test.yaml +1 -1
  30. package/integration/{hubspot → hubspot-test}/test-artifacts/wizard-valid-for-rbac-yaml-test.yaml +1 -1
  31. package/integration/{hubspot → hubspot-test}/test.js +102 -59
  32. package/integration/{hubspot → hubspot-test}/wizard-hubspot-e2e.yaml +2 -2
  33. package/integration/{hubspot → hubspot-test}/wizard-hubspot-platform.yaml +1 -1
  34. package/lib/api/external-test.api.js +1 -1
  35. package/lib/api/service-users.api.js +111 -2
  36. package/lib/api/types/service-users.types.js +41 -0
  37. package/lib/api/wizard.api.js +2 -1
  38. package/lib/app/index.js +2 -2
  39. package/lib/app/prompts.js +2 -2
  40. package/lib/app/readme.js +3 -1
  41. package/lib/app/register.js +3 -1
  42. package/lib/app/rotate-secret.js +3 -0
  43. package/lib/cli/setup-app.js +5 -5
  44. package/lib/cli/setup-auth.js +19 -11
  45. package/lib/cli/setup-dev.js +62 -32
  46. package/lib/cli/setup-environment.js +6 -21
  47. package/lib/cli/setup-infra.js +13 -0
  48. package/lib/cli/setup-secrets.js +45 -6
  49. package/lib/cli/setup-service-user.js +146 -20
  50. package/lib/cli/setup-utility.js +12 -0
  51. package/lib/commands/auth-config.js +25 -19
  52. package/lib/commands/datasource.js +46 -1
  53. package/lib/commands/dev-init.js +1 -1
  54. package/lib/commands/repair-env-template.js +14 -8
  55. package/lib/commands/repair-rbac.js +25 -19
  56. package/lib/commands/repair.js +108 -31
  57. package/lib/commands/secrets-remove.js +1 -1
  58. package/lib/commands/secrets-set.js +6 -0
  59. package/lib/commands/secrets-validate.js +17 -4
  60. package/lib/commands/service-user.js +231 -2
  61. package/lib/commands/up-common.js +25 -0
  62. package/lib/commands/up-dataplane.js +91 -7
  63. package/lib/commands/wizard-core-helpers.js +5 -2
  64. package/lib/commands/wizard-core.js +2 -1
  65. package/lib/commands/wizard-headless.js +6 -1
  66. package/lib/commands/wizard.js +13 -6
  67. package/lib/core/admin-secrets.js +2 -0
  68. package/lib/core/config.js +7 -5
  69. package/lib/core/ensure-encryption-key.js +1 -3
  70. package/lib/core/secrets.js +32 -9
  71. package/lib/core/templates.js +1 -1
  72. package/lib/datasource/abac-validator.js +157 -0
  73. package/lib/datasource/field-reference-validator.js +74 -36
  74. package/lib/datasource/log-viewer.js +221 -0
  75. package/lib/datasource/resolve-app.js +109 -0
  76. package/lib/datasource/test-e2e.js +11 -20
  77. package/lib/datasource/test-integration.js +42 -22
  78. package/lib/datasource/validate.js +5 -2
  79. package/lib/external-system/download-helpers.js +3 -1
  80. package/lib/external-system/generator.js +12 -8
  81. package/lib/external-system/test-system-level.js +1 -1
  82. package/lib/generator/external-controller-manifest.js +3 -3
  83. package/lib/generator/external-schema-utils.js +3 -1
  84. package/lib/generator/external.js +7 -7
  85. package/lib/generator/helpers.js +13 -9
  86. package/lib/generator/index.js +4 -4
  87. package/lib/generator/split.js +45 -10
  88. package/lib/generator/wizard-prompts-secondary.js +39 -7
  89. package/lib/generator/wizard-readme.js +4 -1
  90. package/lib/generator/wizard.js +68 -53
  91. package/lib/infrastructure/helpers.js +50 -35
  92. package/lib/infrastructure/index.js +39 -23
  93. package/lib/schema/env-config.yaml +19 -2
  94. package/lib/schema/external-datasource.schema.json +11 -1
  95. package/lib/schema/wizard-config.schema.json +7 -1
  96. package/lib/utils/app-config-resolver.js +23 -1
  97. package/lib/utils/config-paths.js +48 -4
  98. package/lib/utils/credential-secrets-env.js +16 -1
  99. package/lib/utils/env-map.js +7 -3
  100. package/lib/utils/error-formatter.js +37 -0
  101. package/lib/utils/external-env-template.js +180 -0
  102. package/lib/utils/external-readme.js +33 -1
  103. package/lib/utils/external-system-display.js +43 -0
  104. package/lib/utils/external-system-validators.js +2 -2
  105. package/lib/utils/help-builder.js +3 -5
  106. package/lib/utils/local-secrets.js +26 -3
  107. package/lib/utils/paths.js +2 -1
  108. package/lib/utils/secrets-generator.js +2 -2
  109. package/lib/utils/secrets-utils.js +4 -0
  110. package/lib/utils/secure-file-permissions.js +91 -0
  111. package/lib/utils/token-manager.js +36 -3
  112. package/lib/utils/yaml-preserve.js +59 -1
  113. package/lib/validation/env-template-auth.js +50 -2
  114. package/lib/validation/external-manifest-validator.js +8 -0
  115. package/lib/validation/validate.js +8 -0
  116. package/lib/validation/validator.js +10 -13
  117. package/package.json +6 -2
  118. package/templates/applications/dataplane/env.template +5 -1
  119. package/templates/applications/miso-controller/application.yaml +1 -1
  120. package/templates/applications/miso-controller/env.template +13 -2
  121. package/templates/external-system/README.md.hbs +18 -5
  122. package/templates/external-system/env.template.hbs +22 -0
  123. package/integration/hubspot/README.md +0 -100
  124. package/integration/hubspot/env.template +0 -4
  125. package/integration/hubspot/test-artifacts/wizard-invalid-missing-source.yaml +0 -2
  126. package/integration/hubspot/test-artifacts/wizard-valid-for-dimension-key-test.yaml +0 -5
  127. package/integration/hubspot/test-artifacts/wizard-valid-for-dimension-path-test.yaml +0 -5
  128. /package/integration/{hubspot → hubspot-test}/companies.json +0 -0
  129. /package/integration/{hubspot → hubspot-test}/test-artifacts/wizard-invalid-app-name.yaml +0 -0
  130. /package/integration/{hubspot → hubspot-test}/test-artifacts/wizard-invalid-missing-app.yaml +0 -0
  131. /package/integration/{hubspot → hubspot-test}/test-dataplane-down-helpers.js +0 -0
  132. /package/integration/{hubspot → hubspot-test}/test-dataplane-down-tests.js +0 -0
  133. /package/integration/{hubspot → hubspot-test}/test-dataplane-down.js +0 -0
@@ -163,6 +163,7 @@ function setupRepairCommand(program) {
163
163
  program.command('repair <app>')
164
164
  .description('Repair external integration config: fix drift (file lists, app key, datasource alignment, rbac, manifest)')
165
165
  .option('--auth <method>', 'Set authentication method (oauth2, aad, apikey, basic, queryParam, oidc, hmac, none); updates system file and env.template')
166
+ .option('--doc', 'Regenerate README.md from deployment manifest')
166
167
  .option('--dry-run', 'Report changes only; do not write')
167
168
  .option('--rbac', 'Ensure RBAC permissions per datasource and add default Admin/Reader roles if none exist')
168
169
  .option('--expose', 'Set exposed.attributes on each datasource to all fieldMappings.attributes keys')
@@ -174,9 +175,18 @@ function setupRepairCommand(program) {
174
175
  const { detectAppType } = require('../utils/paths');
175
176
  const { appPath } = await detectAppType(appName);
176
177
  logOfflinePathWhenType(appPath);
178
+ let format = 'yaml';
179
+ try {
180
+ const config = require('../core/config');
181
+ format = (await config.getFormat()) || format;
182
+ } catch (_) {
183
+ // use default yaml when config unavailable
184
+ }
177
185
  const result = await repairExternalIntegration(appName, {
178
186
  auth: options.auth,
187
+ doc: options.doc,
179
188
  dryRun: options.dryRun,
189
+ format,
180
190
  rbac: options.rbac,
181
191
  expose: options.expose,
182
192
  sync: options.sync,
@@ -187,6 +197,8 @@ function setupRepairCommand(program) {
187
197
  result.changes.forEach(c => logger.log(chalk.gray(` ${c}`)));
188
198
  } else if (result.updated) {
189
199
  logger.log(chalk.green('\n✓ Repaired external integration config.'));
200
+ } else if (result.readmeRegenerated) {
201
+ logger.log(chalk.green('\n✓ Regenerated README.md from deployment manifest.'));
190
202
  } else {
191
203
  logger.log(chalk.gray('No changes needed; config already matches files on disk.'));
192
204
  }
@@ -19,34 +19,44 @@ const {
19
19
  validateEnvironment,
20
20
  checkUserLoggedIn
21
21
  } = require('../utils/auth-config-validator');
22
+ const { getControllerUrlFromLoggedInUser } = require('../utils/controller-url');
22
23
  const logger = require('../utils/logger');
23
24
 
24
25
  /**
25
26
  * Handle set-controller command
27
+ * Allows setting the default controller when no credentials are stored, or when already logged in to that controller.
28
+ * If credentials exist for a different controller, throws with a clear message.
29
+ *
26
30
  * @async
27
31
  * @function handleSetController
28
32
  * @param {string} url - Controller URL to set
29
33
  * @returns {Promise<void>}
30
- * @throws {Error} If validation fails
34
+ * @throws {Error} If validation fails or credentials exist for another controller
31
35
  */
32
36
  async function handleSetController(url) {
33
37
  try {
34
- // Validate URL format
35
38
  validateControllerUrl(url);
39
+ const normalizedUrl = url.trim().replace(/\/+$/, '');
36
40
 
37
- // Check if user is logged in to that controller
38
- const isLoggedIn = await checkUserLoggedIn(url);
39
- if (!isLoggedIn) {
40
- throw new Error(
41
- `You are not logged in to controller ${url}.\n` +
42
- 'Please run "aifabrix login" first to authenticate with this controller.'
43
- );
41
+ const loggedInControllerUrl = await getControllerUrlFromLoggedInUser();
42
+ if (!loggedInControllerUrl) {
43
+ // No stored credentials: allow setting controller so "aifabrix login" opens the right place
44
+ await setControllerUrl(url);
45
+ logger.log(chalk.green(`✓ Controller URL set to: ${url}`));
46
+ return;
44
47
  }
45
48
 
46
- // Save controller URL
47
- await setControllerUrl(url);
49
+ const normalizedLoggedIn = loggedInControllerUrl.trim().replace(/\/+$/, '');
50
+ if (normalizedLoggedIn === normalizedUrl) {
51
+ await setControllerUrl(url);
52
+ logger.log(chalk.green(`✓ Controller URL set to: ${url}`));
53
+ return;
54
+ }
48
55
 
49
- logger.log(chalk.green(`✓ Controller URL set to: ${url}`));
56
+ throw new Error(
57
+ `You have credentials for another controller (${loggedInControllerUrl}).\n` +
58
+ 'To use a different controller either run "aifabrix login" with that controller, or run "aifabrix logout" first to clear credentials, then set the new controller with "aifabrix auth --set-controller <url>".'
59
+ );
50
60
  } catch (error) {
51
61
  logger.error(chalk.red(`✗ Failed to set controller URL: ${error.message}`));
52
62
  throw error;
@@ -70,8 +80,7 @@ async function handleSetEnvironment(environment) {
70
80
  const controllerUrl = await getControllerUrl();
71
81
  if (!controllerUrl) {
72
82
  throw new Error(
73
- 'No controller URL found in config.\n' +
74
- 'Please run "aifabrix login" first to set the controller URL.'
83
+ 'No controller URL found in config. Run "aifabrix login" first, or set the controller with "aifabrix auth --set-controller <url>".'
75
84
  );
76
85
  }
77
86
 
@@ -79,8 +88,7 @@ async function handleSetEnvironment(environment) {
79
88
  const isLoggedIn = await checkUserLoggedIn(controllerUrl);
80
89
  if (!isLoggedIn) {
81
90
  throw new Error(
82
- `You are not logged in to controller ${controllerUrl}.\n` +
83
- 'Please run "aifabrix login" first to authenticate with this controller.'
91
+ `You are not logged in to controller ${controllerUrl}. Run "aifabrix login" first to authenticate.`
84
92
  );
85
93
  }
86
94
 
@@ -107,9 +115,7 @@ async function handleSetEnvironment(environment) {
107
115
  async function handleAuthConfig(options) {
108
116
  if (!options.setController && !options.setEnvironment) {
109
117
  throw new Error(
110
- 'No action specified. Use one of:\n' +
111
- ' --set-controller <url>\n' +
112
- ' --set-environment <env>'
118
+ 'No action specified. Use "aifabrix auth --set-controller <url>" or "aifabrix auth --set-environment <env>".'
113
119
  );
114
120
  }
115
121
  if (options.setController) {
@@ -17,6 +17,7 @@ const { compareDatasources } = require('../datasource/diff');
17
17
  const { deployDatasource } = require('../datasource/deploy');
18
18
  const { runDatasourceTestIntegration } = require('../datasource/test-integration');
19
19
  const { runDatasourceTestE2E } = require('../datasource/test-e2e');
20
+ const { runLogViewer } = require('../datasource/log-viewer');
20
21
  const { displayIntegrationTestResults, displayE2EResults } = require('../utils/external-system-display');
21
22
 
22
23
  function setupDatasourceValidateCommand(datasource) {
@@ -81,9 +82,10 @@ function setupDatasourceUploadCommand(datasource) {
81
82
  function setupDatasourceTestIntegrationCommand(datasource) {
82
83
  datasource.command('test-integration <datasourceKey>')
83
84
  .description('Run integration (config) test for one datasource via dataplane pipeline')
84
- .option('-a, --app <appKey>', 'App key (default: resolve from cwd if inside integration/<appKey>/)')
85
+ .option('-a, --app <appKey>', 'App key (optional: resolve from cwd or datasource key if single match)')
85
86
  .option('-p, --payload <file>', 'Path to custom test payload file')
86
87
  .option('-e, --env <env>', 'Environment: dev, tst, or pro')
88
+ .option('-v, --verbose', 'Show detailed step and validation output')
87
89
  .option('--debug', 'Include debug output and write log to integration/<appKey>/logs/')
88
90
  .option('--timeout <ms>', 'Request timeout in milliseconds', '30000')
89
91
  .action(async(datasourceKey, options) => {
@@ -144,18 +146,61 @@ function setupDatasourceTestE2ECommand(datasource) {
144
146
  });
145
147
  }
146
148
 
149
+ function setupDatasourceLogE2ECommand(datasource) {
150
+ datasource.command('log-e2e <datasourceKey>')
151
+ .description('Display latest or specified E2E test log in readable format')
152
+ .option('-a, --app <appKey>', 'App key (optional: resolve from cwd or datasource key if single match)')
153
+ .option('-f, --file <path>', 'Path to log file (default: latest in app logs folder)')
154
+ .action(async(datasourceKey, options) => {
155
+ try {
156
+ await runLogViewer(datasourceKey, {
157
+ app: options.app,
158
+ file: options.file,
159
+ logType: 'test-e2e'
160
+ });
161
+ } catch (error) {
162
+ logger.error(chalk.red('❌ log-e2e failed:'), error.message);
163
+ process.exit(1);
164
+ }
165
+ });
166
+ }
167
+
168
+ function setupDatasourceLogIntegrationCommand(datasource) {
169
+ datasource.command('log-integration <datasourceKey>')
170
+ .description('Display latest or specified integration test log in readable format')
171
+ .option('-a, --app <appKey>', 'App key (optional: resolve from cwd or datasource key if single match)')
172
+ .option('-f, --file <path>', 'Path to log file (default: latest in app logs folder)')
173
+ .action(async(datasourceKey, options) => {
174
+ try {
175
+ await runLogViewer(datasourceKey, {
176
+ app: options.app,
177
+ file: options.file,
178
+ logType: 'test-integration'
179
+ });
180
+ } catch (error) {
181
+ logger.error(chalk.red('❌ log-integration failed:'), error.message);
182
+ process.exit(1);
183
+ }
184
+ });
185
+ }
186
+
147
187
  /**
148
188
  * Setup datasource management commands
149
189
  * @param {Command} program - Commander program instance
150
190
  */
151
191
  function setupDatasourceCommands(program) {
152
192
  const datasource = program.command('datasource').description('Manage external data sources');
193
+ if (typeof datasource.alias === 'function') {
194
+ datasource.alias('ds');
195
+ }
153
196
  setupDatasourceValidateCommand(datasource);
154
197
  setupDatasourceListCommand(datasource);
155
198
  setupDatasourceDiffCommand(datasource);
156
199
  setupDatasourceUploadCommand(datasource);
157
200
  setupDatasourceTestIntegrationCommand(datasource);
158
201
  setupDatasourceTestE2ECommand(datasource);
202
+ setupDatasourceLogE2ECommand(datasource);
203
+ setupDatasourceLogIntegrationCommand(datasource);
159
204
  }
160
205
 
161
206
  module.exports = { setupDatasourceCommands };
@@ -341,7 +341,7 @@ async function runDevRefresh(options = {}) {
341
341
  logger.log(chalk.blue('\n🔄 Fetching settings from Builder Server...\n'));
342
342
  const settings = await devApi.getSettings(auth.serverUrl, clientCertPem, clientKeyPem || undefined);
343
343
  await config.mergeRemoteSettings(settings);
344
- logger.log(chalk.green('✓ Config updated from server. Run "aifabrix dev config" to verify.\n'));
344
+ logger.log(chalk.green('✓ Config updated from server. Run "aifabrix dev show" to verify.\n'));
345
345
  }
346
346
 
347
347
  module.exports = { runDevInit, runDevRefresh };
@@ -11,6 +11,7 @@ const path = require('path');
11
11
  const fs = require('fs');
12
12
  const { systemKeyToKvPrefix, kvEnvKeyToPath, securityKeyToVar } = require('../utils/credential-secrets-env');
13
13
  const { extractEnvTemplate } = require('../generator/split');
14
+ const { generateExternalEnvTemplateContent } = require('../utils/external-env-template');
14
15
 
15
16
  /**
16
17
  * Normalizes a keyvault config entry to canonical KV_* name and path-style value.
@@ -126,20 +127,25 @@ function buildExpectedByKey(effective) {
126
127
  }
127
128
 
128
129
  /**
129
- * Creates env.template when missing. Returns true if created (and change pushed).
130
+ * Creates env.template when missing using the Handlebars template (Authentication + Configuration sections).
130
131
  * @param {string} envPath - Path to env.template
131
- * @param {Map<string, string>} expectedByKey - Expected key->line map
132
+ * @param {Map<string, string>} expectedByKey - Expected key->line map (fallback when no systemParsed)
132
133
  * @param {boolean} dryRun - If true, do not write
133
134
  * @param {string[]} changes - Array to append to
135
+ * @param {Object} [systemParsed] - Parsed system config for template context (preferred)
134
136
  * @returns {boolean}
135
137
  */
136
- function createEnvTemplateIfMissing(envPath, expectedByKey, dryRun, changes) {
138
+ function createEnvTemplateIfMissing(envPath, expectedByKey, dryRun, changes, systemParsed) {
137
139
  if (fs.existsSync(envPath)) return false;
138
- const lines = Array.from(expectedByKey.values());
139
- const content = lines.join('\n');
140
- if (!content) return false;
140
+ if (expectedByKey.size === 0) return false;
141
+ const content = systemParsed
142
+ ? generateExternalEnvTemplateContent(systemParsed)
143
+ : Array.from(expectedByKey.values()).join('\n');
144
+ if (!content || !content.trim()) return false;
145
+ const hasKeyValueLine = /^[A-Z_][A-Z0-9_]*=/m.test(content);
146
+ if (!hasKeyValueLine) return false;
141
147
  if (!dryRun) {
142
- fs.writeFileSync(envPath, content + '\n', { mode: 0o644, encoding: 'utf8' });
148
+ fs.writeFileSync(envPath, content + (content.endsWith('\n') ? '' : '\n'), { mode: 0o644, encoding: 'utf8' });
143
149
  }
144
150
  changes.push('Created env.template from system configuration');
145
151
  return true;
@@ -236,7 +242,7 @@ function repairEnvTemplate(appPath, systemParsed, systemKey, dryRun, changes) {
236
242
  const expectedByKey = buildExpectedByKey(effective);
237
243
  const envPath = path.join(appPath, 'env.template');
238
244
 
239
- if (createEnvTemplateIfMissing(envPath, expectedByKey, dryRun, changes)) {
245
+ if (createEnvTemplateIfMissing(envPath, expectedByKey, dryRun, changes, systemParsed)) {
240
246
  return true;
241
247
  }
242
248
  if (!fs.existsSync(envPath)) {
@@ -10,10 +10,10 @@
10
10
 
11
11
  const path = require('path');
12
12
  const fs = require('fs');
13
- const yaml = require('js-yaml');
14
13
  const chalk = require('chalk');
15
14
  const logger = require('../utils/logger');
16
- const { loadConfigFile } = require('../utils/config-format');
15
+ const { loadConfigFile, writeConfigFile } = require('../utils/config-format');
16
+ const { resolveRbacPath } = require('../utils/app-config-resolver');
17
17
 
18
18
  const DEFAULT_CAPABILITIES = ['list', 'get', 'create', 'update', 'delete'];
19
19
 
@@ -55,26 +55,30 @@ function collectPermissionNames(appPath, datasourceFiles) {
55
55
  }
56
56
 
57
57
  /**
58
- * Loads existing rbac or creates empty structure. Uses extractRbacFromSystem when provided.
58
+ * Loads existing RBAC file (rbac.yaml, rbac.yml, or rbac.json) or creates empty structure.
59
+ * Uses extractRbacFromSystem when no file exists. New file path respects format (rbac.json when format is 'json').
60
+ *
59
61
  * @param {string} appPath - Application path
60
62
  * @param {Object} [systemParsed] - Parsed system for extractRbacFromSystem
61
63
  * @param {Function} extractRbacFromSystem - (system) => rbac or null
62
- * @returns {{ rbac: Object, rbacPath: string, rbacYmlPath: string }}
64
+ * @param {string} [format] - 'json' or 'yaml'; used only when creating a new file (default 'yaml')
65
+ * @returns {{ rbac: Object, rbacPath: string }} rbacPath is resolved path or path.join(appPath, 'rbac.{json|yaml}') for new file
63
66
  */
64
- function loadOrCreateRbac(appPath, systemParsed, extractRbacFromSystem) {
65
- const rbacPath = path.join(appPath, 'rbac.yaml');
66
- const rbacYmlPath = path.join(appPath, 'rbac.yml');
67
+ function loadOrCreateRbac(appPath, systemParsed, extractRbacFromSystem, format) {
68
+ const resolvedPath = resolveRbacPath(appPath);
67
69
  let rbac;
68
- if (fs.existsSync(rbacPath)) {
69
- rbac = loadConfigFile(rbacPath);
70
- } else if (fs.existsSync(rbacYmlPath)) {
71
- rbac = loadConfigFile(rbacYmlPath);
70
+ let rbacPath;
71
+ if (resolvedPath) {
72
+ rbac = loadConfigFile(resolvedPath);
73
+ rbacPath = resolvedPath;
72
74
  } else {
73
75
  rbac = extractRbacFromSystem(systemParsed) || { roles: [], permissions: [] };
74
76
  if (!Array.isArray(rbac.roles)) rbac.roles = [];
75
77
  if (!Array.isArray(rbac.permissions)) rbac.permissions = [];
78
+ const ext = (format === 'json') ? 'rbac.json' : 'rbac.yaml';
79
+ rbacPath = path.join(appPath, ext);
76
80
  }
77
- return { rbac, rbacPath, rbacYmlPath };
81
+ return { rbac, rbacPath };
78
82
  }
79
83
 
80
84
  /**
@@ -123,31 +127,33 @@ function ensureDefaultRoles(rbac, systemKey, displayName, changes) {
123
127
  if (p.name && listGetPerms.includes(p.name) && !p.roles.includes(readerValue)) p.roles.push(readerValue);
124
128
  if (!p.roles.includes(adminValue)) p.roles.push(adminValue);
125
129
  }
126
- changes.push('Added default Admin and Reader roles to rbac.yaml');
130
+ changes.push('Added default Admin and Reader roles to rbac file');
127
131
  return true;
128
132
  }
129
133
 
130
134
  /**
131
135
  * Merges RBAC from datasources: ensures permission per resourceType:capability, adds Admin/Reader roles if none.
136
+ * When creating a new RBAC file, uses rbac.json if format is 'json', otherwise rbac.yaml.
137
+ *
132
138
  * @param {string} appPath - Application path
133
139
  * @param {Object} systemParsed - Parsed system (key, displayName)
134
140
  * @param {string[]} datasourceFiles - Datasource file names
135
141
  * @param {Function} extractRbacFromSystem - (system) => rbac or null
136
- * @param {boolean} dryRun - If true, do not write
137
- * @param {string[]} changes - Array to append change descriptions to
142
+ * @param {{ format?: string, dryRun: boolean, changes: string[] }} options - format ('json'|'yaml'), dryRun, changes array
138
143
  * @returns {boolean} True if rbac was updated (or would be in dry-run)
139
144
  */
140
- function mergeRbacFromDatasources(appPath, systemParsed, datasourceFiles, extractRbacFromSystem, dryRun, changes) {
145
+ function mergeRbacFromDatasources(appPath, systemParsed, datasourceFiles, extractRbacFromSystem, options) {
146
+ const { format = 'yaml', dryRun, changes } = options;
147
+ const rbacFormat = format === 'json' ? 'json' : 'yaml';
141
148
  const permissionNames = collectPermissionNames(appPath, datasourceFiles);
142
149
  if (permissionNames.size === 0) return false;
143
150
  const systemKey = systemParsed?.key || 'system';
144
151
  const displayName = systemParsed?.displayName || systemKey;
145
- const { rbac, rbacPath, rbacYmlPath } = loadOrCreateRbac(appPath, systemParsed, extractRbacFromSystem);
152
+ const { rbac, rbacPath } = loadOrCreateRbac(appPath, systemParsed, extractRbacFromSystem, rbacFormat);
146
153
  let updated = addMissingPermissions(rbac, permissionNames, changes);
147
154
  updated = ensureDefaultRoles(rbac, systemKey, displayName, changes) || updated;
148
155
  if (updated && !dryRun) {
149
- const outPath = fs.existsSync(rbacPath) ? rbacPath : (fs.existsSync(rbacYmlPath) ? rbacYmlPath : rbacPath);
150
- fs.writeFileSync(outPath, yaml.dump(rbac, { indent: 2, lineWidth: -1 }), { mode: 0o644, encoding: 'utf8' });
156
+ writeConfigFile(rbacPath, rbac);
151
157
  }
152
158
  return updated;
153
159
  }
@@ -16,14 +16,14 @@
16
16
  const path = require('path');
17
17
  const fs = require('fs');
18
18
  const chalk = require('chalk');
19
- const yaml = require('js-yaml');
20
- const { detectAppType } = require('../utils/paths');
21
- const { resolveApplicationConfigPath } = require('../utils/app-config-resolver');
19
+ const { detectAppType, getDeployJsonPath } = require('../utils/paths');
20
+ const { resolveApplicationConfigPath, resolveRbacPath } = require('../utils/app-config-resolver');
22
21
  const { loadConfigFile, writeConfigFile, writeYamlPreservingComments, isYamlPath } = require('../utils/config-format');
23
22
  const { systemKeyToKvPrefix, securityKeyToVar } = require('../utils/credential-secrets-env');
24
23
  const logger = require('../utils/logger');
25
24
  const generator = require('../generator');
26
25
  const { repairEnvTemplate, normalizeSystemFileAuthAndConfig } = require('./repair-env-template');
26
+ const { generateReadmeFromDeployJson } = require('../generator/split-readme');
27
27
  const { repairDatasourceFile } = require('./repair-datasource');
28
28
  const { mergeRbacFromDatasources } = require('./repair-rbac');
29
29
  const { discoverIntegrationFiles, buildEffectiveDatasourceFiles } = require('./repair-internal');
@@ -249,6 +249,11 @@ function normalizedAuthPartFromConfigName(name, systemKey) {
249
249
  const rest = n.slice(kvPrefix.length);
250
250
  return rest.toUpperCase().replace(/_/g, '');
251
251
  }
252
+ // Old-style or other KV_* names: treat last segment as var (e.g. KV_HUBSPOT_CLIENTID → CLIENTID)
253
+ if (n.toUpperCase().startsWith('KV_')) {
254
+ const parts = n.split('_').filter(Boolean);
255
+ if (parts.length >= 2) return parts[parts.length - 1].toUpperCase();
256
+ }
252
257
  return n.toUpperCase().replace(/_/g, '');
253
258
  }
254
259
 
@@ -273,21 +278,20 @@ function removeAuthVarsFromConfiguration(systemParsed, systemKey, dryRun, change
273
278
  return true;
274
279
  }
275
280
 
276
- function createRbacFromSystemIfNeeded(appPath, systemFilePath, systemParsed, dryRun, changes) {
277
- const rbacPath = path.join(appPath, 'rbac.yaml');
278
- const rbacYmlPath = path.join(appPath, 'rbac.yml');
279
- if (fs.existsSync(rbacPath) || fs.existsSync(rbacYmlPath)) return false;
281
+ function createRbacFromSystemIfNeeded(appPath, systemFilePath, systemParsed, dryRun, changes, format) {
282
+ if (resolveRbacPath(appPath)) return false;
280
283
  const rbacFromSystem = extractRbacFromSystem(systemParsed);
281
284
  if (!rbacFromSystem) return false;
282
285
  if (!dryRun) {
283
- const rbacYaml = yaml.dump(rbacFromSystem, { indent: 2, lineWidth: -1 });
284
- fs.writeFileSync(rbacPath, rbacYaml, { mode: 0o644, encoding: 'utf8' });
286
+ const rbacFormat = format === 'json' ? 'json' : 'yaml';
287
+ const defaultRbacPath = path.join(appPath, rbacFormat === 'json' ? 'rbac.json' : 'rbac.yaml');
288
+ writeConfigFile(defaultRbacPath, rbacFromSystem, rbacFormat);
285
289
  delete systemParsed.roles;
286
290
  delete systemParsed.permissions;
287
291
  writeConfigFile(systemFilePath, systemParsed);
288
292
  }
289
293
  changes.push('Created rbac.yaml from system roles/permissions');
290
- changes.push('Removed roles/permissions from system file (now in rbac.yaml)');
294
+ changes.push('Removed roles/permissions from system file (now in rbac file)');
291
295
  return true;
292
296
  }
293
297
 
@@ -338,6 +342,36 @@ async function regenerateManifest(appName, appPath, changes) {
338
342
  }
339
343
  }
340
344
 
345
+ /**
346
+ * Regenerates README.md from deployment manifest when options.doc is set.
347
+ * @param {string} appName - Application name
348
+ * @param {string} appPath - Application path
349
+ * @param {Object} options - Options (doc, dryRun)
350
+ * @param {string[]} changes - Array to append change messages to
351
+ * @returns {Promise<boolean>} True if README was regenerated
352
+ */
353
+ async function regenerateReadmeIfRequested(appName, appPath, options, changes) {
354
+ if (!options.doc) return false;
355
+ const deployJsonPath = getDeployJsonPath(appName, 'external', true);
356
+ if (!fs.existsSync(deployJsonPath) && !options.dryRun) {
357
+ await regenerateManifest(appName, appPath, changes);
358
+ }
359
+ if (!fs.existsSync(deployJsonPath)) return false;
360
+ try {
361
+ const deployment = JSON.parse(fs.readFileSync(deployJsonPath, 'utf8'));
362
+ const readmeContent = generateReadmeFromDeployJson(deployment);
363
+ const readmePath = path.join(appPath, 'README.md');
364
+ if (!options.dryRun) {
365
+ fs.writeFileSync(readmePath, readmeContent, { mode: 0o644, encoding: 'utf8' });
366
+ }
367
+ changes.push('Regenerated README.md from deployment manifest');
368
+ return true;
369
+ } catch (err) {
370
+ logger.log(chalk.yellow(`⚠ Could not regenerate README: ${err.message}`));
371
+ return false;
372
+ }
373
+ }
374
+
341
375
  function persistChangesAndRegenerate(configPath, variables, appName, appPath, changes, originalYamlContent) {
342
376
  if (originalYamlContent !== null && originalYamlContent !== undefined && typeof originalYamlContent === 'string' && isYamlPath(configPath)) {
343
377
  writeYamlPreservingComments(configPath, originalYamlContent, variables);
@@ -351,6 +385,7 @@ function persistChangesAndRegenerate(configPath, variables, appName, appPath, ch
351
385
 
352
386
  /**
353
387
  * Apply --auth: replace system file authentication with canonical block for the given method.
388
+ * Preserves existing authentication.variables (e.g. baseUrl, tokenUrl) from the current system file.
354
389
  * @param {Object} ctx - Context with systemParsed, systemKey, auth, dryRun, changes
355
390
  * @returns {boolean} True if auth was replaced
356
391
  */
@@ -362,8 +397,18 @@ function applyAuthMethod(ctx) {
362
397
  `Invalid --auth "${ctx.auth}". Allowed methods: ${ALLOWED_AUTH.join(', ')}`
363
398
  );
364
399
  }
400
+ const existingAuth = ctx.systemParsed.authentication || ctx.systemParsed.auth || {};
365
401
  const { buildAuthenticationFromMethod } = require('../external-system/generator');
366
- ctx.systemParsed.authentication = buildAuthenticationFromMethod(ctx.systemKey, method);
402
+ const newAuth = buildAuthenticationFromMethod(ctx.systemKey, method);
403
+ const existingVars = existingAuth.variables && typeof existingAuth.variables === 'object' ? existingAuth.variables : {};
404
+ const mergedVariables = { ...newAuth.variables, ...existingVars };
405
+ ctx.systemParsed.authentication = {
406
+ ...newAuth,
407
+ variables: Object.keys(mergedVariables).length ? mergedVariables : newAuth.variables
408
+ };
409
+ if (existingAuth.displayName !== undefined) {
410
+ ctx.systemParsed.authentication.displayName = existingAuth.displayName;
411
+ }
367
412
  ctx.changes.push(`Set authentication method to ${method}`);
368
413
  return true;
369
414
  }
@@ -399,7 +444,7 @@ function runRepairSteps(ctx) {
399
444
  ctx.appPath, ctx.datasourceFiles, ctx.systemKey, ctx.dryRun, ctx.changes
400
445
  );
401
446
  const rbacFileCreated = createRbacFromSystemIfNeeded(
402
- ctx.appPath, ctx.systemFilePath, ctx.systemParsed, ctx.dryRun, ctx.changes
447
+ ctx.appPath, ctx.systemFilePath, ctx.systemParsed, ctx.dryRun, ctx.changes, ctx.format
403
448
  );
404
449
  const envTemplateRepaired = repairEnvTemplate(
405
450
  ctx.appPath, ctx.systemParsed, ctx.systemKey, ctx.dryRun, ctx.changes
@@ -420,7 +465,8 @@ function runRepairSteps(ctx) {
420
465
  * @param {string} appName - Application/integration name
421
466
  * @param {Object} [options] - Options
422
467
  * @param {boolean} [options.dryRun] - If true, only report changes; do not write
423
- * @returns {Promise<{ updated: boolean, changes: string[], systemFiles: string[], datasourceFiles: string[], appKeyFixed?: boolean, datasourceKeysFixed?: boolean, rbacFileCreated?: boolean, envTemplateRepaired?: boolean, manifestRegenerated?: boolean }>}
468
+ * @param {boolean} [options.doc] - If true, regenerate README.md from deployment manifest
469
+ * @returns {Promise<{ updated: boolean, changes: string[], systemFiles: string[], datasourceFiles: string[], appKeyFixed?: boolean, datasourceKeysFixed?: boolean, rbacFileCreated?: boolean, envTemplateRepaired?: boolean, manifestRegenerated?: boolean, readmeRegenerated?: boolean }>}
424
470
  */
425
471
  /**
426
472
  * Loads application config and discovers integration files; validates at least one system file exists.
@@ -442,7 +488,36 @@ function loadConfigAndDiscover(appPath, configPath) {
442
488
  return { variables, originalYamlContent, systemFiles, datasourceFiles };
443
489
  }
444
490
 
445
- async function repairExternalIntegration(appName, options = {}) {
491
+ /**
492
+ * Builds the repair result object from steps and flags.
493
+ * @param {Object} steps - Result of runRepairSteps
494
+ * @param {boolean} anyUpdated - Whether any repair made changes
495
+ * @param {boolean} manifestRegenerated - Whether manifest was regenerated
496
+ * @param {boolean} readmeRegenerated - Whether README was regenerated
497
+ * @param {{ changes: string[], systemFiles: string[], datasourceFiles: string[] }} ctx - changes and file lists
498
+ * @returns {Object} Combined result object
499
+ */
500
+ function buildRepairResult(steps, anyUpdated, manifestRegenerated, readmeRegenerated, ctx) {
501
+ return Object.assign(
502
+ { updated: anyUpdated, changes: ctx.changes, systemFiles: ctx.systemFiles, datasourceFiles: ctx.datasourceFiles },
503
+ {
504
+ appKeyFixed: steps.appKeyFixed,
505
+ datasourceKeysFixed: steps.datasourceKeysFixed,
506
+ rbacFileCreated: steps.rbacFileCreated,
507
+ envTemplateRepaired: steps.envTemplateRepaired,
508
+ manifestRegenerated,
509
+ readmeRegenerated
510
+ }
511
+ );
512
+ }
513
+
514
+ /**
515
+ * Validates repair inputs and resolves app path and config path.
516
+ * @param {string} appName - Application name
517
+ * @param {Object} options - Command options (auth)
518
+ * @returns {Promise<{ appPath: string, configPath: string, dryRun: boolean, authOption: string|undefined }>}
519
+ */
520
+ async function validateAndResolveRepairPaths(appName, options) {
446
521
  if (!appName || typeof appName !== 'string') throw new Error('App name is required');
447
522
  const { dryRun = false, auth: authOption } = options;
448
523
  if (authOption !== undefined && authOption !== null && typeof authOption !== 'string') {
@@ -452,6 +527,11 @@ async function repairExternalIntegration(appName, options = {}) {
452
527
  if (!isExternal) throw new Error(`App '${appName}' is not an external integration`);
453
528
  const configPath = resolveApplicationConfigPath(appPath);
454
529
  if (!fs.existsSync(configPath)) throw new Error(`Application config not found: ${configPath}`);
530
+ return { appPath, configPath, dryRun, authOption };
531
+ }
532
+
533
+ async function repairExternalIntegration(appName, options = {}) {
534
+ const { appPath, configPath, dryRun, authOption } = await validateAndResolveRepairPaths(appName, options);
455
535
 
456
536
  const { variables, originalYamlContent, systemFiles, datasourceFiles: initialDatasourceFiles } = loadConfigAndDiscover(appPath, configPath);
457
537
  const changes = [];
@@ -475,30 +555,27 @@ async function repairExternalIntegration(appName, options = {}) {
475
555
  datasourceFiles,
476
556
  dryRun,
477
557
  changes,
478
- auth: authOption
558
+ auth: authOption,
559
+ format: options.format === 'json' ? 'json' : 'yaml'
479
560
  });
480
-
481
- const opts = { expose: Boolean(options.expose), sync: Boolean(options.sync), test: Boolean(options.test) };
482
- const datasourceRepairUpdated = runDatasourceRepairs(appPath, datasourceFiles, opts, dryRun, changes);
561
+ const datasourceRepairUpdated = runDatasourceRepairs(appPath, datasourceFiles, {
562
+ expose: Boolean(options.expose),
563
+ sync: Boolean(options.sync),
564
+ test: Boolean(options.test)
565
+ }, dryRun, changes);
483
566
  const rbacMergeUpdated = options.rbac
484
- ? mergeRbacFromDatasources(appPath, systemParsed, datasourceFiles, extractRbacFromSystem, dryRun, changes)
567
+ ? mergeRbacFromDatasources(appPath, systemParsed, datasourceFiles, extractRbacFromSystem, {
568
+ format: options.format === 'json' ? 'json' : 'yaml',
569
+ dryRun,
570
+ changes
571
+ })
485
572
  : false;
486
573
  const anyUpdated = keysNormalized || steps.updated || datasourceRepairUpdated || rbacMergeUpdated;
487
574
  const manifestRegenerated = (anyUpdated && !dryRun)
488
575
  ? await persistChangesAndRegenerate(configPath, variables, appName, appPath, changes, originalYamlContent)
489
576
  : false;
490
-
491
- return {
492
- updated: anyUpdated,
493
- changes,
494
- systemFiles,
495
- datasourceFiles,
496
- appKeyFixed: steps.appKeyFixed,
497
- datasourceKeysFixed: steps.datasourceKeysFixed,
498
- rbacFileCreated: steps.rbacFileCreated,
499
- envTemplateRepaired: steps.envTemplateRepaired,
500
- manifestRegenerated
501
- };
577
+ const readmeRegenerated = await regenerateReadmeIfRequested(appName, appPath, options, changes);
578
+ return buildRepairResult(steps, anyUpdated, manifestRegenerated, readmeRegenerated, { changes, systemFiles, datasourceFiles });
502
579
  }
503
580
 
504
581
  module.exports = {
@@ -33,7 +33,7 @@ function removeKeyFromFile(key, filePath) {
33
33
  throw new Error(`Secret '${key}' not found.`);
34
34
  }
35
35
  delete data[key];
36
- const yamlContent = yaml.dump(data, { indent: 2, lineWidth: 120, noRefs: true, sortKeys: false });
36
+ const yamlContent = yaml.dump(data, { indent: 2, lineWidth: -1, noRefs: true, sortKeys: false });
37
37
  fs.writeFileSync(filePath, yamlContent, { mode: 0o600 });
38
38
  }
39
39
 
@@ -60,6 +60,12 @@ async function handleSecretsSet(key, value, options) {
60
60
  throw new Error('Secret key is required and must be a string');
61
61
  }
62
62
 
63
+ if (key.startsWith('kv://')) {
64
+ throw new Error(
65
+ 'Secret key must not start with kv://. Use the key path without the prefix (e.g. my-app/clientSecret or hubspot/apiKey).'
66
+ );
67
+ }
68
+
63
69
  if (value === undefined || value === null || value === '') {
64
70
  throw new Error('Secret value is required');
65
71
  }