@aifabrix/builder 2.55.2 → 2.57.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 (88) hide show
  1. package/lib/agent-kit/session-report.js +76 -0
  2. package/lib/agent-kit/start.js +124 -57
  3. package/lib/agent-kit/tmux-sessions.js +163 -0
  4. package/lib/api/environments.api.js +38 -0
  5. package/lib/api/marketplace-bootstrap.api.js +31 -0
  6. package/lib/api/pipeline.api.js +32 -3
  7. package/lib/app/deploy.js +6 -5
  8. package/lib/cli/setup-app.js +7 -6
  9. package/lib/cli/setup-environment.js +76 -19
  10. package/lib/cli/setup-external-system-help.js +15 -3
  11. package/lib/cli/setup-external-system.js +21 -3
  12. package/lib/commands/agent-kit-help.js +32 -15
  13. package/lib/commands/agent-kit.js +90 -8
  14. package/lib/commands/repair-datasource-auto-rbac.js +4 -9
  15. package/lib/commands/upload-openapi-sync-followup.js +59 -0
  16. package/lib/commands/upload-qualification-client.js +131 -0
  17. package/lib/commands/upload-qualification-followups.js +104 -0
  18. package/lib/commands/upload-qualification-poll.js +143 -0
  19. package/lib/commands/upload.js +37 -156
  20. package/lib/deployment/deploy-poll-ui.js +14 -10
  21. package/lib/deployment/deployer-status.js +4 -4
  22. package/lib/deployment/installation/azure-infra-stage.js +160 -0
  23. package/lib/deployment/installation/azure-preflight.js +102 -0
  24. package/lib/deployment/installation/azure-readiness.js +71 -0
  25. package/lib/deployment/installation/docker-register.js +112 -0
  26. package/lib/deployment/installation/environment-auth.js +76 -0
  27. package/lib/deployment/installation/environment-manifest.js +165 -0
  28. package/lib/deployment/installation/environment-stage.js +191 -0
  29. package/lib/deployment/installation/index.js +39 -0
  30. package/lib/deployment/installation/infra-catalog.js +165 -0
  31. package/lib/deployment/installation/infra-manifest.js +161 -0
  32. package/lib/deployment/installation/infra-projection.js +86 -0
  33. package/lib/deployment/installation/public-ip.js +65 -0
  34. package/lib/deployment/installation/stage-input.js +169 -0
  35. package/lib/deployment/installation/stage-output.js +53 -0
  36. package/lib/deployment/installation/workspace-env.js +76 -0
  37. package/lib/deployment/installation/workspace-files.js +178 -0
  38. package/lib/deployment/installation/workspace-schema.js +40 -0
  39. package/lib/deployment/installation/workspace-secrets.js +66 -0
  40. package/lib/deployment/installation/workspace.js +128 -0
  41. package/lib/deployment/marketplace-environment.js +305 -0
  42. package/lib/external-system/deploy.js +46 -51
  43. package/lib/generator/builders.js +4 -0
  44. package/lib/generator/helpers.js +55 -99
  45. package/lib/generator/index.js +0 -4
  46. package/lib/generator/split-variables.js +11 -5
  47. package/lib/internal/fs-real-sync.js +29 -0
  48. package/lib/programmatic/builder-api-request.js +8 -4
  49. package/lib/programmatic/capability-registry.js +2 -0
  50. package/lib/programmatic/get-deploy-status.js +45 -0
  51. package/lib/programmatic/get-upload-status.js +45 -0
  52. package/lib/programmatic/http-accepted-response.js +31 -0
  53. package/lib/programmatic/openapi-descriptions.js +14 -4
  54. package/lib/programmatic/request-timeout.js +21 -15
  55. package/lib/programmatic/route-handler-map.js +6 -0
  56. package/lib/programmatic/run-deploy.js +7 -3
  57. package/lib/programmatic/run-upload.js +11 -7
  58. package/lib/role-assistant/package-validate.js +4 -0
  59. package/lib/role-assistant/test-runner-clarification-resubmit.js +149 -0
  60. package/lib/role-assistant/test-runner.js +14 -17
  61. package/lib/schema/application-schema.json +20 -2
  62. package/lib/schema/infra-parameter.schema.json +7 -0
  63. package/lib/schema/infra.parameter.yaml +32 -43
  64. package/lib/schema/infrastructure-schema.json +1480 -399
  65. package/lib/schema/installation-environment.schema.json +601 -0
  66. package/lib/utils/cli-utils.js +3 -2
  67. package/lib/utils/qualification-poll-ui.js +136 -0
  68. package/lib/utils/variable-transformer.js +3 -0
  69. package/lib/validation/required-variables.js +41 -0
  70. package/lib/validation/validator.js +8 -5
  71. package/package.json +7 -2
  72. package/templates/README.md +4 -0
  73. package/templates/agent-kit/instructions/AGENTKIT.md +6 -6
  74. package/templates/agent-kit/skills/shared/hosts.md +10 -10
  75. package/templates/applications/builder-api/env.template +4 -0
  76. package/templates/applications/dataplane/application.yaml +1 -1
  77. package/templates/applications/dataplane/env.template +1 -1
  78. package/templates/applications/keycloak/Dockerfile +12 -5
  79. package/templates/applications/keycloak/application.yaml +2 -2
  80. package/templates/applications/keycloak/cimd-lenient-parse/AbstractClientIdMetadataDocumentExecutor.java +3 -3
  81. package/templates/applications/keycloak/cimd-lenient-parse/SOURCE.md +6 -6
  82. package/templates/applications/keycloak/env.template +13 -1
  83. package/templates/applications/miso-controller/application.yaml +3 -2
  84. package/templates/applications/miso-controller/env.template +28 -22
  85. package/templates/marketplace/createUiDefinition.json +77 -0
  86. package/templates/marketplace/install.parameters.example.json +9 -0
  87. package/templates/marketplace/main.json +4087 -0
  88. package/lib/generator/deploy-manifest-azure-kv.js +0 -81
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Human-readable output for Agent Kit tmux session commands.
3
+ */
4
+
5
+ 'use strict';
6
+
7
+ const {
8
+ formatBlockingError,
9
+ formatNextActions,
10
+ metadata,
11
+ sectionTitle
12
+ } = require('../utils/cli-layout-chalk');
13
+
14
+ /**
15
+ * @param {string} value
16
+ * @param {number} width
17
+ * @returns {string}
18
+ */
19
+ function pad(value, width) {
20
+ return String(value).padEnd(width);
21
+ }
22
+
23
+ /**
24
+ * @param {Array<{ name: string }>} sessions
25
+ * @returns {{ name: number, status: number, windows: number }}
26
+ */
27
+ function columnWidths(sessions) {
28
+ const longestName = sessions.reduce(
29
+ (width, session) => Math.max(width, String(session.name).length),
30
+ 'Name'.length
31
+ );
32
+ return { name: longestName + 2, status: 10, windows: 7 };
33
+ }
34
+
35
+ /**
36
+ * @param {{ sessions: Array<{ name: string, attached: boolean, windows: number }> }} report
37
+ * @returns {string}
38
+ */
39
+ function formatSessionList(report) {
40
+ const sessions = Array.isArray(report.sessions) ? report.sessions : [];
41
+ const lines = [sectionTitle(`Tmux sessions — ${sessions.length} active:`), ''];
42
+ if (sessions.length === 0) {
43
+ lines.push(metadata(' No active tmux sessions.'));
44
+ return lines.join('\n');
45
+ }
46
+
47
+ const widths = columnWidths(sessions);
48
+ const header = `${pad('Name', widths.name)}${pad('Status', widths.status)}Windows`;
49
+ lines.push(metadata(header));
50
+ lines.push(metadata('-'.repeat(widths.name + widths.status + widths.windows)));
51
+ sessions.forEach((session) => {
52
+ const status = session.attached ? 'attached' : 'detached';
53
+ lines.push(`${pad(session.name, widths.name)}${pad(status, widths.status)}${session.windows}`);
54
+ });
55
+ return lines.join('\n');
56
+ }
57
+
58
+ /**
59
+ * @param {boolean} asJson
60
+ * @returns {{ stderr: string, stdout?: string }}
61
+ */
62
+ function formatMissingKillName(asJson) {
63
+ const error = 'Session name is required.';
64
+ if (asJson) {
65
+ return { stderr: JSON.stringify({ command: 'kill', success: false, error }, null, 2) };
66
+ }
67
+ return {
68
+ stderr: formatBlockingError(error),
69
+ stdout: formatNextActions([
70
+ 'Run: aifabrix agent-kit list',
71
+ 'Run: aifabrix agent-kit kill <name>'
72
+ ])
73
+ };
74
+ }
75
+
76
+ module.exports = { formatSessionList, formatMissingKillName };
@@ -11,6 +11,7 @@ const { execFileSync, spawnSync } = require('child_process');
11
11
  const logger = require('../utils/logger');
12
12
  const { resolveGitRoot } = require('./repo-guard');
13
13
  const { writeApproveAllFiles } = require('./host-approve');
14
+ const { hasOnPath, requireTmux, tmuxSessionExists } = require('./tmux-sessions');
14
15
 
15
16
  const START_APPS = {
16
17
  claude: { bin: 'claude' },
@@ -97,34 +98,6 @@ function resolveRemoteControlName(prefix, session) {
97
98
  return sanitizeSessionName(`${prefix}-${session}`);
98
99
  }
99
100
 
100
- /**
101
- * @param {string} bin
102
- * @param {Function} exec
103
- * @returns {boolean}
104
- */
105
- function hasOnPath(bin, exec) {
106
- try {
107
- exec('which', [bin], {
108
- encoding: 'utf8',
109
- stdio: ['ignore', 'pipe', 'pipe']
110
- });
111
- return true;
112
- } catch {
113
- return false;
114
- }
115
- }
116
-
117
- /**
118
- * @param {object} options
119
- * @returns {void}
120
- */
121
- function validateStartOptions(options) {
122
- const app = String(options.app || '').trim();
123
- if (options.remoteControl && app !== 'claude') {
124
- throw new Error('--remote-control is only valid with claude.');
125
- }
126
- }
127
-
128
101
  /**
129
102
  * Claude argv after the binary (Remote Control names, not Claude --tmux).
130
103
  * @param {object} p
@@ -152,7 +125,7 @@ function buildTmuxSpec(p) {
152
125
  const extra = p.app === 'claude' ? claudeInnerArgs(p) : [];
153
126
  const mode = p.detach
154
127
  ? ['new-session', '-d', '-s', p.session, '-c', p.cwd]
155
- : ['new-session', '-A', '-s', p.session, '-c', p.cwd];
128
+ : ['new-session', '-s', p.session, '-c', p.cwd];
156
129
  const remote = p.app === 'claude' && p.remoteControl;
157
130
  return {
158
131
  bin: 'tmux',
@@ -179,9 +152,7 @@ function buildLaunchSpec(p) {
179
152
  * @returns {void}
180
153
  */
181
154
  function requireBinaries(spec, exec) {
182
- if (spec.needTmux && !hasOnPath('tmux', exec)) {
183
- throw new Error('tmux is not on PATH. Install tmux, then retry.');
184
- }
155
+ requireTmux(exec);
185
156
  if (hasOnPath(spec.appBin, exec)) {
186
157
  return;
187
158
  }
@@ -193,6 +164,45 @@ function requireBinaries(spec, exec) {
193
164
  throw new Error('codex is not on PATH. Install with: npm install -g @openai/codex');
194
165
  }
195
166
 
167
+ /**
168
+ * @param {string} cwd
169
+ * @returns {{ bin: string, args: string[], cwd: string }}
170
+ */
171
+ function buildCodexRemoteSpec(cwd) {
172
+ return {
173
+ bin: 'codex',
174
+ args: ['remote-control', 'start', '--json'],
175
+ cwd
176
+ };
177
+ }
178
+
179
+ /**
180
+ * @param {object} options
181
+ * @param {object} spec
182
+ * @returns {void}
183
+ */
184
+ function startCodexRemote(options, spec) {
185
+ const exec = options.execFileSync || execFileSync;
186
+ try {
187
+ exec(spec.bin, spec.args, {
188
+ cwd: spec.cwd,
189
+ encoding: 'utf8',
190
+ stdio: ['ignore', 'pipe', 'pipe']
191
+ });
192
+ } catch (error) {
193
+ const detail = String((error && error.stderr) || (error && error.message) || '');
194
+ if (/app server is running but is not managed by codex app-server daemon/i.test(detail)) {
195
+ return;
196
+ }
197
+ if (/unrecognized subcommand|unknown command/i.test(detail)) {
198
+ throw new Error(
199
+ 'This Codex CLI does not support Remote Control. Update Codex or pass --no-remote-control.'
200
+ );
201
+ }
202
+ throw error;
203
+ }
204
+ }
205
+
196
206
  /**
197
207
  * @param {object} options
198
208
  * @returns {boolean}
@@ -220,11 +230,26 @@ function spawnLaunch(options, spec) {
220
230
  const result = spawn(spec.bin, spec.args, { stdio: 'inherit', cwd: spec.cwd });
221
231
  const status = result && typeof result.status === 'number' ? result.status : 0;
222
232
  if (status !== 0) {
233
+ const exec = options.execFileSync || execFileSync;
234
+ if (tmuxSessionExists(spec.session, exec)) {
235
+ throw duplicateSessionError(spec.session);
236
+ }
223
237
  throw new Error(`${spec.bin} exited with status ${status}`);
224
238
  }
225
239
  return status;
226
240
  }
227
241
 
242
+ /**
243
+ * @param {string} session
244
+ * @returns {Error}
245
+ */
246
+ function duplicateSessionError(session) {
247
+ return new Error(
248
+ `tmux session "${session}" already exists. Run: aifabrix agent-kit list; ` +
249
+ `aifabrix agent-kit kill ${session}; or choose another --name.`
250
+ );
251
+ }
252
+
228
253
  /**
229
254
  * @param {object} spec
230
255
  * @param {object} extra
@@ -232,12 +257,17 @@ function spawnLaunch(options, spec) {
232
257
  */
233
258
  function toStartResult(spec, extra) {
234
259
  const launch = [spec.bin, ...spec.args];
260
+ const remoteLaunch = extra.remoteSpec
261
+ ? [extra.remoteSpec.bin, ...extra.remoteSpec.args]
262
+ : undefined;
235
263
  return {
236
264
  command: 'start',
237
265
  app: extra.app,
238
266
  session: spec.session,
239
267
  remoteControlName: spec.remoteControlName,
240
268
  remoteControlPrefix: spec.remoteControlPrefix,
269
+ remoteControl: extra.remoteControl,
270
+ remoteLaunch,
241
271
  path: extra.cwd,
242
272
  launch,
243
273
  tmux: spec.needTmux ? launch : undefined,
@@ -252,45 +282,78 @@ function toStartResult(spec, extra) {
252
282
  * @param {object} options
253
283
  * @returns {object}
254
284
  */
255
- function runStart(options) {
256
- validateStartOptions(options);
285
+ function resolveStartContext(options) {
257
286
  const app = normalizeStartApp(options.app);
258
287
  const cwd = resolveGitRoot(options.cwd || process.cwd());
259
288
  const session = resolveSessionName(options.name, cwd);
260
289
  const remoteControlPrefix = hostUserPrefix(options);
261
290
  const remoteControlName = resolveRemoteControlName(remoteControlPrefix, session);
262
- const exec = options.execFileSync || execFileSync;
291
+ const remoteControl = options.remoteControl !== false;
263
292
  const spec = buildLaunchSpec({
264
- app,
265
- cwd,
266
- session,
267
- remoteControlPrefix,
268
- remoteControlName,
269
- detach: Boolean(options.detach),
270
- remoteControl: Boolean(options.remoteControl)
293
+ app, cwd, session, remoteControlPrefix, remoteControlName,
294
+ detach: Boolean(options.detach), remoteControl
271
295
  });
272
296
  spec.cwd = cwd;
273
- const approveAll = !options.skipApproveAll && Boolean(options.approveAll || options.yes);
274
- const approvals = writeApproveAllFiles({
275
- repoRoot: cwd,
276
- hosts: [app],
277
- approveAll,
278
- dryRun: Boolean(options.dryRun)
279
- });
297
+ const remoteSpec = app === 'codex' && remoteControl
298
+ ? buildCodexRemoteSpec(cwd)
299
+ : undefined;
300
+ return { app, cwd, session, remoteControl, spec, remoteSpec };
301
+ }
302
+
303
+ /**
304
+ * @param {object} options
305
+ * @param {object} context
306
+ * @returns {boolean}
307
+ */
308
+ function prepareStart(options, context) {
309
+ const exec = options.execFileSync || execFileSync;
280
310
  if (!options.dryRun) {
281
- requireBinaries(spec, exec);
311
+ requireBinaries(context.spec, exec);
282
312
  }
283
313
  const execute = shouldExecute(options);
284
- if (execute && !options.json) {
285
- logger.log(`Starting ${app} session ${spec.session}`);
314
+ if (execute && tmuxSessionExists(context.session, exec)) {
315
+ throw duplicateSessionError(context.session);
316
+ }
317
+ return execute;
318
+ }
319
+
320
+ /**
321
+ * @param {object} options
322
+ * @param {object} context
323
+ * @param {boolean} execute
324
+ * @returns {void}
325
+ */
326
+ function executeStart(options, context, execute) {
327
+ if (!execute) {
328
+ return;
286
329
  }
287
- if (execute) {
288
- spawnLaunch(options, spec);
330
+ logger.log(`Starting ${context.app} session ${context.spec.session}`);
331
+ if (context.remoteSpec) {
332
+ startCodexRemote(options, context.remoteSpec);
289
333
  }
290
- return toStartResult(spec, {
291
- app,
292
- cwd,
334
+ spawnLaunch(options, context.spec);
335
+ }
336
+
337
+ /**
338
+ * @param {object} options
339
+ * @returns {object}
340
+ */
341
+ function runStart(options) {
342
+ const context = resolveStartContext(options);
343
+ const execute = prepareStart(options, context);
344
+ const approvals = writeApproveAllFiles({
345
+ repoRoot: context.cwd,
346
+ hosts: [context.app],
347
+ approveAll: !options.skipApproveAll,
348
+ dryRun: Boolean(options.dryRun)
349
+ });
350
+ executeStart(options, context, execute);
351
+ return toStartResult(context.spec, {
352
+ app: context.app,
353
+ cwd: context.cwd,
293
354
  approvals,
355
+ remoteControl: context.remoteControl,
356
+ remoteSpec: context.remoteSpec,
294
357
  dryRun: Boolean(options.dryRun),
295
358
  detach: Boolean(options.detach),
296
359
  executed: execute
@@ -306,5 +369,9 @@ module.exports = {
306
369
  resolveRemoteControlName,
307
370
  claudeInnerArgs,
308
371
  buildTmuxSpec,
372
+ buildCodexRemoteSpec,
373
+ startCodexRemote,
374
+ duplicateSessionError,
375
+ resolveStartContext,
309
376
  runStart
310
377
  };
@@ -0,0 +1,163 @@
1
+ /**
2
+ * Exact-name tmux session discovery and termination for Agent Kit.
3
+ * @fileoverview Uses argv execution only; never invokes a shell
4
+ */
5
+
6
+ 'use strict';
7
+
8
+ const { execFileSync } = require('child_process');
9
+
10
+ const LIST_FORMAT = [
11
+ '#{session_name}',
12
+ '#{session_windows}',
13
+ '#{session_attached}',
14
+ '#{session_created}'
15
+ ].join('\t');
16
+
17
+ /**
18
+ * @param {string} bin
19
+ * @param {Function} exec
20
+ * @returns {boolean}
21
+ */
22
+ function hasOnPath(bin, exec) {
23
+ try {
24
+ exec('which', [bin], {
25
+ encoding: 'utf8',
26
+ stdio: ['ignore', 'pipe', 'pipe']
27
+ });
28
+ return true;
29
+ } catch {
30
+ return false;
31
+ }
32
+ }
33
+
34
+ /**
35
+ * @param {Function} exec
36
+ * @returns {void}
37
+ */
38
+ function requireTmux(exec) {
39
+ if (!hasOnPath('tmux', exec)) {
40
+ throw new Error('tmux is not on PATH. Install tmux, then retry.');
41
+ }
42
+ }
43
+
44
+ /**
45
+ * @param {unknown} value
46
+ * @returns {string}
47
+ */
48
+ function normalizeExactSessionName(value) {
49
+ const name = String(value || '').trim();
50
+ const hasControl = Array.from(name).some((char) => {
51
+ const code = char.charCodeAt(0);
52
+ return code < 32 || code === 127;
53
+ });
54
+ if (!name || hasControl || name.includes(':') || name.includes('.')) {
55
+ throw new Error('Invalid tmux session name. Use a non-empty name without control characters, colons, or periods.');
56
+ }
57
+ return name;
58
+ }
59
+
60
+ /**
61
+ * @param {unknown} error
62
+ * @returns {boolean}
63
+ */
64
+ function isNoTmuxServer(error) {
65
+ const message = [error && error.message, error && error.stderr]
66
+ .filter(Boolean)
67
+ .map(String)
68
+ .join(' ');
69
+ return /no server running|failed to connect to server/i.test(message);
70
+ }
71
+
72
+ /**
73
+ * @param {string} name
74
+ * @param {Function} [exec]
75
+ * @returns {boolean}
76
+ */
77
+ function tmuxSessionExists(name, exec = execFileSync) {
78
+ const exact = normalizeExactSessionName(name);
79
+ try {
80
+ exec('tmux', ['has-session', '-t', `=${exact}`], {
81
+ stdio: ['ignore', 'ignore', 'ignore']
82
+ });
83
+ return true;
84
+ } catch (error) {
85
+ if (error && (error.status === 1 || isNoTmuxServer(error))) {
86
+ return false;
87
+ }
88
+ throw error;
89
+ }
90
+ }
91
+
92
+ /**
93
+ * @param {string} line
94
+ * @returns {object|null}
95
+ */
96
+ function parseSessionLine(line) {
97
+ if (!line) {
98
+ return null;
99
+ }
100
+ const [name, windows, attached, created] = line.split('\t');
101
+ return {
102
+ name,
103
+ windows: Number(windows),
104
+ attached: Number(attached) > 0,
105
+ createdAt: Number(created)
106
+ };
107
+ }
108
+
109
+ /**
110
+ * @param {object} [options]
111
+ * @returns {{ command: string, sessions: object[], count: number }}
112
+ */
113
+ function runList(options = {}) {
114
+ const exec = options.execFileSync || execFileSync;
115
+ requireTmux(exec);
116
+ let output;
117
+ try {
118
+ output = exec('tmux', ['list-sessions', '-F', LIST_FORMAT], {
119
+ encoding: 'utf8',
120
+ stdio: ['ignore', 'pipe', 'pipe']
121
+ });
122
+ } catch (error) {
123
+ if (isNoTmuxServer(error)) {
124
+ return { command: 'list', sessions: [], count: 0 };
125
+ }
126
+ throw error;
127
+ }
128
+ const sessions = String(output || '')
129
+ .trim()
130
+ .split(/\r?\n/)
131
+ .map(parseSessionLine)
132
+ .filter(Boolean);
133
+ return { command: 'list', sessions, count: sessions.length };
134
+ }
135
+
136
+ /**
137
+ * @param {object} options
138
+ * @returns {{ command: string, session: string, killed: boolean }}
139
+ */
140
+ function runKill(options) {
141
+ const exec = options.execFileSync || execFileSync;
142
+ const session = normalizeExactSessionName(options.name);
143
+ requireTmux(exec);
144
+ if (!tmuxSessionExists(session, exec)) {
145
+ throw new Error(`tmux session "${session}" is not running. Run: aifabrix agent-kit list`);
146
+ }
147
+ exec('tmux', ['kill-session', '-t', `=${session}`], {
148
+ stdio: ['ignore', 'ignore', 'pipe']
149
+ });
150
+ return { command: 'kill', session, killed: true };
151
+ }
152
+
153
+ module.exports = {
154
+ LIST_FORMAT,
155
+ hasOnPath,
156
+ requireTmux,
157
+ normalizeExactSessionName,
158
+ isNoTmuxServer,
159
+ tmuxSessionExists,
160
+ parseSessionLine,
161
+ runList,
162
+ runKill
163
+ };
@@ -240,7 +240,45 @@ async function listEnvironmentDatasources(controllerUrl, envKey, authConfig, opt
240
240
  });
241
241
  }
242
242
 
243
+ /**
244
+ * Validate an environment deployment request without deploying
245
+ * POST /api/v1/environments/{envKey}/validate
246
+ * @requiresPermission {Controller} controller:deploy
247
+ * @async
248
+ * @function validateEnvironmentDeployment
249
+ * @param {string} controllerUrl - Controller base URL
250
+ * @param {string} envKey - Environment key
251
+ * @param {Object} authConfig - Authentication configuration
252
+ * @param {Object} request - Body with environmentConfig
253
+ * @returns {Promise<Object>} Validation response (`data.valid`, errors, warnings)
254
+ * @throws {Error} If request fails
255
+ */
256
+ async function validateEnvironmentDeployment(controllerUrl, envKey, authConfig, request) {
257
+ const client = new ApiClient(controllerUrl, authConfig);
258
+ return await client.post(`/api/v1/environments/${envKey}/validate`, {
259
+ body: request
260
+ });
261
+ }
262
+
263
+ /**
264
+ * Get server-reported tenant activation status
265
+ * GET /api/v1/tenant/status
266
+ * @requiresPermission {Controller} Authenticated (bearer)
267
+ * @async
268
+ * @function getTenantStatus
269
+ * @param {string} controllerUrl - Controller base URL
270
+ * @param {Object} authConfig - Authentication configuration
271
+ * @returns {Promise<Object>} Tenant status response (`status`: PENDING, ACTIVE, INACTIVE)
272
+ * @throws {Error} If request fails
273
+ */
274
+ async function getTenantStatus(controllerUrl, authConfig) {
275
+ const client = new ApiClient(controllerUrl, authConfig);
276
+ return await client.get('/api/v1/tenant/status');
277
+ }
278
+
243
279
  module.exports = {
280
+ validateEnvironmentDeployment,
281
+ getTenantStatus,
244
282
  listEnvironments,
245
283
  createEnvironment,
246
284
  getEnvironment,
@@ -0,0 +1,31 @@
1
+ /**
2
+ * @fileoverview Mori Marketplace installation bootstrap API (public, unauthenticated).
3
+ * Used only for DOCKER installation registration; AZURE bootstrap runs inside the Marketplace
4
+ * template deployment script.
5
+ * @author AI Fabrix Team
6
+ * @version 2.0.0
7
+ */
8
+
9
+ const { ApiClient } = require('./index');
10
+
11
+ /**
12
+ * Register an installation and receive its Service API Key exactly once.
13
+ * POST /api/v1/marketplace/bootstrap
14
+ * @requiresPermission Public (Mori; no authentication)
15
+ * @async
16
+ * @function bootstrapMarketplaceInstallation
17
+ * @param {string} moriBaseUrl - Mori base URL (no trailing slash)
18
+ * @param {Object} body - Request body
19
+ * @param {string} body.subscriptionId - Stable installation UUID
20
+ * @param {string} body.tenantId - Stable owner UUID
21
+ * @param {string} body.resourceGroupName - Installation label
22
+ * @param {string} body.cloudVendor - AZURE or DOCKER
23
+ * @param {string} [body.marketplaceSubscriptionId] - Marketplace SaaS subscription; empty selects Community
24
+ * @returns {Promise<Object>} API response; `data.apiKey` is secret and must never be logged
25
+ */
26
+ async function bootstrapMarketplaceInstallation(moriBaseUrl, body) {
27
+ const client = new ApiClient(moriBaseUrl, {});
28
+ return await client.post('/api/v1/marketplace/bootstrap', { body });
29
+ }
30
+
31
+ module.exports = { bootstrapMarketplaceInstallation };
@@ -278,7 +278,10 @@ function resolvePipelineUploadTimeoutMs(raw) {
278
278
  * @param {Object} authConfig
279
279
  * @param {Object} payload
280
280
  * @param {Object} [options]
281
- * @param {number|string} [options.timeoutMs] - HTTP + dataplane publish budget (default 300000)
281
+ * @param {number|string} [options.timeoutMs] - HTTP + dataplane publish budget (default 300000); ignored when options.async is true.
282
+ * @param {boolean} [options.async=false] - When true, durably accept the upload for qualification (202 jobId/candidateFingerprint) instead of blocking for the ladder (plan 191.0 task 1).
283
+ * @param {string} [options.gate] - Optional comma-separated checks: operations, trust, governance, full.
284
+ * @param {string} [options.subjectEmail] - Optional comma-separated governance subject emails.
282
285
  */
283
286
  async function uploadApplicationViaPipeline(
284
287
  dataplaneUrl,
@@ -287,8 +290,17 @@ async function uploadApplicationViaPipeline(
287
290
  options = {}
288
291
  ) {
289
292
  const body = { ...payload, status: payload.status ?? 'draft' };
290
- const timeoutMs = resolvePipelineUploadTimeoutMs(options.timeoutMs);
291
293
  const client = createDataplaneApiClient(dataplaneUrl, authConfig);
294
+ if (options.async === true) {
295
+ const params = { async: 'true' };
296
+ if (options.gate) params.gate = options.gate;
297
+ if (options.subjectEmail) params.subjectEmail = options.subjectEmail;
298
+ return await client.post('/api/v1/pipeline/upload', {
299
+ body,
300
+ params
301
+ });
302
+ }
303
+ const timeoutMs = resolvePipelineUploadTimeoutMs(options.timeoutMs);
292
304
  return await client.post('/api/v1/pipeline/upload', {
293
305
  body,
294
306
  timeoutMs,
@@ -297,6 +309,23 @@ async function uploadApplicationViaPipeline(
297
309
  });
298
310
  }
299
311
 
312
+ /**
313
+ * Get durable publish/qualification job status.
314
+ * GET /api/v1/publish-jobs/{jobId}
315
+ * @requiresPermission {Dataplane} external-system:publish or external-system:read
316
+ * @async
317
+ * @function getPublishJobStatus
318
+ * @param {string} dataplaneUrl - Dataplane base URL
319
+ * @param {Object} authConfig - Authentication configuration
320
+ * @param {string} jobId - Publish/qualification job id returned by uploadApplicationViaPipeline({ async: true })
321
+ * @returns {Promise<Object>} API envelope `{ success, data }` where `data` is `{ jobId, state, targetType, targetKey, steps, error, result }`
322
+ * @throws {Error} If request fails
323
+ */
324
+ async function getPublishJobStatus(dataplaneUrl, authConfig, jobId) {
325
+ const client = createDataplaneApiClient(dataplaneUrl, authConfig);
326
+ return await client.get(`/api/v1/publish-jobs/${encodeURIComponent(jobId)}`);
327
+ }
328
+
300
329
  module.exports = {
301
330
  validatePipeline,
302
331
  deployPipeline,
@@ -307,7 +336,7 @@ module.exports = {
307
336
  testSystemViaPipeline,
308
337
  testDatasourceViaPipeline,
309
338
  uploadApplicationViaPipeline,
339
+ getPublishJobStatus,
310
340
  resolvePipelineUploadTimeoutMs,
311
341
  PIPELINE_UPLOAD_TIMEOUT_MS
312
342
  };
313
-
package/lib/app/deploy.js CHANGED
@@ -308,10 +308,12 @@ function validateImageIsPullable(imageRef, appName) {
308
308
  */
309
309
  function throwIfDeploymentFailed(status, statusObj) {
310
310
  if (status !== 'failed' && status !== 'cancelled') return;
311
- const msg =
312
- statusObj.message ||
313
- statusObj.error ||
314
- (status === 'cancelled' ? 'Deployment cancelled' : 'Deployment failed');
311
+ const reportedErrors = Array.isArray(statusObj.errors) ? statusObj.errors.filter(Boolean).join('; ') : '';
312
+ // A failed deployment can retain its last progress message (for example
313
+ // "Database-only mode"). Prefer the terminal error so the CLI reports the cause.
314
+ const msg = status === 'failed'
315
+ ? statusObj.error || reportedErrors || statusObj.message || 'Deployment failed'
316
+ : statusObj.message || statusObj.error || reportedErrors || 'Deployment cancelled';
315
317
  const err = new Error(`Deployment ${status}: ${msg}`);
316
318
  err.formatted = `Deployment ${status}.\n\n${msg}`;
317
319
  err.deploymentStatus = statusObj;
@@ -495,4 +497,3 @@ module.exports = {
495
497
  validateAppName,
496
498
  validateImageIsPullable
497
499
  };
498
-