@aifabrix/builder 2.51.1 → 2.52.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 (69) hide show
  1. package/lib/app/run-container-start.js +4 -0
  2. package/lib/app/run-deploy-job.js +135 -27
  3. package/lib/app/run-env-compose.js +32 -0
  4. package/lib/app/run-env-recovery.js +35 -0
  5. package/lib/app/run-helpers.js +5 -6
  6. package/lib/app/run.js +11 -3
  7. package/lib/channels/approval-guides/chatgpt.js +12 -9
  8. package/lib/channels/channel-targets.js +18 -1
  9. package/lib/channels/chatgpt-runtime-contract.js +1 -1
  10. package/lib/cli/infra-guided-footers.js +1 -1
  11. package/lib/cli/infra-guided-public-url-warn.js +55 -0
  12. package/lib/cli/infra-guided.js +65 -56
  13. package/lib/cli/setup-app.help.js +4 -0
  14. package/lib/cli/setup-app.test-commands.js +59 -28
  15. package/lib/cli/setup-external-system.js +50 -12
  16. package/lib/commands/channel-add-setup.js +8 -2
  17. package/lib/commands/channel.js +16 -5
  18. package/lib/commands/deploy-gate-orchestrator.js +3 -2
  19. package/lib/commands/governance-verify-external.js +2 -5
  20. package/lib/commands/lifecycle-external.js +14 -7
  21. package/lib/commands/role-assistant.js +66 -8
  22. package/lib/commands/test-governance-external.js +2 -5
  23. package/lib/commands/up-miso.js +61 -24
  24. package/lib/commands/upload-data-sync-finalize.js +20 -3
  25. package/lib/commands/verify-governance-external.js +2 -5
  26. package/lib/commands/verify-operations-external.js +2 -5
  27. package/lib/commands/verify-operations-step-plan.js +31 -0
  28. package/lib/commands/verify-trust-command-action.js +2 -5
  29. package/lib/datasource/datasource-identifier.js +4 -3
  30. package/lib/datasource/integration-context.js +3 -3
  31. package/lib/datasource/resolve-app.js +90 -8
  32. package/lib/datasource/test-e2e.js +2 -2
  33. package/lib/datasource/unified-validation-run-body.js +15 -1
  34. package/lib/datasource/unified-validation-run-resolve.js +2 -2
  35. package/lib/external-system/deploy-helpers.js +9 -5
  36. package/lib/external-system/deploy.js +2 -1
  37. package/lib/external-system/test-system-level.js +21 -3
  38. package/lib/external-system/test.js +4 -4
  39. package/lib/generator/builders.js +9 -15
  40. package/lib/role-assistant/knowledge-markdown-frontmatter.js +75 -0
  41. package/lib/role-assistant/knowledge-sync.js +25 -5
  42. package/lib/role-assistant/test-runner-workhub-answers.js +44 -26
  43. package/lib/role-assistant/test-runner-workhub.js +21 -7
  44. package/lib/schema/application-schema.json +10 -28
  45. package/lib/schema/infra.parameter.yaml +0 -20
  46. package/lib/utils/certify-external-workspace.js +40 -0
  47. package/lib/utils/docker-run-ephemeral.js +77 -17
  48. package/lib/utils/health-check-public-warn.js +31 -13
  49. package/lib/utils/health-check.js +11 -1
  50. package/lib/utils/paths.js +13 -15
  51. package/lib/utils/role-assistant-paths.js +76 -14
  52. package/lib/utils/upload-sync-options.js +7 -4
  53. package/lib/validation/reserved-system-key-prefixes.js +56 -0
  54. package/lib/validation/validate.js +54 -19
  55. package/package.json +1 -1
  56. package/templates/applications/builder-api/application.yaml +3 -3
  57. package/templates/applications/dataplane/application.yaml +3 -4
  58. package/templates/applications/dataplane/env.template +2 -2
  59. package/templates/applications/dataplane/rbac.yaml +10 -10
  60. package/templates/applications/keycloak/application.yaml +2 -2
  61. package/templates/applications/keycloak/env.template +5 -2
  62. package/templates/applications/miso-controller/application.yaml +3 -3
  63. package/templates/applications/miso-controller/env.template +16 -3
  64. package/templates/channels/chatgpt/install.template.md.hbs +75 -37
  65. package/templates/channels/chatgpt/integration/README.md +3 -2
  66. package/templates/channels/chatgpt/manifest.template.json.hbs +3 -3
  67. package/templates/channels/slack/install.template.md.hbs +6 -1
  68. package/templates/python/docker-compose.hbs +10 -0
  69. package/templates/typescript/docker-compose.hbs +10 -0
@@ -27,24 +27,18 @@ function sanitizeAuthType(authType) {
27
27
  }
28
28
 
29
29
  /**
30
- * Filters configuration based on registry mode
31
- * When registryMode is "external", only DOCKER_REGISTRY_SERVER_* variables are allowed
30
+ * Historically (incorrectly) stripped all non-DOCKER_REGISTRY_* vars when
31
+ * registryMode was "external", which wiped platform app env/portal config on
32
+ * `aifabrix json`. External only means image pull source — keep full configuration.
33
+ * DOCKER_REGISTRY_SERVER_* may still appear when present in env.template.
34
+ *
32
35
  * @function filterConfigurationByRegistryMode
33
36
  * @param {Array} configuration - Environment configuration
34
- * @param {string} registryMode - Registry mode ('external' or 'internal')
35
- * @returns {Array} Filtered configuration
37
+ * @param {string} _registryMode - Registry mode ('external' or 'acr'); unused
38
+ * @returns {Array} Configuration unchanged
36
39
  */
37
- function filterConfigurationByRegistryMode(configuration, registryMode) {
38
- if (registryMode !== 'external') {
39
- return configuration;
40
- }
41
-
42
- const allowedDockerRegistryVars = [
43
- 'DOCKER_REGISTRY_SERVER_URL',
44
- 'DOCKER_REGISTRY_SERVER_USERNAME',
45
- 'DOCKER_REGISTRY_SERVER_PASSWORD'
46
- ];
47
- return configuration.filter(config => allowedDockerRegistryVars.includes(config.name));
40
+ function filterConfigurationByRegistryMode(configuration, _registryMode) {
41
+ return Array.isArray(configuration) ? configuration : [];
48
42
  }
49
43
 
50
44
  function buildImageReference(variables) {
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Parse optional YAML frontmatter from Role Assistant knowledge markdown.
3
+ *
4
+ * @fileoverview knowledge-markdown-frontmatter
5
+ * @author AI Fabrix Team
6
+ * @version 1.0.0
7
+ */
8
+
9
+ 'use strict';
10
+
11
+ const yaml = require('js-yaml');
12
+
13
+ const SEARCH_META_KEYS = [
14
+ 'aspect',
15
+ 'knowledgeType',
16
+ 'knowledgeKey',
17
+ 'title',
18
+ 'resourceType',
19
+ 'roleKey'
20
+ ];
21
+
22
+ /**
23
+ * Split markdown into optional YAML frontmatter and body.
24
+ * @param {string} content
25
+ * @returns {{ frontmatter: Object|null, body: string }}
26
+ */
27
+ function splitMarkdownFrontmatter(content) {
28
+ const text = typeof content === 'string' ? content : '';
29
+ if (!text.startsWith('---')) {
30
+ return { frontmatter: null, body: text };
31
+ }
32
+ const end = text.indexOf('\n---', 3);
33
+ if (end < 0) {
34
+ return { frontmatter: null, body: text };
35
+ }
36
+ const raw = text.slice(3, end).trim();
37
+ const body = text.slice(end + 4).replace(/^(?:\r?\n)+/, '');
38
+ if (!raw) {
39
+ return { frontmatter: {}, body };
40
+ }
41
+ try {
42
+ const parsed = yaml.load(raw);
43
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
44
+ return { frontmatter: null, body: text };
45
+ }
46
+ return { frontmatter: parsed, body };
47
+ } catch (_error) {
48
+ return { frontmatter: null, body: text };
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Project searchable metadata fields from frontmatter.
54
+ * @param {Object|null|undefined} frontmatter
55
+ * @returns {Object}
56
+ */
57
+ function knowledgeSearchMetadataFromFrontmatter(frontmatter) {
58
+ const out = {};
59
+ if (!frontmatter || typeof frontmatter !== 'object') {
60
+ return out;
61
+ }
62
+ for (const key of SEARCH_META_KEYS) {
63
+ const value = frontmatter[key];
64
+ if (typeof value === 'string' && value.trim()) {
65
+ out[key] = value.trim();
66
+ }
67
+ }
68
+ return out;
69
+ }
70
+
71
+ module.exports = {
72
+ SEARCH_META_KEYS,
73
+ splitMarkdownFrontmatter,
74
+ knowledgeSearchMetadataFromFrontmatter
75
+ };
@@ -28,6 +28,10 @@ const {
28
28
  resolveDocumentStorageKeyFromRoleAssistantSettings,
29
29
  resolveRoleAssistantEnvironmentSettings
30
30
  } = require('./settings');
31
+ const {
32
+ knowledgeSearchMetadataFromFrontmatter,
33
+ splitMarkdownFrontmatter
34
+ } = require('./knowledge-markdown-frontmatter');
31
35
 
32
36
  function resolveSystemNode(input) {
33
37
  if (!input || typeof input !== 'object') {
@@ -108,6 +112,15 @@ async function pushRoleAssistantDocumentStorageSettings(params) {
108
112
  documentStorageKey: documentStorageKey || null
109
113
  };
110
114
  }
115
+ if (documentStorageKey === '') {
116
+ // No Knowledge binding in role-assistant.yaml — skip Assistants settings write.
117
+ // Draft / not-yet-catalogued digital workers would 404 on PATCH otherwise.
118
+ return {
119
+ skipped: true,
120
+ reason: 'no-document-storage-key',
121
+ documentStorageKey: null
122
+ };
123
+ }
111
124
  if (!dataplaneUrl || !authConfig) {
112
125
  return { skipped: true, reason: 'missing-dataplane-auth' };
113
126
  }
@@ -182,20 +195,27 @@ function normalizeDocumentExternalId(knowledgeRoot, filePath) {
182
195
  }
183
196
 
184
197
  function buildDocumentRecord(knowledgeRoot, filePath) {
185
- const content = fs.readFileSync(filePath, 'utf8');
198
+ const raw = fs.readFileSync(filePath, 'utf8');
199
+ const { frontmatter, body } = splitMarkdownFrontmatter(raw);
200
+ const searchMeta = knowledgeSearchMetadataFromFrontmatter(frontmatter);
186
201
  const externalId = normalizeDocumentExternalId(knowledgeRoot, filePath);
187
- const title = path.basename(externalId);
202
+ const title =
203
+ (typeof searchMeta.title === 'string' && searchMeta.title) || path.basename(externalId);
204
+ const content = typeof body === 'string' ? body : raw;
188
205
  const contentHash = crypto.createHash('sha256').update(content, 'utf8').digest('hex');
189
206
  const metadata = {
190
207
  externalId,
191
- title,
192
208
  mimeType: 'text/markdown',
193
209
  sourcePath: path.relative(knowledgeRoot, filePath).replace(/\\/g, '/'),
194
- sourceType: 'role-assistant-markdown'
210
+ sourceType: 'role-assistant-markdown',
211
+ ...searchMeta,
212
+ title
195
213
  };
196
214
  return {
197
215
  key: externalId,
198
- name: title,
216
+ // Prefer sourcePath (*.md) so binary storage uses a known markdown extension
217
+ // instead of inventing ".guide" from path-like keys or titles.
218
+ name: metadata.sourcePath || `${externalId}.md`,
199
219
  contentHash,
200
220
  metadata,
201
221
  binaryBase64: Buffer.from(content, 'utf8').toString('base64'),
@@ -88,40 +88,58 @@ function fillFieldsFromHints(fields, hints) {
88
88
  return Object.keys(out).length > 0 ? out : undefined;
89
89
  }
90
90
 
91
- function synthesizeFromCapability(capability, hints) {
91
+ function _synthContactFields(hints) {
92
+ const out = {};
93
+ if (hints.email) {
94
+ out.email = hints.email;
95
+ }
96
+ if (hints.name || hints.email) {
97
+ out.firstname = String(hints.email || 'user').split('@')[0];
98
+ out.lastname = hints.name || 'Contact';
99
+ }
100
+ return out;
101
+ }
102
+
103
+ function _synthCustomerFields(hints) {
104
+ const out = {};
105
+ if (hints.name) {
106
+ out.name = hints.name;
107
+ }
108
+ if (hints.domain) {
109
+ out.domain = hints.domain;
110
+ }
111
+ if (hints.address) {
112
+ out.address = hints.address;
113
+ }
114
+ return out;
115
+ }
116
+
117
+ function _synthDefaultFields(hints) {
92
118
  const out = {};
119
+ if (hints.name) {
120
+ out.name = hints.name;
121
+ }
122
+ if (hints.email) {
123
+ out.email = hints.email;
124
+ }
125
+ if (hints.domain) {
126
+ out.domain = hints.domain;
127
+ }
128
+ return out;
129
+ }
130
+
131
+ function synthesizeFromCapability(capability, hints) {
132
+ let out = {};
93
133
  if (capability.includes('contact')) {
94
- if (hints.email) {
95
- out.email = hints.email;
96
- }
97
- if (hints.name || hints.email) {
98
- out.firstname = String(hints.email || 'user').split('@')[0];
99
- out.lastname = hints.name || 'Contact';
100
- }
134
+ out = _synthContactFields(hints);
101
135
  } else if (capability.includes('deal')) {
102
136
  if (hints.name) {
103
137
  out.dealname = hints.name;
104
138
  }
105
139
  } else if (capability.includes('customer') || capability.includes('compan')) {
106
- if (hints.name) {
107
- out.name = hints.name;
108
- }
109
- if (hints.domain) {
110
- out.domain = hints.domain;
111
- }
112
- if (hints.address) {
113
- out.address = hints.address;
114
- }
140
+ out = _synthCustomerFields(hints);
115
141
  } else {
116
- if (hints.name) {
117
- out.name = hints.name;
118
- }
119
- if (hints.email) {
120
- out.email = hints.email;
121
- }
122
- if (hints.domain) {
123
- out.domain = hints.domain;
124
- }
142
+ out = _synthDefaultFields(hints);
125
143
  }
126
144
  return Object.keys(out).length > 0 ? out : undefined;
127
145
  }
@@ -180,23 +180,25 @@ async function createActivateAcceptPlan(dataplaneUrl, authConfig, planBody, assi
180
180
  throw lastError || new Error('Work Hub plan create/activate/accept failed.');
181
181
  }
182
182
 
183
- async function maybeApproveTask(dataplaneUrl, authConfig, taskId, ctx) {
183
+ function _approveGateDecision(ctx) {
184
184
  if (ctx.approveStopped) {
185
- return;
185
+ return 'skip';
186
186
  }
187
187
  const status = String(ctx.status || '').trim().toLowerCase();
188
188
  if (status !== 'pendingapproval' && status !== 'pending_approval') {
189
- return;
189
+ return 'skip';
190
190
  }
191
191
  const approvalState = String(ctx.taskRow?.approvalState || '').trim().toLowerCase();
192
192
  if (approvalState && approvalState !== 'pending') {
193
- ctx.approveStopped = true;
194
- return;
193
+ return 'stop';
195
194
  }
196
195
  if ((Number(ctx.approveAttempts) || 0) >= 8) {
197
- ctx.approveStopped = true;
198
- return;
196
+ return 'stop';
199
197
  }
198
+ return 'run';
199
+ }
200
+
201
+ async function _attemptApproveTask(dataplaneUrl, authConfig, taskId, ctx) {
200
202
  try {
201
203
  await approveWorkTask(dataplaneUrl, authConfig, taskId, {});
202
204
  ctx.interactions.push({ type: 'approve', taskId, attempt: ctx.approveAttempts });
@@ -211,6 +213,18 @@ async function maybeApproveTask(dataplaneUrl, authConfig, taskId, ctx) {
211
213
  }
212
214
  }
213
215
 
216
+ async function maybeApproveTask(dataplaneUrl, authConfig, taskId, ctx) {
217
+ const gate = _approveGateDecision(ctx);
218
+ if (gate === 'skip') {
219
+ return;
220
+ }
221
+ if (gate === 'stop') {
222
+ ctx.approveStopped = true;
223
+ return;
224
+ }
225
+ await _attemptApproveTask(dataplaneUrl, authConfig, taskId, ctx);
226
+ }
227
+
214
228
  async function maybeRespondWaiting(dataplaneUrl, authConfig, taskRow, testCase, interactions) {
215
229
  const peId = extractPeFromTask(taskRow);
216
230
  if (!peId) {
@@ -7,12 +7,12 @@
7
7
  "key": "application-schema",
8
8
  "name": "Application Configuration Schema",
9
9
  "description": "JSON schema for validating AI Fabrix application configuration files",
10
- "version": "1.6.0",
10
+ "version": "1.6.1",
11
11
  "type": "schema",
12
12
  "category": "infrastructure",
13
13
  "author": "AI Fabrix Team",
14
14
  "createdAt": "2024-01-01T00:00:00Z",
15
- "updatedAt": "2026-07-28T00:00:00Z",
15
+ "updatedAt": "2026-08-03T00:00:00Z",
16
16
  "compatibility": {
17
17
  "minVersion": "1.0.0",
18
18
  "maxVersion": "2.0.0",
@@ -30,6 +30,14 @@
30
30
  "external-datasource.schema.json"
31
31
  ],
32
32
  "changelog": [
33
+ {
34
+ "version": "1.6.1",
35
+ "date": "2026-08-03T00:00:00Z",
36
+ "changes": [
37
+ "registryMode external no longer restricts configuration item names to DOCKER_REGISTRY_SERVER_*; external is image pull source only and full env/portal configuration remains valid."
38
+ ],
39
+ "breaking": false
40
+ },
33
41
  {
34
42
  "version": "1.6.0",
35
43
  "date": "2026-07-28T00:00:00Z",
@@ -974,32 +982,6 @@
974
982
  "databases"
975
983
  ]
976
984
  }
977
- },
978
- {
979
- "if": {
980
- "properties": {
981
- "registryMode": {
982
- "const": "external"
983
- }
984
- }
985
- },
986
- "then": {
987
- "properties": {
988
- "configuration": {
989
- "items": {
990
- "properties": {
991
- "name": {
992
- "enum": [
993
- "DOCKER_REGISTRY_SERVER_URL",
994
- "DOCKER_REGISTRY_SERVER_USERNAME",
995
- "DOCKER_REGISTRY_SERVER_PASSWORD"
996
- ]
997
- }
998
- }
999
- }
1000
- }
1001
- }
1002
- }
1003
985
  }
1004
986
  ]
1005
987
  }
@@ -340,26 +340,6 @@ parameters:
340
340
  vaultSecretName: dataplane-client-secretKeyVault
341
341
  notes: Per-app OAuth client secret for dataplane; generated on first ensure.
342
342
 
343
- - key: mori-controller-client-idKeyVault
344
- scope: app
345
- generator:
346
- type: literal
347
- value: 'miso-controller-dev-mori-controller'
348
- ensureOn: [upInfra, resolveApp]
349
- azure:
350
- notes: >-
351
- Per-app OAuth client id from controller registration; literal default matches
352
- {controllerId}-{environmentKey}-{applicationKey} (override per environment).
353
-
354
- - key: mori-controller-client-secretKeyVault
355
- scope: app
356
- generator:
357
- type: randomBytes32
358
- ensureOn: [upInfra, resolveApp]
359
- azure:
360
- vaultSecretName: mori-controller-client-secretKeyVault
361
- notes: Per-app OAuth client secret for mori-controller; generated on first ensure.
362
-
363
343
  # Mori / telemetry: optional locally (LICENSE_JWT via miso-controller-jwt-licenseKeyVault=DEVELOPMENT skips Mori).
364
344
  # MORI_AUTH_METHOD=kv://mori-controller-auth-methodKeyVault — local default apiKey.
365
345
  - key: mori-controller-auth-methodKeyVault
@@ -0,0 +1,40 @@
1
+ /**
2
+ * @fileoverview Workspace roots allowed for external certify / verify CLI commands.
3
+ */
4
+
5
+ 'use strict';
6
+
7
+ const CERTIFY_EXTERNAL_BASE_DIRS = new Set([
8
+ 'integration',
9
+ 'role-assistant',
10
+ 'enterprise-knowledge'
11
+ ]);
12
+
13
+ /**
14
+ * @param {string|null|undefined} baseDir
15
+ * @returns {boolean}
16
+ */
17
+ function isCertifyExternalWorkspace(baseDir) {
18
+ return CERTIFY_EXTERNAL_BASE_DIRS.has(baseDir);
19
+ }
20
+
21
+ /**
22
+ * @param {string|null|undefined} baseDir
23
+ * @param {string} commandLabel - e.g. verify-operations
24
+ * @returns {void}
25
+ * @throws {Error}
26
+ */
27
+ function assertCertifyExternalWorkspace(baseDir, commandLabel) {
28
+ if (!isCertifyExternalWorkspace(baseDir)) {
29
+ throw new Error(
30
+ `${commandLabel} applies to external authoring folders only ` +
31
+ '(integration/<systemKey>/, role-assistant/<roleKey>/, or enterprise-knowledge/<key>/).'
32
+ );
33
+ }
34
+ }
35
+
36
+ module.exports = {
37
+ CERTIFY_EXTERNAL_BASE_DIRS,
38
+ isCertifyExternalWorkspace,
39
+ assertCertifyExternalWorkspace
40
+ };
@@ -16,8 +16,12 @@ const { getDockerExecEnv } = require('./remote-docker-env');
16
16
  * @param {string} fullImage - Image:tag
17
17
  * @param {string} cmd - Shell command (passed to sh -c)
18
18
  * @param {string|null} [envFilePath] - Path for docker run --env-file
19
- * @param {{ network?: string|null, volumeMounts?: Array<{ host: string, container: string }> }} [options]
20
- * @returns {Promise<number>} Exit code
19
+ * @param {{
20
+ * network?: string|null,
21
+ * volumeMounts?: Array<{ host: string, container: string }>,
22
+ * quiet?: boolean
23
+ * }} [options] - quiet=true pipes stdio (installer-friendly; dump on failure)
24
+ * @returns {Promise<number|{ code: number, output: string }>} Exit code, or code+output when quiet
21
25
  */
22
26
  async function runDockerRunEphemeral(fullImage, cmd, envFilePath = null, options = {}) {
23
27
  const dockerEnv = await getDockerExecEnv();
@@ -26,22 +30,48 @@ async function runDockerRunEphemeral(fullImage, cmd, envFilePath = null, options
26
30
  ? options.network.trim()
27
31
  : null;
28
32
  const volumeMounts = Array.isArray(options?.volumeMounts) ? options.volumeMounts : [];
29
- return new Promise((resolve) => {
30
- const args = ['run', '--rm'];
31
- if (network) {
32
- args.push('--network', network);
33
- }
34
- if (envFilePath) {
35
- args.push('--env-file', envFilePath);
36
- }
37
- for (const mount of volumeMounts) {
38
- const host = typeof mount?.host === 'string' ? mount.host.trim() : '';
39
- const container = typeof mount?.container === 'string' ? mount.container.trim() : '';
40
- if (host && container) {
41
- args.push('-v', `${host}:${container}`);
42
- }
33
+ const quiet = options?.quiet === true;
34
+ const args = _buildDockerRunArgs(fullImage, cmd, envFilePath, network, volumeMounts);
35
+ if (quiet) {
36
+ return _spawnDockerQuiet(args, dockerEnv);
37
+ }
38
+ return _spawnDockerInherit(args, dockerEnv);
39
+ }
40
+
41
+ /**
42
+ * @param {string} fullImage
43
+ * @param {string} cmd
44
+ * @param {string|null} envFilePath
45
+ * @param {string|null} network
46
+ * @param {Array<{ host: string, container: string }>} volumeMounts
47
+ * @returns {string[]}
48
+ */
49
+ function _buildDockerRunArgs(fullImage, cmd, envFilePath, network, volumeMounts) {
50
+ const args = ['run', '--rm'];
51
+ if (network) {
52
+ args.push('--network', network);
53
+ }
54
+ if (envFilePath) {
55
+ args.push('--env-file', envFilePath);
56
+ }
57
+ for (const mount of volumeMounts) {
58
+ const host = typeof mount?.host === 'string' ? mount.host.trim() : '';
59
+ const container = typeof mount?.container === 'string' ? mount.container.trim() : '';
60
+ if (host && container) {
61
+ args.push('-v', `${host}:${container}`);
43
62
  }
44
- args.push(fullImage, 'sh', '-c', cmd);
63
+ }
64
+ args.push(fullImage, 'sh', '-c', cmd);
65
+ return args;
66
+ }
67
+
68
+ /**
69
+ * @param {string[]} args
70
+ * @param {NodeJS.ProcessEnv} dockerEnv
71
+ * @returns {Promise<number>}
72
+ */
73
+ function _spawnDockerInherit(args, dockerEnv) {
74
+ return new Promise((resolve) => {
45
75
  const proc = spawn('docker', args, {
46
76
  stdio: 'inherit',
47
77
  shell: false,
@@ -52,4 +82,34 @@ async function runDockerRunEphemeral(fullImage, cmd, envFilePath = null, options
52
82
  });
53
83
  }
54
84
 
85
+ /**
86
+ * @param {string[]} args
87
+ * @param {NodeJS.ProcessEnv} dockerEnv
88
+ * @returns {Promise<{ code: number, output: string }>}
89
+ */
90
+ function _spawnDockerQuiet(args, dockerEnv) {
91
+ return new Promise((resolve) => {
92
+ const proc = spawn('docker', args, {
93
+ stdio: ['ignore', 'pipe', 'pipe'],
94
+ shell: false,
95
+ env: dockerEnv
96
+ });
97
+ const chunks = [];
98
+ const push = (buf) => {
99
+ if (buf && buf.length) {
100
+ chunks.push(buf);
101
+ }
102
+ };
103
+ proc.stdout.on('data', push);
104
+ proc.stderr.on('data', push);
105
+ proc.on('close', (code) => {
106
+ resolve({
107
+ code: code !== null ? code : 1,
108
+ output: Buffer.concat(chunks).toString('utf8')
109
+ });
110
+ });
111
+ proc.on('error', () => resolve({ code: 1, output: '' }));
112
+ });
113
+ }
114
+
55
115
  module.exports = { runDockerRunEphemeral };
@@ -38,32 +38,50 @@ async function filterTraefikUrlByDns(traefikUrl, debug, isHostnameResolvableFn)
38
38
  * @param {string} [p.skippedPublicHealthUrl]
39
39
  * @param {string[]} [p.urlsToTry]
40
40
  * @param {number} p.resolvedIndex
41
+ * @returns {string|null} Warning message without leading glyph, or null
41
42
  */
42
- function logPublicHealthUrlWarningIfNeeded(p) {
43
+ function buildPublicHealthUrlWarningMessage(p) {
43
44
  const { skippedPublicHealthUrl, urlsToTry, resolvedIndex } = p;
44
- if (typeof resolvedIndex !== 'number' || resolvedIndex < 0) return;
45
+ if (typeof resolvedIndex !== 'number' || resolvedIndex < 0) return null;
45
46
 
46
47
  if (skippedPublicHealthUrl) {
47
- logger.log(
48
- chalk.yellow(
49
- `⚠ Public URL was not verified (DNS): ${skippedPublicHealthUrl}. ` +
50
- 'The application reported healthy via localhost only. Validate DNS names and Traefik routing for this host.'
51
- )
48
+ return (
49
+ `Public URL was not verified (DNS): ${skippedPublicHealthUrl}. ` +
50
+ 'The application reported healthy via localhost only. Validate DNS names and Traefik routing for this host.'
52
51
  );
53
- return;
54
52
  }
55
53
  if (Array.isArray(urlsToTry) && urlsToTry.length > 1 && resolvedIndex > 0) {
56
54
  const pub = urlsToTry[0];
57
- logger.log(
58
- chalk.yellow(
59
- `⚠ Public URL was not verified: ${pub}. ` +
60
- 'Health checks succeeded via localhost only. Validate Traefik routing, TLS, and DNS.'
61
- )
55
+ return (
56
+ `Public URL was not verified: ${pub}. ` +
57
+ 'Health checks succeeded via localhost only. Validate Traefik routing, TLS, and DNS.'
62
58
  );
63
59
  }
60
+ return null;
61
+ }
62
+
63
+ /**
64
+ * @param {Object} p
65
+ * @param {string} [p.skippedPublicHealthUrl]
66
+ * @param {string[]} [p.urlsToTry]
67
+ * @param {number} p.resolvedIndex
68
+ * @param {boolean} [p.defer]
69
+ * @param {(message: string) => void} [p.onDeferred]
70
+ */
71
+ function logPublicHealthUrlWarningIfNeeded(p) {
72
+ const message = buildPublicHealthUrlWarningMessage(p);
73
+ if (!message) return;
74
+ if (p && p.defer === true) {
75
+ if (typeof p.onDeferred === 'function') {
76
+ p.onDeferred(message);
77
+ }
78
+ return;
79
+ }
80
+ logger.log(chalk.yellow(`⚠ ${message}`));
64
81
  }
65
82
 
66
83
  module.exports = {
67
84
  filterTraefikUrlByDns,
85
+ buildPublicHealthUrlWarningMessage,
68
86
  logPublicHealthUrlWarningIfNeeded
69
87
  };
@@ -339,6 +339,14 @@ function resolveHealthCheckPollOptions(runOptions = {}) {
339
339
  if (typeof runOptions.onPollText === 'function') {
340
340
  pollOptions.onPollText = runOptions.onPollText;
341
341
  }
342
+ const defer =
343
+ runOptions.deferPublicUrlWarning === true || typeof runOptions.onPollText === 'function';
344
+ if (defer) {
345
+ pollOptions.deferPublicUrlWarning = true;
346
+ if (typeof runOptions.onDeferredPublicUrlWarning === 'function') {
347
+ pollOptions.onDeferredPublicUrlWarning = runOptions.onDeferredPublicUrlWarning;
348
+ }
349
+ }
342
350
  return pollOptions;
343
351
  }
344
352
 
@@ -367,7 +375,9 @@ async function pollUntilHealthCheckPasses({
367
375
  logPublicHealthUrlWarningIfNeeded({
368
376
  skippedPublicHealthUrl,
369
377
  urlsToTry,
370
- resolvedIndex: attemptResult.resolvedIndex
378
+ resolvedIndex: attemptResult.resolvedIndex,
379
+ defer: pollOptions.deferPublicUrlWarning === true,
380
+ onDeferred: pollOptions.onDeferredPublicUrlWarning
371
381
  });
372
382
  return;
373
383
  }