@aifabrix/builder 2.44.4 → 2.44.6

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 (214) hide show
  1. package/.cursor/rules/cli-layout.mdc +1 -1
  2. package/.cursor/rules/project-rules.mdc +1 -1
  3. package/.npmrc.token +1 -1
  4. package/README.md +15 -23
  5. package/integration/hubspot-test/README.md +2 -0
  6. package/integration/hubspot-test/test.js +5 -3
  7. package/jest.projects.js +68 -17
  8. package/lib/api/controller-health.api.js +49 -0
  9. package/lib/api/dimension-values.api.js +82 -0
  10. package/lib/api/dimensions.api.js +114 -0
  11. package/lib/api/external-systems.api.js +1 -0
  12. package/lib/api/integration-clients.api.js +168 -0
  13. package/lib/api/types/dimension-values.types.js +28 -0
  14. package/lib/api/types/dimensions.types.js +31 -0
  15. package/lib/api/types/integration-clients.types.js +45 -0
  16. package/lib/api/types/wizard.types.js +2 -1
  17. package/lib/api/validation-runner.js +46 -25
  18. package/lib/app/deploy-config.js +11 -1
  19. package/lib/app/deploy-status-display.js +3 -3
  20. package/lib/app/deploy.js +36 -14
  21. package/lib/app/display.js +15 -11
  22. package/lib/app/push.js +46 -23
  23. package/lib/app/register.js +1 -1
  24. package/lib/app/restart-display.js +95 -0
  25. package/lib/app/rotate-secret.js +1 -1
  26. package/lib/app/run-container-start.js +12 -6
  27. package/lib/app/run-env-compose.js +30 -1
  28. package/lib/app/run-helpers.js +44 -12
  29. package/lib/app/run-reload-sync.js +148 -0
  30. package/lib/app/run-resolve-image.js +51 -1
  31. package/lib/app/run.js +99 -73
  32. package/lib/build/index.js +75 -45
  33. package/lib/cli/doctor-check.js +117 -0
  34. package/lib/cli/index.js +8 -2
  35. package/lib/cli/infra-guided.js +445 -0
  36. package/lib/cli/setup-app.help.js +1 -1
  37. package/lib/cli/setup-app.js +20 -2
  38. package/lib/cli/setup-app.test-commands.js +9 -5
  39. package/lib/cli/setup-auth.js +26 -0
  40. package/lib/cli/setup-dev-path-commands.js +50 -3
  41. package/lib/cli/setup-infra.js +138 -61
  42. package/lib/cli/setup-integration-client.js +182 -0
  43. package/lib/cli/setup-parameters.js +21 -2
  44. package/lib/cli/setup-platform.js +102 -0
  45. package/lib/cli/setup-secrets.js +18 -6
  46. package/lib/cli/setup-utility.js +97 -33
  47. package/lib/commands/datasource-capability-dimension-cli.js +128 -0
  48. package/lib/commands/datasource-capability-output.js +29 -0
  49. package/lib/commands/datasource-capability-relate-cli.js +140 -0
  50. package/lib/commands/datasource-capability.js +411 -0
  51. package/lib/commands/datasource-unified-test-cli.options.js +1 -1
  52. package/lib/commands/datasource.js +53 -13
  53. package/lib/commands/dev-down.js +3 -3
  54. package/lib/commands/dev-infra-gate.js +32 -0
  55. package/lib/commands/dev-init.js +13 -7
  56. package/lib/commands/dimension-value.js +179 -0
  57. package/lib/commands/dimension.js +330 -0
  58. package/lib/commands/integration-client.js +430 -0
  59. package/lib/commands/login-device.js +65 -30
  60. package/lib/commands/login.js +21 -10
  61. package/lib/commands/parameters-validate.js +78 -13
  62. package/lib/commands/repair-datasource-auto-rbac.js +166 -0
  63. package/lib/commands/repair-datasource-keys.js +10 -5
  64. package/lib/commands/repair-datasource.js +19 -7
  65. package/lib/commands/repair-env-template.js +4 -1
  66. package/lib/commands/repair-openapi-sync.js +172 -0
  67. package/lib/commands/repair-persist.js +102 -0
  68. package/lib/commands/repair-rbac-extract.js +27 -0
  69. package/lib/commands/repair-rbac-migrate.js +186 -0
  70. package/lib/commands/repair-rbac.js +225 -19
  71. package/lib/commands/repair-system-alignment.js +246 -0
  72. package/lib/commands/repair-system-permissions.js +168 -0
  73. package/lib/commands/repair.js +120 -354
  74. package/lib/commands/secure.js +1 -1
  75. package/lib/commands/setup-modes.js +455 -0
  76. package/lib/commands/setup-prompts.js +388 -0
  77. package/lib/commands/setup.js +149 -0
  78. package/lib/commands/teardown.js +228 -0
  79. package/lib/commands/test-e2e-external.js +4 -3
  80. package/lib/commands/up-common.js +97 -12
  81. package/lib/commands/up-dataplane.js +33 -11
  82. package/lib/commands/up-miso.js +7 -11
  83. package/lib/commands/upload.js +109 -23
  84. package/lib/commands/wizard-core-helpers.js +14 -11
  85. package/lib/commands/wizard-core.js +58 -15
  86. package/lib/commands/wizard-dataplane.js +2 -2
  87. package/lib/commands/wizard-entity-selection.js +72 -14
  88. package/lib/commands/wizard-headless.js +7 -3
  89. package/lib/commands/wizard-helpers.js +13 -1
  90. package/lib/commands/wizard.js +210 -61
  91. package/lib/constants/infra-compose-service-names.js +40 -0
  92. package/lib/core/env-reader.js +16 -3
  93. package/lib/core/secrets-admin-env.js +101 -0
  94. package/lib/core/secrets-ensure-infra.js +34 -1
  95. package/lib/core/secrets-ensure.js +88 -66
  96. package/lib/core/secrets-env-content.js +432 -0
  97. package/lib/core/secrets-env-write.js +27 -1
  98. package/lib/core/secrets-load.js +248 -0
  99. package/lib/core/secrets-names.js +32 -0
  100. package/lib/core/secrets.js +17 -757
  101. package/lib/datasource/capability/basic-exposure.js +76 -0
  102. package/lib/datasource/capability/capability-diff-slice.js +41 -0
  103. package/lib/datasource/capability/capability-key.js +34 -0
  104. package/lib/datasource/capability/capability-resolve.js +172 -0
  105. package/lib/datasource/capability/capability-storage-keys.js +22 -0
  106. package/lib/datasource/capability/copy-operations.js +348 -0
  107. package/lib/datasource/capability/copy-test-payload.js +139 -0
  108. package/lib/datasource/capability/create-operations.js +235 -0
  109. package/lib/datasource/capability/dimension-operations.js +151 -0
  110. package/lib/datasource/capability/dimension-validate.js +219 -0
  111. package/lib/datasource/capability/json-pointer.js +31 -0
  112. package/lib/datasource/capability/reference-rewrite.js +51 -0
  113. package/lib/datasource/capability/relate-operations.js +325 -0
  114. package/lib/datasource/capability/relate-validate.js +219 -0
  115. package/lib/datasource/capability/remove-operations.js +275 -0
  116. package/lib/datasource/capability/run-capability-copy.js +152 -0
  117. package/lib/datasource/capability/run-capability-diff.js +135 -0
  118. package/lib/datasource/capability/run-capability-dimension.js +291 -0
  119. package/lib/datasource/capability/run-capability-edit.js +377 -0
  120. package/lib/datasource/capability/run-capability-relate.js +193 -0
  121. package/lib/datasource/capability/run-capability-remove.js +105 -0
  122. package/lib/datasource/capability/templates/minimal-fetch.json +18 -0
  123. package/lib/datasource/capability/validate-capability-slice.js +35 -0
  124. package/lib/datasource/list.js +136 -23
  125. package/lib/datasource/log-viewer.js +2 -4
  126. package/lib/datasource/unified-validation-run.js +51 -16
  127. package/lib/datasource/validate.js +53 -1
  128. package/lib/deployment/deploy-poll-ui.js +60 -0
  129. package/lib/deployment/deployer-status.js +29 -3
  130. package/lib/deployment/deployer.js +48 -30
  131. package/lib/deployment/environment.js +7 -2
  132. package/lib/deployment/poll-interval.js +72 -0
  133. package/lib/deployment/push.js +11 -9
  134. package/lib/external-system/deploy.js +4 -1
  135. package/lib/external-system/download.js +61 -32
  136. package/lib/external-system/sync-deploy-manifest.js +33 -0
  137. package/lib/generator/wizard-prompts.js +7 -1
  138. package/lib/generator/wizard.js +34 -0
  139. package/lib/infrastructure/index.js +49 -19
  140. package/lib/infrastructure/orphan-infra-docker-teardown.js +177 -0
  141. package/lib/parameters/infra-kv-discovery.js +29 -4
  142. package/lib/parameters/infra-parameter-catalog.js +6 -3
  143. package/lib/parameters/infra-parameter-validate.js +67 -19
  144. package/lib/resolvers/datasource-resolver.js +53 -0
  145. package/lib/resolvers/dimension-file.js +52 -0
  146. package/lib/resolvers/manifest-resolver.js +133 -0
  147. package/lib/schema/external-datasource.schema.json +183 -53
  148. package/lib/schema/external-system.schema.json +23 -10
  149. package/lib/schema/infra.parameter.yaml +26 -11
  150. package/lib/schema/wizard-config.schema.json +2 -2
  151. package/lib/utils/aifabrix-config-dir-walk.js +40 -0
  152. package/lib/utils/aifabrix-runtime-config-dir.js +26 -3
  153. package/lib/utils/app-run-containers.js +2 -2
  154. package/lib/utils/bash-secret-env.js +59 -0
  155. package/lib/utils/cli-secrets-error-format.js +78 -0
  156. package/lib/utils/cli-test-layout-chalk.js +31 -9
  157. package/lib/utils/cli-utils.js +4 -36
  158. package/lib/utils/datasource-test-run-display.js +8 -0
  159. package/lib/utils/dev-hosts-helper.js +3 -2
  160. package/lib/utils/dev-init-ssh-merge.js +2 -1
  161. package/lib/utils/docker-build.js +17 -9
  162. package/lib/utils/docker-reload-mount.js +127 -0
  163. package/lib/utils/external-readme.js +117 -4
  164. package/lib/utils/external-system-local-test-tty.js +3 -2
  165. package/lib/utils/external-system-readiness-core.js +45 -12
  166. package/lib/utils/external-system-readiness-deploy-display.js +3 -3
  167. package/lib/utils/external-system-readiness-display-internals.js +33 -3
  168. package/lib/utils/external-system-readiness-display.js +10 -1
  169. package/lib/utils/file-upload.js +40 -3
  170. package/lib/utils/health-check-db-init.js +107 -0
  171. package/lib/utils/health-check-public-warn.js +69 -0
  172. package/lib/utils/health-check-url.js +19 -4
  173. package/lib/utils/health-check.js +135 -105
  174. package/lib/utils/help-builder.js +5 -1
  175. package/lib/utils/image-name.js +34 -7
  176. package/lib/utils/integration-file-backup.js +74 -0
  177. package/lib/utils/mutagen-install.js +30 -3
  178. package/lib/utils/paths.js +108 -25
  179. package/lib/utils/postgres-wipe.js +212 -0
  180. package/lib/utils/register-aifabrix-shell-env.js +15 -0
  181. package/lib/utils/remote-dev-auth.js +21 -5
  182. package/lib/utils/remote-docker-env.js +9 -1
  183. package/lib/utils/remote-secrets-loader.js +42 -3
  184. package/lib/utils/resolve-docker-image-ref.js +9 -3
  185. package/lib/utils/secrets-ancestor-paths.js +47 -0
  186. package/lib/utils/secrets-helpers.js +17 -10
  187. package/lib/utils/secrets-kv-refs.js +42 -0
  188. package/lib/utils/secrets-kv-scope.js +19 -2
  189. package/lib/utils/secrets-materialize-local.js +134 -0
  190. package/lib/utils/secrets-path.js +24 -10
  191. package/lib/utils/secrets-utils.js +2 -2
  192. package/lib/utils/system-builder-root.js +34 -0
  193. package/lib/utils/url-declarative-resolve-build.js +6 -1
  194. package/lib/utils/url-declarative-runtime-base-path.js +32 -0
  195. package/lib/utils/url-declarative-vdir-inactive-env.js +2 -1
  196. package/lib/utils/urls-local-registry.js +73 -20
  197. package/lib/utils/validation-poll-ui.js +81 -0
  198. package/lib/utils/validation-run-poll.js +29 -5
  199. package/lib/utils/with-muted-logger.js +53 -0
  200. package/package.json +1 -1
  201. package/templates/applications/dataplane/application.yaml +1 -1
  202. package/templates/applications/dataplane/rbac.yaml +10 -10
  203. package/templates/applications/keycloak/env.template +8 -6
  204. package/templates/applications/miso-controller/application.yaml +7 -0
  205. package/templates/applications/miso-controller/env.template +7 -7
  206. package/templates/applications/miso-controller/rbac.yaml +9 -9
  207. package/templates/external-system/README.md.hbs +89 -102
  208. package/.nyc_output/55e9d034-ddab-4579-a706-e02a91d75c91.json +0 -1
  209. package/.nyc_output/processinfo/55e9d034-ddab-4579-a706-e02a91d75c91.json +0 -1
  210. package/.nyc_output/processinfo/index.json +0 -1
  211. package/lib/api/service-users.api.js +0 -150
  212. package/lib/api/types/service-users.types.js +0 -65
  213. package/lib/cli/setup-service-user.js +0 -187
  214. package/lib/commands/service-user.js +0 -429
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Decide whether `aifabrix run --reload` can bind-mount workspace without Mutagen.
3
+ * Mutagen is only needed when the CLI filesystem and the Docker engine host differ.
4
+ *
5
+ * @fileoverview Co-located Docker detection for reload mounts
6
+ * @author AI Fabrix Team
7
+ * @version 2.0.0
8
+ */
9
+
10
+ 'use strict';
11
+
12
+ const os = require('os');
13
+
14
+ /**
15
+ * @param {string} host
16
+ * @returns {string}
17
+ */
18
+ function normalizeHost(host) {
19
+ return String(host || '')
20
+ .trim()
21
+ .toLowerCase()
22
+ .replace(/^\[|\]$/g, '');
23
+ }
24
+
25
+ /**
26
+ * @param {string} host
27
+ * @returns {boolean}
28
+ */
29
+ function isLocalLoopbackHost(host) {
30
+ const h = normalizeHost(host);
31
+ return h === 'localhost' || h === '127.0.0.1' || h === '::1' || h === '0:0:0:0:0:0:0:1';
32
+ }
33
+
34
+ /**
35
+ * @param {string} rest - after "tcp://"
36
+ * @returns {string|null}
37
+ */
38
+ function tcpHostFromRest(rest) {
39
+ if (rest.startsWith('[')) {
40
+ const end = rest.indexOf(']');
41
+ if (end === -1) return null;
42
+ return normalizeHost(rest.slice(1, end));
43
+ }
44
+ const hostPort = rest.split('/')[0];
45
+ const colonIdx = hostPort.lastIndexOf(':');
46
+ if (colonIdx === -1) {
47
+ return normalizeHost(hostPort);
48
+ }
49
+ const maybePort = hostPort.slice(colonIdx + 1);
50
+ if (/^\d+$/.test(maybePort)) {
51
+ return normalizeHost(hostPort.slice(0, colonIdx));
52
+ }
53
+ return normalizeHost(hostPort);
54
+ }
55
+
56
+ /**
57
+ * Extract host from docker-endpoint (tcp, optional URL form).
58
+ * @param {string} endpoint
59
+ * @returns {string|null} normalized host, or null for unix socket paths / empty
60
+ */
61
+ function extractHostFromDockerEndpoint(endpoint) {
62
+ const s = String(endpoint || '').trim();
63
+ if (!s) return null;
64
+ const lower = s.toLowerCase();
65
+ if (lower.startsWith('unix:')) {
66
+ return null;
67
+ }
68
+ if (lower.startsWith('tcp://')) {
69
+ return tcpHostFromRest(s.slice(6));
70
+ }
71
+ try {
72
+ const u = new URL(s.includes('://') ? s : `tcp://${s}`);
73
+ return normalizeHost(u.hostname);
74
+ } catch {
75
+ return null;
76
+ }
77
+ }
78
+
79
+ /**
80
+ * sync-ssh-host localhost check (same rules as run.js isLocalhostHost for reload gate).
81
+ * @param {string} host
82
+ * @returns {boolean}
83
+ */
84
+ function isLocalhostSyncSshHost(host) {
85
+ if (!host || typeof host !== 'string') return false;
86
+ const h = host.trim().toLowerCase();
87
+ return h === 'localhost' || h === '127.0.0.1';
88
+ }
89
+
90
+ /**
91
+ * True when docker API host is this machine (first label or FQDN match).
92
+ * @param {string} dockerHost - from extractHostFromDockerEndpoint
93
+ * @returns {boolean}
94
+ */
95
+ function hostnameMatchesDockerHost(dockerHost) {
96
+ const h = normalizeHost(dockerHost);
97
+ if (!h) return true;
98
+ if (isLocalLoopbackHost(h)) return true;
99
+ const hn = normalizeHost(os.hostname());
100
+ if (h === hn) return true;
101
+ const hnShort = hn.split('.')[0];
102
+ const hShort = h.split('.')[0];
103
+ if (h === hnShort || hn === hShort) return true;
104
+ return false;
105
+ }
106
+
107
+ /**
108
+ * When true, use a direct bind mount for --reload; Mutagen is not required.
109
+ * @param {string|null|undefined} dockerEndpoint - config `docker-endpoint`
110
+ * @returns {boolean}
111
+ */
112
+ function isReloadBindMountOnEngineHost(dockerEndpoint) {
113
+ const e = String(dockerEndpoint || '').trim();
114
+ if (!e) return true;
115
+ if (e.toLowerCase().startsWith('unix:')) return true;
116
+ const host = extractHostFromDockerEndpoint(e);
117
+ if (host === null) {
118
+ return false;
119
+ }
120
+ return hostnameMatchesDockerHost(host);
121
+ }
122
+
123
+ module.exports = {
124
+ extractHostFromDockerEndpoint,
125
+ isReloadBindMountOnEngineHost,
126
+ isLocalhostSyncSshHost
127
+ };
@@ -15,6 +15,72 @@ const path = require('path');
15
15
  const handlebars = require('handlebars');
16
16
  const { getProjectRoot } = require('./paths');
17
17
 
18
+ /**
19
+ * Extracts secret keys from integration/<appName>/env.template when present.
20
+ * Looks for values like `kv://systemKey/apiKey` and returns that key for
21
+ * `aifabrix secret set <key> <value>`.
22
+ *
23
+ * @param {object} args
24
+ * @param {string} args.projectRoot
25
+ * @param {string} args.appName
26
+ * @returns {Array<{path: string, description: string}>}
27
+ */
28
+ function extractSecretPathsFromEnvTemplate({ projectRoot, appName }) {
29
+ if (!projectRoot || !appName) return [];
30
+
31
+ const envTemplatePath = path.join(projectRoot, 'integration', appName, 'env.template');
32
+ if (!fs.existsSync(envTemplatePath)) return [];
33
+
34
+ const raw = _tryReadTextOrNull(envTemplatePath);
35
+ if (raw === null) return [];
36
+ if (typeof raw !== 'string') return [];
37
+
38
+ function parseSecretKey(value) {
39
+ if (!value || typeof value !== 'string') return null;
40
+ const kvIdx = value.indexOf('kv://');
41
+ if (kvIdx === -1) return null;
42
+ const after = value.slice(kvIdx + 'kv://'.length);
43
+ const key = after.split(/[ \t#]/)[0]?.trim();
44
+ return key || null;
45
+ }
46
+
47
+ function parseLine(line) {
48
+ const trimmed = line.trim();
49
+ if (!trimmed || trimmed.startsWith('#')) return null;
50
+ const eqIdx = trimmed.indexOf('=');
51
+ if (eqIdx <= 0) return null;
52
+ const varName = trimmed.slice(0, eqIdx).trim();
53
+ const value = trimmed.slice(eqIdx + 1).trim();
54
+ const key = parseSecretKey(value);
55
+ if (!key) return null;
56
+ return { varName: varName || 'Secret', key };
57
+ }
58
+
59
+ const out = [];
60
+ const seen = new Set();
61
+
62
+ for (const line of raw.split('\n')) {
63
+ const parsed = parseLine(line);
64
+ if (!parsed) continue;
65
+ const dedupeKey = `${parsed.varName}::${parsed.key}`;
66
+ if (seen.has(dedupeKey)) continue;
67
+ seen.add(dedupeKey);
68
+ out.push({ path: parsed.key, description: parsed.varName });
69
+ }
70
+
71
+ return out;
72
+ }
73
+
74
+ function _tryReadTextOrNull(p) {
75
+ try {
76
+ return fs.readFileSync(p, 'utf8');
77
+ } catch (e) {
78
+ // Some Jest suites partially mock fs.existsSync; treat missing files as absent templates.
79
+ if (e && e.code === 'ENOENT') return null;
80
+ throw e;
81
+ }
82
+ }
83
+
18
84
  /**
19
85
  * Formats a display name from a key
20
86
  * @param {string} key - System or app key
@@ -137,17 +203,51 @@ function rbacOptionalFilename(normalizedExt) {
137
203
  return normalizedExt === '.yaml' || normalizedExt === '.yml' ? 'rbac.yaml' : 'rbac.json';
138
204
  }
139
205
 
206
+ /**
207
+ * Word-wraps plain description text for Markdown (MD013 ~80 columns). Keeps blank
208
+ * lines between paragraphs.
209
+ * @param {string} text - Raw description
210
+ * @param {number} [maxLen=80] - Target max line length
211
+ * @returns {string}
212
+ */
213
+ function wrapPlainTextForMarkdown(text, maxLen = 80) {
214
+ if (!text || typeof text !== 'string') return text;
215
+ const blocks = text.split(/\n\s*\n/);
216
+ const wrapped = blocks.map((block) => {
217
+ const flat = block.replace(/\s+/g, ' ').trim();
218
+ if (!flat) return '';
219
+ const words = flat.split(' ');
220
+ const lines = [];
221
+ let current = '';
222
+ for (const w of words) {
223
+ const candidate = current ? `${current} ${w}` : w;
224
+ if (candidate.length <= maxLen) {
225
+ current = candidate;
226
+ } else {
227
+ if (current) lines.push(current);
228
+ current = w;
229
+ }
230
+ }
231
+ if (current) lines.push(current);
232
+ return lines.join('\n');
233
+ });
234
+ return wrapped.filter(Boolean).join('\n\n');
235
+ }
236
+
140
237
  function buildExternalReadmeContext(params = {}) {
141
238
  const appName = params.appName || params.systemKey || 'external-system';
142
239
  const systemKey = params.systemKey || appName;
143
240
  const displayName = params.displayName || formatDisplayName(systemKey);
144
- const description = params.description || `External system integration for ${systemKey}`;
241
+ const rawDescription = params.description || `External system integration for ${systemKey}`;
242
+ const description = wrapPlainTextForMarkdown(rawDescription);
145
243
  const systemType = params.systemType || 'openapi';
146
244
  const fileExt = params.fileExt !== undefined ? params.fileExt : '.json';
147
245
  const normalizedExt = fileExt && fileExt.startsWith('.') ? fileExt : `.${fileExt || 'json'}`;
148
246
  const datasources = normalizeDatasources(params.datasources, systemKey, fileExt);
149
247
  const authType = params.authType || params.authentication?.type || params.authentication?.method || params.authentication?.authType;
150
- const secretPaths = buildSecretPaths(systemKey, authType);
248
+ const projectRoot = getProjectRoot();
249
+ const secretPathsFromEnv = extractSecretPathsFromEnvTemplate({ projectRoot, appName });
250
+ const secretPaths = secretPathsFromEnv.length > 0 ? secretPathsFromEnv : buildSecretPaths(systemKey, authType);
151
251
  const rbacOptionalFile = rbacOptionalFilename(normalizedExt);
152
252
 
153
253
  return {
@@ -186,13 +286,26 @@ function loadExternalReadmeTemplate() {
186
286
  * @param {Object} params - Context parameters
187
287
  * @returns {string} README content
188
288
  */
289
+ /**
290
+ * Collapses 3+ consecutive newlines to 2 (fixes MD012 from Handlebars spacing).
291
+ * @param {string} md - Markdown body
292
+ * @returns {string}
293
+ */
294
+ function collapseConsecutiveBlankLines(md) {
295
+ if (!md || typeof md !== 'string') return md;
296
+ return md.replace(/\n{3,}/g, '\n\n');
297
+ }
298
+
189
299
  function generateExternalReadmeContent(params = {}) {
190
300
  const template = loadExternalReadmeTemplate();
191
301
  const context = buildExternalReadmeContext(params);
192
- return template(context);
302
+ return collapseConsecutiveBlankLines(template(context));
193
303
  }
194
304
 
195
305
  module.exports = {
196
306
  buildExternalReadmeContext,
197
- generateExternalReadmeContent
307
+ generateExternalReadmeContent,
308
+ wrapPlainTextForMarkdown,
309
+ collapseConsecutiveBlankLines,
310
+ extractSecretPathsFromEnvTemplate
198
311
  };
@@ -19,6 +19,7 @@ const {
19
19
  headerKeyValue,
20
20
  formatStatusKeyValue,
21
21
  colorRollupPrefixedLine,
22
+ formatSuccessLine,
22
23
  metadata: metaGray
23
24
  } = require('./cli-test-layout-chalk');
24
25
 
@@ -338,7 +339,7 @@ function displayLocalExternalTestPlanLayout(results, verbose, appName) {
338
339
  function logVerboseSystemRows(systemResults) {
339
340
  for (const s of systemResults || []) {
340
341
  const ok = s.valid;
341
- logger.log(ok ? chalk.green(` ${s.file}`) : chalk.red(` ✖ ${s.file}`));
342
+ logger.log(ok ? (chalk.green(' ') + formatSuccessLine(s.file)) : chalk.red(` ✖ ${s.file}`));
342
343
  (s.errors || []).forEach(e => logger.log(chalk.red(` - ${e}`)));
343
344
  }
344
345
  }
@@ -346,7 +347,7 @@ function logVerboseSystemRows(systemResults) {
346
347
  function logVerboseDatasourceRows(datasourceResults) {
347
348
  for (const d of datasourceResults || []) {
348
349
  const ok = d.valid;
349
- logger.log(ok ? chalk.green(` ${d.key} (${d.file})`) : chalk.red(` ✖ ${d.key} (${d.file})`));
350
+ logger.log(ok ? (chalk.green(' ') + formatSuccessLine(`${d.key} (${d.file})`)) : chalk.red(` ✖ ${d.key} (${d.file})`));
350
351
  (d.errors || []).forEach(e => logger.log(chalk.red(` - ${e}`)));
351
352
  (d.warnings || []).forEach(w => logger.log(chalk.yellow(` ⚠ ${w}`)));
352
353
  if (d.fieldMappingResults && d.fieldMappingResults.mappedFields) {
@@ -43,30 +43,57 @@ function unwrapPublicationResult(res) {
43
43
  * Rules: inactive/archived → Failed; MCP expected but missing → Partial; draft → Partial; published/deployed + active → Ready.
44
44
  * @param {Object} ds - ExternalDataSourceResponse-like
45
45
  * @param {boolean} generateMcpContract - From application config
46
- * @returns {'ready'|'partial'|'failed'}
46
+ * @returns {{ tier: 'ready'|'partial'|'failed', partialReason: string|null }}
47
47
  */
48
- function classifyDatasourceTierA(ds, generateMcpContract) {
48
+ function classifyDatasourceTierADetail(ds, generateMcpContract) {
49
49
  const active = ds.isActive !== false;
50
50
  const status = String(ds.status || '').toLowerCase();
51
51
  if (!active || status === 'archived') {
52
- return 'failed';
52
+ return { tier: 'failed', partialReason: null };
53
53
  }
54
54
  if (generateMcpContract === true && !ds.mcpContract) {
55
- return 'partial';
55
+ return { tier: 'partial', partialReason: 'mcp_missing' };
56
56
  }
57
57
  if (status === 'draft') {
58
- return 'partial';
58
+ return { tier: 'partial', partialReason: 'draft' };
59
59
  }
60
60
  if (status === 'published' || status === 'deployed') {
61
- return 'ready';
61
+ return { tier: 'ready', partialReason: null };
62
+ }
63
+ return { tier: 'partial', partialReason: 'unknown_status' };
64
+ }
65
+
66
+ /**
67
+ * @param {Object} ds - ExternalDataSourceResponse-like
68
+ * @param {boolean} generateMcpContract - From application config
69
+ * @returns {'ready'|'partial'|'failed'}
70
+ */
71
+ function classifyDatasourceTierA(ds, generateMcpContract) {
72
+ return classifyDatasourceTierADetail(ds, generateMcpContract).tier;
73
+ }
74
+
75
+ /**
76
+ * Short hint for CLI when Tier A is partial (machine codes from classifyDatasourceTierADetail).
77
+ * @param {string|null} partialReason
78
+ * @returns {string}
79
+ */
80
+ function formatTierAPartialHint(partialReason) {
81
+ if (partialReason === 'mcp_missing') {
82
+ return 'no MCP contract stored (OpenAPI link or generation)';
83
+ }
84
+ if (partialReason === 'draft') {
85
+ return 'status draft (trigger paths / certification gate)';
62
86
  }
63
- return 'partial';
87
+ if (partialReason === 'unknown_status') {
88
+ return 'unexpected lifecycle status';
89
+ }
90
+ return '';
64
91
  }
65
92
 
66
93
  /**
67
94
  * @param {Array<Object>} datasources - Datasource list
68
95
  * @param {boolean} generateMcpContract
69
- * @returns {{ rows: Array<{ key: string, tier: string }>, ready: number, partial: number, failed: number }}
96
+ * @returns {{ rows: Array<{ key: string, tier: string, partialReason?: string|null }>, ready: number, partial: number, failed: number }}
70
97
  */
71
98
  function summarizeDatasourceTiersA(datasources, generateMcpContract) {
72
99
  const rows = [];
@@ -75,10 +102,14 @@ function summarizeDatasourceTiersA(datasources, generateMcpContract) {
75
102
  let failed = 0;
76
103
  for (const ds of datasources || []) {
77
104
  const key = ds.key || ds.sourceKey || 'unknown';
78
- const tier = classifyDatasourceTierA(ds, generateMcpContract);
79
- rows.push({ key, tier });
80
- if (tier === 'ready') ready += 1;
81
- else if (tier === 'partial') partial += 1;
105
+ const detail = classifyDatasourceTierADetail(ds, generateMcpContract);
106
+ const row = { key, tier: detail.tier };
107
+ if (detail.tier === 'partial' && detail.partialReason) {
108
+ row.partialReason = detail.partialReason;
109
+ }
110
+ rows.push(row);
111
+ if (detail.tier === 'ready') ready += 1;
112
+ else if (detail.tier === 'partial') partial += 1;
82
113
  else failed += 1;
83
114
  }
84
115
  return { rows, ready, partial, failed };
@@ -401,7 +432,9 @@ module.exports = {
401
432
  unwrapApiData,
402
433
  unwrapPublicationResult,
403
434
  isPublicationResultShape,
435
+ classifyDatasourceTierADetail,
404
436
  classifyDatasourceTierA,
437
+ formatTierAPartialHint,
405
438
  summarizeDatasourceTiersA,
406
439
  aggregateVerdictFromCounts,
407
440
  classifyDatasourceTierB,
@@ -83,7 +83,7 @@ function logDeployProbeDatasourceSection(probeData) {
83
83
  * @param {Object|null} systemFromDataplane
84
84
  * @param {boolean} genMcp
85
85
  */
86
- function logDeployContractsSection(systemFromDataplane, genMcp) {
86
+ function logDeployContractsSection(systemFromDataplane, genMcp, dataplaneUrl, systemKey) {
87
87
  if (!systemFromDataplane) return;
88
88
  logSeparator();
89
89
  logSectionTitle('Contracts:');
@@ -94,7 +94,7 @@ function logDeployContractsSection(systemFromDataplane, genMcp) {
94
94
  } else {
95
95
  logger.log(chalk.gray('○ OpenAPI docs URL not available'));
96
96
  }
97
- logDocsBlock(systemFromDataplane);
97
+ logDocsBlock(systemFromDataplane, { dataplaneUrl, systemKey, genMcp: mcpOk });
98
98
  }
99
99
 
100
100
  /**
@@ -260,7 +260,7 @@ function logDeployReadinessSummary(ctx) {
260
260
 
261
261
  logDeployIdentityAndCredentialBlocks(systemCfg, !!probeData);
262
262
 
263
- logDeployContractsSection(systemFromDataplane, genMcp);
263
+ logDeployContractsSection(systemFromDataplane, genMcp, dataplaneUrl, systemKey);
264
264
  logDeployNextActionsSection(systemKey, probeData, summary, genMcp);
265
265
  }
266
266
 
@@ -9,7 +9,11 @@
9
9
  const chalk = require('chalk');
10
10
  const { failureGlyph, successGlyph } = require('./cli-test-layout-chalk');
11
11
  const logger = require('./logger');
12
- const { extractIdentitySummary, resolveCredentialTestEndpointDisplay } = require('./external-system-readiness-core');
12
+ const {
13
+ extractIdentitySummary,
14
+ resolveCredentialTestEndpointDisplay,
15
+ formatTierAPartialHint
16
+ } = require('./external-system-readiness-core');
13
17
 
14
18
  const SEP = chalk.gray('────────────────────────────────');
15
19
 
@@ -56,8 +60,12 @@ function logDatasourceTable(rows, counts, title) {
56
60
  logSectionTitle(title && String(title).trim() ? String(title).trim() : 'Datasources:');
57
61
  for (const r of rows) {
58
62
  const statusLabel = r.tier === 'ready' ? 'Ready' : r.tier === 'failed' ? 'Failed' : 'Partial';
63
+ const hint =
64
+ r.tier === 'partial' && r.partialReason
65
+ ? chalk.gray(` — ${formatTierAPartialHint(r.partialReason)}`)
66
+ : '';
59
67
  logger.log(
60
- `${tierGlyph(r.tier)} ${r.key.padEnd(14, ' ')} ${chalk.gray('(' + statusLabel + ')')}`
68
+ `${tierGlyph(r.tier)} ${r.key.padEnd(14, ' ')} ${chalk.gray('(' + statusLabel + ')')}${hint}`
61
69
  );
62
70
  }
63
71
  logger.log('');
@@ -120,14 +128,35 @@ function logNextActions(actions, extraLine) {
120
128
  }
121
129
  }
122
130
 
131
+ /**
132
+ * Dataplane serves MCP OpenAPI docs at /api/v1/mcp/{systemKey}/docs.
133
+ *
134
+ * @param {Object} sys - ExternalSystemResponse
135
+ * @param {{ dataplaneUrl?: string, systemKey?: string, genMcp?: boolean }} [opts]
136
+ * @returns {string|null}
137
+ */
138
+ function deriveMcpDocsPageUrl(sys, opts) {
139
+ if (!sys || !opts) return null;
140
+ if (sys.mcpDocsPageUrl) return String(sys.mcpDocsPageUrl);
141
+ const { dataplaneUrl, systemKey, genMcp } = opts;
142
+ if (!genMcp || !dataplaneUrl || !systemKey) return null;
143
+ if (!sys.mcpServerUrl) return null;
144
+ if (!sys.openApiDocsPageUrl && !sys.apiDocumentUrl) return null;
145
+ const base = String(dataplaneUrl).replace(/\/+$/, '');
146
+ return `${base}/api/v1/mcp/${encodeURIComponent(systemKey)}/docs`;
147
+ }
148
+
123
149
  /**
124
150
  * @param {Object} sys - ExternalSystemResponse
151
+ * @param {{ dataplaneUrl?: string, systemKey?: string, genMcp?: boolean }} [opts]
125
152
  */
126
- function logDocsBlock(sys) {
153
+ function logDocsBlock(sys, opts) {
127
154
  if (!sys) return;
128
155
  const urls = [];
129
156
  if (sys.openApiDocsPageUrl) urls.push({ label: 'OpenAPI Docs Page', url: sys.openApiDocsPageUrl });
130
157
  if (sys.apiDocumentUrl) urls.push({ label: 'API Docs', url: sys.apiDocumentUrl });
158
+ const mcpDocs = deriveMcpDocsPageUrl(sys, opts);
159
+ if (mcpDocs) urls.push({ label: 'MCP Docs Page', url: mcpDocs });
131
160
  if (sys.mcpServerUrl) urls.push({ label: 'MCP Server', url: sys.mcpServerUrl });
132
161
  if (urls.length === 0) return;
133
162
  logSeparator();
@@ -147,5 +176,6 @@ module.exports = {
147
176
  logIdentityBlock,
148
177
  logCredentialIntentBlock,
149
178
  logNextActions,
179
+ deriveMcpDocsPageUrl,
150
180
  logDocsBlock
151
181
  };
@@ -44,7 +44,16 @@ function logPublishResultBlock(publication) {
44
44
  }
45
45
  logSeparator();
46
46
  logSectionTitle('MCP Contract:');
47
- logger.log(genMcp ? formatSuccessLine('Generated') : chalk.gray('○ Not requested (generateMcpContract false)'));
47
+ if (genMcp) {
48
+ logger.log(formatSuccessLine('Generation requested (manifest generateMcpContract)'));
49
+ logger.log(
50
+ chalk.gray(
51
+ ' Per-datasource MCP appears when dataplane stores mcpContract (OpenAPI resolves + generation succeeds).'
52
+ )
53
+ );
54
+ } else {
55
+ logger.log(chalk.gray('○ Not requested (generateMcpContract false)'));
56
+ }
48
57
  }
49
58
 
50
59
  /**
@@ -33,10 +33,10 @@ async function validateFileExists(filePath) {
33
33
  * @param {Object} additionalFields - Additional fields
34
34
  * @returns {Promise<FormData>} FormData object
35
35
  */
36
- async function buildFormData(filePath, fieldName, additionalFields) {
36
+ async function buildFormData(filePath, fieldName, additionalFields, opts = {}) {
37
37
  const formData = new FormData();
38
38
  const fileContent = await fs.readFile(filePath);
39
- const fileName = path.basename(filePath);
39
+ const fileName = opts.filenameOverride ? String(opts.filenameOverride) : path.basename(filePath);
40
40
  const fileBlob = new Blob([fileContent], { type: 'application/octet-stream' });
41
41
  formData.append(fieldName, fileBlob, fileName);
42
42
 
@@ -74,6 +74,43 @@ async function uploadFile(url, filePath, fieldName = 'file', authConfig = {}, ad
74
74
  return await client.postFormData(endpointPath, formData);
75
75
  }
76
76
 
77
+ /**
78
+ * Upload a file using multipart/form-data but override the filename sent to the server.
79
+ * Useful when the server derives a key from the upload filename.
80
+ *
81
+ * @async
82
+ * @function uploadFileAs
83
+ * @param {string} url - Full API endpoint URL
84
+ * @param {string} filePath - Path to file to upload
85
+ * @param {string} filenameOverride - Filename to present to server (e.g. 'my-key.json')
86
+ * @param {string} fieldName - Form field name for the file (default: 'file')
87
+ * @param {Object} [authConfig] - Authentication configuration
88
+ * @param {Object} [additionalFields] - Additional form fields to include
89
+ * @returns {Promise<Object>} API response
90
+ */
91
+ async function uploadFileAs(
92
+ url,
93
+ filePath,
94
+ filenameOverride,
95
+ fieldName = 'file',
96
+ authConfig = {},
97
+ additionalFields = {}
98
+ ) {
99
+ await validateFileExists(filePath);
100
+ if (!filenameOverride || typeof filenameOverride !== 'string') {
101
+ throw new Error('filenameOverride is required and must be a string');
102
+ }
103
+
104
+ const parsed = new URL(url);
105
+ const baseUrl = parsed.origin;
106
+ const endpointPath = parsed.pathname + parsed.search;
107
+
108
+ const formData = await buildFormData(filePath, fieldName, additionalFields, { filenameOverride });
109
+ const client = new ApiClient(baseUrl, authConfig);
110
+ return await client.postFormData(endpointPath, formData);
111
+ }
112
+
77
113
  module.exports = {
78
- uploadFile
114
+ uploadFile,
115
+ uploadFileAs
79
116
  };
@@ -0,0 +1,107 @@
1
+ const chalk = require('chalk');
2
+ const { formatSuccessLine } = require('./cli-test-layout-chalk');
3
+ const logger = require('./logger');
4
+ const { execWithDockerEnv } = require('./docker-exec');
5
+
6
+ /**
7
+ * Checks if db-init container exists.
8
+ * @async
9
+ * @param {string} dbInitContainer
10
+ * @returns {Promise<boolean>}
11
+ */
12
+ async function checkDbInitContainerExists(dbInitContainer) {
13
+ try {
14
+ const { stdout } = await execWithDockerEnv(
15
+ `docker ps -a --filter "name=${dbInitContainer}" --format "{{.Names}}"`
16
+ );
17
+ return stdout.trim() === dbInitContainer;
18
+ } catch {
19
+ return false;
20
+ }
21
+ }
22
+
23
+ /**
24
+ * Gets container exit code.
25
+ * @async
26
+ * @param {string} dbInitContainer
27
+ * @returns {Promise<string>}
28
+ */
29
+ async function getContainerExitCode(dbInitContainer) {
30
+ const { stdout: exitCode } = await execWithDockerEnv(
31
+ `docker inspect --format='{{.State.ExitCode}}' ${dbInitContainer}`
32
+ );
33
+ return exitCode.trim();
34
+ }
35
+
36
+ /**
37
+ * Handles exited container status.
38
+ * @async
39
+ * @param {string} dbInitContainer
40
+ * @returns {Promise<boolean>}
41
+ */
42
+ async function handleExitedContainer(dbInitContainer) {
43
+ const { stdout: status } = await execWithDockerEnv(
44
+ `docker inspect --format='{{.State.Status}}' ${dbInitContainer}`
45
+ );
46
+ if (status.trim() === 'exited') {
47
+ const exitCode = await getContainerExitCode(dbInitContainer);
48
+ if (exitCode === '0') {
49
+ logger.log(formatSuccessLine('Database initialization already completed'));
50
+ } else {
51
+ logger.log(chalk.yellow(`⚠ Database initialization exited with code ${exitCode}`));
52
+ }
53
+ return true;
54
+ }
55
+ return false;
56
+ }
57
+
58
+ /**
59
+ * Waits for container to exit (best-effort).
60
+ * @async
61
+ * @param {string} dbInitContainer
62
+ * @param {number} maxAttempts
63
+ * @returns {Promise<void>}
64
+ */
65
+ async function waitForContainerExit(dbInitContainer, maxAttempts) {
66
+ for (let attempts = 0; attempts < maxAttempts; attempts++) {
67
+ const { stdout: currentStatus } = await execWithDockerEnv(
68
+ `docker inspect --format='{{.State.Status}}' ${dbInitContainer}`
69
+ );
70
+ if (currentStatus.trim() === 'exited') {
71
+ const exitCode = await getContainerExitCode(dbInitContainer);
72
+ if (exitCode === '0') {
73
+ logger.log(formatSuccessLine('Database initialization completed'));
74
+ } else {
75
+ logger.log(chalk.yellow(`⚠ Database initialization exited with code ${exitCode}`));
76
+ }
77
+ return;
78
+ }
79
+ await new Promise(resolve => setTimeout(resolve, 1000));
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Checks if db-init container exists and waits for it to complete.
85
+ * @async
86
+ * @param {string} appName
87
+ * @returns {Promise<void>}
88
+ */
89
+ async function waitForDbInit(appName) {
90
+ const dbInitContainer = `aifabrix-${appName}-db-init`;
91
+ try {
92
+ if (!(await checkDbInitContainerExists(dbInitContainer))) {
93
+ return;
94
+ }
95
+
96
+ if (await handleExitedContainer(dbInitContainer)) {
97
+ return;
98
+ }
99
+
100
+ logger.log(chalk.blue('Waiting for database initialization to complete...'));
101
+ await waitForContainerExit(dbInitContainer, 30);
102
+ } catch {
103
+ // db-init container might not exist, which is fine
104
+ }
105
+ }
106
+
107
+ module.exports = { waitForDbInit };